#摘要
为了减少网络流量,有时可允许客户端限制服务器应在其响应中返回的资源部分,即返回资源的一部分字段而非完整的资源表示形式。本文讨论了一种简单的不需定义额外消息结构的实现方式,保证了灵活和可维护性。
#背景
比如下图豆瓣榜单的列表页和详情页,前者只需要展示一些简略的描述信息,如标题,导演,演员,时间和评分等,待到用户感兴趣点击进入后者再展示具体完整的内容,通常信息量会多得多。
常见的做法是在列表页的接口定义个简略的资源消息比如ItemBrief
返回,然后详情页返回完整的ItemDetail
,也就是两个消息结构,用protobuf可能表示成如下:
通常会有嵌套的复杂消息结构,这里为了保持简单扁平不讨论
message ItemBrief {
string title = 1;
string director = 2;
repeated string actors = 3;
string poster = 4;
}
message ItemDetail {
string title = 1;
string director = 2;
repeated string actors = 3;
string poster = 4;
string desc = 5;
repeated string actors_info = 6;
string discussion = 7;
// ....
}
然后分别在ListItem
和GetItem
这俩rpc中作为返回值。这样做比较直接,但增加了认知成本和少了灵活性。
一方面,API的使用者需要知道同一个资源存在多种的表示形式的数据结构;另一方面,如果客户在某些场景比如list接口需要detail信息,但是只有get接口才有,这样又丧失了灵活性。
更进一步,本着接口应该尽可能正交的原则,也就是说接口之间尽可能无关,没有这种约束,随着需求迭代,功能相似的接口很可能越来越多,对客户造成困惑,对自身也产生维护成本。
对于上述一增一减问题,这里提出一种更好的办法,同时满足以上两点要求。
#方案
答案是返回部分响应,也就是允许客户端指定响应资源的部分。具体来说,我们只有一个资源消息:
message Item {
string id = 1;
string title = 2;
string author = 3;
int32 view_count = 5;
string create_time = 4;
string desc = 5;
string content = 6;
// ...
}
然后定义一个枚举类型:
enum ItemView {
// Not specified, default to BRIEF.
ITEM_VIEW_UNSPECIFIED = 0;
// Show only brief fields:
// - id
// - title
// - author
// - view_count
// - create_time
BRIEF = 1;
// Show all the fields.
DETAIL = 2;
}
这枚举用于表示资源视图,也就是资源以怎样的形式呈现:
unspecified
是默认值,即用户未指定,服务器按照自己的方式来,比如list接口返回简洁资源,而get接口返回完整资源brief
简洁版,只返回某些预设的字段detail
完整版,返回全部字段
然后在rpc中,将这个枚举字段增加到请求消息中:
// 获取合同对比任务列表
service Service {
rpc ListItem(ListItemRequest) returns (ListItemResponse) {
option (google.api.http) = {
get: "/v1/items"
};
};
rpc GetItem(GetItemRequest) returns (Item) {
option (google.api.http) = {
get: "/v1/items/{id}"
};
};
}
message ListItemRequest {
int32 page_size = 1;
// ... other fields
// view layout
View view = 2;
// TODO: field mask!
}
message ListItemResponse {
repeated Item items = 1;
int32 total_size = 2;
}
鉴于Get接口一般返回完整版,所以只讨论List接口,新增的view参数可以让用户指定返回的资源视图是简略还是完整版,并且Get和List接口返回的资源数据结构是一样的,甚至,我们还可以允许客户端选择返回的字段。这样就达到了简单一致和灵活性。客户端不需要定义额外的数据类型去做映射。
下面来看下具体怎么做。
#实现
Google在2020年初发布了新的protocol buffer的Golang库,提供了反射的API,使得我们可以递归遍历消息的结构,然后查找字段是否落在指定view的节点里,否则就利用API将该其值清除掉(变成零值),这样最终就得到了想要的字段集合。
具体实现如下(可以用别的数据结构存储想要字段集合,这里只是简单的比较):
func mask(msg proto.Message) {
pr := msg.ProtoReflect()
pr.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool {
if fd.Name() != "required fields" { // 判断是否需要该字段
if fd.Kind() == protoreflect.MessageKind {
mask(v.Message().Interface()) // recursive
}
return true
}
pr.Clear(fd) // 清除该字段
return true
})
}
然后在rpc返回之前,调用上述函数。
#效果
如图所示,在我们的测试环境中,请求列表单页数量为1的情况下,BRIEF
和DETAIL
视图的content-length
分别为642和11069,减少了**94%**的内容传输量。在列表长度更大的情况下,差距会更明显。
当然不同的业务数据对比会很不一样,而且重新定义一个简略版消息类型也可以做到。本文讨论的主旨是,在保证接口简单一致前提下,提供一种灵活可维护的API设计。
#兼容性
因为资源视图枚举View是新增字段,默认值与原有的逻辑兼容即可。
#更进一步
本文讨论的是server side的预设资源视图集合,更进一步,如果也不满足客户端怎么办?有不有一种方法,可以像graphql那样,让client side选择他想要的字段?
答案是有的,这时候就需要用到field mask,它其实也可以用于本文存储资源视图字段集合。
感兴趣的同学可以关注下一篇文章,嘻嘻。