跳到主要内容

工程架构

Cvgo 的目录结构接近 SOA 架构风格,但是多个应用(模块)之间又不用通过 RPC 或 Http 去调用,在模块的上层建立目录就能桥接通信,最终编译出来的可执行程序又是个单体。 不过在云原生时代,特别是 DevOps 流行起来,也许单体架构又会被重新定义。开发时是单体,运行时又可以是分布式的,在开发效率和运行支撑上获得一个折中并且易扩展改造的方案。 这时 Cvgo 目录结构设计的理念。

整理目录结构

  • app 项目业务代码存放目录。
  • console 配套开发工具的代码,你可以自行定制修改。
  • cvgerr 错误类型、错误码定义。
  • kit: 公共函数库、工具包等可复用代码。
  • provider: 服务容器,脚手架服务能力提供者。

app 目录结构

建议采用 Monorepo 方式管理代码,每个 Module 是一个单独应用,internal 隔离,多个应用在一个仓库的大仓模式,方便多个应用复用公共代码。

  • common: 存放多个应用模块公用代码。
  • config: 多模块公共配置文件及配置文件数据结构定义。
  • docker: DevOps 环境配置,开发环境与生产环境使用相同镜像。
  • entity: 模型实体,SOA 架构风格,多模块贫血共享实体。
  • entity: 模块目录,为每个应用创建一个模块。
  • scripts: 一些常用的或一次性的脚本。
  • app.go: 存放一些全局公用函数、常量等。
  • instance.go: 全局实力定义。
  • cvg.json: 运行 cvg 工具时生成的一个记录工具运行信息的文件,被工具所依赖。

模块目录结构

app 目录下每个模块结构:

  • apidebug: 使用 Jquery 发送 AJAX 请求,充当 postman 用于调试接口。每个接口一个 html 文件,通过 cvg 工具创建 api 时会自动在这里生成对应的 html 文件。
  • docs: 使用 Swagger 生成接口文档的默认存放路径。
  • internal: 业务逻辑主目录,通过 golang 的 internal 特性与其他应用隔离。
  • go.mod: 本模块的依赖管理。
  • main.go: 本模块的入口文件。

app.internal 目录结构

internal 目录下结构举例 (非架构层面的代码组织,不是必须遵循,可自由设计) :

  • api: 业务 api 接口 (相当于 MVC 中的控制器)。
  • biz: 业务逻辑解耦层。
  • boot: 启动应用时进行的一些初始化操作,例如获取各种要用到的 provider 实例。
  • consts: 本模块内的常量定义。
  • dto: 用户相关功能数据类型定义,如接口输入输出数据类型的定义。
  • instance: 各种常驻内存的实例(通常为单例),如数据库连接池。
  • routing: api 路由定义。
  • services: 服务层,与 api 目录对于 (相当于 MVC 中的模型)。
  • test: 单元测试文件。

模块的分层架构设计

区别于前后端不分离时代的 MVC 模式,这里分层架构 (Layered Architecture) 与 MVC 不是同一概念。 从上到下依次分为:

  • api 层
  • services 层
  • biz 层
  • entity 层。

在分层架构中,依赖的方向是有严格规定的:

上层(Upper Layers)依赖于下层(Lower Layers),而下层不应该依赖于上层。

这样的依赖关系有助于保持系统的模块化、解耦和可维护性。这种依赖关系的基本原理是,上层可以调用下层提供的服务或方法, 但是下层不能直接调用上层的任何功能。这是为了确保业务需求的变更不会对底层封装产生冲击,同时也避免了循环依赖的问题。

api 层

http 请求的入口,即路由配置的响应函数。其职责主要处理输入输出,调用 services,编写 swagger 文档。 假设在 api 层创建了一个 user.api.go 则这里面每个方法就是一个 http api。

services 层

对应 api 层会创建 user.svc.go 编写业务逻辑提供给 api 层使用。 services 层中的方法可以像 MVC 模型中的方法一样编写业务逻辑。 对于简单业务接口可能直接在 services 中的方法里面就写完返回给 api,生命周期就结束了。 对于复杂业务需要封装拆分函数来解耦,则不要将拆出来的函数放到 services 层,而是放到 biz 层。

biz 层

业务逻辑解耦层,小方法的封装,提供给 services 层调用。对于方法封装的治理,常见的是以代码行数为指标, 例如一个方法不能超过多少行,100行/50行/更有甚者30行。我个人建议不那么严格,以一屏为标准,一个方法长度不超过一屏就不用滚动鼠标,就“一目了然”。 当你在 services 中编写的一个方法超过显示器一屏时,应该考虑抽象出单一职责的方法解耦到 biz 层。

