العودة إلى المدونة

أفضل ممارسات الـ middleware في Go (Golang) لبيئة الإنتاج

أفضل ممارسات الـ middleware في Go لبيئة الإنتاج: ترتيب يمنع تسجيل panic بالرمز 200، وأغلفة ResponseWriter مع Unwrap، ومعرّفات الطلبات، والمهلات والمصادقة.

أفضل ممارسات الـ middleware في Go (Golang) لبيئة الإنتاج

الـ middleware في Go دالة تأخذ http.Handler وتعيد معالجًا جديدًا: func(http.Handler) http.Handler. ويعمل المعالج المُعاد قبل أن يستدعي المعالج الذي يغلّفه أو بعده. وهذا التوقيع هو النمط كله. لا تحتاج إلى إطار عمل (framework) من أجله، ومنذ Go 1.22 لا تحتاج أيضًا إلى موجّه (router) من طرف ثالث، لأن http.ServeMux يطابق طرق HTTP وأنماط المسارات ذات المتغيرات (ملاحظات إصدار Go 1.22).

كتابة middleware لا تتطلب سوى بضعة أسطر. أما في بيئة الإنتاج فأفضل الممارسات تتعلق بالتفاصيل المحيطة به: ترتيب الطبقات، وغلاف ResponseWriter لا يكسر البث (streaming)، والتعافي من panic بطريقة تبلّغ عن الفشل بشكل صحيح، وقيم context لا يمكن أن تتصادم. يبني هذا الدليل طبقات الـ middleware لواجهة برمجية (API) للطلبات والمدفوعات طبقةً طبقة. وكل مقطع من الكود يُترجم على Go 1.27، وتنجح الاختبارات مع go test -race.

الخلاصة

  • اكتب الـ middleware على شكل func(http.Handler) http.Handler واربطها في سلسلة بدالة مساعدة من عشرة أسطر. الـ middleware العام يغلّف الـ mux. والـ middleware الخاص بمسار واحد يغلّف المعالج الذي تسجّله.
  • الترتيب مهم. ضع معرّف الطلب والتسجيل خارج التعافي من panic، حتى يسجّل سطر السجل رمز 500 الذي يكتبه التعافي ويحمل معرّف الطلب.
  • يحتاج غلاف ResponseWriter إلى دالة تابعة Unwrap() http.ResponseWriter. فبدونها لا يستطيع http.ResponseController تنفيذ flush أو ضبط المهل عبر الـ middleware الخاص بك.
  • يجب أن يعيد التعافي إطلاق panic بالقيمة http.ErrAbortHandler، ويجب أن يُجهض الاستجابة بدلًا من كتابة 500 إذا كان المعالج قد أرسل رمز الحالة بالفعل.
  • اضبط ReadHeaderTimeout على الخادم، وامنح كل مسار مهلة عبر الـ context، وحدّ حجم الأجسام باستخدام http.MaxBytesReader.
  • يضع الـ middleware الخاص بالمصادقة هوية المستدعي (principal) في الـ context. ويعيد 401 حين يكون المستدعي مجهولًا، و 403 حين لا يملك المستدعي الصلاحية.
  • اجعل مفتاح تحديد المعدّل عنوان IP أضافه الـ proxy الخاص بك، لا القيمة الأولى من اليسار في X-Forwarded-For.

كيف يبدو الـ middleware الخاص بـ HTTP في Go؟

يستقبل الـ middleware المعالج التالي ويعيد معالجًا يستدعيه. وتسمية النوع تجعل قراءة السلاسل أسهل:

example.gogo
type Middleware func(http.Handler) http.Handler

// Chain wraps h so that the first middleware in the list runs first.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

تطبّق Chain القائمة من آخرها، فينتهي العنصر الأول في الطبقة الخارجية ويرى الطلب أولًا.

وداخل كل middleware يحوّل http.HandlerFunc الـ closure إلى معالج. وينجح هذا التحويل لأن HandlerFunc نوع دالة له دالة تابعة ServeHTTP، وهو ما يشرحه دليل الكلمة المفتاحية func في Go إلى جانب الـ closures. أما النوع Middleware نفسه فهو مجرد http.Handler يدخل ويخرج، ويشرح دليل الكلمة المفتاحية interface في Go لماذا يتركّب interface ذو دالة تابعة واحدة مثل http.Handler بهذه السهولة.

الـ middleware العام مقابل الـ middleware الخاص بكل مسار على http.ServeMux

لا يملك ServeMux دالة Use. غلّف الـ mux كله بالـ middleware الذي يحتاج إليه كل طلب، وغلّف المعالجات الفردية بالباقي:

