Go Web 工程实践总结

引言

早期我们在一些小的 Web 项目中使用了 Go 来开发简单的 REST API,主要参考的是其它部门的核心项目。但当时只是为了尝鲜和入门 Go Web 开发,并没有花较多的时间考虑工程结构、项目质量这些至关重要的问题。

再后来,组内陆续多个项目使用了 Go 语言开发。整体来说,项目结构上大体是相同的,但是在工程实践上还是有不太统一的地方。我们希望新的项目能够在项目结构、工程质量上有所改善,提高工程稳定性与开发幸福感是需要我们共同努力的目标

后来找到机会从一个大的项目中拆出可以完全独立的服务,这次并没有完全照搬其它 Go 项目的工程实践。很多时候,所谓的最佳实践是需要权衡各种利弊得来的。在这次实践中,我们着重于改善如下几个方面:

  • 项目结构:层次结构调整、包命名风格统一
  • 统一 Model 层接口:通过一个类似 GORM 的工具实现
  • 可能更加优雅的 REST API 写法:基于 chi 框架做了一层封装;路由注册尽可能统一到一个文件,集中管理
  • API Schema 数据聚合:实现了一个类似我们在 Python 项目中使用的 marshmallow 库解决
  • 单元测试:运行时 Patch,不需要在写 Handler/Controller/RPC 时都以 interface 优先的方式
  • 返回 Error 而不是 Panic 掉

项目结构

.
├── Gopkg.lock(Dep 包管理工具自动生成、维护)
├── Gopkg.toml(依赖包管理)
├── Makefile
├── README.md(项目文档)
├── bin(二进制可执行文件,可以直接运行的服务:RPC 和 HTTP 服务等)
├── cmd
│   ├── service(对外提供的 RPC 服务入口)
│   │   └── main.go
│   └── web(对外提供的 HTTP API 服务入口)
│       └── main.go
├── gen-go(基于 thrift 编译生成的文件,在实现对外提供的 RPC 服务接口时需要使用)
├── joker.yml
├── pkg(核心代码放到这个目录下)
│   ├── configs(资源配置:MySQL, Redis 等)
│   │   ├── mysql.go
│   │   ├── redis.go
│   ├── consts(常量定义,包括枚举)
│   │   ├── enum.go
│   │   └── macro.go
│   ├── controllers(复杂的业务逻辑放到这儿实现)
│   │   ├── foo.go
│   ├── errs(业务自定义错误类型)
│   ├── middlewares(业务相关的中间件,如果可以复用,就抽到公共仓库维护)
│   ├── models(顾名思义,定义 Model,关联数据库表)
│   ├── rpcs(依赖的第三方 RPC 服务)
│   │   ├── bar.go
│   ├── service(对外提供的 RPC 服务实现)
│   │   ├── demo-service.go
│   │   └── protos
│   │       └── demo-service.thrift
│   ├── utils(可复用的工具:单元测试等)
│   └── web(REST API 服务)
│       ├── handlers
│       ├── routers
│       │   ├── router.go(路由注册)
│       │   └── urls.go(URL 与 Handler 的绑定)
│       ├── schemas
│       │   ├── dump(聚合数据源,定义对外的 API Schema)
│       │   └── load(处理输入,字段校验规则配置)
│       └── validators(可复用的自定义校验规则定义)
├── scripts(脚本)
├── testdata(业务逻辑测试需要用的测试数据)
│   ├── fixtures(造一些测试数据放在里面,默认使用 YAML 格式)
│   │   ├── foo.yml
│   └── schema.sql(数据库表创建语句集合)
└── vendor(各种依赖包)

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 能够解决以下几个问题:

  1. 统一且清晰的增删改查接口
  2. SQL 自动组装,无需人肉拼接
  3. 基于 Model 层的缓存管理统一到工具层解决

引入 BORM

如果需要在项目中引入 BORM,可以采用类似下面的目录结构:

pkg/models
├── init.go
└── material.go

init.go 中,配置 BORM 的数据库连接(或者添加缓存支持):

package models

import (
   "url/to/borm"
   "project/pkg/configs"
)

