构建优雅的API(一):省流版接口

#摘要

为了减少网络流量,有时可允许客户端限制服务器应在其响应中返回的资源部分,即返回资源的一部分字段而非完整的资源表示形式。本文讨论了一种简单的不需定义额外消息结构的实现方式,保证了灵活和可维护性。

#背景

比如下图豆瓣榜单的列表页和详情页,前者只需要展示一些简略的描述信息,如标题,导演,演员,时间和评分等,待到用户感兴趣点击进入后者再展示具体完整的内容,通常信息量会多得多。

Alt text Alt text

常见的做法是在列表页的接口定义个简略的资源消息比如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;
    // ....
}

然后分别在ListItemGetItem这俩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返回之前,调用上述函数。

#效果

Alt text Alt text

如图所示,在我们的测试环境中,请求列表单页数量为1的情况下,BRIEFDETAIL视图的content-length分别为642和11069,减少了**94%**的内容传输量。在列表长度更大的情况下,差距会更明显。

当然不同的业务数据对比会很不一样,而且重新定义一个简略版消息类型也可以做到。本文讨论的主旨是,在保证接口简单一致前提下,提供一种灵活可维护的API设计

#兼容性

因为资源视图枚举View是新增字段,默认值与原有的逻辑兼容即可。

#更进一步

本文讨论的是server side的预设资源视图集合,更进一步,如果也不满足客户端怎么办?有不有一种方法,可以像graphql那样,让client side选择他想要的字段?

答案是有的,这时候就需要用到field mask,它其实也可以用于本文存储资源视图字段集合。

感兴趣的同学可以关注下一篇文章,嘻嘻。

#参考