個人網站:linzyblog.netlify.app/
示例程式碼已經上傳到github:點選跳轉
gRPC官方文件:點選跳轉
1、什麼是攔截器?
在常規的 HTTP 伺服器中,我們可以設定有一箇中介軟體將我們的處理程式包裝在伺服器上。此中介軟體可用於在實際提供正確內容之前執行伺服器想要執行的任何操作,它可以是身份驗證或日誌記錄或任何東西。
中介軟體:
中介軟體供系統軟體和應用軟體之間連線、便於軟體各部件之間的溝通的計算機軟體,相當於不同技術、工具和資料庫之間的橋樑,例如他可以記錄響應時長、記錄請求和響應資料日誌,身份驗證等。
中介軟體可以在攔截到傳送給 handler 的請求,且可以攔截 handler 返回給客戶端的響應
gRPC 不同,它允許在伺服器和客戶端都使用攔截器。
- 伺服器端攔截器是 gRPC 伺服器在到達實際 RPC 方法之前呼叫的函式。它可以用於多種用途,例如日誌記錄、跟蹤、速率限制、身份驗證和授權。
- 同樣,客戶端攔截器是 gRPC 客戶端在呼叫實際 RPC 之前呼叫的函式。
2、gRPC 攔截器核心概念
- 一元是我們大多數人使用的。就是傳送一個請求並獲得一個響應。
- 流是當您傳送或接收 protobuf 訊息的資料管道時。這意味著如果一個 gRPC 服務響應一個流,消費者可以在這個流中期望多個響應。
具體可以看我講《gRPC流》 其中提到了流和一元的詳細概念
攔截器正如他名字的含義,它在 API 請求被執行之前攔截它們。這可用於記錄、驗證或在處理 API 請求之前發生的任何事情
,攔截器還可以做統一介面的認證工作,不需要每一個介面都做一次認證了,多個介面多次訪問,只需要在統一個地方認證即可。使用 HTTP API,這在 Golang 中很容易,你可以使用中介軟體包裝 HTTP 處理程式。
gRPC有兩種資料通訊方式,那必然有兩種攔截器:
UnaryInterceptors
— 用於 API 呼叫,即一個客戶端請求和一個伺服器響應。StreamInterceptors
—用於 API 呼叫,其中客戶端傳送請求但接收回資料流,允許伺服器隨時間響應多個專案。實際上,由於 gRPC 是雙向的,因此客戶端也可以使用它來傳送資料。
3、服務端攔截器和客戶端攔截器
gRPC允許在客戶端和伺服器以及一元和流式呼叫中使用攔截器,上面提到過gRPC允許在伺服器和客戶端都使用攔截器
,那麼我們就有 4 種不同的攔截器。
如果我們去go-grpc庫看看他們是如何處理這個的,我們可以看到四個不同的用例。兩種攔截器型別都可用於伺服器和客戶端。
- UnaryClientInterceptor — 在客戶端攔截所有一元 gRPC 呼叫。
- UnaryServerInterceptor — 在伺服器端攔截一元 gRPC 呼叫。
- StreamClientInterceptor — 攔截器在建立客戶端流時觸發。
- StreamServerInterceptor — 攔截器在伺服器上執行 Stream 之前觸發。
關於gRPC攔截器型別的定義:
type UnaryClientInterceptor func(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error
type UnaryServerInterceptor func(ctx context.Context, req interface{}, info *UnaryServerInfo, handler UnaryHandler) (resp interface{}, err error)
type StreamClientInterceptor func(ctx context.Context, desc *StreamDesc, cc *ClientConn, method string, streamer Streamer, opts ...CallOption) (ClientStream, error)
type StreamServerInterceptor func(srv interface{}, ss ServerStream, info *StreamServerInfo, handler StreamHandler) error
4、Metadata 後設資料
gRPC 允許傳送自定義後設資料。後設資料是鍵值的一個非常簡單的概念。
如果我們檢視golang 後設資料規範,我們可以看到它是一個map[string][]string。
後設資料可以作為header或trailer傳送
- header應該在資料之前傳送。
- trailer應在處理完畢後傳送。
後設資料允許我們在不更改 protobuf 訊息的情況下向請求中新增資料。
這通常用於新增與請求相關但不屬於請求的資料。
例如,我們可以在請求的後設資料中新增 JWT 令牌作為身份驗證。這允許我們在不改變實際伺服器邏輯的情況下使用邏輯擴充套件 API 端點。這對於身份驗證、速率限制或日誌記錄很有用。
理論夠了!我相信我們已經準備好開始測試它了。
1、目錄結構
go-grpc-example
├─client
│ ├─hello_client
│ │ └──client.go
│ ├─stream_client
│ │ └──client.go
├─pkg
│ ├─Interceptor
│ │ └──Interceptor.go
├─proto
│ ├─hello
│ ├─stream
└─server
├─hello_server
│ └──server.go
├─stream_server
│ └──server.go
偷個懶,這裡我們就拿之前的一元和流的示例上使用攔截器
示例我們做一個簡單的 interceptor 示例,顯示攔截器呼叫RPC方法前的時間、當前執行程式的作業系統、RPC方法結束後的時間,以及呼叫RPC的方法名。
建立pkg/Interceptor
目錄,在Interceptor.go
檔案裡我們寫攔截器的方法。
2、一元攔截器
1)UnaryClientInterceptor
作用:這是我們可以使用客戶端後設資料豐富訊息的地方,例如有關客戶端執行的硬體或作業系統的一些資訊,或者可能啟動我們的跟蹤流程。
客戶端一元攔截器型別為 grpc.UnaryClientInterceptor
,具體如下
func UnaryClientInterceptor() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
// 預處理(pre-processing)
start := time.Now()
// 獲取正在執行程式的作業系統
cos := runtime.GOOS
// 將作業系統資訊附加到傳出請求
ctx = metadata.AppendToOutgoingContext(ctx, "client-os", cos)
// 可以看做是當前 RPC 方法,一般在攔截器中呼叫 invoker 能達到呼叫 RPC 方法的效果,當然底層也是 gRPC 在處理。
// 呼叫RPC方法(invoking RPC method)
err := invoker(ctx, method, req, reply, cc, opts...)
// 後處理(post-processing)
end := time.Now()
log.Printf("RPC: %s,,client-OS: '%v' req:%v start time: %s, end time: %s, err: %v", method, cos, req, start.Format(time.RFC3339), end.Format(time.RFC3339), err)
return err
}
}
invoker(ctx, method, req, reply, cc, opts...)
是真正呼叫 RPC 方法。因此我們可以在呼叫前後增加自己的邏輯:比如呼叫前檢查一下引數之類的,呼叫後記錄一下本次請求處理耗時等。
所謂的攔截器其實就是一個函式,可以分為預處理(pre-processing)、呼叫RPC方法(invoking RPC method)、後處理(post-processing)
三個階段。
- ctx:Go語言中的上下文,一般和 Goroutine 配合使用,起到超時控制的效果
- method:當前呼叫的 RPC 方法名
- req:本次請求的引數,只有在處理前階段修改才有效
- reply:本次請求響應,需要在處理後階段才能獲取到
- cc:gRPC 連線資訊
- invoker:可以看做是當前 RPC 方法,一般在攔截器中呼叫 invoker 能達到呼叫 RPC 方法的效果,當然底層也是 gRPC 在處理。
- opts:本次呼叫指定的 options 資訊
作為一個客戶端攔截器,可以在處理前檢查 req 看看本次請求帶沒帶 token 之類的鑑權資料,沒有的話就可以在攔截器中加上。
hello_client
建立連線時透過 grpc.WithUnaryInterceptor 指定要載入的攔截器:
//新增一元攔截器
conn, err := grpc.Dial(":"+PORT, grpc.WithInsecure(),
grpc.WithUnaryInterceptor(Interceptor.UnaryClientInterceptor()))
2)UnaryServerInterceptor
作用:我們可能想要對請求的真實性進行一些檢查,例如對其進行授權,或者檢查某些欄位是否存在/驗證請求。
客戶端攔截器與服務端攔截器類似:
func UnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (resp interface{}, err error) {
// 預處理(pre-processing)
start := time.Now()
// 從傳入上下文獲取後設資料
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, fmt.Errorf("couldn't parse incoming context metadata")
}
// 檢索客戶端作業系統,如果它不存在,則此值為空
os := md.Get("client-os")
// 獲取客戶端IP地址
ip, err := getClientIP(ctx)
if err != nil {
return nil, err
}
// RPC 方法真正執行的邏輯
// 呼叫RPC方法(invoking RPC method)
m, err := handler(ctx, req)
end := time.Now()
// 記錄請求引數 耗時 錯誤資訊等資料
// 後處理(post-processing)
log.Printf("RPC: %s,client-OS: '%v' and IP: '%v' req:%v start time: %s, end time: %s, err: %v", info.FullMethod, os, ip, req, start.Format(time.RFC3339), end.Format(time.RFC3339), err)
return m, err
}
}
// GetClientIP檢查上下文以檢索客戶機的ip地址
func getClientIP(ctx context.Context) (string, error) {
p, ok := peer.FromContext(ctx)
if !ok {
return "", fmt.Errorf("couldn't parse client IP address")
}
return p.Addr.String(), nil
}
handler(ctx, req)
是真正執行 RPC 方法,與invoker的呼叫不一樣,不要搞混了。因此我們可以在真正執行前後檢查資料:比如檢視客戶端作業系統和客戶端IP地址、記錄請求引數,耗時,錯誤資訊等資料。
引數具體含義如下:
- ctx:請求上下文
- req:RPC 方法的請求引數
- info:RPC 方法的所有資訊
- handler:RPC 方法真正執行的邏輯
hello_server
服務端則是在 NewServer 時指定攔截器:
//新增一元攔截器
server := grpc.NewServer(grpc.UnaryInterceptor(Interceptor.UnaryServerInterceptor()))
3)啟動 & 請求
# 啟動服務端
$ go run server.go
API server listening at: 127.0.0.1:51081
2022/11/07 18:37:28 RPC: /hello.UserService/SayHi,client-OS: '[windows]' and IP: '127.0.0.1:51104' req:name:"lin鍾一" start time: 2022-11-07T18:37:28+08:00
, end time: 2022-11-07T18:37:28+08:00, err: <nil>
# 啟動客戶端
$ go run client.go
API server listening at: 127.0.0.1:51102
2022/11/07 18:37:28 RPC: /hello.UserService/SayHi,,client-OS: 'windows' req:name:"lin鍾一" start time: 2022-11-07T18:37:28+08:00, end time: 2022-11-07T18:3
7:28+08:00, err: <nil>
resp: hi lin鍾一---2022-11-07 18:37:28
3、流式攔截器
流攔截器過程和一元攔截器有所不同,同樣可以分為3個階段:
- 1)預處理(pre-processing)
- 2)呼叫RPC方法(invoking RPC method)
- 3)後處理(post-processing)
預處理階段和一元攔截器類似,但是呼叫RPC方法和後處理這兩個階段則完全不同。
StreamAPI 的請求和響應都是透過 Stream 進行傳遞的,更進一步是透過 Streamer 呼叫 SendMsg 和 RecvMsg 這兩個方法獲取的
。
然後 Streamer 又是呼叫RPC方法來獲取的,所以在流攔截器中我們可以對 Streamer 進行包裝
,然後實現 SendMsg
和 RecvMsg
這兩個方法。
1)StreamClientInterceptor
作用:例如,如果我們將 100 個物件的列表傳輸到伺服器,例如檔案或影片的塊,我們可以在傳送每個塊之前攔截,並驗證校驗和等內容是否有效,將後設資料新增到幀等。
本例中透過結構體嵌入的方式,對 Streamer 進行包裝,在 SendMsg 和 RecvMsg 之前列印出具體的值。
func StreamClientInterceptor() grpc.StreamClientInterceptor {
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn,
method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
log.Printf("opening client streaming to the server method: %v", method)
// 呼叫Streamer函式,獲得ClientStream
stream, err := streamer(ctx, desc, cc, method)
return newStreamClient(stream), err
}
}
// 嵌入式 streamClient 允許我們訪問SendMsg和RecvMsg函式
type streamClient struct {
grpc.ClientStream
}
// 對ClientStream進行包裝
func newStreamClient(c grpc.ClientStream) grpc.ClientStream {
return &streamClient{c}
}
// RecvMsg從流中接收訊息
func (e *streamClient) RecvMsg(m interface{}) error {
// 在這裡,我們可以對接收到的訊息執行額外的邏輯,例如
// 驗證
log.Printf("Receive a message (Type: %T) at %v", m, time.Now().Format(time.RFC3339))
if err := e.ClientStream.RecvMsg(m); err != nil {
return err
}
return nil
}
// RecvMsg從流中接收訊息
func (e *streamClient) SendMsg(m interface{}) error {
// 在這裡,我們可以對接收到的訊息執行額外的邏輯,例如
// 驗證
log.Printf("Send a message (Type: %T) at %v", m, time.Now().Format(time.RFC3339))
if err := e.ClientStream.SendMsg(m); err != nil {
return err
}
return nil
}
因為SendMsg 和 RecvMsg 方法 ClientStream介面內的方法,我們需要先呼叫
streamer(ctx, desc, cc, method)
函式獲取到ClientStream
再對他進一步結構體封裝,實現他SendMsg 和 RecvMsg 方法。
stream_client
透過 grpc.WithStreamInterceptor 指定要載入的攔截器
conn, err := grpc.Dial(":"+PORT, grpc.WithInsecure(), grpc.WithStreamInterceptor(Interceptor.StreamClientInterceptor()))
if err != nil {
log.Fatalf("grpc.Dial err: %v", err)
}
defer conn.Close()
2) StreamServerInterceptor
作用:例如,如果我們正在接收上述檔案塊,也許我們想確定在傳輸過程中沒有丟失任何內容,並在儲存之前再次驗證校驗和。
與客戶端類似:
func StreamServerInterceptor() grpc.StreamServerInterceptor {
return func(srv interface{}, ss grpc.ServerStream,
info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
wrapper := newStreamServer(ss)
return handler(srv, wrapper)
}
}
// 嵌入式EdgeServerStream允許我們訪問RecvMsg函式
type streamServer struct {
grpc.ServerStream
}
func newStreamServer(s grpc.ServerStream) grpc.ServerStream {
return &streamServer{s}
}
// RecvMsg從流中接收訊息
func (e *streamServer) RecvMsg(m interface{}) error {
// 在這裡,我們可以對接收到的訊息執行額外的邏輯,例如
// 驗證
log.Printf("Receive a message (Type: %T) at %v", m, time.Now().Format(time.RFC3339))
if err := e.ServerStream.RecvMsg(m); err != nil {
return err
}
return nil
}
// RecvMsg從流中接收訊息
func (e *streamServer) SendMsg(m interface{}) error {
// 在這裡,我們可以對接收到的訊息執行額外的邏輯,例如
// 驗證
log.Printf("Send a message (Type: %T) at %v", m, time.Now().Format(time.RFC3339))
if err := e.ServerStream.SendMsg(m); err != nil {
return err
}
return nil
}
StreamServerInterceptor 攔截器自帶 ServerStream 引數,我們直接同樣的形式進行結構體嵌入封裝,在實現他的方法。
stream_server
透過 grpc.StreamInterceptor 指定要載入的攔截器
server := grpc.NewServer(grpc.StreamInterceptor(Interceptor.StreamServerInterceptor()))
3)啟動 & 請求
# 啟動服務端
$ go run server.go
API server listening at: 127.0.0.1:54096
2022/11/07 19:07:17 Receive a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:17+08:00
2022/11/07 19:07:17 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/07 19:07:17 Receive a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:17+08:00
2022/11/07 19:07:17 Send a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:17+08:00
2022/11/07 19:07:18 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/07 19:07:18 Receive a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:18+08:00
2022/11/07 19:07:18 Send a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:18+08:00
2022/11/07 19:07:19 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/07 19:07:19 Receive a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:19+08:00
2022/11/07 19:07:19 Send a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:19+08:00
2022/11/07 19:07:20 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/07 19:07:20 Receive a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:20+08:00
2022/11/07 19:07:20 Send a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:20+08:00
2022/11/07 19:07:21 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/07 19:07:22 Receive a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:22+08:00
2022/11/07 19:07:22 Send a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:22+08:00
2022/11/07 19:07:23 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/07 19:07:23 Receive a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:23+08:00
2022/11/07 19:07:23 Send a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:23+08:00
2022/11/07 19:07:24 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/07 19:07:24 Receive a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:24+08:00
2022/11/07 19:07:24 Send a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:24+08:00
# 啟動客戶端
$ go run client.go
API server listening at: 127.0.0.1:54108
2022/11/07 19:07:17 opening client streaming to the server method: /proto.StreamService/Route
2022/11/07 19:07:17 Send a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:17+08:00
2022/11/07 19:07:17 Receive a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:17+08:00
2022/11/07 19:07:17 resp: pj.name: gRPC Stream Server: Route, pt.value: 0
2022/11/07 19:07:17 Receive a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:17+08:00
2022/11/07 19:07:18 Send a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:18+08:00
2022/11/07 19:07:18 resp: pj.name: gRPC Stream Server: Route, pt.value: 1
2022/11/07 19:07:18 Receive a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:18+08:00
2022/11/07 19:07:19 Send a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:19+08:00
2022/11/07 19:07:19 resp: pj.name: gRPC Stream Server: Route, pt.value: 2
2022/11/07 19:07:19 Receive a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:19+08:00
2022/11/07 19:07:20 Send a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:20+08:00
2022/11/07 19:07:20 resp: pj.name: gRPC Stream Server: Route, pt.value: 3
2022/11/07 19:07:20 Receive a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:20+08:00
2022/11/07 19:07:21 Send a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:21+08:00
2022/11/07 19:07:22 resp: pj.name: gRPC Stream Server: Route, pt.value: 4
2022/11/07 19:07:22 Receive a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:22+08:00
2022/11/07 19:07:23 Send a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:23+08:00
2022/11/07 19:07:23 resp: pj.name: gRPC Stream Server: Route, pt.value: 5
2022/11/07 19:07:23 Receive a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:23+08:00
2022/11/07 19:07:24 Send a message (Type: *stream.StreamRequest) at 2022-11-07T19:07:24+08:00
2022/11/07 19:07:24 resp: pj.name: gRPC Stream Server: Route, pt.value: 6
2022/11/07 19:07:24 Receive a message (Type: *stream.StreamResponse) at 2022-11-07T19:07:24+08:00
Server Closed
4、實現多個攔截器
gRPC框架中只能為每個服務一起配置一元和流攔截器,,gRPC 會根據不同方法選擇對應型別的攔截器執行,因此所有的工作只能在一個函式中完成。
開源的grpc-ecosystem
專案中的go-grpc-middleware包已經基於gRPC對攔截器實現了鏈式攔截的支援。
1)Interceptor 新增一個一元客戶端攔截器:
func UnaryClientInterceptorTwo() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
fmt.Println("我是第二個攔截器")
// 可以看做是當前 RPC 方法,一般在攔截器中呼叫 invoker 能達到呼叫 RPC 方法的效果,當然底層也是 gRPC 在處理。
// 呼叫RPC方法(invoking RPC method)
_ = invoker(ctx, method, req, reply, cc, opts...)
return nil
}
}
2)Client 使用go-grpc-middleware實現鏈式攔截器:
conn, err := grpc.Dial(":"+PORT, grpc.WithInsecure(),
grpc.WithUnaryInterceptor(
// 按照順序依次執行擷取器
grpc_middleware.ChainUnaryClient(Interceptor.UnaryClientInterceptor(),
Interceptor.UnaryClientInterceptorTwo()),
))
if err != nil {
log.Fatalf("grpc.Dial err: %v", err)
}
defer conn.Close()
3)啟動 & 請求
# 啟動服務端
$ go run server.go
API server listening at: 127.0.0.1:55823
2022/11/07 19:32:28 RPC: /hello.UserService/SayHi,client-OS: '[windows]' and IP: '127.0.0.1:55829' req:name:"lin鍾一" start time: 2022-11-07T19:32:28+08:00
, end time: 2022-11-07T19:32:28+08:00, err: <nil>
# 啟動客戶端
$ go run client.go
我是第一個攔截器
我是第二個攔截器
2022/11/07 19:32:28 RPC: /hello.UserService/SayHi,,client-OS: 'windows' req:name:"lin鍾一" start time: 2022-11-07T19:32:28+08:00, end time: 2022-11-07T19:3
2:28+08:00, err: <nil>
resp: hi lin鍾一---2022-11-07 19:32:28
1、攔截器分類與定義 gRPC 攔截器可以分為:一元攔截器和流攔截器,服務端攔截器和客戶端攔截器。一共有以下4種型別:
grpc.UnaryServerInterceptor
grpc.StreamServerInterceptor
grpc.UnaryClientInterceptor
grpc.StreamClientInterceptor
攔截器本質上就是一個特定型別的函式,所以實現攔截器只需要實現對應型別方法(方法簽名相同)即可。
2、攔截器執行過程
一元攔截器
- 1)預處理
- 2)呼叫RPC方法
- 3)後處理
流攔截器
- 1)預處理
- 2)呼叫RPC方法 獲取 Streamer
- 3)後處理
- 呼叫 SendMsg 、RecvMsg 之前
- 呼叫 SendMsg 、RecvMsg
- 呼叫 SendMsg 、RecvMsg 之後
3、攔截器使用及執行順序
配置多個攔截器時,會按照引數傳入順序依次執行
所以,如果想配置一個 Recovery 攔截器則必須放在第一個,放在最後則無法捕獲前面執行的攔截器中觸發的 panic。
參考:
www.lixueduan.com/posts/grpc/05-in...
本作品採用《CC 協議》,轉載必須註明作者和本文連結