example.gogo
func newServer(logger *slog.Logger, tokens TokenVerifier, limiter *RateLimiter) (*http.Server, error) {
	auth := requireAuth(tokens)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders", listOrders)
	mux.Handle("GET /orders/{id}", auth(http.HandlerFunc(getOrder)))
	mux.Handle("POST /payments", Chain(http.HandlerFunc(createPayment),
		auth,
		requireScope("payments:write"),
		withDeadline(5*time.Second),
	))

	csrf := http.NewCrossOriginProtection()
	if err := csrf.AddTrustedOrigin("https://admin.example.com"); err != nil {
		return nil, err
	}

	handler := Chain(mux,
		requestID,
		logRequests(logger),
		recoverPanics(logger),
		csrf.Handler,
		limiter.Middleware,
		limitBody(1<<20),
	)

	return &http.Server{
		Addr:              ":8080",
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       120 * time.Second,
	}, nil
}

تتخطى قائمة الطلبات العامة المصادقة، ويحتاج جلب طلب واحد إلى رمز وصول (token) صالح، أما إنشاء دفعة فيحتاج أيضًا إلى نطاق صلاحية (scope) ويحصل على ميزانية زمنية من خمس ثوانٍ. ولأن السلسلة الخاصة بكل مسار جزء من تسجيل المسار، يمكنك أن تقرأ من يحق له استدعاء ماذا من شاشة واحدة من الكود.

احتفظ بهذا الربط في مكان واحد، مثل cmd/orders/server.go أو حزمة internal/httpapi. ويشرح مقال هيكلة مشاريع Go أين تنتمي هذه الحزمة في بنية أكبر.

بأي ترتيب يجب أن يعمل الـ middleware في Go؟

يغلّف أول middleware في السلسلة كل ما يأتي بعده. فهو يرى الطلب أولًا والاستجابة أخيرًا. فالطبقة الخارجية لا تستطيع إلا أن ترصد ما تفعله الطبقات الداخلية، والطبقة الداخلية لا ترى أبدًا ما يحدث خارجها. وهذا ترتيب جيد لواجهة HTTP برمجية في بيئة الإنتاج:

#الـ middlewareسبب موضعه هنا
1معرّف الطلبتستطيع كل طبقة لاحقة قراءة المعرّف، بما في ذلك سطر السجل وتقرير panic.
2سجل الوصوليغلّف التعافي، فيسجّل رمز الحالة الذي كتبه التعافي.
3التعافي من panicيلتقط حالات panic من كل طبقة داخلية ومن المعالج، ويحوّلها إلى 500.
4الحماية من الطلبات عبر الأصوليرفض عمليات الكتابة القادمة من متصفح عبر موقع آخر قبل أن تكلّف أي عمل.
5تحديد المعدّل لكل عنوان IPيتخلص من الحركة المسيئة قبل أن تدفع المصادقة ثمن البحث عن رمز الوصول.
6حد حجم الجسميحدّ حجم كل جسم طلب قبل أن يقرأه أي معالج.
7المصادقة ونطاقات الصلاحية، لكل مسارلا تدفع ثمن التحقق من المستدعي إلا المسارات التي تحتاج إليه.
8المهلة، لكل مساريحصل كل مسار على ميزانية زمنية تناسب عمله.

تضع أدلة كثيرة التعافي أولًا حتى يلتقط كل شيء. وهذه سجلات معالج واحد يسبب panic، شُغّل بالترتيبين:

example.texttext
# requestID → logRequests → recoverPanics
level=ERROR msg="handler panic" panic="assignment to entry in nil map" request_id=U5UTAL72SXLQQQPQYWOHHEJVUO
level=INFO msg=request method=GET path=/orders/ord_9 status=500 bytes=22 request_id=U5UTAL72SXLQQQPQYWOHHEJVUO

# recoverPanics → requestID → logRequests
level=INFO msg=request method=GET path=/orders/ord_9 status=200 bytes=0 request_id=NBJGZZOPM4YXB2FT3PS66BJE7K
level=ERROR msg="handler panic" panic="assignment to entry in nil map"

في الحالتين يحصل العميل على 500. لكن حين يكون التعافي في الطبقة الخارجية، يقول سجل الوصول 200، ولا يحمل سطر panic معرّف طلب يربطه بالطلب. والكود الوحيد الذي لا يحميه التعافي الداخلي هو الـ middleware الخاص بمعرّف الطلب والتسجيل، وكلاهما صغير. كما أن خادم net/http يتعافى من أي panic يفلت من معالجك ويسجّله، فلا شيء يُسقط العملية (توثيق Handler في net/http).

تغليف http.ResponseWriter دون كسر Flush

يحتاج التسجيل إلى رمز الحالة وعدد البايتات، ولا يكشف http.ResponseWriter أيًّا منهما. والحل المعتاد غلاف يسجّلهما:

example.gogo
type statusWriter struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (w *statusWriter) WriteHeader(code int) {
	if w.status == 0 {
		w.status = code
	}
	w.ResponseWriter.WriteHeader(code)
}