func init() {
   mysqlConfig := configs.GetMySQLConfig("demo")
   borm.Setup(borm.NewMySQLConfig(
      mysqlConfig.Masters[0],
      mysqlConfig.Slaves,
   ))

   // 如果需要的话,可以添加 Model 缓存支持
   // borm.Use(cache.New(configs.GetRedisConfig("cache-redis"), false))
}

编写 Model

接下来,表演下如何编写 Model,以及如何给 Model 以「属性」的方式关联资源(类似于我们在 Python 中使用 property 获取某个 Model 关联的资源)等:

// MaterialModel 是广告素材资源
type MaterialModel struct {
   // 对于 BORM 而言,默认会使用名叫 `id` 的字段作为主键(注意,这里的大写字段名在生成 SQL 时会自动变成小写模式)
   // 假如你需要指定某个字段为主键,可以另加 tag `borm:pk`
   ID          int64
   Category    string

   // 可以看到,这里使用了一个自定义的类型(实际数据库是 tinyint,BORM 会根据自定义类型实现的接口完成自动映射)
   // 另外,使用 `column:type` 表示可以让 Model 的对外暴露的字段名和数据库实际字段名不同
   Kind        consts.TemplateKind `borm:"column:type"`

   // 这里的 AdScale 在数据实际上是个 JSON 字符串,但由于 *AdScale 类型实现了 BORM 
   // 指定的接口,便能实现自动反序列化,这样你不需要在上层左一个又一个 `json.Unmarshal` 操作
   AdScale     *AdScale            `borm:"column:adscale" portal:"nested"`

   // 由于默认的自动转换字段名的策略会将 LandURL 转换成 land_url,与实际数据库字段名不符
   // 这里依然用 `column:landurl` 自定义字段名
   LandURL     string              `borm:"column:landurl"`
   DeepLinkURL string              `borm:"column:deeplinkurl"`

   // 之所以不能直接用 bool 类型,是因为数据库是 tinyint
   // borm.Bool 实现了指定接口,所以可以实现自动映射
   IsDeleted   borm.Bool

   // 这里使用了 `readonly` 表明我们不用关心这个时间戳的更新,交给数据库自动更新即可
   CreatedAt   time.Time `borm:"readonly"`
   UpdatedAt   time.Time `borm:"readonly"`
}


// TableName 告诉 BORM 查哪张表
func (m *MaterialModel) TableName() string {
   return "material"
}

下面表演下如何通过实现特定接口完成数据库的 JSON 字符串与自定义结构体类型之间的转换的。通过把这种低级别的转换操作放在 Model 层完成,可以让业务上层写起来更爽

type AdScale struct {
   Width  int `json:"width"`
   Height int `json:"height"`
}

func NewAdScale(w, h int) *AdScale {
   return &AdScale{w, h}
}

// Value 接口会在写入数据库时调用,在实现该接口
// 时调用了 json.Marshal 转换成了 JSON 字符串
// 这样在业务层就可以快快乐乐使用 AdScale 结构体
// 如果需要存储,BORM 会自动获取这里序列化后的结果
// 换成 YAML 都不是梦,上层对此无感知!
func (a *AdScale) Value() (interface{}, error) {
   result, err := json.Marshal(a)
   if err != nil {
      return nil, err
   }
   return string(result), nil
}

// SetValue 会在读取数据时调用,当处理到该类型时,
// 通过 `json.Unmarshal` 自动反序列化成 AdScale 类型了
// 对于上层来说依然是透明的
func (a *AdScale) SetValue(v interface{}) error {
   jsonBody, ok := v.([]byte)
   if !ok {
      return errors.New("models.material: expect []byte type")
   }

   if len(jsonBody) != 0 {
      return json.Unmarshal(jsonBody, &a)
   } else {
      return nil
   }
}

对了,还有资源的关联呢?在 Python 中我们可以用属性的方式实现,在 Go 中依然可以实现类似的功能,只是写法不太相同而已:

// Attributes 是广告素材关联的一组自定义「属性」
// 这里就涉及到对另一张关联表的查询
func (m *MaterialModel) Attributes(ctx context.Context) []*AttributeModel {
   var results []*AttributeModel
   err := borm.New().Filter("material_id", m.ID).OrderBy("created_at").All(ctx, &results)
   if err != nil {
      log.Errorf("models.material: failed to get attributes of material '%d': %s", m.ID, err)
   }
   return results
}

