gRPC基本原理

551 阅读12分钟

GRPC的优势

  • 高效的进程通信

GRPC的序列化方式并不是JSON或者XML的文本形式,而是基于protol协议的二进制流,数据量更小更高效

  • 简单且定义良好的接口

gRPC提供一种基于pb文件的接口契约方式,然后可以通过插件自动生成对应语言的文件,如.go、.class等。简单明确又快捷得完成协议代码的开发。

  • 强类型

pb文本中的变量类型都是强类型,如map、string、int8等,这样能克服运行时错误,保证应用的稳定。

  • 支持多语言

之前也提到可以通过插件生成对应语言的协议文件,实现跨语言开发和通信。通常都是面向内部的通信。如果要面向外部,则需要API网关来实现认证、版本、负载均衡等。

  • 云原生支持

gRPC本身就是CNCF的一部分,里面很多项目都已支持gRPC,对于gRPC应用也很好地支持了度量指标和监控,比如prometheus。

通信模式

一元模式

简单来说就是一个请求一个响应模式,大部分进程间的通信都是如此

服务端流模式

即一个请求多个回应,pb文件不同之处在于returns(Result),变成returns(stream Result)。在服务端则是循环向流中写入,而客户端的响应代码中就要添加循环读取的代码,直到流关闭。

客户端流模式

与服务端流模式相反,是多个请求一个响应。区别就是func(Req)变成func(stream Req)。同理客户端循环写入流,而服务器循环读取流,直到流关闭。

双工模式

前两者的组合。。。

底层原理

rpc流

在 RPC 系统中, 服务器端会实现一组可以远程调用的方法。 客户端会 生成一个存根, 该存根为服务器端的方法提供抽象。 这样一来, 客户端 应用程序可以直接调用存根方法, 进而调用服务器端应用程序的远程方法。

当客户端调用一个grpc方法时,会发生什么呢?

  1. 客户端通过存根调用方法,如getProduct
  2. 客户端创建http post请求。content-type 前缀为 application/grpc,(/ProductInfo/getProduct)则是以请求头的方式保存
  3. http请求发送给服务端
  4. 服务端检查请求头,确定要调用的方法,然后将响应传递给grpc服务端骨架。
  5. 服务端骨架解析消息,发起本地的getProduct调用。

使用protocol buffer编码消息

消息都是由一组标签+值组成的。标签又是由字段索引和线路类型组成。字段就是pb文件里字段设置的唯一数字。线路类型则是基于字段类型的,方便确定值的长度。标签=字段索引<<3|字段类型。对于值的编码,则要根据值的类型,如string类型则是UTF-8编码。最后标签+编码后的值汇总成一个字节流,这就是编码后的消息。

基于长度的消息分帧

消息分帧=压缩位+4字节长度位+消息组成,先计算消息的长度,然后用大端方式存入到长度位。

基于http2.0的gRPC

先熟悉一下http2.0的术语

  • 帧(frame):HTTP/2 中最小的通信单元。 每一帧都包含一个 帧头, 它至少要标记该帧所属的流。
  • 消息(message):完整的帧序列,映射为一条逻辑上的 HTTP 消息,由一帧或多帧组成。 这样的话,允许消息进行多路复用, 客户端和服务器端能够将消息分解成独立的帧, 交叉发送 它们, 然后在另一端进行重新组合。
  • 流(stream):在一个已建立的连接上的双向字节流。 一个流 可以携带一条或多条消息。

请求消息

包含三部分:请求头、消息、流结束标识(EOS)。以上述getProduct为例,请求消息则如下