func (w *statusWriter) Write(b []byte) (int, error) {
	if w.status == 0 {
		w.status = http.StatusOK
	}
	n, err := w.ResponseWriter.Write(b)
	w.bytes += n
	return n, err
}

// Unwrap lets http.ResponseController reach Flush, Hijack and deadlines.
func (w *statusWriter) Unwrap() http.ResponseWriter {
	return w.ResponseWriter
}

تضمين http.ResponseWriter لا يرفع إلا الدوال التابعة الثلاث لذلك الـ interface، وهي قاعدة يشرحها دليل الكلمة المفتاحية struct في Go في قسم التضمين. والكاتب الحقيقي تحته يحقق أيضًا http.Flusher و http.Hijacker، لكن غلافك لا يحققهما. لذا فإن معالج server-sent events الذي يكتب w.(http.Flusher) يتوقف عن البث بمجرد أن يغلّفه التسجيل.

إضافة Unwrap تحل المشكلة. فمنذ Go 1.20 يستدعيها http.ResponseController ليصل إلى الكاتب الأصلي. وعندها تستدعي المعالجات http.NewResponseController(w).Flush() بدلًا من تأكيد النوع (type assertion). وبدون Unwrap يعيد ذلك الاستدعاء خطأً يطابق http.ErrNotSupported، ويفشل الاختبار الوارد لاحقًا في هذا الدليل.

والدالة التابعة نفسها تُبقي مهل الكتابة الخاصة بكل طلب تعمل. فقد يستغرق تصدير ملف CSV أكثر من WriteTimeout الخاص بالخادم وهو 30 ثانية، وهذا الإعداد بحسب التوثيق لا «يتيح للمعالجات اتخاذ قرارات لكل طلب على حدة» (http.Server). أما ResponseController فيتيح ذلك:

example.gogo
func exportOrders(w http.ResponseWriter, r *http.Request) {
	rc := http.NewResponseController(w)
	if err := rc.SetWriteDeadline(time.Now().Add(5 * time.Minute)); err != nil {
		slog.WarnContext(r.Context(), "cannot extend write deadline", "error", err)
	}
	w.Header().Set("Content-Type", "text/csv")
	// stream rows from the database, flushing every few hundred rows
}

كيف تتعافى من حالات panic في الـ middleware في Go؟

يؤجّل الـ middleware الخاص بالتعافي استدعاء recover، ويسجّل الـ stack ويكتب 500. وتحتاج نسخة الإنتاج إلى تفصيلين إضافيين:

example.gogo
func recoverPanics(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			sw := &statusWriter{ResponseWriter: w}
			defer func() {
				v := recover()
				if v == nil {
					return
				}
				if v == http.ErrAbortHandler {
					panic(v)
				}
				logger.ErrorContext(r.Context(), "handler panic",
					"panic", v, "stack", string(debug.Stack()))
				if sw.status != 0 {
					// The status line is already on the wire. Abort the
					// connection so the client doesn't take a cut-off
					// body for a complete 200.
					panic(http.ErrAbortHandler)
				}
				http.Error(w, "internal server error", http.StatusInternalServerError)
			}()
			next.ServeHTTP(sw, r)
		})
	}
}

التفصيل الأول هو http.ErrAbortHandler. وهو قيمة مميّزة (sentinel) يُطلق بها الكود panic عمدًا لإجهاض الاستجابة، ويستخدمها httputil.ReverseProxy حين يفشل الخادم الخلفي في منتصف النسخ. ويقول التوثيق إن «إطلاق panic بالقيمة ErrAbortHandler يمنع أيضًا تسجيل stack trace في سجل أخطاء الخادم» (net/http). وإذا ابتلعها الـ middleware الخاص بك، يتحول إجهاض روتيني من الـ proxy إلى سجل خطأ مع stack trace، وإلى محاولة كتابة 500 في استجابة أُرسل نصفها بالفعل.

التفصيل الثاني هو الفحص sw.status != 0. فبعد أن يكتب المعالج رمز الحالة، لا يستطيع استدعاء http.Error تغييره. إذ يسجّل net/http الرسالة "superfluous response.WriteHeader call" ويلحق نص الخطأ بأي جسم كان قيد البث. فمصفوفة JSON تتوقف في المنتصف وتنتهي بـ internal server error تصل مع ذلك بالرمز 200. أما إعادة إطلاق panic بالقيمة ErrAbortHandler فتجعل الخادم يغلق الاتصال أو يعيد ضبط تدفق HTTP/2، فيرى العميل طلبًا فاشلًا بدلًا من نجاح تالف.

كيف تمرّر معرّفات الطلبات وقيمًا أخرى عبر الـ context؟

