引言
早期我们在一些小的 Web 项目中使用了 Go 来开发简单的 REST API,主要参考的是其它部门的核心项目。但当时只是为了尝鲜和入门 Go Web 开发,并没有花较多的时间考虑工程结构、项目质量这些至关重要的问题。
再后来,组内陆续多个项目使用了 Go 语言开发。整体来说,项目结构上大体是相同的,但是在工程实践上还是有不太统一的地方。我们希望新的项目能够在项目结构、工程质量上有所改善,提高工程稳定性与开发幸福感是需要我们共同努力的目标。
后来找到机会从一个大的项目中拆出可以完全独立的服务,这次并没有完全照搬其它 Go 项目的工程实践。很多时候,所谓的最佳实践是需要权衡各种利弊得来的。在这次实践中,我们着重于改善如下几个方面:
- 项目结构:层次结构调整、包命名风格统一
- 统一 Model 层接口:通过一个类似 GORM 的工具实现
- 可能更加优雅的 REST API 写法:基于 chi 框架做了一层封装;路由注册尽可能统一到一个文件,集中管理
- API Schema 数据聚合:实现了一个类似我们在 Python 项目中使用的 marshmallow 库解决
- 单元测试:运行时 Patch,不需要在写 Handler/Controller/RPC 时都以 interface 优先的方式
- 返回 Error 而不是 Panic 掉
项目结构
1 | . |
MVC 怎么实践
Model
关于 Model 层怎么写,这个看起来还是有点争议。之前去听了其它部门 Go 实践经验分享,提倡半手写 SQL(本质上使用了 SQL 构建器)的方式。但这么做感觉还是存在很多问题(主要是考虑到后期维护者的感受):
- 接口复用性不够好
- 写法难以统一,且代码量容易膨胀
- 手工组装 SQL 比较繁琐,且不易于后期变更(如新增字段)
- 重复逻辑不可避免
应用层更应该关注的是核心业务逻辑,而非繁琐重复的代码编写(Keep It Simple)。参照我们在 Python 中的实践,采用了轻量级的 ORM 工具后很大程度上统一了增删改查接口,这样每个维护者都不用烦心了解各种类似 get_xxx_by_wtf_balabala
函数了。因为加了一层抽象,可被复用的逻辑完全从我们的业务层抽离出去维护,也可以大大简化应用层代码。
对于常规业务,如果我们能够接受一定的性能开销,不妨引入一些工具,来改善项目质量并且提高开发效率。
为了方便我们在自己的 Go 项目中,能够使用较为一致的方式实现常规的增删改查需求,所以就花了些时间造了个类似 GORM 的工具 BORM。在经历多次迭代后,目前基本趋于稳定。目前我们已经在多个内部项目中使用,并在实践中修复了不少细节问题,增加了一些非常实用的功能。
以下是该工具提供的一些常用接口:
接口 | 注释 |
---|---|
Create/MustCreate | 新建记录 |
Save/MustSave | 全量更新 |
Update/MustUpdate | 更新指定单个字段 |
Updates/MustUpdates | 更新指定多个字段 |
Delete/MustDelete | 删除记录 |
One/MustOne | 根据条件匹配一条记录 |
All/MustAll | 匹配所有符合条件的记录 |
FindByPK/MustFindByPK | 基于主键查询记录 |
Begin/Commit/Rollback | 事务相关接口 |
总的来说,希望 BORM 能够解决以下几个问题:
- 统一且清晰的增删改查接口
- SQL 自动组装,无需人肉拼接
- 基于 Model 层的缓存管理统一到工具层解决
引入 BORM
如果需要在项目中引入 BORM,可以采用类似下面的目录结构:
1 | pkg/models |
在 init.go
中,配置 BORM 的数据库连接(或者添加缓存支持):
1 | package models |
编写 Model
接下来,表演下如何编写 Model,以及如何给 Model 以「属性」的方式关联资源(类似于我们在 Python 中使用 property
获取某个 Model 关联的资源)等:
1 | // MaterialModel 是广告素材资源 |
下面表演下如何通过实现特定接口完成数据库的 JSON 字符串与自定义结构体类型之间的转换的。通过把这种低级别的转换操作放在 Model 层完成,可以让业务上层写起来更爽!
1 | type AdScale struct { |
对了,还有资源的关联呢?在 Python 中我们可以用属性的方式实现,在 Go 中依然可以实现类似的功能,只是写法不太相同而已:
1 | // Attributes 是广告素材关联的一组自定义「属性」 |
当然啦,如果有资源关联的属性值来自 RPC,也可以放在 Model 层编写一个类似上面的属性。我们希望能够在 Model 层绑定资源的关联数据,这样在业务上层只需要 .Foo()
即可获取关联资源。
注意到,上面的「属性」函数接收了一个 ctx
参数,那是因为在进行数据库查询或者 RPC 服务调用时需要。但有时候我们的「属性」函数并不需要 ctx
参数,比如下面这样这样:
1 | // IsInPromotion 是否在促销中 |
Controller
其它项目的写法
我们先来看下其它项目中是如何编写 Controller 层的。首先看下目录结构:
1 | controller |
其中在 controller/user.go
中定义了该 Controller 的接口,而在 mock
目录下的文件则是由 mock 工具生成的文件。而在 impl
放置的是真正的实现逻辑,写法如下:
1 | type UserControllerImpl struct { |
之所以采用这样的目录结构和实现方法,可能也是为了方便编写单元测试时 Mock 掉关键接口。但通过分析这些代码,也发现了几个问题:
- Controller 层好像也没干啥,调用了 Dao 层的接口?
- 不太符合 Go 圣经中所倡导的方式,接口写太多了
- 每个 Controller 都必须写一个结构体?
但是为了满足 mock 工具苛刻的生成条件(总是基于 interface 生成),也不得不那样实现。但我们在实践中,有个单元测试需要去 Mock time.Now()
函数。这时就遇到了问题,虽然可以基于 time
再定义一个结构体来,再定义下接口,让 mock 工具生成 Mock 版本。但是这样感觉还是比较繁琐,且容易让代码膨胀。明明就是要解决一个看起来并不复杂的问题,却要因为单元测试引入那么多啰嗦的代码。其实我们并不希望因为单元测试而造成业务代码以某种妥协的方式实现。在经过一番调研和实践后,我们发现运行时 Mock 也是能够做到的(细节会在讲单元测试时说明),自然也就不必写得如此啰嗦~
我们的做法
对于比较复杂的业务逻辑,我们依然推荐你在 Controller 层去实现,但是不用教条式地定义一个结构体,再定义一个方法。基本原则就是,能有简单清晰明了的写法即可。比如下面这个例子:
1 | // UpdateMaterial 做一次全量更新吧 |
View
Handler
为了方便编写 REST API,实现了一个基于 go-chi
的轻量级 API 框架,其原型可以参考 REST 项目。
REST 工具提供了如下特性:
- 能够以更加优雅简洁的方式基于一个 ResourceHandler 编写
GET/POST/PATCH/DELETE
等方法 - 采用
return resp, err
模式替代原先RenderJSON/RenderError
的方式: - 框架层可以自动去匹配调用
renderJSON
或者renderError
- 再也不怕原先调用
RenderError
后又忘记return
的问题了 - 可以更好的支持返回错误,意味着我们不用到处
panic
业务错误,然后在上层又recover
- 封装了一些常用的接口:
- 通用的分页 Schema 渲染
- 各种易用的参数获取接口
接下来,看看一个典型的 REST API Handler 实现:
1 | type MaterialsHandler struct { |
Schema
PORTAL 目前已经开源:https://github.com/ifaceless/portal,以下说明已经过时,可以参考 这篇文章 的介绍了解更多特性!
我们通常会在 Schema 层定义接口需要的字段及其类型,并在这层完成数据聚合后,生成 JSON 格式的内容吐给前端使用。由于感受到 marshamllow
引入后给我们的 Python 项目带来了诸多好处后(如更加一致清晰的 Schema 定义方式),就斗胆实现了一个 Go 版本的 marshmallow
工具 PORTAL。但 PORTAL 实际上只是注重数据的聚合,因为 Go 社区已经有很多成熟的工具可以实现 Schema Struct 校验了,自然不用重复造轮子。
PORTAL 的主要特点如下:
- Schema 支持组合,提高 Schema 复用性
- Schema 字段值支持灵活的取值方式(联想 marshamallow 中常用的方式)
- 支持并发填充字段(如不同的字段值可能来源于 RPC/数据库等)
- 字段支持灵活的类型定义,PORTAL 负责尝试类型转换
- 支持可选字段渲染(赋值)
- 尽可能减少冗余且愚蠢且不应该让人类来写的代码(机械式赋值)(脑补下给一个 Schema 的嵌套 List Schema 填充值要写得多么壮观,那如果再嵌套比较深呢?)!
接下来我们看看如何定义用于聚合数据的 Schema:
1 | // TrackSchema 音频信息 |
接下来表演下如何使用类似上面定义的 Schema:
1 | // 从 DB 查询得到 Model 示例 |
当然,虽然引入 PORTAL 可以让我们更加聚焦业务逻辑的编写,尽可能减少冗余且机械的代码,但也由此带来了一些问题:
- 使用
reflect
机制带来的性能损耗就看能不能接受 - 有些因为类型转换的不成功的问题可能到运行时才会发现,排查较困难(但出现情况很少)
所以,这还是一个需要权衡的利弊后才能考虑的方案,但个人觉得它还是有一定价值的~
聊聊路由注册
说到路由注册,个人觉得其它部门的 Go 项目采用的方式并不是很优雅,且相对比较分散。所以,就给 REST 工具引入了类似我们在使用 Tornado 时采用的那种路由注册方式。因为是基于 chi.Mux
封装的 Router,所以完全兼容原先的接口。
对于比较简单的路由,可以采用下面的注册方式:
1 | r.MountHandler("/hello", &hello.DemoHandler{}) |
而对于 API 较多的那种项目,推荐的目录结构如下:
1 | routers |
在 router.go
中编写 Router 初始化的代码,包括中间件配置和路由注册:
1 | // NewRouter web 路由实例创建 |
接下来在 urls.go
中定义 URL 和 Handler 的映射:
1 | var handlers = map[string]rest.Handler{ |
单元测试很重要
先说结论,我们使用了 gomonkey 实现运行时 Monkey Patch。这样我们的 Controller/Handler/RPC 等层无需写得特别啰嗦。
如果想知道其工作原理的话,可以参考 monkey 项目和 Monkey Patching in Go。当然,下面几篇和单元测试有关的文章也可以看看:
此外,还有个用于初始化测试数据的工具也很有帮助,详细可以参见 Fixture 项目。
Panic 还是直接返回 Error
对于业务异常来说,相对系统级错误等严重错误发生地更加频繁,这样一来频繁地 Panic/Recover 会带来一些额外开销。此外,无脑地对任何业务异常都采取 Panic 的式方,真的好吗?不过看起来其它组的项目的确很乐意采用这种方式。
但个人推荐的方式是对于业务错误,依然采用 Go 中典型的方式:返回错误!如果是比较严重的错误(如网络中断等),则可以进行 Panic,然后在上层捕获。
1 | // DeleteMaterial 删除素材 |
枚举定义
我们一般定义完枚举后,都希望能够根据给定的值获得对应的枚举变量,或者得到枚举变量名映射的名称。这里给出一种可行的定义方式:
1 | // AuditStatus 审核状态 |