上一节,我们详细学习了如何部署 miniblog 服务,为之后的学习准备好了实验环境。接下来,我会带着你一步一步构建出一个完整的 Go 项目,并且尽可能地说明开发过程中我的思考。希望能够通过这些思考让你知道如何开发 Go 项目,并且了解其他开发者的开发思路。

项目研发流程介绍

在动手撸代码之前,你最好先鸟瞰整个 Go 项目开发流程,通过对流程的了解,可以减少你在初次进行完整 Go 项目开发中的那种“不知所措”的感觉,可以让你掌握业界最优、也是最标准的项目开发流程,增强你的自信。

业界通常把研发流程分为六个阶段,分别是需求阶段、设计阶段、开发阶段、测试阶段、发布阶段、运营阶段。其中,开发人员需要参与的阶段有 4 个:设计阶段、开发阶段、测试阶段和发布阶段。研发流程及流程中每个阶段的具体内容,如下图所示:

3.项目设计:如何开发一个易扩展、易维护的 Go 应用? - 图1

作为一名开发人员,在设计阶段也会有一些设计类工作需要你去完成,例如:规范设计、目录结构设计、工作流设计、研发流程设计、Go 代码结构设计等,其中比较重要的设计是规范设计、目录结构设计、Go 代码结构设计。接下来,我会详细介绍如何来设计这些内容。

提示:关于开发阶段,如果你对每个阶段的介绍感兴趣,可参考 附录 B:Go 项目开发阶段介绍

规范设计

规范设计内容也比较多,通常包含代码规范、Commit 规范、版本规范、接口规范、日志规范、错误码规范等。miniblog 项目也有自己的规范设计,具体如下:

  • 代码规范: 编码规范内容比较多,本课程就不做详细介绍。minibolog 所遵循的代码规范见:Go 代码开发规范

  • Commit 规范: 社区有多种 Commit 规范,例如 jQuery、Angular 等。在这些规范中,Angular 规范在功能上能够满足开发者 commit 需求,在格式上清晰易读,目前也是用得最多的。Commit 规范对于开发者来说是一个建议遵守的规范,但不是必须的规范。如果你感兴趣可以参考 Conventional Commits (中文翻译版本:Conventional Commits:一份让代码提交记录人机友好的规范)。

  • 版本规范: 当前绝大部分优秀的 Go 项目都有自己的版本规范,其中语义化版本规范(Semantic Versioning,SemVer)是用的最多的规范。具体内容可参考:语义化版本 2.0.0

  • 接口规范: 接口文档又称为 API 文档,一般由后台开发人员编写,用来描述组件提供的 API 接口,以及如何调用这些 API 接口。一个好的 API 接口文档,应该是规范的。当前用的最多的接口规范是 OpenAPI 3.0 规范。因为内容比较多,本课程也不详细介绍,你可以参考官方文档进行学习:OpenAPI Specification(一个不错的中文翻译文档:开放 API 规范中文翻译)。

  • 日志规范: miniblog 为了规范打印日志,也制定了日志规范,具体规范在 第 7 节 有介绍。

  • 错误码规范: 现代的软件架构,很多都是对外暴露 RESTful API 接口,内部系统通信采用 RPC 协议。因为 RESTful API 接口有一些天生的优势,比如规范、调试友好、易懂,所以通常作为直接面向用户的通信规范。既然是直接面向用户,那么首先就要求消息返回格式是规范的;其次,如果接口报错,还要能给用户提供一些有用的报错信息,通常需要包含错误 Code(用来唯一定位一次错误)和 Message(用来展示出错的信息)。这就需要我们设计一套规范的、科学的错误码。miniblog 的错误码规范在 第 11 节 中有详细介绍。

上面只列举了一些常用的规范,对于一个项目来说,如果有需要可以制定更多的规范,例如:开发流程规范、发布规范、校验规范等。这些规范不仅可以提高代码质量,还可以提高研发效率,降低后期的代码维护成本和学习成本。

提示:在实际开发中,大部分团队和项目其实也只用到了这些规范。甚至,我所见过的更多的团队,包括腾讯、阿里内部的不少团队都没有制定过开发规范,或者说一些团队只是在项目开始时曾经制定过规范,但随着项目的迭代,会遗忘这些规范。