ينقل context.WithValue البيانات الخاصة بالطلب من الـ middleware إلى المعالجات. ويقول التوثيق إن المفتاح «يجب ألا يكون من النوع string أو أي نوع مدمج آخر لتجنّب التصادم بين الحزم التي تستخدم context» (context). ونوع struct فارغ غير مُصدَّر لا يمكن أن يتصادم مع أي شيء، لأنه لا توجد حزمة أخرى تستطيع تسميته:

example.gogo
type requestIDKey struct{}

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := rand.Text()
		w.Header().Set("X-Request-ID", id)
		ctx := context.WithValue(r.Context(), requestIDKey{}, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func RequestIDFrom(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok
}

تعيد crypto/rand.Text، التي أُضيفت في Go 1.24، سلسلة عشوائية بترميز base32 من 26 حرفًا، وهذا أكثر من كافٍ لمعرّف. والنمط نفسه ينقل المستخدم المُصادَق عليه لاحقًا في هذا الدليل. واقصر قيم الـ context على البيانات التي تخص الطلب، مثل المعرّفات والمستدعي. فتوثيق context يحذّر من استخدام القيم «لتمرير معاملات اختيارية إلى الدوال». ومكان مقبض قاعدة البيانات أو عميل feature flags هو حقل في struct المعالج.

التسجيل المنظَّم باستخدام slog

لا يفيد معرّف الطلب إلا إذا حمله كل سطر سجل أثناء الطلب. وبدلًا من تمريره إلى كل استدعاء، غلّف slog.Handler بحيث يقرأ المعرّف من الـ context الذي تستقبله InfoContext وأخواتها:

example.gogo
type requestIDHandler struct {
	slog.Handler
}

func (h requestIDHandler) Handle(ctx context.Context, rec slog.Record) error {
	if id, ok := RequestIDFrom(ctx); ok {
		rec.AddAttrs(slog.String("request_id", id))
	}
	return h.Handler.Handle(ctx, rec)
}

func (h requestIDHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return requestIDHandler{h.Handler.WithAttrs(attrs)}
}

func (h requestIDHandler) WithGroup(name string) slog.Handler {
	return requestIDHandler{h.Handler.WithGroup(name)}
}

ابنِ المسجّل باستخدام slog.New(requestIDHandler{slog.NewJSONHandler(os.Stdout, nil)}). والدالتان With مهمتان. فالتضمين يرفع WithAttrs الخاصة بالمعالج الداخلي، وهي تعيد المعالج الداخلي دون غلافك. وعندها سيُسقط أول استدعاء لـ logger.With("service", "orders") معرّفات الطلبات من كل سطر بعده.

يستخدم الـ middleware الخاص بسجل الوصول statusWriter الذي رأيناه سابقًا، ويسجّل داخل defer، لذا يظل يكتب سطرًا حين يمر عبره panic:

example.gogo
func logRequests(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			sw := &statusWriter{ResponseWriter: w}
			defer func() {
				status := sw.status
				if status == 0 {
					status = http.StatusOK
				}
				logger.InfoContext(r.Context(), "request",
					"method", r.Method,
					"path", r.URL.Path,
					"status", status,
					"bytes", sw.bytes,
					"duration", time.Since(start),
				)
			}()
			next.ServeHTTP(sw, r)
		})
	}
}

المعالج الذي لا يكتب شيئًا يحصل على 200 من net/http، ولهذا يُسجَّل رمز الحالة الصفري على أنه 200.

المهلات الزمنية وحدود حجم الجسم في الـ middleware في Go

مهلات الخادم تحمي الاتصالات، والـ middleware يحمي كل طلب على حدة.

على الخادم، اضبط ReadHeaderTimeout دائمًا. فإذا تُرك صفرًا يرجع إلى ReadTimeout، وإذا كان ذلك صفرًا أيضًا فلا حد على الإطلاق. وعندها يستطيع العميل فتح اتصالات وإرسال الترويسات بايتًا بايتًا ما شاء من الوقت. ويبلّغ gosec عن القيمة المفقودة بالرمز G112، بوصفها هجوم Slowloris محتملًا (قواعد gosec). أما ReadTimeout و WriteTimeout و IdleTimeout فتحدّ بقية عمر الاتصال، كما في الدالة newServer أعلاه.

ولميزانية الطلب نفسه، أرفق مهلة بالـ context الخاص به:

example.gogo
func withDeadline(d time.Duration) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx, cancel := context.WithTimeout(r.Context(), d)
			defer cancel()
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

لا ينجح هذا إلا إذا مرّر المعالج r.Context() إلى قاعدة البيانات وعملاء HTTP وأي شيء آخر قد يتعطل. فالمهلة تلغي العمل، والمعالج يقرر ما يكتبه.

