Go 语言中如何以优雅的姿势实现对象序列化?

引言

在我们的 Web 后端项目中,通常将数据源获取相关的结构体定义放在 models.go 中,不管其关联的数据是来自于数据库、Redis 还是 RPC,总之都是收敛在这一层以提供更好的复用性。

而针对不同的场景,如 C 端 HTTP API 要求返回的数据,则定义对应的 Schema struct,从而聚合需要的数据下发出去。当然,对于 Admin API 和 RPC API 也会根据需要定义不同的 Schema struct。但是,它们都会复用相同的 models。想必这些应该都是比较常规的操作了吧。

但在实际使用中,也遇到了诸多问题:

  1. API Schema 的字段类型和 Model 中定义的不同(比如我们使用发号器获得的 ID 在 Model struct 中定义的是 int64,但是为了避免 json.Marshal 时溢出(浏览器截断),统一返回了 string 类型的 ID),就需要手动进行类型转换;
  2. API Schema 的字段名称和 Model 中定义的可能不同;
  3. 支持灵活的 Schema 字段过滤比较麻烦,不同的项目实现可能不同;
  4. 在某些情况下,如课程 API Schema 关联的一些数据来自于其它服务(需要通过 RPC 调用),这时如果能够并发加载就有提高接口响应速度的可能,但是需要每次在应用层重新实现(当然可以再抽一层出来,不过还是很麻烦,会有心智负担)。
  5. ……

那么,有没有更加优雅的解决办法呢?

怎么解决? 🤔

我们之前在使用 Python 项目开发时,使用到了 marshmallow 这个轻量级的对象序列化框架。当然,它不仅仅提供序列化的能力,还有反序列化以及字段校验的能力。如果能够恰当的使用它,是可以提升开发效率的。如果在 Go 语言社区中存在这样一个框架的话,它是可以解决上面提到的一些问题的。

在经过一番思想斗争后,斗胆实现了一个类似的框架 portal 用于解决上面提到的一些问题。portal 聚焦于以优雅且一致的方式处理对象序列化的问题;而对于 Struct Fields 的校验问题,我们可以直接使用已有的第三方库如 go-playground/validatorasaskevich/govalidator

目前来说,核心功能均已按照最初的设计实现了,主要功能如下:

  1. 提供简洁易用的 API 接口
  2. 支持非常灵活的字段过滤能力(任意深度的嵌套字段过滤)
  3. 自动尝试类型转换,远离手动编写太多没什么灵魂的类型转换代码(早点下班不好吗?)
  4. 支持并发填充字段值:
    1. 可手动指定哪些字段异步加载
    2. 可设置全局的 goroutine 池大小

使用 PORTAL

可以通过下面的方式安装该包:

1
get get -u github.com/ifaceless/portal

第一步:定义 Model 结构体

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
type NotificationModel struct {
ID int
Title string
Content string
}

type UserModel struct {
ID int
}

func (u *UserModel) Fullname() string {
// 名称甚至可以来自 RPC 调用等,只是一个示例
return fmt.Sprintf("user:%d", u.ID)
}

// Notifications 返回用户关联的一些通知信息列表
func (u *UserModel) Notifications() (result []*NotificationModel) {
for i := 0; i < 1; i++ {
result = append(result, &NotificationModel{
ID: i,
Title: fmt.Sprintf("title_%d", i),
Content: fmt.Sprintf("content_%d", i),
})
}
return
}

type TaskModel struct {
ID int
UserID int
Title string
}

// User 返回 Task 关联的用户是谁
func (t *TaskModel) User() *UserModel {
return &UserModel{t.UserID}
}

第二步:定义 API Schema 结构体

以下 Schema 在定义时,都添加了 json tag,并且标记为 omitempty。这样做的目的是,当我们选择过滤某些字段的时候,portal 就不会填充对应的 Schema Fields。因此,标记了 omitempty 的字段在 json.Marshal 后就不会出现,从而达到字段过滤的目的。

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
type NotiSchema struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
}

type UserSchema struct {
ID string `json:"id,omitempty"`
// 名称是从 User.Fullname() 方法中获取,我们把它称为 User 的一个属性,使用 `attr` 标记
Name string `json:"name,omitempty" portal:"attr:Fullname"`
// nested 表明该字段的值是一个复合类型,portal 会自动将 notifications 数据填充到对应的 schema 列表
Notifications []*NotiSchema `json:"notifications,omitempty" portal:"nested"`
AnotherNotifications []*NotiSchema `json:"another_notifications,omitempty" portal:"nested;attr:Notifications"`
}

type TaskSchema struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty" portal:"meth:GetDescription"`
// UserSchema is a nested schema
User *UserSchema `json:"user,omitempty" portal:"nested"`
// We just want `Name` field for `SimpleUser`.
// Besides, the data source is the same with `UserSchema`
SimpleUser *UserSchema `json:"simple_user,omitempty" portal:"nested;only:Name;attr:User"`
}

// GetDescription 我们可以通过自定义方法来提供想要的数据
// 一个常见的场景是,我们可以在自定义方法中根据用户状态返回不同的文案
func (ts *TaskSchema) GetDescription(model *model.TaskModel) string {
return "Custom description"
}

第三步:按需序列化

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
package main

import (
"encoding/json"
"github.com/ifaceless/portal"
)

