Go 工程化标准实践
本文参考 Go 微服务框架 go-kratos/kratos 的项目结构及相关最佳实践,更多技术分享可关注公众号 Kratos 开源社区。
Kratos 一套轻量级 Go 微服务框架,包含大量微服务相关功能及工具。
项目结构
标准项目结构
/cmd
1 | |-- cmd |
项目的主干,每个应用程序目录名与可执行文件的名称匹配。该目录不应放置太多代码。
/internal
1 | |-- internal |
私有应用程序和库代码。该目录由 Go 编译器强制执行(更多细节请参阅 Go 1.4 release notes),在项目树的任何级别上都可以有多个 /internal 目录。
可在 /internal 包中添加额外结构,以分隔共享和非共享的内部代码。对于较小的项目而言不是必需,但最好有可视化线索显示预期的包的用途。
实际应用程序代码可放在 /internal/app 目录下(比如 /internal/app/myapp),应用程序共享代码可放在 /internal/pkg 目录下(比如 /internal/pkg/myprivlib)。
相关服务(比如账号服务内部有 rpc、job、admin 等)整合一起后需要区分 app。单一服务则可以去掉 /internal/myapp。
/pkg
1 | |-- pkg |
1 | . |
外部应用程序可以使用的库代码。可以显式地表示该目录代码对于其他人而言是安全可用的。
/pkg 目录内可参考 Go 标准库的组织方式,按照功能分类。/internal/pkg 一般用于项目内的跨应用公共共享代码,但其作用域仅在单个项目工程内。
pkg 和 internal 目录的相关描述可以参考 I’ll take pkg over internal。
当根目录包含大量非 Go 组件和目录时,这也是一种将 Go 代码分组到一个位置的方法,使得运行各种 Go 工具更容易组织。
工具包项目结构
1 | |-- cache |
应当为不同的微服务建立统一的 kit 工具包项目(基础库/框架)和 app 项目。
基础库 kit 为独立项目,公司级建议只有一个。由于按照功能目录来拆分会带来不少的管理工作,建议合并整合。
其具备以下特点:
统一
标准库方式布局
高度抽象
支持插件
服务应用项目结构
1 | . |
/api
API 协议定义目录,比如 protobuf 文件和生成的 go 文件。
通常把 API 文档直接在 proto 文件中描述。
/configs
配置文件模板或默认配置。
/test
外部测试应用程序和测试数据。可随时根据需求构造 /test 目录。
对于较大的项目数据子目录是很有意义的。比如可使用 /test/data 或 /test/testdata(如果需要忽略目录中的内容)。
Go 会忽略以“.”或“_”开头的目录或文件,因此在命名测试数据目录方面有更大灵活性。
GitLab Project
1 | |-- app |
一个 GitLab project 中可以放置多个微服务 app(类似 monorepo),也可以按照 GitLab 的 group 里建立多个 project,每个 project 对应一个 app。
微服务结构
1 | |-- cmd 负责程序的:启动、关闭、配置初始化等。 |
以下这种目录结构风格:
1 | |-- service |
app 目录下有 api、cmd、configs、internal 目录。一般还会放置 README、CHANGELOG、OWNERS。
项目的依赖路径为:model -> dao -> service -> api,model struct 串联各个层,直到 api 做 DTO 对象转换。
另一种结构风格是将 DDD 设计思想和工程结构做了简化,映射到 api、service、biz、data 各层。
1 | . |


