本期将从rpc背景知识开始了解,如何安装进行开发前的环境准备,protobuf文件格式了解,客户端服务端案例分享等,逐渐深入了解如何使用grpc-go框架进行实践开发。
文章内容比较长,干货不少,并且贴了不少代码,需要耐心看完,相信你可以的!
📚 全文字数 : 13k+
⏳ 阅读时长 : 18min
📢 关键词 : rpc、grpc-go、protobuf、protoc-gen-go
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 接口通常支持异步调用,可以更好地处理高并发场景。
Google远程过程调用(Google Remote Procedure Call,gRPC)是基于 HTTP 2.0传输层协议和 protobuf 序列化协议进行开发承载的高性能开源RPC软件框架。
rpc和grpc之间的关系是什么?
这就很好理解了,rpc是一种协议,grpc是基于rpc协议实现的一种框架
grpc-go则是google 的开源框架基于语言实现的grpc版本,因此grpc-go同样是以 HTTP2 作为应用层协议,使用 protobuf 作为数据序列化协议以及接口定义语言。
grpc-go 项目地址在这里:https://github.com/grpc/grpc-go
小总结:小伙伴们这些应该对这几个rpc相关不同概念了解了吧,还是不清楚的看下图加深三者之间的记忆:
在正式进入开发环境准备之前我们对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下载地址 https://github.com/protocolbuffers/protobuf/releases,(这里以windows为例) 进入后找到对应系统的版本,现在后进行解压可以在bin目录找到protoc.exe,然后添加到系统环境变量下。
安装成功后,打开cmd,运行protoc --version,查看是否安装成功。
> protoc --version
libprotoc 24.3
这插件的作用是将我们写得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
做过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版本库
go get -u google.golang.org/grpc
上面这些流程下来其实就是安装好了进行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方法,并处理响应结果
看了服务端代码的你是不是感觉好简单,短短几行代码就把服务起了,我们来看下内部是怎么实现的,如何进行初始化、注册、监听的
我们看下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 只支持一个拦截器,如果有多个只会取第一个)
• 初始化负载均衡策略
• 初始化并解析地址信息
• 建立和服务端的连接
这里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 服务端使用介绍及源码分析
推荐阅读