網上有很多討論 grpc 和 grpc-gateway 復用同一端口的文章,然基本都是互相 copy 通常主要就是告訴你 在 ServeHTTP 中判斷下如果是 grpc 請求就交給 grpc 服務處理 ,否則交給 gateway 進行處理,代碼類似這樣:
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
contextType := r.Header.Get(`Content-Type`)
if r.ProtoMajor == 2 && strings.Contains(contextType, `application/grpc`) {
s.gtcp.ServeHTTP(w, r) // application/grpc 路由給 grpc
} else {
s.proxyMux.ServeHTTP(w, r) // 非 grpc 路由給 gateway
}
}
上述代碼可以工作,但這是不夠的,要正常用於生成環境,至少還有兩個問題需要詳細討論
- gateway 和 grpc 其實可以不通過 socket 而直接 memory copy 通訊
- h2c 的支持
本文主要討論上述兩個問題,本文假設你已經熟悉了 golang grpc grpc-gateway 等技術,不會對這些基礎技術進行詳細說明,畢竟本文討論的是對這些技術熟悉後的技巧。
端口復用
首先是端口復用其它文章已經有詳細說明本文還是粗略講下,因爲 gateway 對外提供的是 http1.1兼容接口,同時 grpc 使用 http2,http2 又兼容 http1.1 所以只需調用 net.Listen 監聽一個端口,然後在 ServeHTTP 中判斷下連接協議將其分流即可,代碼見文章開頭的示例。
此處其它文章中 gateway 會通過 socket 連接本地的 grpc 進行中轉,然這其實應該優化一下,因爲如果通過 socket 通訊,數據必須提交給 os,os 提交給網卡,這無疑增加了不小的額外開銷,但 gateway 和 grpc 服務其實工作在同一程式中所以完全可以通過 memory copy 來通訊。並且即使對外提供 h2 的 grpc 服務,gateway也可以使用 h2c 和 grpc 通訊也節省了一點加密解密的開銷。
要實現 memory copy 通訊也很簡單,將 memory 實現 net.Conn 與 net.Listener 接口即可,得益與 golang 強大的接口,gateway 和 grpc 代碼其實是工作在 net.Conn 和 net.Listener 接口上而非工作在 tcp 相關代碼上。本喵上篇文章已經詳細討論了如何以 memory copy 來實現 這兩個接口,此處不再討論,請看上篇文章《net.Listener 技巧 net.Pipe 的妙用》。
h2c 的支持
要支持 h2c 不能使用標準庫的 http.Serve 否則 grpc 客戶端將無法正常工作,這是因爲標準庫並沒有支持 h2c 協議,解決也很容易 x 庫已經提供了對 h2c 的支持,使用 h2c 時調用 x 庫 提供的 h2c 即可。
示例代碼
有了上述理論下面來一步步的實現個示例代碼。
首先定義一組 grpc 接口:
syntax = "proto3";
package math;
option go_package = "test/grpc/math";
import "google/api/annotations.proto";
service Cat {
rpc Sum (SumRequest) returns (SumResponse){
option (google.api.http) = {
post: "/api/v1/math/sum"
body: "*"
};
}
rpc Version (VersionRequest) returns (VersionResponse){
option (google.api.http) = {
get: "/api/v1/math/version"
};
}
}
message SumRequest{
repeated int32 vals = 1;
}
message SumResponse{
int32 val = 1;
}
message VersionRequest{
}
message VersionResponse{
string val = 1;
}
然後創建一個 math.go 在裏面實現服務器,並且提供一個 newGRPCServer 函數用於創建 grpc 服務:
package main
import (
"context"
grpc_math "test/grpc/protocol/math"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
)
func newGRPCServer(mux *runtime.ServeMux, cc *grpc.ClientConn) *grpc.Server {
s := grpc.NewServer()
// register to grpc
grpc_math.RegisterCatServer(s, Math{})
if mux != nil && cc != nil {
// register to gateway
grpc_math.RegisterCatHandler(context.Background(), mux, cc)
}
return s
}
type Math struct {
grpc_math.UnimplementedCatServer
}
func (Math) Sum(ctx context.Context, req *grpc_math.SumRequest) (*grpc_math.SumResponse, error) {
var sum int32 = 0
for _, val := range req.Vals {
sum += val
}
return &grpc_math.SumResponse{
Val: sum,
}, nil
}
func (c Math) Version(context.Context, *grpc_math.VersionRequest) (*grpc_math.VersionResponse, error) {
return &grpc_math.VersionResponse{
Val: `v1.0.0`,
}, nil
}
然後創建一個 Server 來實現上述理論,我們首先考慮 Server 需要的屬性包括
- PipeListener 與 grpc.Server 用於提供 momory copy 的 grpc 服務
- net.Listener 與 grpc.Server 用於提供 對外的 grpc 服務
- gateway 的 runtime.ServeMux 用於將 grpc 包裝爲 http 對外提供服務
其定義如下:
type Server struct {
pipe *PipeListener
gpipe *grpc.Server
tcp net.Listener
gtcp *grpc.Server
proxyMux *runtime.ServeMux
}
func NewServer(addr string) (s *Server, e error) {
tcp, e := net.Listen(`tcp`, addr)
if e != nil {
return
}
pipe := ListenPipe()
clientConn, e := grpc.Dial(`pipe`,
grpc.WithInsecure(),
grpc.WithContextDialer(func(c context.Context, s string) (net.Conn, error) {
return pipe.DialContext(c, `pipe`, s)
}),
)
if e != nil {
return
}
proxyMux := runtime.NewServeMux()
s = &Server{
pipe: pipe,
tcp: tcp,
gpipe: newGRPCServer(proxyMux, clientConn),
gtcp: newGRPCServer(nil, nil),
proxyMux: proxyMux,
}
return
}
注意上述 grpc.Dial 調用,使用 grpc.WithContextDialer 用 PipeDialer 替代了默認的 tcpDialer 來實現 memory copy 通訊。
之後如文章開頭的示例在 ServeHTTP 中分流即可:
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
contextType := r.Header.Get(`Content-Type`)
if r.ProtoMajor == 2 && strings.Contains(contextType, `application/grpc`) {
s.gtcp.ServeHTTP(w, r) // application/grpc 路由給 grpc
} else {
s.proxyMux.ServeHTTP(w, r) // 非 grpc 路由給 gateway
}
}
在 h2c 時需要調用 x 庫支持 h2c,h2 可以直接調用標準庫即可:
func (s *Server) Serve() (e error) {
go s.gpipe.Serve(s.pipe)
// 配置 h2c
var httpServer http.Server
var http2Server http2.Server
e = http2.ConfigureServer(&httpServer, &http2Server)
if e != nil {
return
}
httpServer.Handler = h2c.NewHandler(s, &http2Server)
// http.Serve 不支持 h2c
// 如果直接使用 http.Serve 將使用 grpc 客戶端 無法正常訪問
e = httpServer.Serve(s.tcp)
return
}
func (s *Server) ServeTLS(certFile, keyFile string) (e error) {
go s.gpipe.Serve(s.pipe)
e = http.ServeTLS(s.tcp, s, certFile, keyFile)
return
}