0x00 引言
疫情期间学的东西比较杂(比如学习了如何在市场行情不好的时候还盲目加仓 ),没什么干货值得分享。不过考虑到很久都没有更新了,还是要强迫自己写一点东西,不然容易变懒。总之,多思考,多实践还是很重要。
今天主要是想把三个月前就放到 GitHub 上的 go-rest-api-starter 项目介绍下,目的有两个:
- 分享下我们目前的工程实践;
- 给 portal 增加点曝光度 。虽然我在 Go 语言中如何以优雅的姿势实现对象序列化 做过介绍,不过一直没有给过我们在真实环境中的具体用法,今天提供的示例也刚好可以帮助感兴趣的同学了解下它的用法。
0x01 为什么?
好的工程规范是在项目实践中不断踩坑总结出来的,期间我们也遇到各种写起来不够优雅的地方,自然就需要想办法解决这些问题。经过若干项目的实践,结合遇到的问题,也造了些轮子便于提升开发幸福度。沉淀和提炼的结果,便是一套可以沿袭的项目脚手架,集成了一些我们认为的「最佳实践」。
笔者相信不同的团队有不同的想法,但本质上都是更好的服务于业务。我们希望能够以一致、清晰的方式来编写代码,并且保证项目结构不被随意破坏,项目的可维护性大于一切。另外,对于业务框架,也不应该一味地排斥。合理地使用业务框架,既有利于简化业务代码编写,又有利于理解和维护。
0x02 是什么?
go-rest-api-starter 是一个完整的 RESTful API 项目(商品管理后台+前台接口,源自 aizoo 管理后台),演示了我们目前的工程实践是什么样的。当然,团队内部版本使用了一些非开源框架,不过同样的理念换成别的框架依然可行。所以,在该项目中,笔者将一些内部框架换成了开源框架,方便大家参考。
从这个项目中可以了解到什么?
- 整体的项目结构,分层情况;
- Model 层怎么编写,关联资源如何以属性方式暴露;
- Schema 层怎么编写,表单校验逻辑放在什么位置;
- 复杂业务逻辑如何在 Controller 层实现;
- 如何保证 Handler 层整洁清晰;
- portal 所扮演的角色,如何简化字段格式化逻辑。
首先来看下项目结构:
├── .env(环境变量配置,资源连接串等)
├── .env_unittest(跑单元测试使用的测试资源配置)
├── LICENSE
├── Makefile
├── README.md
├── bin
│ ├── starter-admin(面向管理后台的 RESTful API 服务器)
│ └── starter-web(面向客户端、PC 等前台的 RESTful API 服务器)
├── cmd(各个服务启动的入口)
│ ├── admin(管理后台)
│ │ └── main.go
│ ├── bee(离线异步任务)
│ │ └── main.go
│ ├── service(RPC 服务)
│ │ └── main.go
│ └── web(客户端、PC、小程序等前台)
│ └── main.go
├── go.mod(依赖包 go modules)
├── pkg
│ ├── admin(管理后台服务)
│ │ ├── handler
│ │ ├── router.go
│ │ ├── schema(和返回的 JSON 数据关联的资源结构体定义)
│ │ └── validator(通用的校验代码)
│ ├── config(资源配置)
│ │ ├── fixture.go
│ │ ├── init.go
│ │ └── mysql.go
│ ├── constant(常量、枚举定义)
│ │ ├── enum.go
│ │ ├── gen_enum.go
│ │ └── macro.go
│ ├── controller(复杂业务逻辑)
│ │ ├── company.go
│ │ ├── company_test.go
│ │ └── product.go
│ ├── job(异步离线任务业务逻辑)
│ │ └── after_product_created.go
│ ├── middleware(可复用的中间件)
│ │ └── cors.go
│ ├── model(Models,可能还会聚合来自 RPC 等数据源,数据模型抽象)
│ │ ├── company.go
│ │ ├── doc.go
│ │ ├── init.go
│ │ └── product.go
│ ├── util(工具集)
│ │ ├── orderby.go
│ │ ├── pic
│ │ ├── rest
│ │ ├── seqgen
│ │ └── toolkit
│ └── web(前台服务)
│ ├── handler
│ ├── router.go
│ └── schema
├── script(一些脚本文件)
│ └── 20200101
└── testdata(单元测试有关测试数据、表结构定义)
├── fixtures
│ ├── company.yml
│ ├── doc.yml
│ └── product.yml
└── schema.sql
0x03 说 Handler
我们希望 Handler 层的逻辑不要太过复杂,曾经在看某后台项目时,某 Handler 足足近百行代码,糅杂了大量的字段校验逻辑、业务逻辑、数据格式化逻辑等,极度啰嗦,难以修改和维护,这个是我们不太想要的。
理想情况下,Handler 层应该保证足够轻量,它就应该像是胶水,负责将 Model 层、Controller 层以及 Schema 层粘在一起。我们来看看下面的示例:
func GetProducts(c *rest.Context) (rest.Response, error) {
// 1. 复杂逻辑下沉到 Controller 层实现
products, total, err := controller.GetProducts(
c.Query("title"),
c.QueryWithFallback("order_by", "-created_at"),
c.Offset(),
c.Limit(),
)
// 2. Model -> Schema 渲染
var schemas []schema.OutputProductSchema
// 借助 portal.Only,可以在请求的时候指定只吐出需要的字段值
err = portal.Dump(&schemas, products, portal.Only(strings.Split(c.QueryWithFallback("only", ""), ",")...))
if err != nil {
return nil, err
}
// 3. 返回结果
return rest.NewPage(c, schemas, total), nil
}
func CreateProduct(c *rest.Context) (rest.Response, error) {
// 1. 表单校验和处理
var input schema.InputProductSchema
err := c.BindJSON(&input)
if err != nil {
return nil, err
}
var product model.ProductModel
err = portal.Dump(&product, input)
if err != nil {
return nil, err
}
// 2. 业务逻辑处理
prodID, err := controller.CreateProduct(&product, &input)
if err != nil {
return nil, err
}
// 3. 返回处理结果
return gin.H{"success": true, "product_id": cast.ToString(prodID)}, nil
}
总结来看,Handler 层无非执行如下几个步骤(Keep It Simple, Stupid):
- 输入参数校验和处理(实际逻辑要么封装在 Schema 层,要么在框架层解决);
- 业务逻辑处理(Controller 层负责);
- 结果返回。
0x04 讲 Schema 与 Model
这两个适合放在一起将,因为 Schema 的字段映射的正是对应的 Model 中相关字段。也许这里会有疑惑,为什么我们不直接在 Model 层,给某个 Field 加个 Tag json: field_name
,直接序列化返回出去呢?为何非要生硬地写一个 Schema 结构体再做映射呢?
接下来将结合具体的场景来回答这个问题:
- API 要求返回的字段类型和 Model 中实际类型不一致(如 model.ID 为 int 类型,但 API 要求返回 string 类型);
- 某些字段要求是可选返回字段(这时可以轻松地修改 Schema 中的字段为
*type
即可)。
总之,Model 层作为 Source of Truth,保持最原始的格式最好。Schema 层则根据具体的业务场景进行变动,对于不适配的场景,自定义 format 方法完成转换即可。这样可以将改动只聚焦在较小的范围,避免到处修改类型,想想也心累。
最后要提的一点是 Model 的关联资源,我们推荐的写法如下:
type ProductModel struct {
ID int64
CompanyID int64
// ... other fields ignored
}
// Company 返回商品关联的公司(来源于别的表)
func (pd *ProductModel) Company() (*CompanyModel, error) {
var company CompanyModel
err := DB.Where("id = ?", pd.CompanyID).Find(&company).Error
if err != nil {
if gorm.IsRecordNotFoundError(err) {
return nil, nil
}
return nil, errors.WithStack(err)
}
return &company, nil
}
// Rate 返回商品关联的评分(来源于 RPC 调用)
func (pd *ProductModel) Rate() (float64, error) {
return rpc.GetProductRate(pd.ID)
}
这样在 Schema 层,我们只需要声明需要映射的字段,在经过 portal.Dump
时,框架会自动完成相关字段映射,并将返回的值填充到对应的 Schema 字段中:
type OutputCompanySchema struct {
ID string `json:"id"`
// ...
}
type OutputProductSchema struct {
ID *string `json:"id,omitempty"`
// ...
Company *OutputCompanySchema `json:"company,omitempty" portal:"nested;async"`
CreatedAt *field.Timestamp `json:"created_at,omitempty"`
}
var output OutputProductSchema
portal.Dump(&output, product)
// 此时将 output json 序列化,就是想要得到的结果
0x05 碎碎念
最近几天在尝试重构某个项目的某个巨长的函数(接近 250 行,代码就不贴了,怕被打 ),每次修改它的时候心态都要崩。但说起来,它也没有多么复杂的业务逻辑,只是产品线类型较多,糅杂了资源获取逻辑、字段格式化逻辑等等。严格来说,完全可以使用上述的 Model & Schema 分层思路进行重构,但是该函数做了些优化:
- 使用
batch_get_resource
接口替代get_resource
接口; - 并发获取多个产品线的章节信息等。
因为这种优化的引入,会导致上述写法上存在一些不太优雅的地方。接下来举个栗子能够更好地说明现在遇到的问题:
先看看常规的 StudentModel & StudentSchema 定义:
type StudentModel struct {
MemberID int64
}
// Member 返回关联的账号详情
func (s *StudentModel) Member(ctx context.Member) *rpc.Member {
// 注意,这里使用的是单次调用,而非批量调用
return rpc.GetMemberByID(s.MemberID)
}
type MemberSchema struct { /*...*/ }
type StudentSchema struct {
Member *MemberSchema `portal: nested`
}
假设一页需要获取 20 个学生信息,portal 序列化时,会默认启动 20 个 goroutine 分别处理 20 个 StudentSchema 的渲染。这样的话,具体到 StudentModel.Member 获取时,就会产生 20 个并发的 GetMemberByID
请求,相对串行执行,这种方式自然可以提高速度。但是代价也很明显,产生的请求较多。对于一些后台项目这样做还好,但是对于 C 端接口,如果请求量较高,那请求放大会比较严重。
students := DBGetStudents(20)
var output []StudentSchema
portal.Dump(&output, students)
假设上游为我们提供了类似 func BatchGetMemberByID(memberIDs []int64) map[int64]*Member
方法,那么我么可以通过批量调用的方式解决上面提到的问题。不过,此时我们需要同时修改原有的 Model 层和 Schema 层如下:
type StudentModel struct {
MemberID int64
}
type StudentSchema struct {
Member *MemberSchema `portal: nested`
}
func (s *StudentSchema) GetMember(ctx context.Context, student *StudentModel) *rpc.Member {
// 假设外层批量调用结果放在 ctx 中传入
v := ctx.Value("members").(map[int64]*rpc.Member)
return v[student.MemberID]
}
然后在 Handler 层,我们需要手动调用 BatchGetMemberByID
接口:
students := DBGetStudents(20)
// 收集 member_ids
memberIDs := make([]int64, len(students))
for i, s := range students {
memberIDs[i] = s.MemberID
}
// 批量调用一次
members := rpc.BatchGetMemberByID(memberIDs)
ctx = context.WithValue("members", members)
var output []StudentSchema
portal.DumpWithContext(ctx, &output, students)
嗯,似乎看起来并没有多么繁琐嘛。不过这样的法会容易导致 Handler 层膨胀,试想再加点别的关联资源获取呢?那各种 BatchGetShit
就怼进去了。
所以,我们究竟怎么才能做到原有的 Model 层依然保持简单的 rpc.GetResourceByID
这种简单的调用方式;Schema 层也不用做侵入式修改;Handler 层更不用忍受可能导致的代码膨胀问题呢?也许我们可以对 rpc.GetResourceByID
做点包装,在底层框架上,自动支持请求合并;而对于上层调用方无感知。
熟悉 HTTP/2 的同学应该也会了解到其中一个特色是多路复用,避免每次新的请求都要进行 TCP+TLS 握手。那么如果我们能够做到在底层将上层的 rpc.GetResourceByID
自动合并为 rpc.BatchGetResourceByID
,也能很大程度上提高请求效率,减少上游服务的请求压力。虽然相对于并发 20 个 rpc.GetResourceByID
请求,自动合并技术可能因为优化不到位或者策略上的问题,响应时间可能稍长,但是相对于串行调用,速度理论上会有很大提升。
关于这个请求合并的策略如何实现呢?可以想象下 rpc.BatchGetResourceByID
就像一辆往返于两地的公共汽车,假设它有两个关键属性:
- 车上乘客一旦坐满立刻出发(不许加塞,咱们要合法经营);
- 到达一定超时时间立刻出发;
- 到达目的地后,还要在返程时将同一批乘客尽可能全部带回来(假设乘客们要么买到了想要的礼物
result
或者像笔者一样比较穷就什么也没买error
)。
基于这样的假设,尝试使用 Go 语言实现了一个简单的 Demo,感兴趣的同学可以前往 rpcx 查看。尝试引入了一个 Proxy 层,由 Proxy 层收集业务方的调用参数,并批量发起调用,最后将结果分派给业务方。当然,目前 Proxy 是以一个单独的 goroutine 部署;理想情况下,如果能用 sidecar 方式部署,甚至可以做到语言无关。
func getMembersAutoAgg(ctx context.Context, max int) {
var wg sync.WaitGroup
for i := 0; i <= max; i++ {
wg.Add(1)
// 底层自动将并发单次调用转换成批量调用
go func(n int) {
r, err := rsdk.GetMember(ctx, int64(n)+1)
_, _ = r, err
wg.Done()
}(i)
}
wg.Wait()
}
当然,想要在生产环境搞事情,还有很长的路要走。比如 Proxy 监控怎么做?是否存在单点问题?是否会成为接口调用瓶颈?如何与公司现有的 RPC 框架结合?收益是否真的达到预期?
0x06 总结
以上分享了一些关于工程实践方面的思考,欢迎指正,有什么好的想法也欢迎留言交流~