序列化工具之 PORTAL

引言

在我们的 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

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

get get -u github.com/ifaceless/portal

第一步:定义 Model 结构体

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 后就不会出现,从而达到字段过滤的目的。

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"
}

第三步:按需序列化

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

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% 的难题,完全可以用别的方法解决。切勿本末倒置!

声明

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,922评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,591评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,546评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,467评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,553评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,580评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,588评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,334评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,780评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,092评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,270评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,925评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,573评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,194评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,437评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,154评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352

推荐阅读更多精彩内容