grpc-go 從使用到實現原理全解析!

前言

本期將從 rpc 背景知識開始瞭解,如何安裝進行開發前的環境準備,protobuf 文件格式瞭解,客戶端服務端案例分享等,逐漸深入瞭解如何使用 grpc-go 框架進行實踐開發。

背景知識瞭解

rpc

rpc(Remote Procedure Call)遠程過程調用協議,採用的是客戶端 / 服務端模式,常用於微服務架構,通過網絡從遠程計算機上請求服務,而不需要了解底層網絡技術的協議,從而獲得一種像調用本地方法一樣的調用遠程服務的過程。

rpc 協議常用於和 restful 架構設計風格的 http 協議進行比較,相對於 http 我們也看看 rpc 的相同和區別之處:

    1. 通信協議不同:HTTP 使用文本協議,RPC 使用二進制協議。
    1. 調用方式不同:HTTP 接口通過 URL 進行調用,RPC 接口通過函數調用進行調用。
    1. 參數傳遞方式不同:HTTP 接口使用 URL 參數或者請求體進行參數傳遞,RPC 接口使用函數參數進行傳遞。
    1. 接口描述方式不同:HTTP 接口使用 RESTful 架構描述接口,RPC 接口使用接口定義語言(IDL)描述接口。
    1. 性能表現不同: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)
 }
}

客戶端

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)
}

客戶端的代碼核心邏輯比較簡單

淺談服務端實現

看了服務端代碼的你是不是感覺好簡單,短短几行代碼就把服務起了,我們來看下內部是怎麼實現的,如何進行初始化、註冊、監聽的

創建 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()
        }()
    }
}

對於監聽處理請求來說,核心實現爲:

淺談客戶端實現

從前面客戶端的代碼中我們可以看出,代碼一樣不多,主要流程就是創建連接、實例化、調用

連接

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)
    ...
}

主要承擔瞭如下功能:

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 方法主要包括三部分:

關閉連接

defer onn.Close() 來延遲關閉連接,該方法會取消 ClientConn 上下文,同時關閉所有底層傳輸,主要涉及:

總結

本期給大家分享了關於 RPC 的一些知識,引入 grpc-go 框架,梳理了一下服務端和客戶端的實現邏輯,不過關於 grpc 的內容還有很多,比如攔截器、流處理、服務註冊 / 發現、負載均衡等。這裏就不做過多延伸了,後面有機會繼續分享!

參考:

https://segmentfault.com/a/1190000019608421#item-4-7

grpc-go 服務端使用介紹及源碼分析

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Zh0YYgUF7OMvfV_CH9QoSQ