gRPC 应用程序通常会通过 gRPC 服务和消费者之间的 RPC 来共享信息。在大多数场景中,与服务业务逻辑和消费者直接相关的信息会作为远程方法调用参数的一部分,但在某些场景中,因为预期共享的关于RPC 的信息可能与 RPC 业务上下文并没有关联,所以它们不应该作为RPC 参数的一部分。在这样的场景中,可以使用 gRPC 元数据(gRPCmetadata),元数据可以在 gRPC 服务或 gRPC 客户端发送和接收。如图 5-5 所示,在客户端或服务器端创建的元数据,可以通过 gRPC 头信息在客户端应用程序和服务器端应用程序之间进行交换。元数据的构造遵循键(字符串)–值对的形式。

元数据最常见的一个用途就是在 gRPC 应用程序之间交换安全头信息。

与之类似,可以使用这种方式在 gRPC 应用程序之间交换任意类似信息。拦截器一般会大量使用 gRPC 元数据 API。下面将探讨 gRPC 如何支持在客户端和服务器端之间发送元数据。

图 5-5:在客户端应用程序和服务器端应用程序之间交换元数据

5.6.1 创建和检索元数据

在 gRPC 应用程序中,创建元数据非常简单直接。在如下的 Go 代码片段中,可以发现两种创建元数据的方式。在 Go 语言中,元数据以正常的 map 形式来表述,可以通过metadata.New(map[string]string{"key1": "val1", "key2":"val2"}) 格式进行创建。另外,还可以通过 metadata.Pairs 来创建元数据对,具有相同键的元数据会被合并为一个列表:

// 元数据创建:方案1
md := metadata.New(map[string]string{"key1": "val1", "key2": "val2"})
// 元数据创建:方案2
md := metadata.Pairs(
    "key1", "val1",
    "key1", "val1-2", // "key1"的map值为[]string{"val1", "val1-2"}
    "key2", "val2",
)

二进制数据也可以设置为元数据值。以元数据值形式所设置的二进制数据在发送之前会进行 base64 编码,在传输之后,则会进行解码。

在客户端或服务器端读取元数据,则可以通过传入的 RPC 上下文以metadata.FromIncomingContext(ctx) 函数来实现,它会返回 Go 语言的元数据 map:

func (s *server) AddOrder(ctx context.Context, orderReq *pb.Order)
(*wrappers.StringValue, error) {
md, metadataAvailable := metadata.FromIncomingContext(ctx)
// 从"md"元数据map中读取所需的元数据

对于发送和接收元数据,下面分别来看客户端和服务器端针对不同的一元 RPC 和流 RPC 所用的方式。

5.6.2 发送和接收元数据:客户端

在客户端,要发送元数据到 gRPC 服务,可以创建元数据并将其设置到RPC 上下文中。在 Go 实现中,可以采用两种方式来实现这一点。如代码清单 5-11 所示,可以使用 NewOutgoingContext 创建带有新元数据的上下文,或者使用 AppendToOutgoingContext 将元数据附加到已有的上下文中,但当使用 NewOutgoingContext 时会替换掉上下文中所有已有的元数据。在创建完带有元数据的上下文后,它就可以用于一元RPC 或流 RPC 了。如第 4 章所述,在上下文中所设置的元数据会转换成线路层的 gRPC 头信息(位于 HTTP/2 上)或 trailer。这样一来,在客户端发送这些头信息后,收件方会以头信息的形式接收它们。

代码清单 5-11 在 gRPC 客户端发送元数据

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。

因此,在客户端接收元数据的时候,需要将它们视为头信息或 trailer。

在代码清单 5-12 中,可以看到为一元 RPC 和流 RPC 接收元数据的 Go代码示例。

代码清单 5-12 在 gRPC 客户端接收元数据

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,用于发送状态码和状态消息。

从对应的 RPC 操作获取值之后,就可以像一般的 map 那样对它们进行处理,进而处理所需的元数据。

接下来看一下如何在服务器端发送和接收元数据。

5.6.3 发送和接收元数据:服务器端

在服务器端接收元数据非常简单直接。使用 Go 语言,只需在远程方法调用中使用 metadata.FromIncomingContext(ctx),即可读取元数据(见代码清单 5-13)。

代码清单 5-13 在 gRPC 服务器端读取元数据

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。
  • ❹ 从流中获取上下文并从中读取元数据。

要从服务器端发送元数据,可以根据元数据发送头信息或者设置trailer。创建元数据的方法与前文讨论的相同。在代码清单 5-14 中,可以看到在服务器端自一元 RPC 和流 RPC 远程方法中发送元数据的 Go代码示例。

代码清单 5-14 在 gRPC 服务器端发送元数据

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 一起发送元数据。

在一元 RPC 和流 RPC 这两种场景中,都可以通过 grpc.SendHeader来发送元数据。如果想将元数据作为 trailer 的一部分发送,则需要通过grpc.SetTrailer 或对应流的 SetTrailer 方法,将元数据设置为上下文 trailer 中的一部分。

接下来看一下调用 gRPC 应用程序所涉及的另外一项常用的技术:命名解析。

5.6.4 命名解析器

命名解析器(name resolver)接受一个服务的名称并返回后端 IP 的列表。代码清单 5-15 所使用的解析器会将 lb.example.grpc.io 解析为localhost:50051localhost:50052

代码清单 5-15 gRPC 命名解析器的 Go 语言实现

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:50051localhost:50052
  • ❹ 为 example 模式创建的解析器。
  • ❺ 命名解析器的结构。

基于这个命名解析器实现,可以为所选的任意服务注册中心实现解析器,如 Consul、etcd 和 Zookeeper。gRPC 负载均衡的需求可能非常依赖所使用的部署模式或使用场景。随着容器编排平台(如 Kubernetes)和更高层次抽象(如服务网格)越来越普及,在客户端实现负载均衡逻辑的需求变得越来越小。第 7 章将探索一些在本地容器和 Kubernetes 上部署 gRPC 应用程序的最佳实践。

下面先来看一下 gRPC 应用程序最常见的需求之一,也就是负载均衡,在其中某些特定的情况下,可以使用命名解析器。

文档更新时间: 2023-09-02 06:19   作者:Minho