والخيار الآخر في المكتبة القياسية هو http.TimeoutHandler. فهو يكتب 503 حين يتجاوز المعالج وقته، ويجعل عمليات الكتابة اللاحقة للمعالج تفشل بالخطأ http.ErrHandlerTimeout. لكنه أيضًا يخزّن الاستجابة كاملة في ذاكرة مؤقتة، وبحسب توثيقه «لا يدعم الـ interfaces Hijacker و Flusher» (net/http). وتظل goroutine المعالج تعمل حتى تعود، لذا لا يزال على المعالج أن يحترم الـ context الخاص به. استخدم TimeoutHandler لنقاط النهاية الصغيرة التي تعيد JSON، حين يستحق ضمان الـ 503 كلفة التخزين المؤقت. واستخدم مهلة الـ context لأي شيء يعتمد على البث.

تحمي حدود حجم الجسم من عميل يرسل غيغابايت إلى نقطة نهاية تتوقع كائن JSON صغيرًا:

example.gogo
func limitBody(n int64) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			r.Body = http.MaxBytesReader(w, r.Body, n)
			next.ServeHTTP(w, r)
		})
	}
}

لا يرفض MaxBytesReader أي شيء مسبقًا. فالقراءة التي تتجاوز الحد تعيد *http.MaxBytesError (منذ Go 1.19)، ويحوّلها المعالج إلى 413:

example.gogo
func createPayment(w http.ResponseWriter, r *http.Request) {
	var req CreatePayment
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		if tooBig, ok := errors.AsType[*http.MaxBytesError](err); ok {
			msg := fmt.Sprintf("body larger than %d bytes", tooBig.Limit)
			http.Error(w, msg, http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	p, _ := PrincipalFrom(r.Context())
	slog.InfoContext(r.Context(), "payment accepted", "user_id", p.UserID, "order_id", req.OrderID)
	w.WriteHeader(http.StatusAccepted)
}

وصلت errors.AsType في Go 1.26. وفي الإصدارات الأقدم صرّح عن var tooBig *http.MaxBytesError واستدعِ errors.As(err, &tooBig). ويشرح مقال أخطاء Go الشائعة التي يجب تجنّبها لماذا تجب مطابقة الأخطاء بنوعها لا برسالتها.

كيف يجب أن يعمل الـ middleware الخاص بالمصادقة؟

يتحقق الـ middleware الخاص بالمصادقة من المستدعي، ويخزّن هويته في الـ context ثم يمرّر الطلب. ولا يثق أبدًا بهوية قدّمها العميل مباشرةً:

example.gogo
type Principal struct {
	UserID string
	Scopes []string
}

type TokenVerifier interface {
	Verify(ctx context.Context, token string) (Principal, error)
}

type principalKey struct{}

func PrincipalFrom(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey{}).(Principal)
	return p, ok
}

func requireAuth(verifier TokenVerifier) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
			if !ok || token == "" {
				w.Header().Set("WWW-Authenticate", "Bearer")
				http.Error(w, "missing bearer token", http.StatusUnauthorized)
				return
			}
			p, err := verifier.Verify(r.Context(), token)
			if err != nil {
				w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
				http.Error(w, "invalid token", http.StatusUnauthorized)
				return
			}
			ctx := context.WithValue(r.Context(), principalKey{}, p)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func requireScope(scope string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			p, ok := PrincipalFrom(r.Context())
			if !ok {
				// A wiring bug, not a client error: requireScope ran without requireAuth.
				http.Error(w, "internal server error", http.StatusInternalServerError)
				return
			}
			if !slices.Contains(p.Scopes, scope) {
				http.Error(w, "missing scope "+scope, http.StatusForbidden)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

تظهر في هذا الكود ثلاث قواعد. الرمز 401 يعني «لا أعرف من أنت»، ويحمل ترويسة WWW-Authenticate. والرمز 403 يعني «أعرف من أنت والجواب لا». وكل رفض ينتهي بـ return، لأن الـ middleware الذي يكتب خطأً ثم يستدعي next رغم ذلك يشغّل معالج الدفع لمستدعٍ غير مُصادَق عليه.

TokenVerifier interface صغير معرَّف حيث يُستخدم، فيستطيع الاختبار تمرير بديل وهمي (fake) مبني على map، بينما تمرّر بيئة الإنتاج تنفيذًا يعتمد على JWT أو على مخزن للجلسات. أما ترويسات مثل X-User-ID القادمة من بوابة API فلا تكون آمنة إلا إذا كانت البوابة هي الطريق الوحيد إلى خدمتك. فإذا أمكن الوصول إلى الخدمة مباشرةً، يستطيع أي أحد إرسال تلك الترويسة.

كيف تحدّ معدّل الطلبات في الـ middleware في Go؟

توفّر golang.org/x/time/rate دلوًا للرموز (token bucket). فالاستدعاء rate.NewLimiter(10, 20) يسمح بعشرة أحداث في الثانية مع دفعات تصل إلى 20، وتبلّغ Allow عمّا إذا كان الحدث الحالي ضمن الحد (x/time/rate). ويحتفظ المحدِّد الخاص بكل عميل بدلو واحد لكل مفتاح وينسى العملاء الخاملين:

example.gogo
type client struct {
	limiter  *rate.Limiter
	lastSeen time.Time
}

type RateLimiter struct {
	mu      sync.Mutex
	clients map[string]*client
	limit   rate.Limit
	burst   int
}

func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter {
	return &RateLimiter{clients: make(map[string]*client), limit: limit, burst: burst}
}

func (rl *RateLimiter) allow(key string) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()
	c, ok := rl.clients[key]
	if !ok {
		c = &client{limiter: rate.NewLimiter(rl.limit, rl.burst)}
		rl.clients[key] = c
	}
	c.lastSeen = time.Now()
	return c.limiter.Allow()
}

// Cleanup drops clients idle for longer than maxIdle until ctx is done.
func (rl *RateLimiter) Cleanup(ctx context.Context, maxIdle time.Duration) {
	ticker := time.NewTicker(time.Minute)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			rl.mu.Lock()
			for key, c := range rl.clients {
				if time.Since(c.lastSeen) > maxIdle {
					delete(rl.clients, key)
				}
			}
			rl.mu.Unlock()
		}
	}
}

