grpc-go 從使用到實現原理全解析!
前言
本期將從 rpc 背景知識開始瞭解,如何安裝進行開發前的環境準備,protobuf 文件格式瞭解,客戶端服務端案例分享等,逐漸深入瞭解如何使用 grpc-go 框架進行實踐開發。
背景知識瞭解
rpc
rpc(Remote Procedure Call)遠程過程調用協議,採用的是客戶端 / 服務端模式,常用於微服務架構,通過網絡從遠程計算機上請求服務,而不需要了解底層網絡技術的協議,從而獲得一種像調用本地方法一樣的調用遠程服務的過程。
rpc 協議常用於和 restful 架構設計風格的 http 協議進行比較,相對於 http 我們也看看 rpc 的相同和區別之處:
-
- 通信協議不同:HTTP 使用文本協議,RPC 使用二進制協議。
-
- 調用方式不同:HTTP 接口通過 URL 進行調用,RPC 接口通過函數調用進行調用。
-
- 參數傳遞方式不同:HTTP 接口使用 URL 參數或者請求體進行參數傳遞,RPC 接口使用函數參數進行傳遞。
-
- 接口描述方式不同:HTTP 接口使用 RESTful 架構描述接口,RPC 接口使用接口定義語言(IDL)描述接口。
-
- 性能表現不同:RPC 接口通常比 HTTP 接口更快,因爲它使用二進制協議進行通信,而且使用了一些性能優化技術,例如連接池、批處理等。此外,RPC 接口通常支持異步調用,可以更好地處理高併發場景。
grpc
Google 遠程過程調用(Google Remote Procedure Call,gRPC)是基於 HTTP 2.0 傳輸層協議和 protobuf 序列化協議進行開發承載的高性能開源 RPC 軟件框架。
rpc 和 grpc 之間的關係是什麼?
這就很好理解了,rpc 是一種協議,grpc 是基於 rpc 協議實現的一種框架
grpc-go
grpc-go 則是 google 的開源框架基於語言實現的 grpc 版本,因此 grpc-go 同樣是以 HTTP2 作爲應用層協議,使用 protobuf 作爲數據序列化協議以及接口定義語言。
grpc-go 項目地址在這裏:https://github.com/grpc/grpc-go
小總結:小夥伴們這些應該對這幾個 rpc 相關不同概念瞭解了吧,還是不清楚的看下圖加深三者之間的記憶:
protobuf 語法
在正式進入開發環境準備之前我們對 protobuf 做個簡單瞭解,Protobuf 是 Protocol Buffers 的簡稱(下文可能簡稱 pb),它是 Google 公司開發的一種數據描述語言。
pb 文件後綴是. proto,最基本的數據單元是 message,是類似 Go 語言中結構體的存在,如下
新建文件名位 resp.proto,基本的含義和結構定義也做了部分說明
// 指定protobuf的版本,proto3是最新的語法版本
syntax = "proto3";
//定義服務,也就是定義RPC服務接口
service HelloService {
//Hello接口接收Request結構Message,返回Reponse結構Message
rpc Hello(Request) returns (Response);
}
//請求數據結構
message Request{
string name = 1; // string類型的字段,字段名字爲name, 序號爲1
}
// 響應數據結構,message 你可以想象成go結構體
message Response {
string data = 1; // string類型的字段,字段名字爲data, 序號爲1
int32 status = 2; // int32類型的字段,字段名字爲status, 序號爲2
}
關於 pb 語法和更詳細的使用這裏就不多做介紹了,可以看看這篇文章 Protobuf-language-guide,或者自己搜搜,相關的知識很多的
開發環境準備
在開發使用之前我們還需要做一些準備工作,因爲我們是寫的是 pb 文件,使用之前需要生成爲 pb.go 和 grpc.pb.go 文件,那麼需要利用幾個工具,這裏一個個教你進行安裝。
protoc 編譯器
protoc 下載地址 https://github.com/protocolbuffers/protobuf/releases,(這裏以 windows 爲例) 進入後找到對應系統的版本,現在後進行解壓可以在 bin 目錄找到 protoc.exe,然後添加到系統環境變量下。
安裝成功後,打開 cmd,運行 protoc --version,查看是否安裝成功。
> protoc --version
libprotoc 24.3
protoc-gen-go
這插件的作用是將我們寫得 pb 文件生成 xx.pb.go 文件,文件的內容是把通信協議的輸入輸出參數和服務接口轉爲 go 語言表示
go get -u google.golang.org/protobuf/cmd/protoc-gen-go
go install google.golang.org/protobuf/cmd/protoc-gen-go
go install 指令默認會將插件安裝到 $GOPATH/bin 目錄下,安裝完成後,檢查是否安裝成功。
protoc-gen-go --version
> protoc-gen-go v1.28.1
protoc-gen-go-grpc
做過 go-micro 服務開發的同學知道需要安裝 protoc-gen-micro,同樣 protoc-gen-go-grpc 是爲 grpc-go 框架生成的通信代碼,也是基於 pb 文件生成
xx_grpc.pb.go 文件。
安裝完成後檢查是否安裝成功
protoc-gen-go-grpc --version
> protoc-gen-go-grpc 1.2.0
grpc-go 庫
關鍵的一點別忘了,就是安裝 grpc 包的 go 版本庫
go get -u google.golang.org/grpc
pb.go 文件生成
上面這些流程下來其實就是安裝好了進行 grpc 開發的基本環境,我們可以用這些插件來生成開發所需要的文件,我們來試下!
我們創建了 vacation.proto 的文件在 proto 文件夾下,pb 文件具體的定義如下
//協議爲proto3
syntax = "proto3";
// 指定生成的Go代碼在你項目中的導入路徑
option go_package="./;proto";
package proto;
// 定義服務接口
// 可定義多個服務,每個服務可定義多個接口
service VacationService {
// WorkCall接口
rpc WorkCall (WorkCallReq) returns (WorkCallResp) {}
}
// 請求參數結構
message WorkCallReq {
string name = 1;
}
// 響應參數結構
message WorkCallResp {
string reply = 1;
}
定義好之後就需要講 pb 文件生成我們需要用到的 go 文件了,可以用如下指令一鍵生成
protoc --go_out=. --go-grpc_out=. proto/vacation.proto
--go_out:指定 xxpb.go 文件的生成位置
--go-grpcout:指定 xx_grpc.pb.go 文件的生成位置
proto/vacation.proto:指定了 pb 文件的所在位置在 proto 目錄下
細心的你看可以看出來 xx.pb.go 的文件代碼內容是我們定義的 pb 文件的接口和消息的 Go 語言的描述,包括一些結構的方法,以 WorkCallReq 生成的 pb.go 文件內容爲例
type WorkCallReq struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Name string `protobuf:"bytes,1,opt,`
}
// 獲取name參數的值
func (x *WorkCallReq) GetName() string {
if x != nil {
return x.Name
}
return ""
}
除了定義結構體請求參數,還有一些方法,這個就自己去看吧,其中 init() 函數主要是用來初始化四個變量,分別是
var File_vacation_proto
var file_vacation_proto_rawDesc
var file_vacation_proto_rawDescOnce
var file_vacation_proto_rawDescData
再看另一個_grpc.pb.go 文件,這裏是基於 pb 文件生成的 grpc 框架代碼,這裏其實分爲兩部分,一部分是定義的給客戶端調用的接口,另一部分是服務端需要註冊的接口實現。
客戶端 pb 文件代碼
//pb定義的接口
type VacationServiceClient interface {
// SayHello 方法
WorkCall(ctx context.Context, in *WorkCallReq, opts ...grpc.CallOption) (*WorkCallResp, error)
}
// 實現接口的結構體
type vacationServiceClient struct {
cc grpc.ClientConnInterface
}
//構造一個client,實際返回的是一個接口
func NewVacationServiceClient(cc grpc.ClientConnInterface) VacationServiceClient {
return &vacationServiceClient{cc}
}
//客戶端調用的接口WorkCall
func (c *vacationServiceClient) WorkCall(ctx context.Context, in *WorkCallReq, opts ...grpc.CallOption) (*WorkCallResp, error) {
out := new(WorkCallResp)
err := c.cc.Invoke(ctx, "/proto.VacationService/WorkCall", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
NewVacationServiceClient 構造函數中,變量 vacationServiceClient 是私有化的,通過創建一個可被訪問的實現的接口,但是接口的底層實現依然是私有的,使用者無法直接創建一個實例。
服務端 pb 文件代碼
//服務註冊
func RegisterVacationServiceServer(s grpc.ServiceRegistrar, srv VacationServiceServer) {
s.RegisterService(&VacationService_ServiceDesc, srv)
}
func _VacationService_WorkCall_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor
grpc.UnaryServerInterceptor) (interface{}, error) {
...
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/proto.VacationService/WorkCall",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VacationServiceServer).WorkCall(ctx, req.(*WorkCallReq))
}
return interceptor(ctx, in, info, handler)
}
//服務、接口實現映射
var VacationService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "proto.VacationService",
HandlerType: (*VacationServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "WorkCall",
Handler: _VacationService_WorkCall_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "vacation.proto",
}
服務端部分的代碼主要是:建立基於方法名(WorkCall)到具體處理函數(_VacationService_WorkCall_Handler)的映射關係,然後進行註冊,爲後續的客戶端提供調用。
而服務註冊主要是添加到 grpc 框架的 Server.services 這個 map 中,也就是將服務名爲 key,具體的實現內容爲 vlalue 存在一個 map,然後客戶端調用接口的時候會帶上服務名。
使用案例
前面講了不少前置知識和 pb 這塊的內容,現在來看下如何使用和通信的吧,grpc 也是基於 client/server 架構的,我們看下怎麼用,直接上代碼
服務端
type VacationServer struct {
proto.UnimplementedVacationServiceServer
}
func (s *VacationServer) WorkCall(ctx context.Context, req *proto.WorkCallReq) (resp *proto.WorkCallResp, err error) {
return &proto.WorkCallResp{Reply: "I am on vacation"}, nil
}
func main() {
//創建listen監聽端口
listener, err := net.Listen("tcp", ":8093")
if err != nil {
panic(err)
}
//創建 gRPC Server 對象
s := grpc.NewServer()
//處理註冊到grpc服務中
proto.RegisterVacationServiceServer(s, &VacationServer{})
// 運行 grpc server
if err = s.Serve(listener); err != nil {
panic(err)
}
}
-
• 定義 VacationServer 結構體 ,實現方法定義的 WorkCall 接口
-
• 調用 net.Listen 方法,創建 tcp 端口監聽器
-
• grpc.NewServer 方法,創建一個 grpc server 對象,可理解爲 server 端的抽象
-
• 調用 pb 文件生成好的 proto.RegisterHelloServiceServer,將 HelloService 註冊到 grpc server 對象當中
-
• 運行 server.Serve 方法,監聽指定的端口,真正啓動 grpc server,開始接收 lis.Accept,直到 stop
客戶端
func main() {
//連接服務
conn, err := grpc.Dial("127.0.0.1:8093", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
panic(err)
}
// 延遲關閉連接
defer conn.Close()
client := proto.NewVacationServiceClient(conn)
// 初始化上下文,設置請求超時時間爲1秒
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// 延遲關閉請求會話
defer cancel()
resp, err := client.WorkCall(ctx, &proto.WorkCallReq{
Name: "Let's get started",
})
if err != nil {
log.Fatalf("could not send msg: %v", err)
}
// 打印服務的返回的消息
log.Printf("Greeting: %s", resp.Reply)
}
客戶端的代碼核心邏輯比較簡單
-
• 調用 grpc.Dial 方法,和指定地址端口的 grpc 服務端建立連接
-
• 用 pb 文件中的方法 proto.NewVacationServiceClient,創建 pb 文件中生成好的 grpc 客戶端對象
-
• 發送 grpc 請求,調用 client.WorkCall 方法,並處理響應結果
淺談服務端實現
看了服務端代碼的你是不是感覺好簡單,短短几行代碼就把服務起了,我們來看下內部是怎麼實現的,如何進行初始化、註冊、監聽的
創建 server
我們看下 grpc.NewServer() 是如何創建 Server 的,NewServer 創建了一個 gRPC 服務器,該服務器沒有註冊任何服務,並且未開始接受請求,可以看到實際上是對 Server 結構體進行了初始化,並且返回了它的地址。
func NewServer(opt ...ServerOption) *Server {
opts := defaultServerOptions
for _, o := range globalServerOptions {
o.apply(&opts)
}
for _, o := range opt {
o.apply(&opts)
}
s := &Server{
lis: make(map[net.Listener]bool),
opts: opts,
conns: make(map[string]map[transport.ServerTransport]bool),
services: make(map[string]*serviceInfo),
quit: grpcsync.NewEvent(),
done: grpcsync.NewEvent(),
czData: new(channelzData),
}
...
return s
}
核心數據結構
看的出來 Server 是很重要的結構,這裏拿幾個關鍵的字段進行下注釋說明
type Server struct {
// 服務選項,這塊包含 Credentials、Interceptor 以及一些基礎配置
opts serverOptions
// 互斥鎖保證併發安全
mu sync.Mutex
// tcp 端口監聽器池
lis map[net.Listener]bool
// 連接池
conns map[string]map[transport.ServerTransport]bool
// 業務服務信息映射
services map[string]*serviceInfo // service name -> service info
// 退出信號
quit *grpcsync.Event
// 完成信號
done *grpcsync.Event
}
其中通過 Server 中的 map 數據類型的 services 屬性,它記錄了由服務名到具體業務服務模塊的映射關係,我們看下 ServerInfo 有啥
type serviceInfo struct {
serviceImpl any
methods map[string]*MethodDesc
streams map[string]*StreamDesc
mdata any
}
serviceInfo 包裝是有關服務的信息,通過一個名爲 methods 的 map 記錄了由方法名到具體實現方法的映射關係
type MethodDesc struct {
MethodName string
Handler methodHandler
}
type methodHandler func(srv any, ctx context.Context, dec func(any) error, interceptor UnaryServerInterceptor) (any, error)
而 MethodDesc 是一個 RPC 服務方法的規範,methodHandler 是具體的處理方法類型
核心數據結構之間的層次如下圖:
註冊
註冊是傳遞的是我們初始化的 Server 和實現方法的類型地址,這個類型實現了 VacationServiceServer 接口,這個接口就是我們定義的 pb 文件生成的 pb.go 代碼約束
proto.RegisterVacationServiceServer(s, &VacationServer{})
type VacationServiceServer interface {
// SayHello 方法
WorkCall(context.Context, *WorkCallReq) (*WorkCallResp, error)
mustEmbedUnimplementedVacationServiceServer()
}
而傳入的是 Service 的功能接口實現者 VacationServer,而 Register 最終調用的是 RegisterService,這裏的 VacationService_ServiceDesc 就是我們方法名和具體實現的描述,最終註冊的時候是遍歷 ServiceDesc 註冊到 Server 結構體的 serviceInfo map 結構中。
func RegisterVacationServiceServer(s grpc.ServiceRegistrar, srv VacationServiceServer) {
s.RegisterService(&VacationService_ServiceDesc, srv)
}
func (s *Server) RegisterService(sd *ServiceDesc, ss any) {
...
s.register(sd, ss)
}
var VacationService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "proto.VacationService",
HandlerType: (*VacationServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "WorkCall",
Handler: _VacationService_WorkCall_Handler,
},
},
// 注意,如果是流式調用, 則保存到這裏
Streams: []grpc.StreamDesc{},
Metadata: "vacation.proto",
}
這就是註冊的全流程,根據 Method 創建對應的 map,並將名稱作爲鍵,方法描述 (指針) 作爲值,添加到相應的 map 中。就是爲了將服務接口信息、服務描述信息給註冊到內部 service 去,以便於後續實際調用的使用。
監聽 / 處理
func (s *Server) Serve(lis net.Listener) error {
//根據外部傳入的 Listener 不同而調用不同的監聽模式
...
//監聽客戶端連接
for {
rawConn, err := lis.Accept()
if err != nil {
//lis.Accept 失敗,則觸發休眠重試機制
}
//lis.Accept 成功, 處理客戶端請求
s.serveWG.Add(1)
//每個新的tcp連接使用單獨的goroutine處理
go func() {
s.handleRawConn(lis.Addr().String(), rawConn)
s.serveWG.Done()
}()
}
}
對於監聽處理請求來說,核心實現爲:
-
• 不斷地從 lis.Accept 取出連接,如果返回 error,則觸發休眠(沒必要返回 error 了還要一直去拿)
-
• 休眠策略爲,第一次休眠 5ms,不斷翻倍,最大 1s
-
• 如果監聽到請求,那麼會重置休眠時間,並用一個 goroutine 去處理請求,也就是說每一個請求都是不同的 goroutine 在處理
-
• 加入 waitGroup 用來處理優雅重啓或退出,等待所有 goroutine 執行結束之後纔會退出
淺談客戶端實現
從前面客戶端的代碼中我們可以看出,代碼一樣不多,主要流程就是創建連接、實例化、調用
-
• 調用 grpc.Dial 方法,指定目標服務端,創建 grpc 連接代理對象 ClientConn
-
• 調用 proto.NewVacationServiceClient 方法,基於 pb 代碼構造客戶端實例
-
• 調用 client.WorkCall 方法,發起 grpc 請求
連接
grpc.Dial 方法實際上是對於 grpc.DialContext 的封裝,它的功能是創建與給定目標的客戶端連接,
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
cc := &ClientConn{
target: target,
conns: make(map[*addrConn]struct{}),
dopts: defaultDialOptions(),
czData: new(channelzData),
}
cc.idlenessState = ccIdlenessStateIdle
cc.retryThrottler.Store((*retryThrottler)(nil))
cc.safeConfigSelector.UpdateConfigSelector(&defaultConfigSelector{nil})
cc.ctx, cc.cancel = context.WithCancel(context.Background())
cc.exitIdleCond = sync.NewCond(&cc.mu)
...
}
主要承擔瞭如下功能:
-
• 初始化 ClientConn 對象
-
• 初始化重試規則
-
• 執行一些可選方法
-
• 初始化一元 / 流式攔截器(比較坑的是 grpc 只支持一個攔截器,如果有多個只會取第一個)
-
• 初始化負載均衡策略
-
• 初始化並解析地址信息
-
• 建立和服務端的連接
client 實例化
這裏 vacationServiceClient 實現了 VacationServiceClient 接口,比較簡單
func NewVacationServiceClient(cc grpc.ClientConnInterface) VacationServiceClient {
return &vacationServiceClient{cc}
}
調用
調用 WorkCall 方法,實際調用的是 Invoke,有我們定義的接口方法名
func (c *vacationServiceClient) WorkCall(ctx context.Context, in *WorkCallReq, opts ...grpc.CallOption) (*WorkCallResp, error) {
out := new(WorkCallResp)
err := c.cc.Invoke(ctx, "/proto.VacationService/WorkCall", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (cc *ClientConn) Invoke(ctx context.Context, method string, args, reply any, opts ...CallOption) error {
...
return invoke(ctx, method, args, reply, cc, opts...)
}
func invoke(ctx context.Context, method string, req, reply any, cc *ClientConn, opts ...CallOption) error {
cs, err := newClientStream(ctx, unaryStreamDesc, cc, method, opts...)
if err != nil {
return err
}
if err := cs.SendMsg(req); err != nil {
return err
}
return cs.RecvMsg(reply)
}
可以看到在調用 invoke 函數前,主要是做一下數組組裝工作,最後會調用 invoke 方法。
invoke 方法主要包括三部分:
-
• newClientStream:獲取傳輸層 Trasport 並組合封裝到 ClientStream 中返回,在這塊會涉及負載均衡、超時控制等操作
-
• SendMsg:發送 RPC 請求
-
• RecvMsg:阻塞等待接受到的 RPC 方法響應結果並返回
關閉連接
defer onn.Close() 來延遲關閉連接,該方法會取消 ClientConn 上下文,同時關閉所有底層傳輸,主要涉及:
-
• Context Cancel
-
• 清空並關閉客戶端連接
-
• 清空並關閉解析器連接
-
• 清空並關閉負載均衡連接
-
• 移除當前通道信息
總結
本期給大家分享了關於 RPC 的一些知識,引入 grpc-go 框架,梳理了一下服務端和客戶端的實現邏輯,不過關於 grpc 的內容還有很多,比如攔截器、流處理、服務註冊 / 發現、負載均衡等。這裏就不做過多延伸了,後面有機會繼續分享!
參考:
https://segmentfault.com/a/1190000019608421#item-4-7
grpc-go 服務端使用介紹及源碼分析
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Zh0YYgUF7OMvfV_CH9QoSQ