了解grpc/protobuf
gRPC是一个高性能、通用的开源RPC框架,其由Google主要面向移动应用开发并基于HTTP/2协议标准而设计,基于ProtoBuf(Protocol Buffers)序列化协议开发,且支持众多开发语言。
gRPC提供了一种简单的方法来精确地定义服务和为iOS、Android和后台支持服务自动生成可靠性很强的客户端功能库。客户端充分利用高级流和链接功能,从而有助于节省带宽、降低的TCP链接次数、节省CPU使用、和电池寿命。
Protobuf(Protocol Buffers),是 Google 开发的一种跨语言、跨平台的可扩展机制,用于序列化结构化数据。
grpc与传统的 REST 架构相比,REST架构通过 http 传输 JSON 或者 XML ,会带来了一个问题:服务 A 把原始数据编码成 JSON/XML 格式,发送一长串字符给服务 B,B 通过解码还原成原始数据,通信的总体数据量很大。
但在两个微服务的通信间,我们不需要字符串中的所有数据,所以我们采用难理解但更加轻量的二进制数据进行交互。gRPC 采用的是支持二进制数据的 HTTP 2.0 规范,而protobuf负责处理二进制数据, 它更小、更快、更便捷。
protobuf 目前支持 C++、Java、Python、Objective-C,如果使用 proto3,还支持 C#、Ruby、Go、PHP、JavaScript 等语言。
官网地址:https://developers.google.cn/protocol-buffers/
GitHub 地址:https://github.com/protocolbuffers/protobuf
优点:
- 性能好
- 跨语言
缺点:
- 二进制格式可读性差:为了提高性能,protobuf 采用了二进制格式进行编码,这直接导致了可读性差。
- 缺乏自描述:XML 是自描述的,而 protobuf 不是,不配合定义的结构体是看不出来什么作用的。
安装
1、下载protobuf的编译器protoc
地址:https://github.com/google/protobuf/releases
注意:
- 我的系统是win10,64位,下载的文件为protoc-3.10.1-win64.zip
- 下载后解压到已经添加到环境变量的目录。
2、获取protobuf的编译器插件protoc-gen-go
go get -u github.com/golang/protobuf/protoc-gen-go
注意:下载完成,目录下面会多出一个bin目录,里面生成protoc-gen-go.exe文件。
调用过程
1、客户端(gRPC Stub)调用 A 方法,发起 RPC 调用。
2、对请求信息使用 Protobuf 进行对象序列化压缩(IDL)。
3、服务端(gRPC Server)接收到请求后,解码请求体,进行业务逻辑处理并返回。
4、对响应结果使用 Protobuf 进行对象序列化压缩(IDL)。
5、客户端接受到服务端响应,解码请求体。回调被调用的 A 方法,唤醒正在等待响应(阻塞)的客户端调用并返回响应结果。
调用方式
一、Unary RPC:一元 RPC
Server
type SearchService struct{} func (s *SearchService) Search(ctx context.Context, r *pb.SearchRequest) (*pb.SearchResponse, error) { return &pb.SearchResponse{Response: r.GetRequest() + " Server"}, nil } const PORT = "9001" func main() { server := grpc.NewServer() pb.RegisterSearchServiceServer(server, &SearchService{}) lis, err := net.Listen("tcp", ":"+PORT) ... server.Serve(lis) }
-
创建 gRPC Server 对象,你可以理解为它是 Server 端的抽象对象。
-
将 SearchService(其包含需要被调用的服务端接口)注册到 gRPC Server。的内部注册中心。这样可以在接受到请求时,通过内部的 “服务发现”,发现该服务端接口并转接进行逻辑处理。
-
创建 Listen,监听 TCP 端口。
-
gRPC Server 开始 lis.Accept,直到 Stop 或 GracefulStop。
Client
func main() { conn, err := grpc.Dial(":"+PORT, grpc.WithInsecure()) ... defer conn.Close() client := pb.NewSearchServiceClient(conn) resp, err := client.Search(context.Background(), &pb.SearchRequest{ Request: "gRPC", }) ... }
-
创建与给定目标(服务端)的连接句柄。
-
创建 SearchService 的客户端对象。
-
发送 RPC 请求,等待同步响应,得到回调后返回响应结果。
二、Server-side streaming RPC:服务端流式 RPC
Server
func (s *StreamService) List(r *pb.StreamRequest, stream pb.StreamService_ListServer) error { for n := 0; n <= 6; n++ { stream.Send(&pb.StreamResponse{ Pt: &pb.StreamPoint{ ... }, }) } return nil }
Client
func printLists(client pb.StreamServiceClient, r *pb.StreamRequest) error { stream, err := client.List(context.Background(), r) ... for { resp, err := stream.Recv() if err == io.EOF { break } ... } return nil }
三、Client-side streaming RPC:客户端流式 RPC
Server
func (s *StreamService) Record(stream pb.StreamService_RecordServer) error { for { r, err := stream.Recv() if err == io.EOF { return stream.SendAndClose(&pb.StreamResponse{Pt: &pb.StreamPoint{...}}) } ... } return nil }
Client
func printRecord(client pb.StreamServiceClient, r *pb.StreamRequest) error { stream, err := client.Record(context.Background()) ... for n := 0; n < 6; n++ { stream.Send(r) } resp, err := stream.CloseAndRecv() ... return nil }
四、Bidirectional streaming RPC:双向流式 RPC
Server
func (s *StreamService) Route(stream pb.StreamService_RouteServer) error { for { stream.Send(&pb.StreamResponse{...}) r, err := stream.Recv() if err == io.EOF { return nil } ... } return nil }
Client
func printRoute(client pb.StreamServiceClient, r *pb.StreamRequest) error { stream, err := client.Route(context.Background()) ... for n := 0; n <= 6; n++ { stream.Send(r) resp, err := stream.Recv() if err == io.EOF { break } ... } stream.CloseSend() return nil }
浅谈理解
服务端
为什么四行代码,就能够起一个 gRPC Server,内部做了什么逻辑。你有想过吗?接下来我们一步步剖析,看看里面到底是何方神圣。
一、初始化
// grpc.NewServer() func NewServer(opt ...ServerOption) *Server { opts := defaultServerOptions for _, o := range opt { o(&opts) } s := &Server{ lis: make(map[net.Listener]bool), opts: opts, conns: make(map[io.Closer]bool), m: make(map[string]*service), quit: make(chan struct{}), done: make(chan struct{}), czData: new(channelzData), } s.cv = sync.NewCond(&s.mu) ... return s }
这块比较简单,主要是实例 grpc.Server 并进行初始化动作。涉及如下:
-
lis:监听地址列表。
-
opts:服务选项,这块包含 Credentials、Interceptor 以及一些基础配置。
-
conns:客户端连接句柄列表。
-
m:服务信息映射。
-
quit:退出信号。
-
done:完成信号。
-
czData:用于存储 ClientConn,addrConn 和 Server 的channelz 相关数据。
-
cv:当优雅退出时,会等待这个信号量,直到所有 RPC 请求都处理并断开才会继续处理。
二、注册
pb.RegisterSearchServiceServer(server, &SearchService{})
步骤一:Service API interface
// search.pb.go type SearchServiceServer interface { Search(context.Context, *SearchRequest) (*SearchResponse, error) } func RegisterSearchServiceServer(s *grpc.Server, srv SearchServiceServer) { s.RegisterService(&_SearchService_serviceDesc, srv) }
还记得我们平时编写的 Protobuf 吗?在生成出来的.pb.go
文件中,会定义出 Service APIs interface 的具体实现约束。而我们在 gRPC Server 进行注册时,会传入应用 Service 的功能接口实现,此时生成的RegisterServer
方法就会保证两者之间的一致性。
步骤二:Service API IDL
你想乱传糊弄一下?不可能的,请乖乖定义与 Protobuf 一致的接口方法。但是那个&_SearchService_serviceDesc
又有什么作用呢?代码如下:
// search.pb.go var _SearchService_serviceDesc = grpc.ServiceDesc{ ServiceName: "proto.SearchService", HandlerType: (*SearchServiceServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "Search", Handler: _SearchService_Search_Handler, }, }, Streams: []grpc.StreamDesc{}, Metadata: "search.proto", }
这看上去像服务的描述代码,用来向内部表述 “我” 都有什么。涉及如下:
-
ServiceName:服务名称
-
HandlerType:服务接口,用于检查用户提供的实现是否满足接口要求
-
Methods:一元方法集,注意结构内的
Handler
方法,其对应最终的 RPC 处理方法,在执行 RPC 方法的阶段会使用。 -
Streams:流式方法集
-
Metadata:元数据,是一个描述数据属性的东西。在这里主要是描述
SearchServiceServer
服务
步骤三:Register Service
func (s *Server) register(sd *ServiceDesc, ss interface{}) { ... srv := &service{ server: ss, md: make(map[string]*MethodDesc), sd: make(map[string]*StreamDesc), mdata: sd.Metadata, } for i := range sd.Methods { d := &sd.Methods[i] srv.md[d.MethodName] = d } for i := range sd.Streams { ... } s.m[sd.ServiceName] = srv }
在最后一步中,我们会将先前的服务接口信息、服务描述信息给注册到内部service
去,以便于后续实际调用的使用。涉及如下:
-
server:服务的接口信息
-
md:一元服务的 RPC 方法集
-
sd:流式服务的 RPC 方法集
-
mdata:metadata,元数据
小结
在这一章节中,主要介绍的是 gRPC Server 在启动前的整理和注册行为,看上去很简单,但其实一切都是为了后续的实际运行的预先准备。因此我们整理一下思路,将其串联起来看看,如下:
三、监听
接下来到了整个流程中,最重要也是大家最关注的监听/处理阶段,核心代码如下:
func (s *Server) Serve(lis net.Listener) error { ... var tempDelay time.Duration for { rawConn, err := lis.Accept() if err != nil { if ne, ok := err.(interface { Temporary() bool }); ok && ne.Temporary() { if tempDelay == 0 { tempDelay = 5 * time.Millisecond } else { tempDelay *= 2 } if max := 1 * time.Second; tempDelay > max { tempDelay = max } ... timer := time.NewTimer(tempDelay) select { case <-timer.C: case <-s.quit: timer.Stop() return nil } continue } ... return err } tempDelay = 0 s.serveWG.Add(1) go func() { s.handleRawConn(rawConn) s.serveWG.Done() }() } }
Serve 会根据外部传入的 Listener 不同而调用不同的监听模式,这也是net.Listener
的魅力,灵活性和扩展性会比较高。而在 gRPC Server 中最常用的就是TCPConn
,基于 TCP Listener 去做。接下来我们一起看看具体的处理逻辑,如下:
-
循环处理连接,通过
lis.Accept
取出连接,如果队列中没有需处理的连接时,会形成阻塞等待。 -
若
lis.Accept
失败,则触发休眠机制,若为第一次失败那么休眠 5ms,否则翻倍,再次失败则不断翻倍直至上限休眠时间 1s,而休眠完毕后就会尝试去取下一个 “它”。 -
若
lis.Accept
成功,则重置休眠的时间计数和启动一个新的 goroutine 调用handleRawConn
方法去执行/处理新的请求,也就是大家很喜欢说的 “每一个请求都是不同的 goroutine 在处理”。 -
在循环过程中,包含了 “退出” 服务的场景,主要是硬关闭和优雅重启服务两种情况。
客户端
一、创建拨号连接
// grpc.Dial(":"+PORT, grpc.WithInsecure()) func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) { cc := &ClientConn{ target: target, csMgr: &connectivityStateManager{}, conns: make(map[*addrConn]struct{}), dopts: defaultDialOptions(), blockingpicker: newPickerWrapper(), czData: new(channelzData), firstResolveEvent: grpcsync.NewEvent(), } ... chainUnaryClientInterceptors(cc) chainStreamClientInterceptors(cc) ... }
grpc.Dial
方法实际上是对于grpc.DialContext
的封装,区别在于ctx
是直接传入context.Background
。其主要功能是创建与给定目标的客户端连接,其承担了以下职责:
-
初始化 ClientConn
-
初始化(基于进程 LB)负载均衡配置
-
初始化 channelz
-
初始化重试规则和客户端一元/流式拦截器
-
初始化协议栈上的基础信息
-
相关 context 的超时控制
-
初始化并解析地址信息
-
创建与服务端之间的连接
连没连
之前听到有的人说调用grpc.Dial
后客户端就已经与服务端建立起了连接,但这对不对呢?我们先鸟瞰全貌,看看正在跑的 goroutine。如下:
我们可以有几个核心方法一直在等待/处理信号,通过分析底层源码可得知。涉及如下:
func (ac *addrConn) connect()func (ac *addrConn) resetTransport()func (ac *addrConn) createTransport(addr resolver.Address, copts transport.ConnectOptions, connectDeadline time.Time)func (ac *addrConn) getReadyTransport()
在这里主要分析 goroutine 提示的resetTransport
方法,看看都做了啥。核心代码如下:
func (ac *addrConn) resetTransport() { for i := 0; ; i++ { if ac.state == connectivity.Shutdown { return } ... connectDeadline := time.Now().Add(dialDuration) ac.updateConnectivityState(connectivity.Connecting) newTr, addr, reconnect, err := ac.tryAllAddrs(addrs, connectDeadline) if err != nil { if ac.state == connectivity.Shutdown { return } ac.updateConnectivityState(connectivity.TransientFailure) timer := time.NewTimer(backoffFor) select { case <-timer.C: ... } continue } if ac.state == connectivity.Shutdown { newTr.Close() return } ... if !healthcheckManagingState { ac.updateConnectivityState(connectivity.Ready) } ... if ac.state == connectivity.Shutdown { return } ac.updateConnectivityState(connectivity.TransientFailure) } }
在该方法中会不断地去尝试创建连接,若成功则结束。否则不断地根据Backoff
算法的重试机制去尝试创建连接,直到成功为止。从结论上来讲,单纯调用DialContext
是异步建立连接的,也就是并不是马上生效,处于Connecting
状态,而正式下要到达Ready
状态才可用。
二、实例化 Service API
type SearchServiceClient interface { Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error) } type searchServiceClient struct { cc *grpc.ClientConn } func NewSearchServiceClient(cc *grpc.ClientConn) SearchServiceClient { return &searchServiceClient{cc} }
这块就是实例 Service API interface,比较简单。
三、调用
// search.pb.go func (c *searchServiceClient) Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error) { out := new(SearchResponse) err := c.cc.Invoke(ctx, "/proto.SearchService/Search", in, out, opts...) if err != nil { return nil, err } return out, nil }
proto 生成的 RPC 方法更像是一个包装盒,把需要的东西放进去,而实际上调用的还是grpc.invoke
方法。如下:
func invoke(ctx context.Context, method string, req, reply interface{}, 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) }
通过概览,可以关注到三块调用。如下:
-
newClientStream:获取传输层 Trasport 并组合封装到 ClientStream 中返回,在这块会涉及负载均衡、超时控制、 Encoding、 Stream 的动作,与服务端基本一致的行为。
-
cs.SendMsg:发送 RPC 请求出去,但其并不承担等待响应的功能。
-
cs.RecvMsg:阻塞等待接受到的 RPC 方法响应结果。
连接
// clientconn.go func (cc *ClientConn) getTransport(ctx context.Context, failfast bool, method string) (transport.ClientTransport, func(balancer.DoneInfo), error) { t, done, err := cc.blockingpicker.pick(ctx, failfast, balancer.PickOptions{ FullMethodName: method, }) if err != nil { return nil, nil, toRPCErr(err) } return t, done, nil }
在newClientStream
方法中,我们通过getTransport
方法获取了 Transport 层中抽象出来的 ClientTransport 和 ServerTransport,实际上就是获取一个连接给后续 RPC 调用传输使用。
四、关闭连接
// conn.Close() func (cc *ClientConn) Close() error { defer cc.cancel() ... cc.csMgr.updateState(connectivity.Shutdown) ... cc.blockingpicker.close() if rWrapper != nil { rWrapper.close() } if bWrapper != nil { bWrapper.close() } for ac := range conns { ac.tearDown(ErrClientConnClosing) } if channelz.IsOn() { ... channelz.AddTraceEvent(cc.channelzID, ted) channelz.RemoveEntry(cc.channelzID) } return nil }
该方法会取消 ClientConn 上下文,同时关闭所有底层传输。涉及如下:
-
Context Cancel
-
清空并关闭客户端连接
-
清空并关闭解析器连接
-
清空并关闭负载均衡连接
-
添加跟踪引用
-
移除当前通道信息
总结
-
gRPC 基于 HTTP/2 + Protobuf。
-
gRPC 有四种调用方式,分别是一元、服务端/客户端流式、双向流式。
-
gRPC 的附加信息都会体现在 HEADERS 帧,数据在 DATA 帧上。
-
Client 请求若使用 grpc.Dial 默认是异步建立连接,当时状态为 Connecting。
-
Client 请求若需要同步则调用 WithBlock(),完成状态为 Ready。
-
Server 监听是循环等待连接,若没有则休眠,最大休眠时间 1s;若接收到新请求则起一个新的 goroutine 去处理。
-
grpc.ClientConn 不关闭连接,会导致 goroutine 和 Memory 等泄露。
-
任何内/外调用如果不加超时控制,会出现泄漏和客户端不断重试。
-
特定场景下,如果不对 grpc.ClientConn 加以调控,会影响调用。
-
拦截器如果不用 go-grpc-middleware 链式处理,会覆盖。
-
在选择 gRPC 的负载均衡模式时,需要谨慎。
参考
-
http://doc.oschina.net/grpc
-
https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
-
https://juejin.im/post/5b88a4f56fb9a01a0b31a67e
-
https://www.ibm.com/developerworks/cn/web/wa-http2-under-the-hood/index.html
-
https://github.com/grpc/grpc-go/issues/1953
-
https://www.zhihu.com/question/52670041
可以拷贝的代码见:
https://github.com/EDDYCJY/blog/blob/master/golang/gRPC/2019-06-28-talking-grpc.md
原文链接:https://mp.weixin.qq.com/s/qet7FX26HGnXgLIG-lOSyw