func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			ip = r.RemoteAddr
		}
		if !rl.allow(ip) {
			w.Header().Set("Retry-After", "1")
			http.Error(w, "too many requests", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

يجب أن تنشئ NewRateLimiter الـ map، وإلا فإن أول استدعاء لـ allow سيسبب panic على map قيمتها nil. وبدون Cleanup تضيف الـ map مدخلًا لكل عنوان اتصل بالخادم ولو مرة، ولا تصغر أبدًا. شغّلها مرة واحدة باستخدام go limiter.Cleanup(ctx, 10*time.Minute) وألغِ ctx عند الإيقاف.

اختيار المفتاح أصعب. فخلف موازن الأحمال (load balancer) يكون r.RemoteAddr عنوان الموازن، فيتشارك كل العملاء دلوًا واحدًا. والحل البديهي هو أول عنوان في X-Forwarded-For، لكن العميل هو من يتحكم في هذا العنوان. وتنص MDN على أنه إذا أمكن الوصول إلى الخادم مباشرةً من الإنترنت، «فلا يمكن اعتبار أي جزء من قائمة عناوين IP في X-Forwarded-For جديرًا بالثقة أو آمنًا للاستخدامات المتعلقة بالأمان» (MDN). خذ العنوان الذي ألحقه الـ proxy الخاص بك، عادًّا من اليمين بعدد الـ proxies الموثوقة التي أمامك. وتأكد من أنه لا يمكن الوصول إلى الخدمة بالالتفاف حولها.

ويعمل هذا المحدِّد أيضًا داخل عملية واحدة. فمع أربع نسخ (replicas) يحصل العميل على أربعة أضعاف الحد. وإذا أردت حدًّا عامًا صارمًا، فطبّقه على البوابة أو في مخزن مشترك مثل Redis.

كيف تحمي معالجات Go من هجمات CSRF؟

أضافت Go 1.25 http.CrossOriginProtection. وهو يرفض طلبات المتصفح غير الآمنة القادمة من أصل آخر بفحص الترويسة Sec-Fetch-Site، أو بمقارنة المضيف في الترويسة Origin مع Host حين تغيب تلك الترويسة. وتمر GET و HEAD و OPTIONS دائمًا. ولا يحتاج إلى رموز (tokens)، لذا يعمل دون تغيير القوالب. وهو مهم لأي مسار يصادق باستخدام ملفات تعريف الارتباط (cookies). فالمتصفحات لا ترفق رمز bearer من تلقاء نفسها أبدًا، لذا فإن الواجهة البرمجية التي تعتمد على الرموز وحدها ليست معرّضة لـ CSRF.

تعرض الدالة newServer أعلاه الإعداد كاملًا: http.NewCrossOriginProtection()، و AddTrustedOrigin لواجهة أمامية منفصلة للإدارة، و csrf.Handler في السلسلة العامة. وتُعامَل الطلبات التي لا تحمل أيًّا من الترويستين على أنها من الأصل نفسه أو من خارج المتصفح ويُسمح بها، وهذا ما تريده للاستدعاءات بين الخوادم.

وهو ليس CORS. فترويسات CORS تحدد الأصول الأخرى التي يحق لها قراءة استجاباتك، و CrossOriginProtection لا يضبط أيًّا منها. وإذا كان تطبيق صفحة واحدة (SPA) على نطاق آخر يستدعي واجهتك البرمجية، فستظل بحاجة إلى middleware صغير لـ CORS مع قائمة سماح صريحة بالأصول. ومكانه قرب أعلى السلسلة، لأن المتصفحات ترسل طلبات OPTIONS التمهيدية (preflight) دون بيانات اعتماد. وأضف الأصل نفسه باستخدام AddTrustedOrigin أيضًا. فالواجهة الأمامية على app.example.com التي تستدعي api.example.com ترسل Sec-Fetch-Site: same-site، ويرفض CrossOriginProtection عمليات الكتابة الصادرة منها حتى حين يسمح بها CORS.

اختبار الـ middleware في Go باستخدام httptest

اختبر كل middleware وحده باستخدام httptest، مع معالج صغير جدًا بوصفه next يسجّل هل عمل أم لا. و staticTokens بديل وهمي لـ TokenVerifier مبني على map:

example.gogo
func TestRequireAuthRejectsMissingToken(t *testing.T) {
	called := false
	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
	})
	h := requireAuth(staticTokens{})(next)

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/payments", nil))

	res := rec.Result()
	if res.StatusCode != http.StatusUnauthorized {
		t.Fatalf("status = %d, want 401", res.StatusCode)
	}
	if got := res.Header.Get("WWW-Authenticate"); got != "Bearer" {
		t.Errorf("WWW-Authenticate = %q, want Bearer", got)
	}
	if called {
		t.Error("next handler ran for an unauthenticated request")
	}
}

