Junedayday Blog

六月天天的个人博客

0%

Go语言学习 - RPC篇:深入gRPC-Gateway-探索常用数据类型

概览

gRPC-Gateway的相关方案我们已经在上一篇详细描述。为了更方面地方便大家理解,我这边整理了一个最简化的git项目:https://github.com/Junedayday/grpc-gateway-buf-example/tree/v0.0.1

它主要包含两个特点:

  1. 用buf工具构建项目
  2. 同时启动了gRPC和gRPC-Gateway服务,支持两种协议的调用

今天,我们先迈出第一步:探索RPC服务中的数据类型。掌握常见的数据类型,灵活地运用到接口设计中,能帮助我们快速地提供优雅的接口类服务。

基础类型

protobuf的基础数据类型可参考链接:https://developers.google.com/protocol-buffers/docs/proto3#scalar

这部分属于是protobuf的基础知识,如果对这块不清楚,可以花5~10分钟快速过一下。

默认值问题

基础类型有一个很值得思考的问题:每一种基础类型都有一个默认值,如string的默认值为""int32的默认值是0。这就带来了一个问题:当一个字段被解析为默认值时,怎么区分是未传值,还是传的就是默认值

举个具体的例子,比如我们的传入参数为:

1
2
3
4
{
"a":0,
"b":1
}

1
2
3
{
"b":1
}

我们将数据定义为

1
2
3
4
message Foo {
int32 a = 1;
int32 b = 2;
}

最终解析到Go结构体中的Foo.A字段都为0,但是,调用方对 未传值默认值 很可能有不同的定义。

这个问题有三种常规的解决思路:

  1. 利用编程语言特性,区分 未传值默认值 两种情况;
  2. 两边利用协议约定,保证未传值默认值等同;
  3. 新增加描述性字段,表明相关字段是否生效;

为了方便理解,我对上面三个case各举个例子:

方案1 - 在编程语言中区分

Go语言为例,会利用指针的特性,

1
2
3
4
type Foo struct {
A *int32
B *int32
}

在解析示例的json时,可以按如下方式进行区分:

  • 当为默认值0时,将A指向为0的指针
  • 当未传值时,将A指为nil

但是,这种实现对语言有一定要求:

  1. 要求语言支持指针(protobuf目标是跨语言的RPC方案)
  2. 对指针变量的操作需要不少额外的判断、转化操作

虽然方案1的普适性不高,但在Go语言的开源项目中很常见,比如各种共有云的Go SDK。

方案2 - 协议约定效果等同

方案2更多是一种内部约定。比如,定义了一个数据

1
2
3
4
5
message Book {
int64 id = 1;
string name = 2;
float price = 3;
}

双方约定了:无论字段传的是默认值还是未传值,我们都按默认值处理。

但是,在接口中,我们会高频地复用数据结构。例如,Book这个数据结构在创建时没有问题,但将这个结构用在更新接口时,往往会有如下思路:

  • 如果是默认值,接口是希望将这个字段修改为默认值,如name为空
  • 如果未传值,接口是希望不更改这个字段,即不要修改name字段

所以,在方案2时,我们只能二选一:当遇到默认值时,要么认为是不改、要么认为是改成默认值。而如果要兼容,那就新增字段或者新增结构。

方案2虽然存在局限性,但是频率最高的使用方式:毕竟一般情况下调用方就几个,双方简单沟通一下就可以解决问题。但如果面向成百上千的调用方时,这个解释成本就很高了。

下面的方案3则是对其的一种演进:

方案3 - 新增加描述性字段

基于方案2,我们可以直接增加一个字段进行标识(类似于一种掩码的效果),如mask=["id","name"],表示:

  • id,name这两个字段生效
  • price字段不生效

这时,前面的问题就得以解决:

  • 如果希望修改name为空,mask中增加name字段
  • 如果不希望修改name,mask中不出现name字段

这个实现,就是Google推荐的FieldMask的实现思路,下面我们会再次说明。

枚举类型

protobuf的枚举的是一种可读性很强的定义,可以参考如下链接了解:https://developers.google.com/protocol-buffers/docs/proto3#enum

需要注意的是,官方推荐的将默认值0定义为XXX_UNSPECIFIED(即不在规定中,不具备实际意义),如

1
2
3
4
5
enum Corpus {
CORPUS_UNSPECIFIED = 0;
CORPUS_UNIVERSAL = 1;
CORPUS_WEB = 2;
}

它的实现思路与上面的方案2很像:规定默认值为未规定的,是一个无需关心的情况。这就要求使用方尽可能地使用非默认值的枚举值,减少歧义。

特殊类型

Any

