grpc中使用fieldmask以提升接口的灵活性

最近刷到一篇Netflix的技术博客,提到用 FieldMask 在grpc中实现类似 GraphQL 中的 field selectors 或 Json Api 标准中的 Sparse Fieldsets

文章中提到的痛点目前公司系统中也是存在的,但是没有那么明显,通过很不优雅的添加一个额外标记字段解决了99%的问题。如今遇到好的解决方案记录,学习以增加姿势储备

痛点

  1. 文章中提到,Netflix后端通信使用grpc。在处理请求时,如果能知道哪些响应字段是可忽略不返回的,是很有用的,毕竟某些字段可能需要经过复杂的下游请求或密集计算的结果
    对于此痛点,在GraphQ / Json Api 标准中,分别有对应的解决方案 field selectors / Sparse Fieldsets,而在grpc场景下,他们选择了 FieldMask
  2. 对于一个拥有众多属性的对象要进行更新操作时,本着职责单一的原则,一个需求会定义一个接口,会导致接口数量随着操作需求的增加而不断增加
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // separate RPC for every field, not recommended
    service ProductionService {
    rpc UpdateProductionFormat (UpdateProductionFormatRequest) {...}

    rpc UpdateProductionTitle (UpdateProductionTitleRequest) {...}

    rpc UpdateProductionSchedule (UpdateProductionScheduleRequest) {...}

    rpc UpdateProductionScripts (UpdateProductionScriptsRequest) {...}
    }

    message UpdateProductionFormatRequest {...}

    message UpdateProductionTitleRequest {...}

    message UpdateProductionScheduleRequest {...}

    message UpdateProductionScriptsRequest {...}

但这显然看起来是很凌乱的,实际上维护起来也是很困难的,如果要穷尽组合,那么接口数量将十分可观
当然,也有只提供一个接口,然后update所有字段的操作,太骚了,把握不住不推荐

FieldMask

grpc使用protobuf作为 IDL (interface definition language, 接口定义语言)及接口序列化协议
FieldMask是field_mask.proto中定义的一个message,如下

1
2
3
4
5
6
7
8
9
// ## Field Mask Verification
//
// The implementation of any API method which has a FieldMask type field in the
// request should verify the included field paths, and return an
// `INVALID_ARGUMENT` error if any path is unmappable.
message FieldMask {
// The set of field mask paths.
repeated string paths = 1;
}

Get 场景应用

博客中通过一个根据 production_id 获取 production 详情的例子来说明FieldMask在Get场景下的作用,如下的proto定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Contains Production-related information  
message Production {
string id = 1;
string title = 2;
ProductionFormat format = 3;
repeated ProductionScript scripts = 4;
ProductionSchedule schedule = 5;
// ... more fields
}

service ProductionService {
// returns Production by ID
rpc GetProduction (GetProductionRequest) returns (GetProductionResponse);
}

message GetProductionRequest {
string production_id = 1;
}

message GetProductionResponse {
Production production = 1;
}

其中 ProductionScript/ProductionSchedule两个字段都是需要通过额外的grpc请求才能获取到数据
所以如果请求方并不需要这两个字段,那么这些额外的rpc请求就是被浪费的,不仅耗费更多的时间,也增加网络传输的成本,属于费力不讨好
然后也举例可以通过增加额外标记字段(我们仍在用,惭愧)的方式来判断是否需要返回这些字段

1
2
3
4
5
6
7
// Request with one-off "include" fields, not recommended
message GetProductionRequest {
string production_id = 1;
bool include_format = 2;
bool include_schedule = 3;
bool include_scripts = 4;
}

This approach requires adding a custom includeXXX field for every expensive response field and doesn’t work well for nested fields.
It also increases the complexity of the request, ultimately making maintenance and support more challenging.

但它显而易见的一堆劣势,额外的字段、嵌套结构不适用、提高复杂性、使用/维护困难。。然后进入正题,使用FieldMask解决问题

1
2
3
4
5
6
7
// 记得确保本地已有或通过`-I`指定搜寻路径
import "google/protobuf/field_mask.proto";

message GetProductionRequest {
string production_id = 1;
google.protobuf.FieldMask field_mask = 2;
}

1
2
3
4
5
6
7
8
9
// FieldMask的定义在 google.golang.org/protobuf/types/known/fieldmaskpb/field_mask.pb.go
type struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields

// The set of field mask paths.
Paths []string `protobuf:"bytes,1,rep,name=paths,proto3" json:"paths,omitempty"`
}

具体使用可参考下方示例代码

Update 场景

为了解决Update场景下的痛点,需要客户端设置fieldmask,然后由服务端通过判断来决定如何操作
对于单个或多个字段的更新操作,可由一个接口来代替
服务端通过 fieldmask 信息,来判断需要处理哪些字段,不再是无脑全量更新或一个操作一个接口的模式,极大的提升接口的灵活性
对于 fieldmask,java中由 FieldMaskUtil 提供的工具十分方便,目前go的工具还比较少
可以考虑下 fieldmask-utils,或自行按需求封装轮子

API designers should aim for simplicity, but make their APIs open for extension and evolution.
It’s often not easy to keep APIs simple and future-proof.
Utilizing FieldMask in APIs helps us achieve both simplicity and flexibility.

具体使用可参考下方示例代码

示例代码,包含了Get及Update场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
package main

