0x00 引言
疫情期间学的东西比较杂(比如学习了如何在市场行情不好的时候还盲目加仓 🙂),没什么干货值得分享。不过考虑到很久都没有更新了,还是要强迫自己写一点东西,不然容易变懒。总之,多思考,多实践还是很重要。
今天主要是想把三个月前就放到 GitHub 上的 go-rest-api-starter 项目介绍下,目的有两个:
- 分享下我们目前的工程实践;
- 给 portal 增加点曝光度 😊。虽然笔者在 Go 语言中如何以优雅的姿势实现对象序列化 做过介绍,不过一直没有给过我们在真实环境中的具体用法,今天提供的示例也刚好可以帮助感兴趣的同学了解下它的用法。
0x01 为什么?
好的工程规范是在项目实践中不断踩坑总结出来的,期间我们也遇到各种写起来不够优雅的地方,自然就需要想办法解决这些问题。经过若干项目的实践,结合遇到的问题,也造了些轮子便于提升开发幸福度。沉淀和提炼的结果,便是一套可以沿袭的项目脚手架,集成了一些我们认为的「最佳实践」。
笔者相信不同的团队有不同的想法,但本质上都是更好的服务于业务。我们希望能够以一致、清晰的方式来编写代码,并且保证项目结构不被随意破坏,项目的可维护性大于一切。另外,对于业务框架,也不应该一味地排斥。合理地使用业务框架,既有利于简化业务代码编写,又有利于理解和维护。
0x02 是什么?
go-rest-api-starter 是一个完整的 RESTful API 项目(商品管理后台+前台接口,源自 aizoo 管理后台),演示了我们目前的工程实践是什么样的。当然,团队内部版本使用了一些非开源框架,不过同样的理念换成别的框架依然可行。所以,在该项目中,笔者将一些内部框架换成了开源框架,方便大家参考。
从这个项目中可以了解到什么?
- 整体的项目结构,分层情况;
- Model 层怎么编写,关联资源如何以属性方式暴露;
- Schema 层怎么编写,表单校验逻辑放在什么位置;
- 复杂业务逻辑如何在 Controller 层实现;
- 如何保证 Handler 层整洁清晰;
- portal 所扮演的角色,如何简化字段格式化逻辑。
首先来看下项目结构:
1 | ├── .env(环境变量配置,资源连接串等) |
0x03 说 Handler
我们希望 Handler 层的逻辑不要太过复杂,曾经在看某后台项目时,某 Handler 足足近百行代码,糅杂了大量的字段校验逻辑、业务逻辑、数据格式化逻辑等,极度啰嗦,难以修改和维护,这个是我们不太想要的。
理想情况下,Handler 层应该保证足够轻量,它就应该像是胶水,负责将 Model 层、Controller 层以及 Schema 层粘在一起。我们来看看下面的示例:
1 | func GetProducts(c *rest.Context) (rest.Response, error) { |
总结来看,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 的关联资源,我们推荐的写法如下:
1 | type ProductModel struct { |
这样在 Schema 层,我们只需要声明需要映射的字段,在经过 portal.Dump
时,框架会自动完成相关字段映射,并将返回的值填充到对应的 Schema 字段中:
1 | type OutputCompanySchema struct { |
0x05 碎碎念
最近几天在尝试重构某个项目的某个巨长的函数(接近 250 行,代码就不贴了,怕被打 😝),每次修改它的时候心态都要崩。但说起来,它也没有多么复杂的业务逻辑,只是产品线类型较多,糅杂了资源获取逻辑、字段格式化逻辑等等。严格来说,完全可以使用上述的 Model & Schema 分层思路进行重构,但是该函数做了些优化:
- 使用
batch_get_resource
接口替代get_resource
接口; - 并发获取多个产品线的章节信息等。
因为这种优化的引入,会导致上述写法上存在一些不太优雅的地方。接下来举个栗子🌰能够更好地说明现在遇到的问题:
先看看常规的 StudentModel & StudentSchema 定义:
1 | type StudentModel struct { |
假设一页需要获取 20 个学生信息,portal 序列化时,会默认启动 20 个 goroutine 分别处理 20 个 StudentSchema 的渲染。这样的话,具体到 StudentModel.Member 获取时,就会产生 20 个并发的 GetMemberByID
请求,相对串行执行,这种方式自然可以提高速度。但是代价也很明显,产生的请求较多。对于一些后台项目这样做还好,但是对于 C 端接口,如果请求量较高,那请求放大会比较严重。
1 | students := DBGetStudents(20) |
假设上游为我们提供了类似 func BatchGetMemberByID(memberIDs []int64) map[int64]*Member
方法,那么们可以通过批量调用的方式解决上面提到的问题。不过,此时我们需要同时修改原有的 Model 层和 Schema 层如下:
1 | type StudentModel struct { |
然后在 Handler 层,我们需要手动调用 BatchGetMemberByID
接口:
1 | students := DBGetStudents(20) |
嗯,似乎看起来并没有多么繁琐嘛。不过这样的写法很容易导致 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 方式部署,甚至可以做到语言无关,独立升级,其它语言实现的服务也可以享受到同样的优化。
1 | func main() { |
当然,想要在生产环境搞事情,还有很长的路要走。比如 Proxy 监控怎么做?单点问题怎么解解决?是否会成为接口调用瓶颈?如何与公司现有的 RPC 框架结合?收益是否真的达到预期(分析具体场景)?
0x06 总结
以上分享了一些关于工程实践方面的思考,欢迎指正,有什么好的想法也欢迎留言交流~