1
2
3
4
5
import "google/protobuf/any.proto";

message ErrorStatus {
repeated google.protobuf.Any details = 1;
}

Any可以简单理解为protobuf协议中的任意类型(但必须是由proto定义的)。我们可以从两个问题来理解它:

  • Any如何保证兼容性?
    • 内部将数据转化成了byte数组,就能存储任意数据了
  • Any如何解析到特定的proto结构?
    • 结合上面的byte数组和对应定义的proto文件

因此,传递的数据包含2个字段:

  • byte数组,表示具体数据
  • proto文件的定义,比如 "@type": "type.googleapis.com/junedayday.grpc_gateway_buf_example.echo_service.v1.EchoRequest"

但在实际场景中,Any使用并不方便,往往仅用在protobuf的内部协议中,不适合作为通用的API。

Oneof

1
2
3
4
5
6
message Book {
oneof unique_id {
int64 id = 1;
string uuid = 2;
}
}

Oneof适用的场景是多个字段中仅允许生效其中一个,这避免了理解上的冲突。例如,我们要查找书,每本书有2个唯一标识:iduuid

  • 如果传任意一个,我们能正常地查到
  • 如果同时传了iduuid,可能存在多种理解:
    • 同时根据两个条件查
    • 先根据id查,未查到再根据uuid查
    • 现根据uuid查,未查到再根据id查

从调用方来说,只能阅读你的接口文档,阅读各字段的注释。而Oneof字段呢,就在接口定义上直接告诉了你,二者只能选其一;如果你硬要传2个参数,就直接返回参数错误。

Oneof特性看起来很好用,但实际接口开发中的使用频率很低,毕竟通过有效的注释或者接口拆分,也能解决这个问题。

map

1
2
3
message EchoRequest {
map<string, string> info = 1;
}

map是一个很常用的特性,定义和使用也十分简单。如示例,就会自动对应到Go语言中的map[string]string

但从API的设计来说,map这个容器有很高的扩展性,缺牺牲了一定的可读性,如key中代表的含义、有哪些限制等等,只能通过注释进行说明。

因此,map的特性要节制地使用,优先考虑用明确的结构定义来表示。

扩展类型

Value

1
2
3
4
5
import "google/protobuf/struct.proto";

message EchoRequest {
google.protobuf.Value info = 1;
}

不同于AnyValue不需要依赖proto的定义,更趋近于通用意义上的泛型。它本质上是一种Oneof

1
2
3
4
5
6
7
8
9
10
message Value {
oneof kind {
NullValue null_value = 1;
double number_value = 2;
string string_value = 3;
bool bool_value = 4;
Struct struct_value = 5;
ListValue list_value = 6;
}
}

内部也提供了多个数据类型的转化,可按需调用,如GetXXXValue()

Struct

1
2
3
4
5
import "google/protobuf/struct.proto";

message EchoRequest {
google.protobuf.Struct info = 1;
}

Strcut可快速对应到Go语言中的结构体,可以快速地转化为 map[string]structpb.Value。接下来的使用方式同上面的Value

FieldMask

1
2
3
4
5
import "google/protobuf/field_mask.proto";

message EchoRequest {
google.protobuf.FieldMask field_mask = 1;
}

FieldMask就是上面基础类型中方案3的具体实现。它的定义很简单,就是一个字符串的数组:

1
2
3
message FieldMask {
repeated string paths = 1;
}

这里面的每个元素,表示一个具体要生效的字段,支持多层的数据结构,如a.b

Duration

持续时间,需要一个数字+单位,如2s,减少了单位理解上的歧义。它由两个部分组成,很容易理解

1
2
3
4
message Duration {
int64 seconds = 1;
int32 nanos = 2;
}

TimeStamp

时间处理是一个很麻烦的方式,我们往往是采用string的方式传递、然后再次解析,相对来说比较折腾。

而官方提供了如下方式

1
2
3
4
5
import "google/protobuf/timestamp.proto";

message EchoRequest {
google.protobuf.Timestamp time_stamp = 1;
}

我们可以利用AsTime()方法,快速地转化到Go语言中的time.Time结构,非常省力。对与输入方来说,时间要遵循 rfc3339 格式,如 2006-01-02T15:04:05Z

虽然我们更常用YYYY-MM-DD HH:mm:ss来表示,但rfc3339更具兼容性,建议尽可能地尝试替换。

小结

除了基础类型和枚举,我对今天谈到了8种类型进行了简单的概括:

数据类型 使用频率 可读性
Any
Oneof
map
Value
Struct
FieldMask
Duration
TimeStamp

同时,文中对默认值问题的分析,也希望能对大家在接口设计上有一定的启发。

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码