أجرِ التحقق على rec.Result()، لا على rec.Header(). فالدالة Header() تعيد الـ map الحية التي تعدّلها المعالجات، ويوجّهك التوثيق إلى Result «لاختبار الترويسات التي كُتبت بعد اكتمال المعالج» (httptest). و Result().Header لقطة تُؤخذ عند أول كتابة، وهي تطابق ما يستقبله العميل الحقيقي. وفي هذا الـ middleware خطأ لا يكشفه إلا Result:

example.gogo
// serverTiming sets a header after the handler has written, which is a bug.
func serverTiming(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		w.Header().Set("Server-Timing", fmt.Sprintf("app;dur=%d", time.Since(start).Milliseconds()))
	})
}

يعيد rec.Header().Get("Server-Timing") قيمة، فينجح أي اختبار يعتمد عليه. أما rec.Result().Header.Get("Server-Timing") فيعيد سلسلة فارغة، لأن المعالج كان قد كتب الاستجابة بالفعل، ولن يرى العميل الحقيقي الترويسة أبدًا.

وتغطي ثلاثة اختبارات أخرى الإصلاحات في هذه الطبقات. لاختبار الدالة Unwrap، شغّل معالجًا يستدعي http.NewResponseController(w).Flush() عبر logRequests وافحص rec.Flushed. ولحالتي التعافي كلتيهما، غلّف معالجًا يطلق panic بالقيمة http.ErrAbortHandler، ومعالجًا آخر يكتب جزءًا من الجسم قبل أن يطلق panic، ثم استدعِ recover في الاختبار وتحقق من أن القيمة هي http.ErrAbortHandler. ولغلاف slog، استدعِ .With(...) على المسجّل وتأكد من أن معرّف الطلب لا يزال يظهر. وحين تكتب هذه الاختبارات، احذف السطر الذي يحميه كل اختبار وتحقق من أن الاختبار يفشل. فإذا ظل ينجح، فهو لا يختبر ذلك الإصلاح.

أخطاء شائعة في الـ middleware في Go

الخطأما الذي يحدثالإصلاح
التعافي خارج التسجيلتُسجَّل حالات panic بالرمز 200 دون معرّف طلبالترتيب: معرّف الطلب، ثم التسجيل، ثم التعافي
غلاف دون Unwrapتفشل Flush و SetWriteDeadline بالخطأ ErrNotSupportedأضف Unwrap() http.ResponseWriter
ابتلاع http.ErrAbortHandlerتتحول حالات إجهاض الـ proxy إلى stack traces واستجابات 500 مكسورةأعد إطلاق panic به
كتابة 500 بعد إرسال رمز الحالةيصل جسم مقطوع بالرمز 200أجهض الاستجابة بـ http.ErrAbortHandler
مفاتيح context من النوع stringتصادم بين الحزم، staticcheck SA1029مفتاح struct غير مُصدَّر ودالة وصول محددة النوع
ضبط الترويسات بعد next.ServeHTTPتُسقَط الترويسة بصمتاضبط الترويسات قبل استدعاء next
غياب return بعد استجابة الخطأيظل المعالج المحمي يعملعُد مباشرةً بعد كتابة الخطأ
غياب ReadHeaderTimeoutيُبقي العملاء البطيئون الاتصالات مفتوحة، gosec G112اضبطه على كل http.Server
القيمة الأولى من اليسار في X-Forwarded-For مفتاحًا لتحديد المعدّليختار العملاء دلوهم بأنفسهماستخدم العنوان الذي ألحقه الـ proxy الخاص بك
map لمحدِّد المعدّل دون تنظيفتنمو الذاكرة مع كل عنوان عميلاحذف المدخلات الخاملة بواسطة ticker

