Mọi service gRPC đều cần những việc chung cho mọi RPC: ghi log ai gọi gì, đo thời gian mỗi call để tìm endpoint chậm, kiểm tra xác thực, đếm metric, bắt panic. Cách ngây thơ là viết những việc đó trong từng handler — nhưng với hàng chục RPC, bạn lặp cùng một đoạn code hàng chục lần, và quên một chỗ là mất log/mất auth ở đó. Lời giải là interceptor: cơ chế middleware của gRPC, một lớp bọc quanh mọi RPC để làm việc chung một lần mà áp cho tất cả. Nó tương đương middleware của HTTP framework, nhưng cho gRPC. Bài này (phần 6 loạt gRPC nâng cao) chạy thật một chuỗi interceptor làm logging và auth.

Interceptor là gì: hàm bọc quanh handler

Một unary server interceptor là một hàm nhận handler thật và bọc quanh nó — nó chạy code trước, gọi handler, rồi chạy code sau:

// interceptor nhận handler thật, bọc quanh nó
func logInterceptor(ctx, req, info, handler) (any, error) {
    t := time.Now()
    resp, err := handler(ctx, req)            // gọi handler THẬT
    log(info.FullMethod, time.Since(t), status.Code(err))
    return resp, err
}

func authInterceptor(ctx, req, info, handler) (any, error) {
    if info.FullMethod == ".../Secure" {
        md,_ := metadata.FromIncomingContext(ctx)
        if md.Get("token") != valid {
            return nil, status.Error(codes.Unauthenticated, ...)  // chặn, handler KHÔNG chạy
        }
    }
    return handler(ctx, req)
}

// CHAIN nhiều interceptor khi tạo server:
grpc.NewServer(grpc.ChainUnaryInterceptor(log, auth))

Điểm then chốt: interceptor có info.FullMethod nên biết đang bọc RPC nào, và có thể không gọi handler (như auth trả lỗi sớm → handler không bao giờ chạy). Nhiều interceptor ghép bằng ChainUnaryInterceptor, chạy theo thứ tự khai báo.

Ảnh chụp đoạn mã Go nền tối interceptor middleware bọc mọi RPC, logInterceptor nhận handler thật bọc quanh t Now resp err handler ctx req gọi handler thật log FullMethod Since t status Code err return, authInterceptor if FullMethod Secure md FromIncomingContext token không hợp lệ return Unauthenticated chặn handler không chạy else return handler, chain nhiều interceptor grpc NewServer ChainUnaryInterceptor log auth, một chỗ duy nhất cho logging metrics auth retry toàn hệ

Hình 1: Interceptor là hàm nhận handler thật và bọc quanh — chạy code trước/sau, và có thể không gọi handler (auth trả lỗi sớm). info.FullMethod cho biết RPC nào. Ghép nhiều interceptor bằng ChainUnaryInterceptor(log, auth) — một chỗ duy nhất cho logging/metrics/auth/retry toàn hệ.

Đo thật: log mỗi call và auth chặn

Mình dựng server với chain hai interceptor (log + auth) trên go-lab, gọi vài RPC và xem log do interceptor tự sinh:

Ảnh chụp output thật nền tối log từ interceptor auth chặn chain log auth server gRPC Go, interceptor wrap mọi call không sửa 1 dòng handler nào method thời gian status, LOG Fast 1 µs OK, LOG Slow 45.99 ms OK handler sleep 40ms, gọi Secure không token auth interceptor chặn LOG Secure 15 µs Unauthenticated client nhận lỗi Unauthenticated handler không chạy, gọi Secure có token đúng LOG Secure 1 µs OK client nhận msg secret, log cộng đo thời gian cộng auth viết một lần ở interceptor áp cho mọi RPC handler chỉ lo nghiệp vụ không lặp code

Hình 2: Kết quả thật. Interceptor log tự sinh cho mọi call mà không sửa handler: Fast 1µs OK, Slow 45.99ms OK (handler sleep 40ms). Gọi /Secure không token → auth interceptor chặn, log ghi Unauthenticated, client nhận lỗi, handler không chạy. Gọi /Secure có token đúng → OK, client nhận msg="secret".

