grpc-go 从使用到实现原理全解析!
2023-12-1 08:54:51 Author: Go语言中文网(查看原文) 阅读量:4 收藏

前言

本期将从rpc背景知识开始了解,如何安装进行开发前的环境准备,protobuf文件格式了解,客户端服务端案例分享等,逐渐深入了解如何使用grpc-go框架进行实践开发。

文章内容比较长,干货不少,并且贴了不少代码,需要耐心看完,相信你可以的!

📚 全文字数 : 13k+

⏳ 阅读时长 : 18min

📢 关键词 : rpc、grpc-go、protobuf、protoc-gen-go

背景知识了解

rpc

rpc(Remote Procedure Call)远程过程调用协议,采用的是客户端/服务端模式,常用于微服务架构,通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议,从而获得一种像调用本地方法一样的调用远程服务的过程。

rpc协议常用于和restful 架构设计风格的http协议进行比较,相对于http我们也看看rpc的相同和区别之处:

  1. 1. 通信协议不同:HTTP 使用文本协议,RPC 使用二进制协议。

  2. 2. 调用方式不同:HTTP 接口通过 URL 进行调用,RPC 接口通过函数调用进行调用。

  3. 3. 参数传递方式不同:HTTP 接口使用 URL 参数或者请求体进行参数传递,RPC 接口使用函数参数进行传递。

  4. 4. 接口描述方式不同:HTTP 接口使用 RESTful 架构描述接口,RPC 接口使用接口定义语言(IDL)描述接口。

  5. 5. 性能表现不同: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=name,proto3" json:"name,omitempty"`
}

// 获取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 服务端使用介绍及源码分析


推荐阅读

福利
我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。


文章来源: http://mp.weixin.qq.com/s?__biz=MzAxMTA4Njc0OQ==&mid=2651454829&idx=1&sn=1ffcd2bf825da760e604122821eb654e&chksm=80bb239fb7ccaa89fa203b0465087e74fac9f08784133683442a26a6f4d4ddb663a5a4adb785&scene=0&xtrack=1#rd
如有侵权请联系:admin#unsafe.sh