grpc-go 从使用到实现原理全解析!

前言

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

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

📚 全文字数 : 9k+

⏳ 阅读时长 : 13min

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

背景知识了解

rpc

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

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

  1. 通信协议不同:HTTP 使用文本协议,RPC 使用二进制协议。
  2. 调用方式不同:HTTP 接口通过 URL 进行调用,RPC 接口通过函数调用进行调用。
  3. 参数传递方式不同:HTTP 接口使用 URL 参数或者请求体进行参数传递,RPC 接口使用函数参数进行传递。
  4. 接口描述方式不同:HTTP 接口使用 RESTful 架构描述接口,RPC 接口使用接口定义语言(IDL)描述接口。
  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 项目地址在这里:github.com/grpc/grpc-g...

小总结:小伙伴们这些应该对这几个rpc相关不同概念了解了吧,还是不清楚的看下图加深三者之间的记忆:

protobuf语法

在正式进入开发环境准备之前我们对protobuf做个简单了解,Protobuf是Protocol Buffers的简称(下文可能简称 pb),它是Google公司开发的一种数据描述语言。

pb文件后缀是.proto,最基本的数据单元是message,是类似Go语言中结构体的存在,如下

新建文件名位 resp.proto,基本的含义和结构定义也做了部分说明

ini 复制代码
// 指定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,查看是否安装成功。

css 复制代码
>  protoc --version
libprotoc 24.3

protoc-gen-go

这插件的作用是将我们写得pb文件生成xx.pb.go文件,文件的内容是把通信协议的输入输出参数和服务接口转为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 目录下,安装完成后,检查是否安装成功。

go 复制代码
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文件。

安装完成后检查是否安装成功

go 复制代码
protoc-gen-go-grpc --version
> protoc-gen-go-grpc 1.2.0

grpc-go库

关键的一点别忘了,就是安装grpc包的go版本库

arduino 复制代码
go get -u google.golang.org/grpc

pb.go文件生成

上面这些流程下来其实就是安装好了进行grpc开发的基本环境,我们可以用这些插件来生成开发所需要的文件,我们来试下!

我们创建了vacation.proto的文件在proto文件夹下,pb文件具体的定义如下

ini 复制代码
//协议为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文件了,可以用如下指令一键生成

css 复制代码
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文件内容为例

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()函数主要是用来初始化四个变量,分别是

csharp 复制代码
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文件代码

go 复制代码
//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文件代码

go 复制代码
//服务注册
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架构的,我们看下怎么用,直接上代码

服务端

go 复制代码
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

客户端

go 复制代码
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结构体进行了初始化,并且返回了它的地址。

go 复制代码
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是很重要的结构,这里拿几个关键的字段进行下注释说明

go 复制代码
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有啥

go 复制代码
type serviceInfo struct {
 serviceImpl any
 methods     map[string]*MethodDesc
 streams     map[string]*StreamDesc
 mdata       any
}

serviceInfo包装是有关服务的信息,通过一个名为 methods 的 map 记录了由方法名到具体实现方法的映射关系

go 复制代码
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代码约束

scss 复制代码
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结构中。

scss 复制代码
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 去,以便于后续实际调用的使用。

监听/处理

scss 复制代码
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 的封装,它的功能是创建与给定目标的客户端连接,

go 复制代码
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接口,比较简单

go 复制代码
func NewVacationServiceClient(cc grpc.ClientConnInterface) VacationServiceClient {
 return &vacationServiceClient{cc}
}

调用

调用WorkCall方法,实际调用的是Invoke,有我们定义的接口方法名

go 复制代码
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 服务端使用介绍及源码分析

相关推荐
Marktowin3 小时前
Mybatis-Plus更新操作时的一个坑
java·后端
赵文宇3 小时前
CNCF Dragonfly 毕业啦!基于P2P的镜像和文件分发系统快速入门,在线体验
后端
程序员爱钓鱼4 小时前
Node.js 编程实战:即时聊天应用 —— WebSocket 实现实时通信
前端·后端·node.js
Libby博仙4 小时前
Spring Boot 条件化注解深度解析
java·spring boot·后端
源代码•宸5 小时前
Golang原理剖析(Map 源码梳理)
经验分享·后端·算法·leetcode·golang·map
小周在成长5 小时前
动态SQL与MyBatis动态SQL最佳实践
后端
瓦尔登湖懒羊羊5 小时前
TCP的自我介绍
后端
小周在成长5 小时前
MyBatis 动态SQL学习
后端
子非鱼9215 小时前
SpringBoot快速上手
java·spring boot·后端