当然啦,如果有资源关联的属性值来自 RPC,也可以放在 Model 层编写一个类似上面的属性。我们希望能够在 Model 层绑定资源的关联数据,这样在业务上层只需要 .Foo() 即可获取关联资源。

注意到,上面的「属性」函数接收了一个 ctx 参数,那是因为在进行数据库查询或者 RPC 服务调用时需要。但有时候我们的「属性」函数并不需要 ctx 参数,比如下面这样这样:

// IsInPromotion 是否在促销中
func (m *Model) IsInPromotion() bool {
   now := time.Now().Unix()
   return now >= m.PromotionStartsAt && now < m.PromotionEndsAt
}

Controller

其它项目的写法

我们先来看下其它项目中是如何编写 Controller 层的。首先看下目录结构:

controller
├── user.go
├── impl
│   ├── user.go
│   └── user_test.go
└── mock
    └── user.go

其中在 controller/user.go 中定义了该 Controller 的接口,而在 mock 目录下的文件则是由 mock 工具生成的文件。而在 impl 放置的是真正的实现逻辑,写法如下:

type UserControllerImpl struct {
        userDao dao.UserDao
}

var _ controller.UserController = (*UserControllerImpl)(nil)

var DefaultUserController *UserControllerImpl

func init() {
        DefaultUserController = NewUserController()
}

func NewUserController() *UserControllerImpl {
        return &UserControllerImpl{
                userDao: daoImpl.DefaultUserDao,
        }
}

func (c *UserControllerImpl) GetBar(ctx context.Context, uid int64) int64 {
        return c.userDao.GetBar(ctx, uid)
}

之所以采用这样的目录结构和实现方法,可能也是为了方便编写单元测试时 Mock 掉关键接口。但通过分析这些代码,也发现了几个问题:

  • Controller 层好像也没干啥,调用了 Dao 层的接口?
  • 不太符合 Go 圣经中所倡导的方式,接口写太多了
  • 每个 Controller 都必须写一个结构体?

但是为了满足 mock 工具苛刻的生成条件(总是基于 interface 生成),也不得不那样实现。但我们在实践中,有个单元测试需要去 Mock time.Now() 函数。这时就遇到了问题,虽然可以基于 time 再定义一个结构体来,再定义下接口,让 mock 工具生成 Mock 版本。但是这样感觉还是比较繁琐,且容易让代码膨胀。明明就是要解决一个看起来并不复杂的问题,却要因为单元测试引入那么多啰嗦的代码。其实我们并不希望因为单元测试而造成业务代码以某种妥协的方式实现。在经过一番调研和实践后,我们发现运行时 Mock 也是能够做到的(细节会在讲单元测试时说明),自然也就不必写得如此啰嗦~

我们的做法

对于比较复杂的业务逻辑,我们依然推荐你在 Controller 层去实现,但是不用教条式地定义一个结构体,再定义一个方法。基本原则就是,能有简单清晰明了的写法即可。比如下面这个例子:

// UpdateMaterial 做一次全量更新吧
func UpdateMaterial(ctx context.Context, materialID int64, schema *MaterialSchema) (err error) {
   material, err := GetMaterial(ctx, materialID)
   if err != nil {
      return
   }

   tx := borm.New().Begin(ctx)

   // 素材更新,这里分为两块
   err = updateMaterial(ctx, tx, material, schema)
   // 然后是素材的属性更新(但由于是全量,为了方便,会删除先前的属性,然后替换成新的)
   err = updateMaterialAttributes(ctx, tx, material, schema)

   if err != nil {
      tx.Rollback(ctx)
   } else {
      tx.Commit(ctx)
   }
   return nil
}

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 实现:

type MaterialsHandler struct {
   rest.BaseHandler
}