المبدأ وراء الترتيب

قاعدة الترتيب تنطبق على أكثر من هذه الطبقات. فالطبقة لا ترصد إلا ما يحدث داخلها، لذا ضع الطبقات التي تسجّل النتائج خارج الطبقات التي تغيّرها. في خادم HTTP، مكان سجلات الوصول والمقاييس (metrics) ونطاقات التتبّع (trace spans) خارج التعافي والمصادقة والحدود، حتى تسجّل ما حصل عليه العميل فعلًا. وفي gRPC، مكان الـ interceptor الخاص بالتسجيل خارج الـ interceptor الذي يحوّل حالات panic إلى رموز حالة. والغلاف الذي يجمع المقاييس حول معاملة قاعدة بيانات يجب أن يغلّف حلقة إعادة المحاولة، وإلا فسيعدّ استدعاءً واحدًا بينما رأت قاعدة البيانات ثلاثة.

أين يأتي دور LevelUpGo

يعلّم LevelUpGo هذه الأنماط عبر تمارين تشغّل كود Go حقيقيًا في المتصفح. في دورة HTTP & Networking تكتب middleware للتسجيل والربط في سلاسل والمصادقة باستخدام net/http. وتغطي دورة Real-World Patterns نمط الـ middleware والتعافي من panic والسلاسل. وتتعمق دورة Logging with slog أكثر في التسجيل في بيئة الإنتاج الذي يقف خلف الـ middleware الخاص بسجل الوصول.

الأسئلة الشائعة

ما الـ middleware في Go؟

الـ middleware في Go دالة توقيعها func(http.Handler) http.Handler. وهي تغلّف معالجًا لتشغّل كودًا قبله أو بعده، من أجل التسجيل أو المصادقة أو التعافي أو الحدود. ولأنها تأخذ الـ interface القياسي http.Handler وتعيده، يتركّب الـ middleware من أي حزمة مع أي موجّه يقبل http.Handler، بما في ذلك http.ServeMux.

هل أحتاج إلى إطار عمل أو موجّه مثل chi لكتابة الـ middleware في Go؟

لا. فمنذ Go 1.22 يدعم http.ServeMux مطابقة طرق HTTP والمسارات ذات المتغيرات مثل /orders/{id}، والـ middleware مجرد دالة تغلّف معالجًا. ودالة Chain مساعدة من عشرة أسطر تكفي للطبقات العامة والطبقات الخاصة بكل مسار. وتضيف موجّهات مثل chi وسائل راحة مثل مجموعات المسارات، والـ middleware فيها يستخدم التوقيع نفسه، لذا يمكنك الانتقال لاحقًا دون إعادة كتابته.

بأي ترتيب يجب أن يعمل الـ middleware في Go؟

شغّل معرّف الطلب أولًا، ثم سجل الوصول، ثم التعافي من panic، ثم عمليات الرفض الرخيصة مثل فحوص الطلبات عبر الأصول وتحديد المعدّل وحدود حجم الجسم. وضع المصادقة والمهلات الخاصة بكل مسار في النهاية. ويجب أن يكون التسجيل خارج التعافي، وإلا فستُسجَّل حالات panic برمز الحالة 200 ودون معرّف طلب.

كيف أمرّر قيمًا من الـ middleware إلى المعالجات في Go؟

استخدم context.WithValue مع نوع مفتاح غير مُصدَّر مثل type principalKey struct{}، وأعطِ المعالجات دالة وصول محددة النوع مثل PrincipalFrom(ctx) (Principal, bool). ولا تستخدم string مفتاحًا أبدًا، لأن حزمتين تختاران السلسلة نفسها ستكتب كل منهما فوق قيم الأخرى.

كيف تختبر الـ middleware الخاص بـ HTTP في Go؟

غلّف معالجًا بديلًا (stub) بالـ middleware، واستدعِ ServeHTTP مع httptest.NewRecorder() و httptest.NewRequest، ثم أجرِ التحقق على rec.Result(). افحص رمز الحالة، والترويسات التي سيستقبلها العميل، وهل عمل المعالج البديل أم لا. وللـ middleware الخاص بالتعافي، استدعِ recover في الاختبار وقارن القيمة بـ http.ErrAbortHandler.

المصادر

اكتب Go كما يكتبها مهندس أول

دروس تفاعلية في متصفحك. الدروس الأولى مجانية.

جرّب درسًا مجانيًاأو أنشئ حسابًا مجانيًا