规范是开发人员制定的软约束,靠开发人员自觉的去遵守,并不太可行。在实际开发中,我们要多思考怎么通过工具去保证这些规范被遵守,例如:可以通过 golangci-lint 进行静态代码检查,来保证代码规范;通过 gitlint 工具来保证 Commit 规范;通过 gsemver 工具来生 SemVer 格式的版本号;通过 OpenAPI 编辑器来编写 OpenAPI 接口文档;通过调用统一的请求返回方法,来确保错误码规范;通过开发规范化的日志包,来保证日志规范等。

目录结构设计

上面,我介绍了一些设计阶段需要制定的开发规范,接下来我再来详细介绍下,设计阶段另外一个需要设计的点:目录结构设计。

目录结构是一个项目的门面。很多时候,根据目录结构就能看出开发者对这门语言的掌握程度。所以,在我看来,遵循一个好的目录规范,把代码目录设计的可维护、可扩展,甚至比文档规范、Commit 规范都要重要。通常,根据功能,可以将目录结构分为以下两种。

  • 平铺式目录结构: 主要用在 Go 包中,相对简单。

  • 结构化目录结构: 主要用在 Go 应用中,相对复杂。

平铺式目录结构

一个 Go 项目可以是一个应用,也可以是一个代码库,当项目是代码库时,比较适合采用平铺式目录结构。

平铺方式就是在项目的根目录下存放项目的代码,整个目录结构看起来更像是一层的,这种方式在很多库中存在,使用这种方式的好处是引用路径长度明显减少。例如 glog 包就是平铺式的,目录内容如下:

go

复制代码

$ ls glog/ glog_file.go glog.go glog_test.go go.mod LICENSE README.md

对于一些功能简单的工具或项目,也可以采用平铺式的目录结构,以减小开发者的理解成本和编写复杂度。

结构化目录结构

当前 Go 社区比较推荐的结构化目录结构是 project-layout。虽然它并不是官方和社区的规范,但因为组织方式比较合理,被很多 Go 开发人员接受。所以,我们可以把它当作是一个事实上的规范。本实战项目 miniblog 目录也遵循了 project-layout 项目的目录规范。

miniblog 目录结构设计