// Get 获取素材列表页
func (hd *MaterialsHandler) Get() (rest.Response, error) {
   ctx := hd.R.Context()
   output, err := controllers.ListMaterials(
      ctx,
      hd.OffsetInt64(),
      hd.LimitInt64(),
      hd.QueryArgumentWithFallback("order_by", "-created_at"))
   if err != nil {
      return nil, err
   }

   var schemas []dump.MaterialSchema
   return rest.Pagination{
      Context:     ctx,
      Data:        output.Materials,
      ToSchemaPtr: &schemas,
      IsAdmin:     true,
      Total:       int(output.Total),
   }, nil
}

// Post 新建广告素材
func (hd *MaterialsHandler) Post() (rest.Response, error) {
   var materialSchema load.MaterialSchema
   err := JSONArgs(hd.R, &materialSchema)
   if err != nil {
      return nil, err
   }

   err = controllers.CreateMaterial(hd.R.Context(), &materialSchema)
   if err != nil {
      return nil, err
   }
   return map[string]bool{"success": true}, nil
}


type MaterialHandler struct {
   rest.BaseHandler
}

// Get 获取某个广告素材详情
func (hd *MaterialHandler) Get() (rest.Response, error) {
   id, err := hd.getMaterialID()
   if err != nil {
      return nil, err
   }

   material, err := controllers.GetMaterial(hd.R.Context(), id)
   if err != nil {
      return nil, err
   }

   var schema dump.MaterialSchema
   err = portal.New().Dump(hd.R.Context(), material, &schema)
   if err != nil {
      log.Errorf("handlers.material.get: failed to dump material: %s", err)
   }

   return schema, nil
}

// Put 更新素材信息
func (hd *MaterialHandler) Put() (rest.Response, error) {
   // 参数处理
   materialID, err := hd.getMaterialID()
   if err != nil {
      return nil, err
   }

   var materialSchema load.MaterialSchema
   err = JSONArgs(hd.R, &materialSchema)
   if err != nil {
      return nil, err
   }

   // 更新逻辑交给 controller 完成
   err = controllers.UpdateMaterial(hd.R.Context(), materialID, &materialSchema)
   if err != nil {
      return nil, err
   }

   return map[string]bool{"success": true}, nil
}

// Delete 删除指定素材
func (hd *MaterialHandler) Delete() (rest.Response, error) {
   id, err := hd.getMaterialID()
   if err != nil {
      return nil, err
   }

   err = controllers.DeleteMaterial(hd.R.Context(), id)
   if err != nil {
      return nil, err
   }
   return map[string]bool{"success": true}, nil
}

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:

// TrackSchema 音频信息
type TrackSchema struct {
   ID            string `json:"id"`
   Title         string `json:"title"`
   // 这里我们对外的字段名实际是 audio,但对应取值来源 Model 的 AudioURL
   Audio         string `json:"audio,omitempty" portal:"attr:AudioURL"`
   AudioDuration int    `json:"audio_duration" portal:"attr:Duration"`
   // 这里我们可以使用自定义的方法取值
   PlayedAt      int    `json:"played_at" portal:"meth:GetPlayedAt"`
   Description   string `json:"description,omitempty" portal:"attr:Description.Description"`
}

// GetPlayedAt 返回用户播放的进度
func (*TrackSchema) GetPlayedAt(ctx context.Context, track interface{}) int {
   return 90
}

// SpeakerSchema 主讲人信息
type SpeakerSchema struct {
   // 嵌套一个可复用的 MemberSchema
   UserSchema

   // 额外信息
   Role    string `json:"role"`
}

接下来表演下如何使用类似上面定义的 Schema:

// 从 DB 查询得到 Model 示例
var track models.Track
borm.New().FindByPK(ctx, &track, pk)

// 调用 Dump 即可完成 Schema 字段数据填充
var trackSchema dump.TrackSchema
portal.New().Dump(ctx, &track, &trackSchema)

// 接下来将 trackSchema 序列化成 JSON 返回即可

当然,虽然引入 PORTAL 可以让我们更加聚焦业务逻辑的编写,尽可能减少冗余且机械的代码,但也由此带来了一些问题:

  • 使用 reflect 机制带来的性能损耗就看能不能接受
  • 有些因为类型转换的不成功的问题可能到运行时才会发现,排查较困难(但出现情况很少)

所以,这还是一个需要权衡的利弊后才能考虑的方案,但个人觉得它还是有一定价值的~