HEADERS (flags = END_HEADERS)
:method = POST ➊
:scheme = http ➋
:path = /ProductInfo/getProduct ➌
:authority = abc.com ➍
te = trailers ➎
grpc-timeout = 1S ➏
content-type = application/grpc ➐
grpc-encoding = gzip ➑
authorization = Bearer xxxxxx 
  • ❶ 定义HTTP方法。对gRPC来说,:method头信息始终为POST。
  • ❷ 定义HTTP模式。如果启用传输层安全协议(Transport Level Security,TLS),就将模式设置为https,否则设置为http。
  • ❸ 定义端点路径。对gRPC来说,这个值的构造为/{服务名}/{方法名}。
  • ❹ 定义目标URI的虚拟主机名。
  • ❺ 定义对不兼容代理的检测。在gRPC中,这个值必须为 trailers。
  • ❻ 定义调用的超时时间。如果没有指定,服务器端会假定超时时间无 穷大。
  • ❼ 定义 content-type。对gRPC 来说,content-type 应该以application/grpc 开头。 否则,gRPC会给出HTTP状态为415(不支持的媒体类型)的响应。
  • ❽ 定义消息的压缩类型。可选的值是identity、gzip、deflate、snappy和{custom}。
  • ❾ 这是可选的元数据。authorization元数据用来访问安全的端点。

响应消息

响应消息由响应头、以长度为前缀的消息、trailer组成。 请求头如下

HEADERS (flags = END_HEADERS)
:status = 200 ➊
grpc-encoding = gzip ➋
content-type = application/grpc ➌

结束标志不会随着消息发送,而是单独的头消息发送。

HEADERS (flags = END_STREAM, END_HEADERS)
grpc-status = 0 # OK ➊
grpc-message = xxxxxx ➋

理解通信模式中的消息流

一元模式

客户端发送请求之后,会处于半关闭状态,自身无法向服务端发送数据,只能接收服务端的数据。服务端收到请求后,返回响应,然后整个通信关闭。

image.png

服务端流模式

客户端无差别,只是服务端会持续地发送消息,直到客户端全部接收完,然后发送trailer消息,整个通信关闭。

image.png

客户端流模式

服务端与一元模式无差别,客户端会持续发送以长度为前缀的消息。服务端接收完请求后,回复响应,整个通信结束

image.png

双向流模式

客户端和服务端两种模式的综合,双方无需等待结束,自行发送消息且自行关闭连接,直到双方都关闭,通信结束。

image.png

grpc实现架构

简单可分为应用层、核心层、传输层。应用层是供用户使用的,核心层封装了整个网络请求过程,使得远程调用如同本地调用一般。传输层则是http/2+SSL层,作为通信协议支撑。

image.png

小结

gRPC通过protocol buffer和http/2两种协议,建立一个简单、高效的远程服务调用框架。前者通过二进制极致压缩传输数据,后者通过多路复用提高通信效率。

高级应用

除了提供基础的远程调用服务,gRPC还提供了诸多高级功能,其中有拦截器、截止时间、取消、多路复用、错误处理、元数据、负载均衡。

拦截器

方法调用前通常会执行一些通用操作,比如日志、认证、性能度量等。此时就可以使用gRPC的拦截器功能。拦截器分为一元拦截器和流式拦截器,满足多种通信模式。

服务端拦截器

  1. 一元拦截器