miniblog 目录结构遵循了 project-layout 的目录结构设计。项目目录及功能介绍如下:

  1. ├── api # Swagger / OpenAPI 文档存放目录
  2. └── openapi
  3. └── openapi.yaml # OpenAPI 3.0 API 接口文档
  4. ├── cmd # main 文件存放目录
  5. └── miniblog
  6. └── miniblog.go
  7. ├── configs # 配置文件存放目录
  8. ├── miniblog.sql # 数据库初始化 SQL
  9. ├── miniblog.yaml # miniblog 配置文件
  10. └── nginx.conf # Nginx 配置
  11. ├── docs # 项目文档
  12. ├── devel # 开发文档
  13. ├── en-US # 英文文档
  14. └── zh-CN # 中文文档
  15. ├── architecture.md # miniblog 架构介绍
  16. ├── conversions # 规范文档存放目录
  17. ├── api.md # 接口规范
  18. ├── commit.md # Commit 规范
  19. ├── directory.md # 目录结构规范
  20. ├── error_code.md # 错误码规范
  21. ├── go_code.md # 代码规范
  22. ├── log.md # 日志规范
  23. └── version.md # 版本规范
  24. └── README.md
  25. ├── guide # 用户文档
  26. ├── en-US # 英文文档
  27. └── zh-CN # 中文文档
  28. ├── announcements.md # 动态与公告
  29. ├── best-practice # 最佳实践
  30. ├── faq # 常见问题
  31. ├── installation # 安装指南
  32. ├── introduction # 产品介绍
  33. ├── operation-guide # 操作指南
  34. ├── quickstart # 快速入门
  35. └── README.md
  36. └── images # 项目图片存放目录
  37. ├── examples # 示例源码
  38. ├── go.mod
  39. ├── go.sum
  40. ├── init # Systemd Unit 文件保存目录
  41. ├── miniblog.service # miniblog systemd unit
  42. ├── internal # 内部代码保存目录,这里面的代码不能被外部程序引用
  43. ├── miniblog # miniblog 代码实现目录
  44. ├── biz # biz 层代码
  45. ├── controller # controller 层代码
  46. └── v1 # API 接口版本
  47. ├── post # 博客相关代码实现
  48. ├── create.go # 创建博客
  49. ├── delete_collection.go #批量删除博客
  50. ├── delete.go # 删除博客
  51. ├── get.go # 获取博客详情
  52. ├── list.go # 获取博客列表
  53. ├── post.go # 博客 Controller 结构定义、创建
  54. └── update.go # 更新博客
  55. └── user
  56. ├── change_password.go # 修改用户密码
  57. ├── create.go #创建用户
  58. ├── delete.go # 删除用户
  59. ├── get.go # 获取用户详情
  60. ├── list.go # 获取用户列表
  61. ├── login.go # 用户登录
  62. ├── update.go # 更新用户
  63. └── user.go # 用户 Controller 结构定义、创建
  64. ├── helper.go # 工具类代码存放文件
  65. ├── miniblog.go # miniblog 主业务逻辑实现代码
  66. ├── router.go # Gin 路由加载代码
  67. └── store # store 层代码
  68. └── pkg # 内部包保存目录
  69. ├── core # core 包,用来保存一些核心的函数
  70. ├── errno # errno 包,实现了 miniblog 的错误码功能
  71. ├── code.go # 错误码定义文件
  72. └── errno.go # errno 包功能函数文件
  73. ├── known # 存放项目级的常量定义
  74. ├── log # miniblog 自定义 log 包
  75. ├── middleware # Gin 中间件包
  76. ├── authn.go # 认证中间件
  77. ├── authz.go # 授权中间件
  78. ├── header.go # 指定 HTTP Response Header
  79. └── requestid.go # 请求 / 返回头中添加 X-Request-ID
  80. └── model # GORM Model
  81. ├── LICENSE # 声明代码所遵循的开源协议
  82. ├── Makefile # Makefile 文件,一般大型软件系统都是采用 make 来作为编译工具
  83. ├── _output # 临时文件存放目录
  84. ├── pkg # 可供外部程序直接使用的 Go 包存放目录
  85. ├── api # REST API 接口定义存放目录
  86. ├── proto # Protobuf 接口定义存放目录
  87. ├── auth # auth 包,用来完成认证、授权功能
  88. ├── authn.go # 认证功能
  89. └── authz.go # 授权功能
  90. ├── db # db 包,用来完成 MySQL 数据库连接
  91. ├── token # JWT Token 的签发和解析
  92. ├── util # 工具类包存放目录
  93. └── id # id 包,用来生成唯一短 ID
  94. └── version # version 包,用来保存 / 输出版本信息
  95. ├── README-en.md # 英文 README
  96. ├── README.md # 中文 README
  97. ├── scripts # 脚本文件
  98. ├── boilerplate.txt # 指定版权头信息
  99. ├── coverage.awk # awk 脚本,用来计算覆盖率
  100. ├── make-rules # 子 Makefile 保存目录
  101. ├── common.mk # 存放通用的 Makefile 变量
  102. ├── golang.mk # 用来编译源码
  103. └── tools.mk # 用来完成工具的安装
  104. └── wrktest.sh # wrk 性能测试脚本
  105. └── third_party # 第三方 Go 包存放目录

跟 project-layout 目录规范唯一不一样的地方是,miniblog 将具体的实现目录 miniblog 放在 internal/ 目录下,而非 internal/app/ 目录下,例如:

  1. $ ls internal/
  2. miniblog pkg

这样做既可以保证 internal 目录下的文件功能清晰、整齐,又能缩短引用路径。

代码结构设计

miniblog 项目代码设计遵循简洁架构设计,一个简洁架构具有以下 5 个特性:

  • 独立于框架: 该架构不会依赖于某些功能强大的软件库存在。这可以让你使用这样的框架作为工具,而不是让你的系统陷入到框架的约束中。

  • 可测试性: 业务规则可以在没有 UI、数据库、Web 服务或其他外部元素的情况下进行测试,在实际的开发中,我们通过 Mock 来解耦这些依赖。

  • 独立于UI : 在无需改变系统其他部分的情况下,UI 可以轻松地改变。例如,在没有改变业务规则的情况下,Web UI 可以替换为控制台 UI。

  • 独立于数据库: 你可以用 Mongo、Oracle、Etcd 或者其他数据库来替换 MariaDB,你的业务规则不要绑定到数据库。

  • 独立于外部媒介: 实际上,你的业务规则可以简单到根本不去了解外部世界。

