grpc 和 grpc-gateway 復用同一端口的正確姿勢

grpc 和 grpc-gateway 復用同一端口的方案

網上有很多討論 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
}

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *