一、更新 John 的数据
假设我们有一条关于 John 的数据如下:
1 | GET /people/1 HTTP/1.1 |
现在我们想更新他的年龄和所在城市,于是发起了一个请求:
1 | PATCH /people/1 HTTP/1.1 |
作为 Go 服务端开发人员,我们如何才能正确处理这个部分更新请求呢?
二、Go 零值与 JSON
乍一看并不难,我们立马写下了结构体定义:
1 | type Address struct { |
JSON 反序列化?自然也不在话下:
1 | blob := []byte(`{"age": 25, "address": {"city": "Guangzhou"}}`) |
对应的输出结果(Go Playground):
1 | person: {Name: Age:25 Address:{Country: Province: City:Guangzhou}} |
很显然,如果我们直接用 person 去更新 John 的数据,他的姓名、所在国家和省份都会被清空!
那服务端该如何正确识别客户端的原始意图呢?具体到 John 的例子,在 Go 中如何做到 “只更新他的年龄和所在城市” 呢?
三、业界通用解法
据我所知,对于上述问题,业界通常有以下三种解法。
使用指针
因为 Go 的零值特性,普通类型无法表达 “未初始化” 的状态,典型解法就是使用指针。
采用指针后,上面的结构体定义将变成:
1 | type Address struct { |
再次进行 JSON 反序列化:
1 | blob := []byte(`{"age": 25, "address": {"city": "Guangzhou"}}`) |
对应的输出结果(Go Playground):
1 | person: {Name:<nil> Age:0xc000018218 Address:0xc00000c138}, address: &{Country:<nil> Province:<nil> City:0xc0000103f0} |
可以看到只有 Age 和 Address.City 的值不为 nil,于是我们只需要更新不为 nil 的字段即可:
1 | func (a *Address) Update(other *Address) { |
1 | func (p *Person) Update(other *Person) { |
参考完整代码(Go Playground)不难发现,使用指针后的 Person 结构体,操作起来会非常繁琐。比如:
- 修改 address 前,需要首先保证
p.Address
不能为 nil - 此外,Initialization 初始化操作尤其麻烦
客户端维护的 FieldMask
受 Protocol Buffers 设计的影响,另一种较为流行的做法是在请求中新增一个 FieldMask 参数,用来补充说明需要更新的字段名称。
1 | type Address struct { |
1 | blob := []byte(`{"person": {"age": 25, "address": {"city": "Guangzhou"}}, "field_mask": "age,address.city"}`) |
对应的输出结果(Go Playground):
1 | req: {Person:{Name: Age:25 Address:{Country: Province: City:Guangzhou}} FieldMask:age,address.city} |
有了 FieldMask 的补充说明,服务端就能正确进行部分更新了。但是对于客户端而言,FieldMask 其实是多余的,而且维护成本也不低(特别是待更新字段较多时),这也是我认为该方案最明显的一个不足之处。
改用 JSON Patch
前面讨论的方案,本质上都是 JSON Merge Patch 风格的。部分更新还有另外一个比较有名的风格,那就是 JSON Patch。
具体到 John 的例子,部分更新请求变成了:
1 | PATCH /people/1 HTTP/1.1 |
相比于前面的解法而言,该解法的主要缺点是 PATCH 请求体跟待更新文档的 JSON 数据格式差异太大,表达上不太符合直觉。
四、服务端维护的 FieldMask
如果我们坚持 JSON Merge Patch 风格的部分更新,综合来看「客户端维护的 FieldMask」是相对较好的方案。那有没有可能进一步规避该方案的不足,即不增加客户端的维护成本呢?经过一段时间的研究和思考,我认为答案是肯定的。
有经验的读者可能会发现,Go 的 JSON 反序列化其实有两种:
- 将 JSON 反序列化为结构体(优势:操作直观方便;不足:有零值问题)
- 将 JSON 反序列化为
map[string]interface{}
(优势:能够准确表达 JSON 中有无特定字段;不足:操作不够直观方便)
可想而知,如果我们直接把 Person 从结构体改为 map[string]interface{}
,操作体验可能会比使用带指针的结构体更糟糕!
那如果我们只是把 map[string]interface{}
作为一个反序列化的中间结果呢?比如:
- 首先将 JSON 反序列化为
map[string]interface{}
- 然后用
map[string]interface{}
来充当(服务端维护的)FieldMask - 最后将
map[string]interface{}
解析为结构体(幸运的是,已经有现成的库 mapstructure 可以做到!)
通过一些探索和试验,结果表明上述想法是可行的。为此,我还专门开发了一个小巧的库 fieldmask,用来辅助实现基于该想法的部分更新。
具体到 John 的例子,借助 fieldmask 库,结构体可以定义成最自然的方式(不需要使用指针):
1 | type Address struct { |
注意,其中 JSON 反序列化的核心代码是 UnmarshalJSON
。对应的更新逻辑如下(完整示例):
1 | func (a *Address) Update(other Address, fm fieldmask.FieldMask) { |
1 | func (p *Person) Update(other Person, fm fieldmask.FieldMask) { |
1 | john := Person{ |
个人觉得,相比其他方案而言,上述代码实现非常简单、自然(如果还有优化空间,欢迎指正👏🏻)。
当然该方案也不是完美的,目前来说,我认为至少有一个瑕疵就是需要两次解码:JSON -> map[string]interface{}
-> 结构体,会增加一点性能上的开销。