所以,基于这些约束,每一层都必须是独立的和可测试的。miniblog 代码架构分为 4 层:模型层(Model)、控制层(Controller)、业务层 (Biz)、仓库层(Store)。从控制层、业务层到仓库层,从左到右层级依次加深。模型层独立于其他层,可供其他层引用。代码架构如下图所示:

3.项目设计:如何开发一个易扩展、易维护的 Go 应用? - 图2

层与层之间导入包时,都有严格的导入关系,这可以防止包的循环导入问题。导入关系如下:

  • 模型层的包可以被仓库层、业务层和控制层导入。

  • 控制层能够导入业务层和仓库层的包。这里需要注意,如果没有特殊需求,控制层要避免导入仓库层的包,控制层需要完成的业务功能都通过业务层来完成。这样可以使代码逻辑更加清晰、规范。

  • 业务层能够导入仓库层的包。

miniblog 四层架构

接下来,我们就来详细看下每一层所完成的功能,以及其中的一些注意点:

  1. 模型层(Model):模型层在有些软件架构中也叫做实体层(Entities),模型会在每一层中使用,在这一层中存储对象的结构和它的方法。
  2. 控制层(Controller):控制层接收 HTTP 请求,并进行参数解析、参数校验、逻辑分发处理、请求返回这些操作。控制层会将逻辑分发给业务层,业务层处理后返回,返回数据在控制层中被整合再加工,最终返回给请求方。控制层相当于实现了业务路由的功能。具体流程如下图所示:

3.项目设计:如何开发一个易扩展、易维护的 Go 应用? - 图3

  1. 业务层 (Biz):业务层主要用来完成业务逻辑处理,我们可以把所有的业务逻辑处理代码放在业务层。业务层会处理来自控制层的请求,并根据需要请求仓库层完成数据的 CURD 操作。业务层功能如下图所示:

3.项目设计:如何开发一个易扩展、易维护的 Go 应用? - 图4

  1. 仓库层(Store):仓库层用来跟数据库/第三方服务进行 CURD 交互,作为应用程序的数据引擎进行应用数据的输入和输出。这里需要注意,仓库层仅对数据库/第三方服务执行 CRUD 操作,不封装任何业务逻辑。这一层也会起到数据转换的作用:将从数据库/微服务中获取的数据转换为控制层、业务层能识别的数据结构,将控制层、业务层的数据格式转换为数据库或微服务能识别的数据格式。

层之间的通信

上面,我介绍了 miniblog 采用的 4 层结构,接下来我们再看看每一层之间是如何通信的。

除了模型层,控制层、业务层、仓库层之间都是通过接口进行通信的。通过接口通信,一方面可以使相同的功能支持不同的实现(也就是说具有插件化能力),另一方面也使得每一层的代码变得可测试。关于层通信我会在 第 12 节 详细介绍。

miniblog 代码测试

控制层、业务层和仓库层之间是通过接口来通信的。通过接口通信有一个好处,就是可以让各层变得可测。那接下来,我们就来看下如何测试各层的代码。因为 第 18 节 ****会详细介绍如何测试 Go 代码,所以这里只介绍下测试思路。

  1. 模型层

因为模型层不依赖其他任何层,我们只需要测试其中定义的结构及其函数和方法即可。

  1. 控制层

控制层依赖于业务层,意味着该层需要业务层来支持测试。你可以通过 golang/mock 来 mock 业务层,测试用例可参考 TestPostController_Create

  1. 业务层

因为该层依赖于仓库层,意味着该层需要仓库层来支持测试。你可以通过 golang/mock 来 mock 仓库层,测试用例可以参考 Test_userBiz_List

  1. 仓库层

仓库层依赖于数据库,如果调用了其他微服务,那还会依赖第三方服务。我们可以通过 sqlmock 来模拟数据库连接,通过 httpmock 来模拟 HTTP 请求。

小结

本节主要展示了如何完成设计阶段中,开发人员需要完成的工作。比较重要的工作有:规范设计、目录结构设计和代码结构设计。其中规范类,本课程采用了业界的最佳规范实践,目录结构采用了当前最受 Go 开发者欢迎的 project-layout 目录结构,代码结构采用了简洁架构。