按需序列化使用指南
什么是 Thrift FieldMask?
FieldMask 是受到 Protobuf 的启发,用于在 RPC 调用时指示用户关心的数据并过滤掉无用的数据的一种手段。该技术不但可以在 RPC 服务中实现特定字段的屏蔽,同时还可以减少消息传输开销以提升服务性能,目前已广泛应用于 Protobuf服务中。
对于 thrift RPC 服务来说,有如下潜在使用场景:
- 下发字段管控。如 隐私合规,请求打包 等业务
- 减少公共结构体中带来的冗余字段传输。如多个业务方调用同一个 A 服务由 统一公共仓库 生成的 kitex client
使用方式
更新生成代码
首先,您必须使用两个选项生成此功能的代码 with_fieldmask
和 with_reflection
$ kitex -thrift with_field_mask -thrift with_reflection ${your_idl}
构建 FieldMask
要构建字段掩码,您需要两件事:
- ThriftPath - 用于描述您想要(或屏蔽)的字段
- 类型描述符 TypeDescriptor - 用于验证您传递的 Thrift 路径是否与消息定义(IDL)兼容
ThriftPath
表示 Thrift 消息的任意一个端点位置的路径字符串。它用于从 Thrift 根消息出发中定位数据,自顶向下定义。例如,有如下的 Thrift 消息:
struct Example {
1: string Foo,
2: i64 Bar: Example Self
}
ThriftPath $.Foo
表示 Example.Foo 的字符串值,$.Self.Bar
表示第二层 i64 值 Example.Self.Bar。Thrift 路径还支持在所有四种嵌套类型(LIST/SET/MAP/STRUCT)类型的对象中定位元素(见下文),而不仅仅是 STRUCT。实际构建 NewFieldmask
需要多个 thrift path 进行组合。
详细语法
这是基本假设:
fieldname
是结构体中字段的字段名,它必须只包含'[a-zA-Z]‘字母,整数和字符’_'。index
是列表或集合中元素的索引,它必须只包含整数。key
是映射中元素的字符串类型键。它可以包含任何字母,但必须是带引号的字符串。id
是映射中元素的整型键,它必须只包含整数。- 除
key
中之外,ThriftPath 任何位置都不能含有空白(\n\r\b\t)字符
这是详细的语法:
类型描述符
类型描述符是消息定义的运行时表示,定位与Protobuf 描述符一致。详见 Thriftgo 反射使用文档
要获得类型描述符,您必须先启用 thrift 反射功能,该功能是在 thriftgo v0.3.0 中引入的。您可以使用选项 with_reflection
为此功能生成相关代码。生成成功后,每个 STRUCT 类型都将带上 GetTypeDescriptor**() **这个函数。
示例
下面,我们以一个 IDL 为例,展示 fieldmask 的使用方式。具体代码详见 main_test. go。
namespace go fieldmask
struct BizRequest {
1: string A
2: required string B
3: optional binary RespMask //这里用于传递fieldmask
}
struct BizResponse {
1: string A
2: required string B
3: string C
}
service BizService {
BizResponse BizMethod1(1: BizRequest req)
}
您可以在应用程序的初始化阶段创建一个字段掩码(建议),或者每次在 返回响应/发出请求 之前在 bizhandler 中创建一个字段掩码。
import (
"sync"
"github.com/cloudwego/thriftgo/fieldmask"
)
var fieldmaskCache sync.Map
// initialize request and response fieldmasks and cache them
func init() {
// construct a fieldmask with TypeDescriptor and thrift pathes
respMask, err := fieldmask.NewFieldMask(
(*fieldmask0.BizResponse)(nil).GetTypeDescriptor(),
"$.A")
if err != nil {
panic(err)
}
fmCache.Store("BizResponse", respMask)
reqMask, err := fieldmask.NewFieldMask(
(*fieldmask0.BizRequest)(nil).GetTypeDescriptor(),
"$.B", "$.RespMask")
if err != nil {
panic(err)
}
fmCache.Store("BizRequest", reqMask)
}
(OPTION)如果你想将你的 fieldmask 设置为黑名单模式(什么是黑名单见【可见性】),可以在创建时开启选项 [Options.BlackListMode](https://github.com/cloudwego/thriftgo/blob/main/fieldmask/mask.go#L111)
:
// black list mod
respMaskBlack, err := fieldmask.Options{BlackListMode: true}.NewFieldMask((*fieldmask0.BizResponse)(nil).GetTypeDescriptor(), "$.A", "$.B")
if err != nil {
panic(err)
}
fmCache.Store("BizResponse-Black", respMaskBlack)
reqMaskBlack, err := fieldmask.Options{BlackListMode: true}.NewFieldMask((*fieldmask0.BizRequest)(nil).GetTypeDescriptor(), "$.A")
if err != nil {
panic(err)
}
fmCache.Store("BizRequest-Black", reqMaskBlack)
设置 FieldMask 到序列化对象(当前进程使用)
现在你可以在你的请求或响应对象上使用生成的 API Set_FieldMask
设置字段掩码。然后 kitex 本身会注意到字段掩码,并在请求/响应的序列化过程中使用它,无论是客户端还是服务器端。
- 服务端
type BizResponse struct {
A string `thrift:"A,1" frugal:"1,default,string" json:"A"`
B string `thrift:"B,2,required" frugal:"2,required,string" json:"B"`
C string `thrift:"C,3" frugal:"3,default,string" json:"C"`
_fieldmask *fieldmask.FieldMask
}
func (s *BizServiceImpl) BizMethod1(ctx context.Context, req *biz.BizRequest) (resp *biz.BizResponse, err error) {
resp := biz.NewBizResponse() // resp = biz.NewBizResponse
resp.A = "A"
resp.B = "B"
resp.C = "C"
// try set resp's fieldmask
respMask, ok := fmCache.Load("BizResponse")
if ok {
resp.Set_FieldMask(respMask.(*fieldmask.FieldMask))
}
return resp, nil
}
- 客户端
req := biz.NewBizRequest()
req.A = "A"
req.B = "B"
// try set request's fieldmask
reqMask, ok := fmCache.Load("BizRequest")
if ok {
req.Set_FieldMask(reqMask.(*fieldmask.FieldMask))
}
resp, err := cli.BizMethod1(context.Background(), req)
传递 FieldMask(对端使用)
如果你想要通知对端服务屏蔽特定字段,目前您可以在你的请求定义中添加一个二进制字段来携带字段掩码,并将您正在使用的字段掩码显式序列化到该字段中。我们提供了两个封装的 API 用于序列化/反序列化:
- thriftgo/fieldmask. Marshal/Unmarshal:包函数,将 Fieldmask 序列化/反序列化二进制字节。我们建议您使用这个 API 而不是下面一个,因为由于使用缓存,它要快得多——除非您的应用程序缺少内存或者需要频繁更新 fieldmask。
- 字段掩码. MarshalJSON/UnmarshalJSON:FieldMask 对象方法,将字段掩码序列化/反序列化为/从 JSON 字节(不推荐)
例如,我们可以将响应的字段掩码作为请求的一个 binary 类型字段进行传递,例如:
- 客户端设置&传递
type BizRequest struct {
A string `thrift:"A,1" frugal:"1,default,string" json:"A"`
B string `thrift:"B,2,required" frugal:"2,required,string" json:"B"`
RespMask []byte `thrift:"RespMask,3,optional" frugal:"3,optional,binary" json:"RespMask,omitempty"`
}
func TestClient() {
req := fieldmask0.NewBizRequest()
req.A = "A"
req.B = "B"
// try get reponse's fieldmask
respMask, ok := fmCache.Load("BizResponse")
if ok {
// serialize the respMask
fm, err := fieldmask.Marshal(respMask.(*fieldmask.FieldMask))
if err != nil {
t.Fatal(err)
}
// let request carry fm
req.RespMask = fm
}
resp, err := cli.BizMethod1(context.Background(), req)
}
- 服务器端接受&处理
// BizMethod1 implements the BizServiceImpl interface.
func (s *BizServiceImpl) BizMethod1(ctx context.Context, req *fieldmask0.BizRequest) (resp *fieldmask0.BizResponse, err error) {
resp = fieldmask0.NewBizResponse()
resp.A = "A"
resp.B = "B"
// check if request carries a fieldmask
if req.RespMask != nil {
println("got fm", string(req.RespMask))
fm, err := fieldmask.Unmarshal(req.RespMask)
if err != nil {
return nil, err
}
// set fieldmask for response
resp.Set_FieldMask(fm)
}
return
}
最终效果
一旦您为请求/响应设置了 fieldmask,另一方将仅接收到 fieldmask 设置的非必需字段的真实值,或者 fieldmask 未屏蔽的必需字段的零值。业务预期效果如下
- 客户端
if resp.A == "" { // resp.A in mask
t.Fatal()
}
if resp.B == "" { // resp.B not in mask, but it's required, so still written
t.Fail()
}
if resp.C != "" { // resp.C not in mask
t.Fail()
}
- 服务器端
if req.A != "" { // req.A not in mask
return nil, errors.New("request must mask BizRequest.A!")
}
if req.B == "" { // req.B in mask
return nil, errors.New("request must not mask BizRequest.B!")
}
查看 FieldMask(Reflection of FieldMask)
如果业务有需要检查对端传入的 FieldMask 并实现特定业务逻辑,我们提供了一下 API:
- 类型:使用 fieldmask.Type() 获取当前 fieldmask 类型
- 定位:使用 fieldmask.GetPath() 定位到某个 thrift path 下的节点
- 遍历:使用 fiedmask.ForEachChild() 遍历当前 fieldmask 的 下一层 节点
详细使用见 mask_test.go
注意事项
可见性(黑名单 or 白名单)
**FieldMask 默认为白名单 :**掩码中的一个字段表示“通过”(将被 序列化/反序列化),不在掩码中的字段表示“过滤”(不会被序列化/反序列化)。
但是也允许用户将 FieldMask **设置为黑名单,**需要在创建时开启选项 Options.BlackListMode
。此时掩码中的一个字段表示“过滤”(不将被 序列化/反序列化),不在掩码中的字段表示“通过”(会被序列化/反序列化)。具体如何使用详见 main_test. go。
实现约定
-
空的 fieldmask 表示“全部通过”(无论黑名单还是白名单)
-
对于既不是 string 也不是 int 类型建的 map,只允许'*‘标记作为键
-
安全起见,Required 字段可以不在掩码中(过滤),但是它们序列化时仍将被写入当前值。
- Tips:如果想不在掩码中的 required 字段写成零值,可以开启选项 -
thrift field_mask_zero_required
并重新生成代码。需要注意的是,STRUCT 类型也写零值(写入一个 FieldStop(0))——这意味着, 如果该 STRUCT 中含有 required 字段会可能会引起对端报错
- Tips:如果想不在掩码中的 required 字段写成零值,可以开启选项 -
-
FieldMask 设置必须从请求/响应根对象开始(IDL 中定义的 method 的 respXX/requstXX 结构体),并对整个对象生效
- Tips:如果您想从某一非根对象设置 FieldMask 并生效,需要添加 -
thrift field_mask_halfway
选项并重新生成代码。但是这样会有一个潜在风险:如果不同父对象引用了同一个子对象,并且这两个父对象分别设置了不同的 fieldmask,那只有其中一个父对象相对于这个子对象的 fieldmask 会生效。
- Tips:如果您想从某一非根对象设置 FieldMask 并生效,需要添加 -
性能
- 构建 fieldmask 的开销很高。但是考虑到大部分业务需求中不会频繁更新 fieldmask,建议用户采用上述示例中的
init()+sync.Map
方式构建和获取。同样,传输 fieldmask 也有一定开销,因此建议使用 thriftgo 提供的 fieldmask.Marshal()/Unmarshal() 进行(见【传递 FieldMask】) - 序列化性能:主要取决于过滤数据的比例,过滤比例越大最终服务序列化性能越好。参见baseline_test. go
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkFastWriteNesting/full-16 4505 ns/op 0 B/op 0 allocs/op
BenchmarkFastWriteNesting/half-16 2121 ns/op 0 B/op 0 allocs/op
BenchmarkFastReadNesting/full-16 13864 ns/op 11874 B/op 173 allocs/op
BenchmarkFastReadNesting/half-16 7938 ns/op 5273 B/op 77 allocs/op
案例解释:
- Nesting:两层字段结构体,数据大小 6455B
- FastWrite:序列化测试
- FastRead:反序列化测试
- full:开启 with_field_mask 选项生成,但不使用字段掩码
- half:开启 with_field_mask 选项生成,并使用字段掩码来过滤一半的数据
获取代码
这个功能目前正在开发中,如果你想尝试,可以获取分支代码,并安装相应的二进制工具。
-
ThriftGo:版本应 >= v0.3.12
- 命令行工具:用 thriftgo -version 确认;如版本较低,可手动安装最新版:
go install github.com/cloudwego/thriftgo@latest
- go.mod: 使用 go get 更新项目依赖
go get github.com/cloudwego/thriftgo@latest
-
Kitex 命令行:版本应 >= v0.10.0
- 命令行工具
go install github.com/cloudwego/kitex/tool/cmd/kitex@latest
- 框架
go get github.com/cloudwego/kitex@latest