// 服务器端一元拦截器
func orderUnaryServerInterceptor(ctx context.Context, req interface{},
    info *grpc.UnaryServerInfo, handler grpc.UnaryHandler)(interface{}, error) {
    // 前置处理逻辑
    
    // 通过检查传入的参数, 获取关于当前RPC的信息
    log.Println("======= [Server Interceptor] ", info.FullMethod) ➊
    
    // 调用handler完成一元RPC的正常执行
    m, err := handler(ctx, req) ➋
    
    // 后置处理逻辑
    log.Printf(" Post Proc Message : %s", m) ➌
    return m, err ➍
} 
// ...
func main() {
...
// 在服务器端注册拦截器
s := grpc.NewServer(grpc.UnaryInterceptor(orderUnaryServerInterceptor)) ➎
  • ❶ 前置处理阶段:可以在调用对应的RPC之前拦截消息。
  • ❷ 通过UnaryHandler调用RPC方法。
  • ❸ 后置处理阶段:可以在这里处理RPC响应。
  • ❹ 将RPC响应发送回去。
  • ❺ 使用gRPC服务器端注册一元拦截器。
  1. 流式拦截器
// 服务器端流拦截器
// wrappedStream包装嵌入的grpc.ServerStream
// 并拦截对RecvMsg函数和SendMsg函数的调用
type wrappedStream struct { 
    ➊grpc.ServerStream
} 
➋func (w *wrappedStream) RecvMsg(m interface{}) error {
    log.Printf("====== [Server Stream Interceptor Wrapper] " + 
    "Receive a message (Type: %T) at %s",m, time.Now().Format(time.RFC3339))
    return w.ServerStream.RecvMsg(m)
} 
➌func (w *wrappedStream) SendMsg(m interface{}) error {
    log.Printf("====== [Server Stream Interceptor Wrapper] " +
    "Send a message (Type: %T) at %v",
    m, time.Now().Format(time.RFC3339))
    return w.ServerStream.SendMsg(m)
} 
➍func newWrappedStream(s grpc.ServerStream) grpc.ServerStream {
    return &wrappedStream{s}
} 
➎func orderServerStreamInterceptor(srv interface{},ss grpc.ServerStream, info *grpc.StreamServerInfo,handler grpc.StreamHandler) error {
    log.Println("====== [Server Stream Interceptor] ",info.FullMethod) ➏
    err := handler(srv, newWrappedStream(ss)) ➐
    if err != nil {
        log.Printf("RPC failed with error %v", err)
    }
    return err
} 
...
// 注册拦截器
s := grpc.NewServer(
grpc.StreamInterceptor(orderServerStreamInterceptor)) ➑
...
  • ❶ grpc.ServerStream的包装器流。
  • ❷ 实现包装器的RecvMsg函数,来处理流RPC所接收到的消息。
  • ❸ 实现包装器的SendMsg函数,来处理流RPC所发送的消息。
  • ❹ 创建新包装器流的实例。
  • ❺ 流拦截器的实现。
  • ❻ 前置处理阶段。
  • ❼ 使用包装器流调用流 RPC。
  • ❽ 注册拦截器。

客户端拦截器

  1. 一元拦截器

和服务端类似。主要分为前置处理、执行、后置处理三步。go参考代码如下:

func orderUnaryClientInterceptor(ctx context.Context, method string, req, reply interface{},cc *grpc.ClientConn,invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
    // 前置处理阶段
    log.Println("Method : " + method) ➊
    // 调用远程方法
    err := invoker(ctx, method, req, reply, cc, opts...) ➋
    // 后置处理阶段
    log.Println(reply) ➌
    return err ➍
}
...
func main() {
    // 建立到服务器端的连接
    conn, err := grpc.Dial(address, grpc.WithInsecure(),
    grpc.WithUnaryInterceptor(orderUnaryClientInterceptor)) ➎
...
  • ❶ 前置处理阶段能够在RPC请求发送至服务器端之前访问它。
  • ❷ 通过UnaryInvoker调用RPC方法。
  • ❸ 后置处理阶段,可以在这里处理响应结果或错误结果。
  • ❹ 向gRPC客户端应用程序返回错误,同时包含作为参数传递进来的答复。
  • ❺ 通过传入一元拦截器作为grpc.Dial 的选项,建立到服务器端的连接
  1. 流式拦截器

和服务端流式类似,主要分为前置处理、包装、注册、拦截器处理

func clientStreamInterceptor(ctx context.Context, desc *grpc.StreamDesc,cc *grpc.ClientConn, method string,streamer grpc.Streamer, opts ...grpc.CallOption)(grpc.ClientStream, error) {
    log.Println("======= [Client Interceptor] ", method) ➊
    s, err := streamer(ctx, desc, cc, method, opts...) ➋
    if err != nil {
        return nil, err
    }
    return newWrappedStream(s), nil ➌
} 
type wrappedStream struct { ➍
    grpc.ClientStream
} 
func (w *wrappedStream) RecvMsg(m interface{}) error { ➎
    log.Printf("====== [Client Stream Interceptor] " +
    "Receive a message (Type: %T) at %v",
    m, time.Now().Format(time.RFC3339))
    return w.ClientStream.RecvMsg(m)
} 
func (w *wrappedStream) SendMsg(m interface{}) error { ➏
    log.Printf("====== [Client Stream Interceptor] " +
    "Send a message (Type: %T) at %v",
    m, time.Now().Format(time.RFC3339))
    return w.ClientStream.SendMsg(m)
}
func newWrappedStream(s grpc.ClientStream) grpc.ClientStream {
    return &wrappedStream{s}
} 
...
func main() {
// 建立到服务器端的连接
conn, err := grpc.Dial(address, grpc.WithInsecure(),grpc.WithStreamInterceptor(clientStreamInterceptor)) ➐
  • ❶ 前置处理阶段能够在将RPC请求发送至服务器端之前访问它。
  • ❷ 调用传入的streamer来获取ClientStream。
  • ❸ 包装 ClientStream, 使用拦截逻辑重载其方法并返回给客户端应用程序。
  • ❹ grpc.ClientStream 的包装器流。
  • ❺ 拦截流 RPC 所接收消息的函数。
  • ❻ 拦截流 RPC 所发送消息的函数。
  • ❼ 注册流拦截器。

截止时间

截止时间通常用于防止因gRPC超时而阻塞自身的一种措施。不同语言的gRPC api,有不同的截止时间,如go是通过context.withDeadLine来实现,而Java则是通过sdk包自带的实现。

取消

有时客户端会因某些原因而取消gRPC的调用,与截止时间类似,不同语言不同实现。go是通过context.withCancel来实现.

错误处理

gRPC提供了一套内置的错误码和错误信息,详情可以查看官网。通常会对原有的错误信息进行包装,只有几种语言才能获取到详情,如go、Java等。

多路复用

gRPC支持同一服务器内多个gRPC实例,也支持多个客户端访问同一个gRPC实例。但生产中,往往一台服务器部署一个gRPC服务,多个gRPC服务的负载均衡通常交给k8s来完成。

元数据

服务调用中往往涉及一些与服务业务无关的数据,比如日志、认证等,这时可以使用元数据来传输,拦截器一般就会大量用到元数据。

客户端元数据发送和接收

以go为例

发送元数据

    md := metadata.Pairs(
    "timestamp", time.Now().Format(time.StampNano),
        "kn", "vn",
    ) ➊
    mdCtx := metadata.NewOutgoingContext(context.Background(), md) ➋
    ctxA := metadata.AppendToOutgoingContext(mdCtx,
        "k1", "v1", "k1", "v2", "k2", "v3") ➌
    // 发送一元RPC
    response, err := client.SomeRPC(ctxA, someRequest) ➍
    // 也可以发送流RPC
    stream, err := client.SomeStreamingRPC(ctxA) ➎
  • ❶ 创建元数据。
  • ❷ 基于新的元数据创建新的上下文。
  • ❸ 在现有的上下文中附加更多的元数据。
  • ❹ 一元 RPC 使用带有元数据的新上下文。
  • ❺ 相同的上下文也可用于流 RPC。

接收元数据

    var header, trailer metadata.MD ➊
    // *****一元RPC*****
    r, err := client.SomeRPC( ➋
        ctx,someRequest,
        grpc.Header(&header),
        grpc.Trailer(&trailer),
    ) 
    // 在这里处理头信息和trailer map

    // *****流RPC*****
    stream, err := client.SomeStreamingRPC(ctx)
    // 检索头信息
    header, err := stream.Header() ➌
    // 检索trailer
    trailer := stream.Trailer() ➍
    // 在这里处理头信息和trailer map
  • ❶ 用来存储 RPC 所返回的头信息和 trailer 的变量。
  • ❷ 传递头信息和 trailer 引用来存储一元 RPC 所返回的值。
  • ❸ 从流中获取头信息。
  • ❹ 从流中获取 trailer, 用于发送状态码和状态消息。

进行对应操作之后,就可以像map一样操作元数据了。

服务端元数据发送与接收

接收元数据


func (s *server) SomeRPC(ctx context.Context,
    in *pb.someRequest) (*pb.someResponse, error) { 
        ➊md, ok := metadata.FromIncomingContext(ctx) 
        ➋// 使用元数据执行某些操作
} 
func (s *server) SomeStreamingRPC(
    stream pb.Service_SomeStreamingRPCServer) error { ➌
    md, ok := metadata.FromIncomingContext(stream.Context()) ➍
    // 使用元数据执行某些操作
}
  • ❶一元 RPC。
  • ❷ 从远程方法传入的上下文中读取元数据 map。
  • ❸ 流 RPC。
  • ❹ 从流中获取上下文并从中读取元数据。

发送元数据

func (s *server) SomeRPC(ctx context.Context, in *pb.someRequest) (*pb.someResponse, error) {
    // 创建并发送头信息
    header := metadata.Pairs("header-key", "val")
    grpc.SendHeader(ctx, header) ➊
    // 创建并设置trailer
    trailer := metadata.Pairs("trailer-key", "val")
    grpc.SetTrailer(ctx, trailer) ➋
} 
func (s *server) SomeStreamingRPC(stream pb.Service_SomeStreamingRPCServer) error {
    // 创建并发送头信息
    header := metadata.Pairs("header-key", "val")
    stream.SendHeader(header) ➌
    // 创建并设置trailer
    trailer := metadata.Pairs("trailer-key", "val") stream.SetTrailer(trailer)➍
}
  • ❶ 以头信息的形式发送元数据。
  • ❷ 和 trailer 一起发送元数据。
  • ❸ 在流中, 以头信息的形式发送元数据。
  • ❹ 和流的 trailer 一起发送元数据。

命名解析器

在一个复杂的微服务系统中,服务端往往都是多个节点的,那么客户端是如何感知到有多个节点呢?那就是通过命名解析器了。命名解析器接收一个服务名称,然后返回其后端节点的ip列表。

type exampleResolverBuilder struct{} ➊

func (*exampleResolverBuilder) Build(target resolver.Target,cc resolver.ClientConn,opts resolver.BuildOption) (resolver.Resolver, error) {
    r := &exampleResolver{ ➋
        target: target,
        cc: cc,
        addrsStore: map[string][]string{
            exampleServiceName: addrs, ➌
        },
    }
    r.start()
    return r, nil
}
func (*exampleResolverBuilder) Scheme() string { return exampleScheme } ➍

type exampleResolver struct { ➎
    target resolver.Target
    cc resolver.ClientConn
    addrsStore map[string][]string
} 
func (r *exampleResolver) start() {
    addrStrs := r.addrsStore[r.target.Endpoint]
    addrs := make([]resolver.Address, len(addrStrs))
    for i, s := range addrStrs {
        addrs[i] = resolver.Address{Addr: s}
    }
    r.cc.UpdateState(resolver.State{Addresses: addrs})
}
func (*exampleResolver) ResolveNow(o resolver.ResolveNowOption) {}
func (*exampleResolver) Close() {}
func init() {
    resolver.Register(&exampleResolverBuilder{})
}
  • ❶命名解析器构建器。
  • ❷ 创建解析lb.example.grpc.io的示例解析器。
  • ❸ 将lb.example.grpc.io解析为localhost:50051和localhost:50052。可静态也可从外部获取,比如etcd等。
  • ❹ 为example模式创建的解析器。
  • ❺ 命名解析器的结构。

负载均衡

gRPC通常是两种负载均衡机制:负载均衡器代理和客户端负载均衡。当然随着k8s的兴起,负载均衡通常交给k8s来完成。

负载均衡器代理

负载均衡器代理通常是由一个负载均衡器来发现和分发gRPC服务节点,客户端只需要知道负载均衡器代理的端点即可。常用的有Nginx、Envoy。

客户端负载均衡

客户端通过命名解析器来获取服务端的ip列表,然后在客户端侧就实现负载均衡。