grpcgo 从使用到实现原理全解析!

2023年 10月 8日 111.7k 0

前言

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

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

📚 全文字数 : 9k+

⏳ 阅读时长 : 13min

📢 关键词 : 事务、事务隔离级别、MVCC、ReadView

背景知识了解

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 项目地址在这里:github.com/grpc/grpc-g…

    小总结:小伙伴们这些应该对这几个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下载地址 github.com/protocolbuf…,(这里以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的内容还有很多,比如拦截器、流处理、服务注册/发现、负载均衡等。这里就不做过多延伸了,后面有机会继续分享!

    👨👩 朋友,希望本文对你有帮助~🌐

    欢迎点赞 👍、收藏 💙、关注 💡 三连支持一下~🎈

    我是小许,下期见~🙇💻

    参考:

    segmentfault.com/a/119000001…

    grpc-go 服务端使用介绍及源码分析

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论