概览
gRPC-Gateway的相关方案我们已经在上一篇详细描述。为了更方面地方便大家理解,我这边整理了一个最简化的git项目:https://github.com/Junedayday/grpc-gateway-buf-example/tree/v0.0.1
它主要包含两个特点:
- 用buf工具构建项目
- 同时启动了gRPC和gRPC-Gateway服务,支持两种协议的调用
今天,我们先迈出第一步:探索RPC服务中的数据类型。掌握常见的数据类型,灵活地运用到接口设计中,能帮助我们快速地提供优雅的接口类服务。
基础类型
protobuf
的基础数据类型可参考链接:https://developers.google.com/protocol-buffers/docs/proto3#scalar
这部分属于是protobuf
的基础知识,如果对这块不清楚,可以花5~10分钟快速过一下。
默认值问题
基础类型有一个很值得思考的问题:每一种基础类型都有一个默认值,如string
的默认值为""
,int32
的默认值是0
。这就带来了一个问题:当一个字段被解析为默认值时,怎么区分是未传值,还是传的就是默认值?
举个具体的例子,比如我们的传入参数为:
代码语言:javascript复制{
"a":0,
"b":1
}
或
代码语言:javascript复制{
"b":1
}
我们将数据定义为
代码语言:javascript复制message Foo {
int32 a = 1;
int32 b = 2;
}
最终解析到Go结构体中的Foo.A
字段都为0,但是,调用方对 未传值
和 默认值
很可能有不同的定义。
这个问题有三种常规的解决思路:
- 利用编程语言特性,区分
未传值
与默认值
两种情况; - 两边利用协议约定,保证
未传值
与默认值
等同; - 新增加描述性字段,表明相关字段是否生效;
为了方便理解,我对上面三个case各举个例子:
方案1 - 在编程语言中区分
以Go
语言为例,会利用指针的特性,
type Foo struct {
A *int32
B *int32
}
在解析示例的json时,可以按如下方式进行区分:
- 当为默认值0时,将A指向为0的指针
- 当未传值时,将A指为nil
但是,这种实现对语言有一定要求:
- 要求语言支持指针(
protobuf
目标是跨语言的RPC方案) - 对指针变量的操作需要不少额外的判断、转化操作
虽然方案1的普适性不高,但在Go语言的开源项目中很常见,比如各种共有云的Go SDK。
方案2 - 协议约定效果等同
方案2更多是一种内部约定。比如,定义了一个数据
代码语言:javascript复制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
(即不在规定中,不具备实际意义),如
enum Corpus {
CORPUS_UNSPECIFIED = 0;
CORPUS_UNIVERSAL = 1;
CORPUS_WEB = 2;
}
它的实现思路与上面的方案2很像:规定默认值为未规定的,是一个无需关心的情况。这就要求使用方尽可能地使用非默认值的枚举值,减少歧义。
特殊类型
Any
代码语言:javascript复制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
代码语言:javascript复制message Book {
oneof unique_id {
int64 id = 1;
string uuid = 2;
}
}
Oneof
适用的场景是多个字段中仅允许生效其中一个,这避免了理解上的冲突。例如,我们要查找书,每本书有2个唯一标识:id
和uuid
。
- 如果传任意一个,我们能正常地查到
- 如果同时传了
id
和uuid
,可能存在多种理解:- 同时根据两个条件查
- 先根据id查,未查到再根据uuid查
- 现根据uuid查,未查到再根据id查
从调用方来说,只能阅读你的接口文档,阅读各字段的注释。而Oneof
字段呢,就在接口定义上直接告诉了你,二者只能选其一;如果你硬要传2个参数,就直接返回参数错误。
Oneof
特性看起来很好用,但实际接口开发中的使用频率很低,毕竟通过有效的注释或者接口拆分,也能解决这个问题。
map
代码语言:javascript复制message EchoRequest {
map<string, string> info = 1;
}
map是一个很常用的特性,定义和使用也十分简单。如示例,就会自动对应到Go语言中的map[string]string
。
但从API的设计来说,map
这个容器有很高的扩展性,缺牺牲了一定的可读性,如key中代表的含义、有哪些限制等等,只能通过注释进行说明。
因此,map
的特性要节制地使用,优先考虑用明确的结构定义来表示。
扩展类型
Value
代码语言:javascript复制import "google/protobuf/struct.proto";
message EchoRequest {
google.protobuf.Value info = 1;
}
不同于Any
,Value
不需要依赖proto的定义,更趋近于通用意义上的泛型。它本质上是一种Oneof
:
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
代码语言:javascript复制import "google/protobuf/struct.proto";
message EchoRequest {
google.protobuf.Struct info = 1;
}
Strcut
可快速对应到Go语言中的结构体,可以快速地转化为 map[string]structpb.Value
。接下来的使用方式同上面的Value
。
FieldMask
代码语言:javascript复制import "google/protobuf/field_mask.proto";
message EchoRequest {
google.protobuf.FieldMask field_mask = 1;
}
FieldMask
就是上面基础类型中方案3的具体实现。它的定义很简单,就是一个字符串的数组:
message FieldMask {
repeated string paths = 1;
}
这里面的每个元素,表示一个具体要生效的字段,支持多层的数据结构,如a.b
。
Duration
持续时间,需要一个数字 单位,如2s
,减少了单位理解上的歧义。它由两个部分组成,很容易理解
message Duration {
int64 seconds = 1;
int32 nanos = 2;
}
TimeStamp
时间处理是一个很麻烦的方式,我们往往是采用string
的方式传递、然后再次解析,相对来说比较折腾。
而官方提供了如下方式
代码语言:javascript复制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