架构与数据模型
松散分层架构(Relaxed Layered System):层间关系不太严格,每层都可能使用它下面所有层的服务(而不仅是下一层)。每层都可能是半透明的,意味着有些服务只对上一层可见,而有些服务对上面的所有层都可见。
1 | [ api ] |
继承分层架构(Layering Through Inheritance):高层继承并实现低层接口。需要调整各层顺序,将基础设施层移动到最高层。这依然是单向依赖,意味着领域层、应用层、表现层将不能依赖基础设施层,而基础设施层可以依赖它们。
1 | [ data ] |
数据模型:
失血模型:仅包含数据定义和 getter/setter 方法,业务逻辑和应用逻辑都放到服务层中。在 Java 中称为 POJO,在 .NET 中称为 POCO。
贫血模型:包含一些业务逻辑,但不包含依赖持久层的业务逻辑(会放在服务层中),领域对象不依赖于持久层。
充血模型:包含所有业务逻辑,领域层依赖于持久层,简单表示就是:UI 层 -> 服务层 -> 领域层 <-> 持久层。
胀血模型:和业务逻辑不想关的其他应用逻辑(如授权、事务等)放到领域模型中(反而是另外一种失血模型,服务层缺失、由领域层代劳)。
生命周期
考虑服务应用对象初始化和生命周期管理,所有 HTTP/gRPC 依赖的前置资源初始化(包括 data、biz、service),之后再启动监听服务。
资源初始化和关闭步骤繁琐,比较容易出错。可利用依赖注入的思路,使用 google/wire 管理资源依赖注入,方便测试和实现单次初始化与复用。
1 | svr := http.NewServer() |
另外还支持静态生成代码,便于诊断(而不是在运行时通过 reflection 实现)。
API 设计
为了统一检索和规范 API,可在内部建立统一的仓库,整合所有对内对外 API(可参考 googleapis/googleapis、envoyproxy/data-plane-api、istio/api)。
API 仓库,方便跨部门协作。
版本管理,基于 git 控制。
规范化检查(API lint)。
API design review(变更 diff)。
权限管理,目录 OWNERS
gRPC
gRPC 是一种高性能的开源统一 RPC 框架:
基于 Proto 的请求响应,支持多种语言。
轻量级、高性能:序列化支持 Protocol Buffer 和 JSON。
可插拔:支持多种插件扩展。
IDL:基于文件定义服务,通过 proto3 生成指定语言的数据结构、服务端接口以及客户端 Stub(所有语言都是一致的,可代表文档)。
移动端基于标准 HTTP/2 设计,支持双向流、消息头压缩、单 TCP 多路复用、服务端推送等特性,使得 gRPC 在移动端设备上更加省电和网络流量(传输层透明,便于升级到 HTTP/3、QUIC)。
1 | syntax = "proto3"; |
1 | protoc --go_out=.--go_opt=paths=source_relative \ |
设计原则:
服务而非对象,消息而非引用:促进微服务系统间粗粒度消息交互设计理念。
负载无关:不同服务使用不同的消息类型和编码,例如 protocol buffers、JSON、XML、Thrift。
流:Streaming API。
阻塞 / 非阻塞:支持异步和同步处理在客户端和服务端间交互消息序列。
元数据交换:常见的横切关注点,如认证或跟踪,依赖数据交换。
标准化状态码:客户端以有限方式响应 API 调用返回的错误(优先使用标准的 HTTP 状态码)。
设计时不要过早关注性能问题,先实现标准化。
目录结构
参考:
1 | |-- bapis |
兼容性
维护 API 需要注意总是保持向后兼容(非破坏性)的修改:
为服务添加 API(从协议的角度来看始终是安全的)。
为请求消息添加字段(客户端在新版和旧版中对字段的处理保持一致,添加请求字段就是兼容的)。
为响应消息添加字段(在不改变其他字段的前提下,非资源响应消息可以扩展而不必破坏客户端的兼容性。即使会引入冗余,先前在响应中填充的任何字段应继续使用相同的语义填充)。
应避免破坏性的修改(一般需要修改 major 版本号):
删除或重命名服务,字段,方法或枚举值(如果客户端代码可引用的内容,删除或重命名它都是不兼容的变化)。
修改字段的类型(即使新类型是传输格式兼容的,也可能导致客户端生成代码发生变化,对于静态语言而言会容易引入编译错误)。
修改现有请求的可见行为(客户端通常依赖于 API 行为和语义,即使没有被明确支持或记录。在大多数情况下,修改 API 数据的行为或语义将被消费者视为是破坏性的。如果行为没有加密隐藏,应该假设用户已经发现并依赖于它)。
给资源消息添加读写字段。
命名规范
包名为应用的标识(appid),用于生成 gRPC 请求路径或 proto 之间引用 Message。
文件中声明的包名称应该与产品和服务名称一致,带有版本的 API 的软件包名称必须以此版本结尾。
参考():
示例 | |
---|---|
产品名称 | Google Calendar API |
服务名称 | calendar.googleapis.com |
软件包名称 | google.calendar.v3 |
接口名称 | google.calendar.v3.CalendarService |
来源目录 | //google/calendar/v3 |
API 名称 | calendar |
请求 URL:/package_name.version.service_name/method
原始字段
gRPC 默认使用 Protobuf v3 格式,去除了 required 和 optional 关键字(默认全部是 optional)。没有赋值的字段默认是基础类型字段的默认值,比如 0 或者 “”。
1 | // proto2 |
将无法区分默认值或未赋值。因此在 Protobuf v3 中建议使用:wrappers.proto。Wrapper 类型的字段即包装一个 message,使用时变为指针。
1 | message DoubleValue { |
Protobuf 作为强 schema 约束的描述文件,也方便扩展,因此也可以用于配置文件定义。
异常处理
首先由于会为服务监控带来麻烦,明确禁止在 HTTP Status Code 中统一设置为 200、在 Body 中再定义 code 字段标记具体错误类型的做法。
使用标准错误配合具体错误:比如服务端使用一个标准 google.rpc.Code.NOT_FOUND
错误代码告知客户端无法找到特定资源(大类:404,小类:具体资源)。
状态空间变小降低了文档的复杂性。
在客户端库中提供了更好的惯用映射,降低了逻辑复杂性。
不限制是否包含可操作信息(
/google/rpc/error_details
)。
错误传播:如果 API 服务依赖于其他服务,不应盲目地将服务错误传播到客户端。在翻译错误时建议:
隐藏详细信息和机密信息。
调整负责该错误的一方。比如一个服务端从其它服务接收到 INVALID ARGUMENT 错误,应该将 INTERNAL 传播给自己的调用者。
全局错误码 是松散、契约易被破坏的,应在每个服务传播错误时做一次翻译,保证每个服务 + 错误枚举是唯一的,定义在 proto 中(可作为文档)。
设计规则
有时接口复用会带来歧义,比如一些字段给 A 方法用、另一些给 B 方法用;如果为不同方法定义 struct 又会造成冗余。
1 | service LibraryService { |
gRPC 推荐的做法是利用 FieldMask 的部分更新:客户端可执行需要更新的字段信息,空 FieldMask 默认应用到所有字段。
1 | service LibraryService { |
配置管理
通常包括以下内容:
环境配置:Region、Zone、Cluster、Environment、Color、Discovery、AppID、Host 等之类的环境变量信息,通过在线运行时平台打入到容器或物理机,供 kit 库读取使用。比如 Dev、UAT、Preprod、Prod、DR 等环境。
静态配置:即资源需要初始化的配置信息,比如 HTTP/gRPC server、Redis、MySQL 等,通常不建议运行时变更(很可能会导致业务出现不可预期的事故),变更静态配置和发布 bianry app 没有区别,应该走迭代发布流程。在设计上应考虑 协议卸载:将有状态、需要运行时变更的业务逻辑下沉,而避免安排在接入节点层(比如TCP Server,无状态)。
动态配置:应用程序可能需要比较简单的在线开关控制业务策略,会频繁的调整和使用,这类用于动态变更业务流的(比如 AB Test 的 flag,一般是基础类型 int、bool 等)配置可收归在一起,考虑结合 expvar 使用,与配置中心打通。
全局配置:通常各类依赖组件、中间件都有大量默认配置或指定配置,在各个项目里大量复制容易出现意外。所以使用配置模板来定制化常用组件,在特化应用进行局部替换。
配置传参先参考 net/http 库:
1 | func main() { |
缺点是无法获知修改公共字段是否会有副作用,字段的含义也要自行查阅文档。
改进是自行设计 config struct,建议使用 functional options:
符合编程直觉,可实现高度的可配置化,容易维护和扩展。
自文档描述,代码可读、容易上手。
代码直观,无歧义(比如空值)。
1 | type Server struct { |
在实践中应注意配置文件到配置数据之间映射的解耦:
仅保留 options API。
config file 和 options struct 解耦:比如利用 gRPC 的 Protobuf 的强 schema 约束定义 Config 对象,实现语义验证、语法高亮和 lint、格式化。
1 | [Config Web UI] <----+---------+ |
YAML:需要先转换成 JSON,再转成 Protobuf。Protobuf 的 Config 对象不能直接扩展方法,所以还需要加一个 Options 方法。
1 | func ApplyYAML(s *redis.Config, yml string) error { |
Protobuf:使用 wrap struct 区分是否有值。
1 | syntax = "proto3"; |
最终实现配置注入:
1 | func main() { |
最佳实践
实现代码变更系统功能是冗长且复杂的过程,往往还涉及 CR、测试等流程。而更改单个配置选项也可能对功能产生重大影响,且通常情况下修改配置还容易被忽略、未经测试就上线。
配置管理的目标:
避免复杂:依赖的通用基础中间件使用配置中心支持的全局配置化模板。
多样的配置:配置模板通过覆盖某些字段实现多样化。
区分必选项和可选项,向简单化努力:尽可能减少必要的配置项(最佳实践)。
以基础设施 -> 面向用户进行转变。
配置的防御编程。
权限和变更跟踪。
配置的版本和应用对齐。
安全的配置变更:逐步部署、回滚更改、自动回滚。
模块管理
Go 依赖管理是通过 Git 仓库模式实现,并随着版本的更迭逐渐完善。
早期是 GOPATH 模式:GOPATH 目录是所有工程的公共依赖包目录,所有需要编译的 go 工程的依赖包都放在 GOPATH 目录下。
后续引入多版本支持的 Vendor 特性:go 1.6 之后开启了 vendor 目录,以支持各个工程对于不同版本的依赖包使用的需求(每个工程拷贝一份代码)。
Go Module 管理:Go1.11 实现了依赖包的升级更新,在 Go1.13 版本后默认打开。
GOPATH
GOPATH 为 Go 开发环境时所设置的一个环境变量。
历史版本的 go 语言开发时,需要将代码放在 GOPATH 目录的 src 文件夹下。go get 命令获取依赖,也会自动下载到 GOPATH 的 src 下。以下命令会将代码下载到 $GOPATH/src/github.com/foo/bar
。
1 | go get github.com/foo/bar |
GOPATH 具体结构如下,必须包含三个文件夹:
1 | GOPATH |
GO Modules
从 Go 1.11 开始初步支持,解决了依赖版本的信息管理,并且保证安全性 。
由 go.mod 和 go.sum 组成,包括依赖模块路径定义,通过 checksum 保证包的安全性,并且可以在 GOPATH 外创建和编译项目。
使用 go mod init
命令初始化项目,生成 go.mod 文件:
1 | go mod init example.com.hello |
1 | module example.com/hello |
使用 go get github.com/sirupsen/logrus
可下载或更新依赖包:
1 | module example.com/hello |
各关键字含义:
module: 定义当前项目的模块路径。
go: 标识当前模块的 Go 语言版本。
require: 依赖包及其版本。
exclude: 在使用中排除特定的模块版本。
replace:替换 require 中声明的依赖,使用另外的依赖及其版本号。
Checksum
为解决 Go Modules 的包被篡改的安全隐患,引入 go.sum 文件以记录每个依赖包的哈希值,在构建时如果本地的依赖包 hash 值与 go.sum 文件中记录的不一致,则会拒绝构建。
go.sum 文件中每行记录由 module 名、版本和哈希组成,以空格分隔。
引入新依赖时,通常使用
go get
命令获取,将包下载到本地缓存目录$GOPATH/pkg/mod/cache/download
,该包后缀为 .zip,并把哈希运算同步到 go.sum 文件中。在构建应用时,从本地缓存中查找所有 go.mod 中记录的依赖包,并计算本地包的哈希值 ,与 go.sum 中的记录对比,如果校验失败,go 命令将拒绝构建。
Proxy
Go 1.13 的 GOPROXY 默认为 https://proxy.golang.org,在国内需要配置代理才能使用。GOPROXY 也可以解决公司内部的使用问题:
访问内网的 git server。
防止公网仓库变更导致线上编译失败或者紧急回退失败。
满足公司审计和安全需要。
防止内部开发人员配置不当造成 import path 泄露。
cache 热点依赖,降低公司公网出口带宽。
1 | export GOPROXY=https://goproxy.io,direct |
Private
用于控制 go 命令把某些仓库视作私有仓库,可以跳过 proxy server 和 checksum 检查,GOPRIVATE 的值同时作为 GONOPROXY 和 GONOSUMDB 默认值:
1 | 以逗号分隔。 |
推荐同时配置 GOPROXY 和 GOPRIVATE 使用,GOPRIVATE 也可以识别 Git SSH KEY 进行权限效验。
GOPROXY 编译部署
goproxy.io 是 Go Modules 开源代理,也可作为公司内部代理。
1 | 下载编译: |
访问内网 Git 仓库:
用户本地配置
GONOSUMDB=github.com/private
goproxy server 配置 exclude 进行排除所代理仓库
goproxy server 配置 SSH Key,并且在仓库添加只读权限
goproxy server 配置 .gitconfig 把 ssh 替换成 http 方式访问
1 | [url "git@github.com:"] |
测试
小型测试带来优秀的代码质量、良好的异常处理、优雅的错误报告;大中型测试会带来整体产品质量和数据验证。
不同类型的项目对测试的需求不同,总体上有 70/20/10 经验法则:70% 小型测试,20% 中型测试,10% 大型测试。
如果一个项目是面向用户的,拥有较高的集成度或用户接口比较复杂,就应该有更多的中型和大型测试;如果是基础平台或者面向数据的项目(例如索引或网络爬虫),则最好有大量的小型测试。
单元测试
单元测试的基本要求:
快速
环境一致
任意顺序
并行
基于 docker-compose 实现跨平台跨语言环境的容器依赖管理方案,以解决运行 unittest 场景下的容器依赖问题:
本地安装 Docker。
无侵入式的环境初始化。
快速重置环境。
随时随地运行(不依赖外部服务)。
语义式 API 声明资源。
真实外部依赖,而非 in-process 模拟。
包含测试的项目目录结构:
1 | |-- service |
要满足以下原则:
正确地对容器内服务进行健康检测,避免测试启动时资源还未准备好。
应该交由 app 自己初始化数据,比如 db 的 scheme,初始 sql 数据等。为了满足测试的一致性,在每次结束后都会销毁容器。
在单元测试开始前导入封装好的 testing 库,方便启动和销毁容器。
对于 service 的单元测试,使用 gomock 等库把 mock DAO 层。在设计包时,应该面向接口编程。
在本地启动依赖 Docker 容器,在 CI 环境里执行单元测试,需要考虑物理机中的容器网络,或在容器里再次启动一个 Docker。
1 | func TestMain(m *testing.M) { |
最佳实践
利用 go 官方提供的 Subtests + Gomock 完成整个单元测试。对于每层代码:
/api:更适合进行集成测试,使用 API 测试框架(YApi)维护大量业务测试 case。
/data:使用 docker compose 模拟底层基础设施,可以去掉 infra 的抽象层。
/biz:依赖 repo、rpc client,利用 gomock 模拟 interface 实现来进行业务单元测试。
/service:依赖 biz 实现,构建 biz 实现类传入进行单元测试。
一般的开发测试流程:
基于 git branch 进行 feature 开发。
开发过程,在本地执行单元测试。
提交 gitlab merge request 进行 CI 的单元测试。
基于 feature branch 进行构建。
完成功能测试之后合并 master。
上线前进行集成测试。
上线后进行回归测试。
参考
golang-standards/project-layout: Standard Go Project Layout (github.com)
领域驱动设计系列文章(1)——通过现实例子显示领域驱动设计的威力 - Cat Qi - 博客园 (cnblogs.com)
领域驱动设计系列文章(2)——浅析VO、DTO、DO、PO的概念、区别和用处 - Cat Qi - 博客园 (cnblogs.com)
protobuf/wrappers.proto at master · protocolbuffers/protobuf (github.com)
Functional options for friendly APIs – The acme of foolishness (cheney.net)
command center: Self-referential functions and the design of options
GopherCon 2018: Kat Zien - How Do You Structure Your Go Apps - YouTube
paper-code/packageorienteddesign.md at master · danceyoung/paper-code (github.com)
Clean Architecture using Golang. Update | by Elton Minetto | Medium
Standard Package Layout. Addressing one of the biggest technical… | by Ben Johnson | Medium
Applying The Clean Architecture to Go applications • Manuel Kießling (kiessling.net)
katzien/go-structure-examples: Examples for my talk on structuring go apps (github.com)
Ashley McNamara + Brian Ketelsen. Go best practices. - YouTube
DTO to Entity and Entity to DTO Conversion - Apps Developer Blog
Compile-time Dependency Injection With Go Cloud’s Wire - The Go Blog (golang.org)
google/wire: Compile-time Dependency Injection for Go (github.com)
Integration Testing in Go: Part I - Executing Tests with Docker (ardanlabs.com)
Integration Testing in Go: Part II - Set-up and Writing Tests (ardanlabs.com)
Using Subtests and Sub-benchmarks - The Go Blog (golang.org)
Module Mirror and Checksum Database Launched - The Go Blog (golang.org)
A GoMock Quick Start Guide. An opinionated tutorial for unit… | by Che Dan | Better Programming