聊聊路由注册

说到路由注册,个人觉得其它部门的 Go 项目采用的方式并不是很优雅,且相对比较分散。所以,就给 REST 工具引入了类似我们在使用 Tornado 时采用的那种路由注册方式。因为是基于 chi.Mux 封装的 Router,所以完全兼容原先的接口。

对于比较简单的路由,可以采用下面的注册方式:

r.MountHandler("/hello", &hello.DemoHandler{})

而对于 API 较多的那种项目,推荐的目录结构如下:

routers
├── router.go
└── urls.go

router.go 中编写 Router 初始化的代码,包括中间件配置和路由注册:

// NewRouter web 路由实例创建
func NewRouter() Router {
   r := rest.NewRouter()

   // 注册各种需要的中间件
   r.Use(FooMiddleware)

   r.MountHandlers(handlers)
   return r
}

接下来在 urls.go 中定义 URL 和 Handler 的映射:

var handlers = map[string]rest.Handler{
   "/tasks":                           &task.TasksHandler{},
   "/tasks/{id:(\\d+)}":               &task.TaskHandler{},
}

单元测试很重要

先说结论,我们使用了 gomonkey 实现运行时 Monkey Patch。这样我们的 Controller/Handler/RPC 等层无需写得特别啰嗦。

如果想知道其工作原理的话,可以参考 monkey 项目和 Monkey Patching in Go。当然,下面几篇和单元测试有关的文章也可以看看:

  • GoConvey框架使用指南
  • Monkey 框架使用指南
  • GoStub 框架使用指南
  • GoMock 框架使用指南
  • gomonkey 1.0 正式发布!

此外,还有个用于初始化测试数据的工具也很有帮助,详细可以参见 Fixture 项目。

Panic 还是直接返回 Error

对于业务异常来说,相对系统级错误等严重错误发生地更加频繁,这样一来频繁地 Panic/Recover 会带来一些额外开销。此外,无脑地对任何业务异常都采取 Panic 的式方,真的好吗?不过看起来其它组的项目的确很乐意采用这种方式。

但个人推荐的方式是对于业务错误,依然采用 Go 中典型的方式:返回错误!如果是比较严重的错误(如网络中断等),则可以进行 Panic,然后在上层捕获。

// DeleteMaterial 删除素材
func DeleteMaterial(ctx context.Context, materialID int64) error {
   material, err := GetMaterial(ctx, materialID)
   if err != nil {
      return err
   }

   err = borm.New().Model(&material).Update(ctx, "is_deleted", true)
   if err != nil {
      log.Errorf("controllers.material.DeleteMaterial: failed to update: %s", err)
   }
   return err
}

枚举定义

我们一般定义完枚举后,都希望能够根据给定的值获得对应的枚举变量,或者得到枚举变量名映射的名称。这里给出一种可行的定义方式:

// AuditStatus 审核状态
type AuditStatus int

// 定义三种审核状态
const (
   AuditAwaiting AuditStatus = iota
   AuditPassed
   AuditRefused
)

// auditStatusNames 定义每种枚举状态对应的名称
var auditStatusNames = []string{
   "awaiting",
   "passed",
   "refused",
}

func (s AuditStatus) String() string {
   return auditStatusNames[s]
}

// AuditStatusByName 可以根据名称映射得到枚举变量
func AuditStatusByName(name string) AuditStatus {
   for i, v := range auditStatusNames {
      if v == name {
         return AuditStatus(i)
      }
   }
   panic(fmt.Sprintf("audit status name not found: '%s'", name))
}

// Value 实现的是 BORM 指定的接口,完成和数据库类型(tinyint)映射(写入)
func (s *AuditStatus) Value() (interface{}, error) {
   return int(*s), nil
}

// SetValue 实现的是 BORM 指定的接口,完成和数据库类型(tinyint)映射(读取)
func (s *AuditStatus) SetValue(v interface{}) error {
   *s = AuditStatus(cast.ToInt(v))
   return nil
}

声明

  • 本文链接: http://ifaceless.space/2018/12/16/golang-web-dev-practice-summary/
  • 版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!

你可能感兴趣的:(Go Web 工程实践总结)