func main() {
// log debug info
portal.SetDebug(true)
// set max worker pool size
portal.SetMaxPoolSize(1024)
// make sure to clean up.
defer portal.CleanUp()

// write to a specified task schema
var taskSchema schema.TaskSchema
portal.Dump(&taskSchema, &taskModel)
// data: {"id":"1","title":"Finish your jobs.","description":"Custom description","user":{"id":"1","name":"user:1","notifications":[{"id":"0","title":"title_0","content":"content_0"}],"another_notifications":[{"id":"0","title":"title_0","content":"content_0"}]},"simple_user":{"name":"user:1"}}
data, _ := json.Marshal(taskSchema)

// select specified fields
portal.Dump(&taskSchema, &taskModel, portal.Only("Title", "SimpleUser"))
// data: {"title":"Finish your jobs.","simple_user":{"name":"user:1"}}
data, _ := json.Marshal(taskSchema)

// select fields with alias defined in the json tag.
// actually, the default alias tag is `json`, `portal.FieldAliasMapTagName("json")` is optional.
portal.Dump(&taskSchema, &taskModel, portal.Only("title", "SimpleUser"), portal.FieldAliasMapTagName("json"))
// data: {"title":"Finish your jobs.","simple_user":{"name":"user:1"}}
data, _ := json.Marshal(taskSchema)

// you can keep any fields for any nested schemas
// multiple fields are separated with ','
// nested fields are wrapped with '[' and ']'
portal.Dump(&taskSchema, &taskModel, portal.Only("ID", "User[ID,Notifications[ID],AnotherNotifications[Title]]", "SimpleUser"))
// data: {"id":"1","user":{"id":"1","notifications":[{"id":"0"}],"another_notifications":[{"title":"title_0"}]},"simple_user":{"name":"user:1"}}
data, _ := json.Marshal(taskSchema)

// ignore specified fields
portal.Dump(&taskSchema, &taskModel, portal.Exclude("Description", "ID", "User[Name,Notifications[ID,Content],AnotherNotifications], SimpleUser"))
// data: {"title":"Finish your jobs.","user":{"id":"1","notifications":[{"title":"title_0"}]}}
data, _ := json.Marshal(taskSchema)

// dump multiple tasks
var taskSchemas []schema.TaskSchema
portal.Dump(&taskSchemas, &taskModels, portal.Only("ID", "Title", "User[Name]"))
// data: [{"id":"0","title":"Task #1","user":{"name":"user:100"}},{"id":"1","title":"Task #2","user":{"name":"user:101"}}]
data, _ := json.Marshal(taskSchema)
}

以上仅仅是 PORTAL 的一些简单场景的应用,详细可以查看完整示例,在使用指南中提供了一些详细的使用说明。

核心 API

1
2
3
4
5
6
func New(opts ...Option) (*Chell, error)
func Dump(dst, src interface{}, opts ...Option) error
func DumpWithContext(ctx context.Context, dst, src interface{}, opts ...Option)
func SetDebug(v bool)
func SetMaxPoolSize(size int)
func CleanUp()

关于并发加载的策略

  • 当某个 Schema 结构体字段标记了 portal:"async" 标签时会异步填充字段值;
  • 当序列化 Schema 列表时,会分析 Schema 中有无标记了 async 的字段,如果存在的话,则使用并发填充策略;否则只在当前 goroutine 中完成序列化;
  • 可以在 Dump 时添加 portal.DisableConcurrency() 禁用并发序列化的功能。

FAQ

Q: 为什么需要全局 worker pool 存在?
A: 考虑到在 Web 服务中,每个请求过来都会启动一个新的 goroutine 处理。而在处理请求中,如果不限制 PORTAL 并发加载字段值时的 goroutine 数量,可能会导致非常严重的资源消耗问题。所以这里使用了 ants 框架。

Q: 性能 v.s 开发效率?
A:其实引入这种框架,势必会对接口处理时的内存占用,处理性能产生影响。因为内部实现中也不可避免地大量使用了反射。所以,如果你追求的是高性能的话,那还是不推荐使用了。就我们的应用场景来说,很多接口的 QPS 并不高(尤其是一些后台接口),不管是 CPU 还是内存资源都是充足的。这个时候使用 PORTAL 是可以有效提高开发效率的(个人愚见),毕竟可以少写一些代码,让机器干些蠢活脏活。

Q: 实际项目中是如何使用 portal 的?有什么体会?带来了什么收益?
A:历经将近一个月的实际项目实践,portal 目前已经趋于稳定,并且修复了大量问题,发布了 22 个版本。目前该工具包应应用在多个线上服务中(包括 HTTP RESTful API 和 RPC 中 Model 到 thrift 定义类型的映射),整体感受就是开发体验成倍提高,而且带来了性能影响并没有最开始认为的那么大。

总结

个人认为,框架的引入正是为了提高开发效率,提升项目质量的。框架层的抽象和封装可以让我们不用每次都在业务代码层编写重复机械式的代码,同时能够保证编写方式的一致性,提升项目的可维护性。所谓的性能问题,也许根本不是问题;所谓的提前优化,也许只是过度优化。我们应该用 20% 时间解决 80% 的常规问题,并且是高效率高质量的那种。而剩下 20% 的难题,完全可以用别的方法解决。切勿本末倒置!

  • 本文作者: iFaceless
  • 本文链接: /2019/11/28/portal/
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!
0%