biz 层之间要避免大量相互调用,避免出现 “调用栈迷宫”。 在一个 biz 层方法中大量调用其他 biz 方法,就意味着破坏了单一职责原则,非单一职责的方法就不容易被复用。

三层调用链

当你在 biz 层中封装方法时想调用另一个 biz 层的方法,应该考虑将这个调用行为往上抛给 service 层,让整个接口逻辑的调用链保持在 api -> service -> biz 这三层的长度。 自顶向下,分而治之,上层的代码是下层方法的调用清单,避免出现 “调用栈迷宫”,特别时那种多个文件之间来回切,在同一个文件中再多个方法来回跳的方法调用链,在阅读代码时容易看到后面忘记前面,增加维护人员心智负担。

entity 层

模型实体,实体中也是可以定义方法的,比如数据库中有个 vip 过期时间字段,要判断用户是否 vip 的场景, 就可以在 entity 实体上直接定义个 IsVip() 方法。在 SOA 架构风格中 entity 实体应当保持贫血模式, 即不要在这上面填充具体业务逻辑。

架构总结

  1. 采用 Monorepo 代码管理方式,使用 go work + go mod 在全局架构中简化子系统管理、统一开发环境。
  2. 采用 SOA 架构风格 + 模块化软件工程方法。退一步可享受单体开发的便利,进一步可更方便拆分微服务。
  3. 分层架构中,越上层的改动会越频繁,越下层的改动风险越大。在日常开发中拿到业务需求后,抽象封装biz层是最核心也是最吃经验的一步。 自顶而下,分而治之,理想的治理是在biz层编写一个个小方法(30行一个/50行一个),然后serviecs层只需要组合调用一个个的biz方法。 我把这些一个个的biz层方法称之为“业务轮子”,当轮子越来越多的时候,会发现无论改需求还是新加需求,在serviecs层进行组装的时候都很爽。

编码规范

there are only two hard things in Computer Science: cache invalidation and naming things.

通过 cvg 工具生成的文件/代码是遵循这个规范的。

文件命名

  • api 文件采用.api作为后缀,如:user.api.go
  • services 层文件采用.svc作为后缀,如:user.svc.go
  • biz 层文件采用.biz作为后缀,如:user.biz.go
  • entity 层文件采用.ent作为后缀,如:user.ent.go
  • dto 文件采用 .dto 作为后缀,如:user.dto.go
  • bridge 文件采用 .brg 作为后缀,如:user.brg.go

其他以此类推,尽量使用望文生义的后缀标识文件用途。

常量定义

对于数据库字段的值的常量定义,应该放在 entity 层,例如 user 表字段值对应的常量定义就放在 user.api.go

变量命名

采用驼峰式。

  • dto 输入结构体命名,路由方法名 + Req 后缀,如:UserLoginReq
  • dto 输出结构体命名,路由方法名 + Res 后缀,如:UserLoginRes
  • dto 输出的字段采用驼峰式,不能采用 snake_case,因为客户端 kotlin 语法在 Android Studio 中对下划线命名报警告。

开协程

所有开出的协程内都应当捕获 panic,在协程内发生的 panic 是无法被框架的 Recover 中间件捕获的,如下代码执行会导致程序崩溃退出:

package api

func Test(c *fiber.Ctx) error {
go getPanic()
return c.SendString("Hello!")
}

func getPanic() {
panic("")
}

cvgo 封装了便捷捕获异常开协程的方式,使用 cvgo/kit/gokit 包提供的 GoWithRecover() 方法开协程,如下代码不会导致程序崩溃退出:

package api

import "cvgo/kit/gokit"

func Test(c *fiber.Ctx) error {
go gokit.GoWithRecover(getPanic)
go gokit.GoWithRecover(func() {
getPanicWithInfo("make panic")
})
return c.SendString("Hello!")
}

func getPanic() {
panic("")
}

func getPanicWithInfo(info string) {
panic(info)
}

实体定义

数据库的实体定义都放在 entity/mysql目录中,结构体以 Entity 为后缀命名,并且统一内嵌通用字段。示例如下:

func (UserEntity) TableName() string {
return "user"
}

type UserEntity struct {
BaseFields
Name string
}

Redis 数据的结构体定义都放在 entity/redis 目录中,key 的定义放在 entity/redis/rediskey 目录。 用函数返回一个 key 的名称和过期时间。示例如下:

func Userinfo(uid string) (key string, duration time.Duration) {
key = "user:info:" + uid
duration = time.Second * 3600
return
}

推荐遵循

推荐 Uber 公司编码规范: https://github.com/xxjwxc/uber_go_guide_cn 对编写简洁、高效、健壮的代码有帮助。