Đọc kết quả:

  • Log tự động cho mọi call: Fast mất 1µs, Slow mất 45.99ms (handler sleep 40ms + overhead). Quan trọng: không một dòng code log nào nằm trong handler Fast/Slow/Secure — toàn bộ do interceptor. Thêm RPC mới, nó tự động được log và đo. Đây là nơi lý tưởng để đặt metric (như http_request_duration_seconds của loạt Observability) cho gRPC.
  • Auth chặn trước khi handler chạy: gọi /Secure không token → auth interceptor thấy thiếu token, trả Unauthenticated ngay, handler Secure không bao giờ chạy. Log vẫn ghi call đó (15µs, Unauthenticated) vì log interceptor bọc ngoài. Client nhận đúng mã lỗi Unauthenticated.
  • Qua được khi token đúng: gọi lại với token=secret123 trong metadata → auth cho qua, handler chạy, trả msg="secret". Cùng một RPC, hai kết quả, khác biệt hoàn toàn do interceptor — handler không biết gì về auth.

Thông điệp cốt lõi: interceptor tách mối quan tâm xuyên suốt (cross-cutting concern) khỏi logic nghiệp vụ. Handler chỉ lo việc của nó (trả order, tính tiền); log, đo, auth, retry sống ở interceptor — viết một lần, áp cho tất cả, không lặp và không sót.

Thứ tự chain quan trọng

ChainUnaryInterceptor(log, auth) chạy log ngoài cùng, rồi auth, rồi handler — như các lớp vỏ hành. Thứ tự này có ý nghĩa: ở đây log bọc ngoài auth nên mọi call đều được log kể cả call bị auth chặn (ta thấy log "Secure 15µs Unauthenticated"). Nếu đảo lại (auth, log), call bị auth chặn sẽ không được log (vì auth trả lỗi trước khi tới log). Tuỳ mục đích: muốn log mọi call (kể cả bị từ chối) thì đặt log ngoài cùng; muốn chỉ log call hợp lệ thì đặt log trong. Thứ tự interceptor là một quyết định thiết kế, không tuỳ tiện.

Đánh đổi cần cân nhắc

Interceptor chạy cho MỌI call — một interceptor chậm làm chậm tất cả. Vì interceptor nằm trên đường đi của mọi RPC, bất kỳ việc nặng nào trong đó (gọi DB để check quyền mỗi call, ghi log đồng bộ ra đĩa) sẽ cộng độ trễ vào từng RPC. Đo thật cho thấy log interceptor chỉ tốn ~1µs — rẻ. Nhưng một auth interceptor query database mỗi call có thể thêm hàng chục ms cho mọi endpoint. Giữ interceptor nhẹ: cache kết quả auth, log bất đồng bộ, tránh I/O chặn trong đường nóng.

Streaming cần interceptor riêng. Demo dùng UnaryServerInterceptor — chỉ áp cho unary RPC. Streaming RPC (bài 3-5) cần StreamServerInterceptor, có chữ ký khác (bọc ServerStream thay vì req/resp đơn). Nếu service có cả unary lẫn streaming, phải đăng ký cả hai loại interceptor, và logic (như auth) thường phải viết hai lần hoặc dùng helper chung. Đây là điểm dễ sót: thêm auth cho unary mà quên streaming là lỗ hổng bảo mật.

Đừng nhồi quá nhiều vào interceptor. Interceptor tiện nên dễ bị lạm dụng — nhét cả logic nghiệp vụ, biến đổi dữ liệu, điều hướng phức tạp vào đó. Nhưng interceptor vô hình với người đọc handler: ai đọc handler Secure không thấy auth ở đâu cả, dễ nhầm. Giữ interceptor cho đúng cross-cutting concern thật (log, metric, auth, retry, recover panic); logic riêng của một RPC nên ở trong handler đó để rõ ràng.

Ba ý mang về

  1. Interceptor bọc mọi RPC, tách cross-cutting concern khỏi handler: đo thật log + đo thời gian tự sinh cho Fast (1µs) và Slow (46ms) mà không sửa handler nào — thêm RPC mới tự động được log/đo.
  2. Interceptor có thể chặn trước khi handler chạy: đo thật auth interceptor chặn /Secure thiếu token (trả Unauthenticated, handler không chạy) và cho qua khi token đúng — handler không cần biết gì về auth.
  3. Chain có thứ tự, giữ interceptor nhẹ và đúng vai: thứ tự chain quyết định call bị chặn có được log không; interceptor chạy mọi call nên phải nhẹ (tránh I/O chặn); streaming cần interceptor riêng; và chỉ đặt cross-cutting concern, không nhồi logic nghiệp vụ.

Nguồn

Phần sau ta xử lý deadline và cancellation: cách gRPC lan truyền thời hạn qua context, và demo thật một call vượt deadline bị huỷ ngay thay vì treo vô hạn.