import (
"context"
"fmt"
"log"
"net"
"time"

blog "gotest/grpc/blog/proto"

"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)

var client blog.BlogServiceClient

func main() {
go startServer()
time.Sleep(time.Second)
setClient()

get()
update()
}

// 只获取 count、title字段
func get() {
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*3)
defer cancel()

var err error
var m *blog.Article
var fm *fieldmaskpb.FieldMask

// blog.GetRequest 如果没有xx字段,将报错 proto: invalid path "xx" for message "blog.GetRequest"
// 即此处fieldmask是要跟对象绑定的,
fm, err = fieldmaskpb.New(m, "title")
if err != nil {
log.Fatalf("new fiedmask err %+v", err)
}

// 继续添加
err = fm.Append(m, "count", "title")
if err != nil {
log.Fatalf("append fiedmask err %+v", err)
}

fmt.Println("after append", fm) // after append paths:"title" paths:"count" paths:"title"

// 排序及移除多余的
fm.Normalize()

fmt.Println("after normalize", fm) // after normalize paths:"count" paths:"title"

r, err := client.Get(ctx, &blog.GetRequest{
Id: 1,
FieldMask: fm,
})
if err != nil {
log.Fatalf("failed to get: %v", err)
}
// 服务端判断了paths,只返回指定的字段
fmt.Printf("get response %+v \n", r) // get response title:"do not panic" count:42
}

// 只更新title、author字段
func update() {
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*3)
defer cancel()

m := &blog.Article{
Id: 1,
Title: "don't panic",
Author: "update-author",
Count: 20,
}

fm := &fieldmaskpb.FieldMask{Paths: []string{"author", "title"}}
if !fm.IsValid(m) {
log.Fatalln("invalid fieldmask")
}

r, err := client.Update(ctx, &blog.UpdateRequest{
Book: m,
FieldMask: fm,
})
if err != nil {
log.Fatalf("failed to update: %v", err)
}
fmt.Printf("update response %+v \n", r) // update response id:1 title:"don't panic" author:"update-author" count:42
}

func setClient() {
conn, err := grpc.Dial(
"localhost:8001",
grpc.WithInsecure(),
)
if err != nil {
log.Fatalf("failed to dial: %v", err)
}
client = blog.NewBlogServiceClient(conn)
}

func startServer() {
lis, err := net.Listen("tcp", ":8001")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
blog.RegisterBlogServiceServer(s, &server{})
if err = s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

type server struct{}

func (s server) Get(ctx context.Context, request *blog.GetRequest) (*blog.Article, error) {
fmt.Printf("server get %d, %+v \n", request.GetId(), request.GetFieldMask()) // server get 1, paths:"count" paths:"title"

r := &blog.Article{}

// balabla 。。。逻辑

// 只使用顶级名称,不考虑层级
// 实际使用可以考虑 https://github.com/mennanov/fieldmask-utils
for _, s := range request.GetFieldMask().GetPaths() {
// request.GetFieldMask().ProtoReflect().Descriptor().Fields().ByNumber()
switch s {
case "id":
r.Id = 10
case "title":
r.Title = "do not panic"
case "author":
r.Author = "Douglas"
case "count":
r.Count = 42
}
}

return r, nil
}

func (s server) Update(ctx context.Context, request *blog.UpdateRequest) (*blog.Article, error) {
fmt.Printf("server update %+v, %+v \n", request.GetBook(), request.GetFieldMask()) // server update id:1 title:"don't panic" author:"update-author" count:20, paths:"author" paths:"title"

// select * from x where id={request.GetId()}
raw := &blog.Article{
Id: request.GetBook().GetId(),
Title: "title-raw",
Author: "author-raw",
Count: 42,
}

// 需要判断哪些字段是要处理的
for _, s := range request.GetFieldMask().GetPaths() {
switch s {
case "title":
raw.Title = request.GetBook().GetTitle()
case "author":
raw.Author = request.GetBook().GetAuthor()
case "count":
raw.Count = request.GetBook().GetCount()
}
}

fmt.Printf("server update raw %+v \n", raw)

// 按什么什么的逻辑处理指定的那些字段就行了

return raw, nil
}

proto定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
syntax = "proto3";

import "google/protobuf/field_mask.proto";
import "google/protobuf/descriptor.proto";

package blog;

option go_package = "./;blog";

service BlogService {
rpc Get (GetRequest) returns (Article) {}
rpc Update (UpdateRequest) returns (Article) {}
}

message Article {
int64 id = 1;
string title = 2;
string author = 3;
int64 count = 4;
}

message GetRequest {
int64 id = 1;
google.protobuf.FieldMask field_mask = 2;
}

message UpdateRequest {
Article book = 1;
google.protobuf.FieldMask field_mask = 2;
}

注意

缺省场景

没有设置fieldmask或为空时,应当处理所有的信息(如返回,update

重命名

fieldmask 是利用字符串形式的字段名来匹配的,而protobuf传递过程中是忽略字段名而是传递序号,接收方通过需要解析出字段名
所以,如果服务端修改了字段名,而客户端未同步,那么不用fieldmask的情况下是没有问题的,但如果在fieldmask中使用了该字段,将导致无法解析
文中提到三个方案:

  1. 不运行重命名字段,但这似乎是不可能的
  2. 要求服务端支持所有旧字段名,向后兼容,这意味着额外的代码
  3. 废弃字段,然后创建一个新的字段(最优)

参考链接

Netflix 技术博客 1
Netflix 技术博客 2
field_mask.proto
fieldmask-utils
原文链接