文章目录
-
- 一、前言
- [二、什么是 Go 工程化](#二、什么是 Go 工程化)
- [三、Go 工程化的核心原则](#三、Go 工程化的核心原则)
-
- [3.1 保持简单](#3.1 保持简单)
- [3.2 高内聚、低耦合](#3.2 高内聚、低耦合)
- [3.3 依赖方向稳定](#3.3 依赖方向稳定)
- [3.4 显式优于隐式](#3.4 显式优于隐式)
- [3.5 自动化优于口头约定](#3.5 自动化优于口头约定)
- [四、Go Module 与依赖管理](#四、Go Module 与依赖管理)
-
- [4.1 初始化项目](#4.1 初始化项目)
- [4.2 整理依赖](#4.2 整理依赖)
- [4.3 查看依赖关系](#4.3 查看依赖关系)
- [4.4 不要随意使用 replace](#4.4 不要随意使用 replace)
- [4.5 控制第三方依赖](#4.5 控制第三方依赖)
- 五、项目目录结构
-
- [5.1 cmd](#5.1 cmd)
- [5.2 internal](#5.2 internal)
- [5.3 pkg 是否必须存在](#5.3 pkg 是否必须存在)
- [5.4 migrations](#5.4 migrations)
- [5.5 testdata](#5.5 testdata)
- 六、按业务能力组织代码
-
- [6.1 实体](#6.1 实体)
- [6.2 仓储接口](#6.2 仓储接口)
- [6.3 应用服务](#6.3 应用服务)
- 七、接口应该定义在哪里
-
- [7.1 避免大接口](#7.1 避免大接口)
- [7.2 不要为每个结构体创建接口](#7.2 不要为每个结构体创建接口)
- 八、依赖注入与应用启动
-
- [8.1 避免全局变量](#8.1 避免全局变量)
- [8.2 构造函数校验依赖](#8.2 构造函数校验依赖)
- 九、配置管理
-
- [9.1 从环境变量读取](#9.1 从环境变量读取)
- [9.2 配置加载后立即校验](#9.2 配置加载后立即校验)
- [9.3 不要把密钥写进代码仓库](#9.3 不要把密钥写进代码仓库)
- [十、context 的正确使用](#十、context 的正确使用)
-
- [10.1 context 使用原则](#10.1 context 使用原则)
- [10.2 避免丢失取消信号](#10.2 避免丢失取消信号)
- 十一、错误处理规范
-
- [11.1 使用错误包装保留上下文](#11.1 使用错误包装保留上下文)
- [11.2 定义稳定的业务错误](#11.2 定义稳定的业务错误)
- [11.3 不要在每一层重复记录错误](#11.3 不要在每一层重复记录错误)
- [11.4 不要把内部错误直接返回给客户端](#11.4 不要把内部错误直接返回给客户端)
- [十二、HTTP 服务设计](#十二、HTTP 服务设计)
-
- [12.1 Handler 只处理协议转换](#12.1 Handler 只处理协议转换)
- [12.2 限制请求体大小](#12.2 限制请求体大小)
- [12.3 严格解析 JSON](#12.3 严格解析 JSON)
- [12.4 设置 Server 超时](#12.4 设置 Server 超时)
- 十三、中间件设计
-
- [13.1 请求 ID 中间件](#13.1 请求 ID 中间件)
- [13.2 Panic 恢复](#13.2 Panic 恢复)
- [13.3 中间件顺序](#13.3 中间件顺序)
- 十四、结构化日志
-
- [14.1 创建 Logger](#14.1 创建 Logger)
- [14.2 日志字段规范](#14.2 日志字段规范)
- [14.3 不记录敏感信息](#14.3 不记录敏感信息)
- 十五、数据库访问工程化
-
- [15.1 SQL 必须支持取消](#15.1 SQL 必须支持取消)
- [15.2 统一事务边界](#15.2 统一事务边界)
- [15.3 防止 SQL 注入](#15.3 防止 SQL 注入)
- [15.4 数据库迁移独立执行](#15.4 数据库迁移独立执行)
- 十六、优雅启动与优雅关闭
- 十七、测试体系
-
- [17.1 表驱动测试](#17.1 表驱动测试)
- [17.2 使用 httptest 测试 Handler](#17.2 使用 httptest 测试 Handler)
- [17.3 测试替身](#17.3 测试替身)
- [17.4 集成测试](#17.4 集成测试)
- [17.5 覆盖率](#17.5 覆盖率)
- [17.6 模糊测试](#17.6 模糊测试)
- [十八、并发测试与 Race Detector](#十八、并发测试与 Race Detector)
-
- [18.1 不要无上限创建 Goroutine](#18.1 不要无上限创建 Goroutine)
- [18.2 所有 Goroutine 都应该有退出路径](#18.2 所有 Goroutine 都应该有退出路径)
- 十九、代码格式与静态检查
-
- [19.1 go vet](#19.1 go vet)
- [19.2 静态检查工具](#19.2 静态检查工具)
- [19.3 控制忽略规则](#19.3 控制忽略规则)
- 二十、安全工程
-
- [20.1 依赖漏洞扫描](#20.1 依赖漏洞扫描)
- [20.2 输入验证](#20.2 输入验证)
- [20.3 设置超时](#20.3 设置超时)
- [20.4 最小权限](#20.4 最小权限)
- 二十一、性能分析与诊断
-
- [21.1 Benchmark](#21.1 Benchmark)
- [21.2 pprof](#21.2 pprof)
- [21.3 先测量,再优化](#21.3 先测量,再优化)
- 二十二、可观测性建设
-
- [22.1 日志](#22.1 日志)
- [22.2 指标](#22.2 指标)
- [22.3 链路追踪](#22.3 链路追踪)
- [22.4 健康检查](#22.4 健康检查)
- 二十三、构建与版本信息
-
- [23.1 可重复构建](#23.1 可重复构建)
- [二十四、Makefile 统一开发命令](#二十四、Makefile 统一开发命令)
- [二十五、Docker 容器化](#二十五、Docker 容器化)
-
- [25.1 多阶段构建的作用](#25.1 多阶段构建的作用)
- [25.2 容器化注意事项](#25.2 容器化注意事项)
- [25.3 .dockerignore](#25.3 .dockerignore)
- [二十六、CI 持续集成](#二十六、CI 持续集成)
- [二十七、CD 持续交付](#二十七、CD 持续交付)
-
- [27.1 镜像版本](#27.1 镜像版本)
- [27.2 发布策略](#27.2 发布策略)
- [27.3 回滚条件](#27.3 回滚条件)
- 二十八、分支与代码审查规范
- 二十九、常见工程化错误
-
- [29.1 过度设计目录](#29.1 过度设计目录)
- [29.2 所有代码都放在一个包](#29.2 所有代码都放在一个包)
- [29.3 到处创建 interfaces 包](#29.3 到处创建 interfaces 包)
- [29.4 滥用 utils](#29.4 滥用 utils)
- [29.5 忽略错误](#29.5 忽略错误)
- [29.6 随意使用 panic](#29.6 随意使用 panic)
- [29.7 把 Context 当参数容器](#29.7 把 Context 当参数容器)
- [29.8 测试只追求覆盖率](#29.8 测试只追求覆盖率)
- [29.9 每个请求创建新的数据库连接池](#29.9 每个请求创建新的数据库连接池)
- [29.10 pprof 暴露到公网](#29.10 pprof 暴露到公网)
- 三十、推荐的开发流程
- 三十一、生产级项目检查清单
- 三十二、总结
一、前言
Go 语言语法简洁、编译速度快,并且拥有完善的并发模型和标准工具链,因此经常被用于开发:
- 微服务;
- API 网关;
- 云原生基础设施;
- 命令行工具;
- 区块链服务;
- 实时数据处理系统;
- DevOps 平台;
- 高并发网络服务。
完成一个 Go 程序并不困难:
go
package main
import "fmt"
func main() {
fmt.Println("Hello, Go")
}
但是,当项目逐渐变大、开发人数不断增加、服务开始进入生产环境后,就会出现一系列新的问题:
- 目录应该如何划分;
- 业务代码应该放在哪里;
- 包之间如何避免循环依赖;
- 配置和密钥如何管理;
- 错误如何统一处理;
- 日志如何关联请求;
- 如何安全地启动和关闭服务;
- 如何编写单元测试和集成测试;
- 如何管理数据库变更;
- 如何保证每次构建结果一致;
- 如何进行安全扫描和持续交付。
这些问题已经不再是单纯的语法问题,而是 Go 工程化需要解决的问题。
Go 工程化的目标可以概括为:
让项目在多人协作、持续迭代和生产运行的情况下,仍然保持结构清晰、质量可控、故障可查和发布可重复。
二、什么是 Go 工程化
Go 工程化是围绕 Go 项目的开发、测试、构建、发布和运行建立的一套规范与工具体系。
完整的工程化流程通常包括:
text
需求分析
↓
架构与模块设计
↓
编码规范
↓
自动格式化
↓
静态检查
↓
单元测试
↓
集成测试
↓
安全扫描
↓
构建制品
↓
部署发布
↓
监控告警
↓
故障复盘
一个具备工程化能力的项目,不应该只在某一位开发人员的电脑上运行,而应该能够做到:
- 新成员可以快速理解项目;
- 本地、测试和生产环境行为一致;
- 代码提交后自动完成质量检查;
- 构建过程可重复;
- 服务发生故障后可以快速定位;
- 版本可以升级,也可以安全回滚;
- 依赖和安全风险能够被及时发现。
三、Go 工程化的核心原则
3.1 保持简单
Go 项目不需要为了"架构完整"而创建大量无意义的目录和接口。
简单项目可以从简单结构开始,随着业务增长再逐步拆分。
text
不要因为未来可能存在多个实现,
就在项目第一天为所有类型创建接口。
工程化的目的不是让代码看起来复杂,而是降低长期维护成本。
3.2 高内聚、低耦合
每个包应该围绕一组相关能力组织。
例如:
text
order
├── 订单模型
├── 订单业务规则
├── 订单仓储接口
└── 订单服务
不建议创建以下包:
text
utils
common
helper
manager
service
model
然后把所有业务混合放进去。
这类按照技术名称划分的大包,随着项目增长很容易变成难以维护的公共代码仓库。
3.3 依赖方向稳定
推荐让外部实现依赖内部业务规则:
text
HTTP Handler
↓
Application Service
↓
Domain
↑
Repository Interface
↑
Database Implementation
业务核心不应该依赖具体数据库、HTTP 框架或消息队列客户端。
3.4 显式优于隐式
Go 倾向于使用显式依赖:
go
service := NewOrderService(
orderRepository,
paymentClient,
logger,
)
相比隐藏在全局变量中的依赖,显式构造更容易:
- 阅读;
- 测试;
- 替换;
- 排查初始化问题。
3.5 自动化优于口头约定
如果团队规定提交前必须执行:
text
格式化
单元测试
静态检查
安全扫描
就应该把这些步骤写入自动化流程,而不是只放在开发文档中。
四、Go Module 与依赖管理
Go 使用 Module 管理项目和依赖。一个 Module 由 go.mod 文件定义,可以包含一个或多个 Package。
Go 官方文档对 Module、Package 和代码组织进行了说明:Go Modules Reference。
4.1 初始化项目
bash
mkdir order-service
cd order-service
go mod init github.com/example/order-service
生成的 go.mod:
go
module github.com/example/order-service
go 1.26
引入依赖后,go.mod 会记录依赖版本,go.sum 用于记录依赖内容的校验信息。
text
order-service
├── go.mod
└── go.sum
这两个文件都应该提交到代码仓库。
4.2 整理依赖
bash
go mod tidy
该命令会:
- 增加代码实际需要的依赖;
- 删除代码不再使用的依赖;
- 更新
go.sum。
建议在提交代码前检查 go.mod 和 go.sum 是否出现非预期变化。
4.3 查看依赖关系
bash
go list -m all
查看依赖图:
bash
go mod graph
查看某个依赖为什么被引入:
bash
go mod why -m golang.org/x/sync
4.4 不要随意使用 replace
开发阶段可以使用:
go
replace github.com/example/common => ../common
但不建议把依赖个人本地路径的 replace 提交到主分支,否则其他环境无法复现构建。
如果确实需要在多个仓库之间进行本地联调,可以使用 Go Workspace:
bash
go work init ./order-service ./common
go.work 是否提交到仓库,应根据团队的仓库结构统一决定。
4.5 控制第三方依赖
引入依赖之前需要评估:
- 是否仍在维护;
- 是否有稳定版本;
- 是否存在安全漏洞;
- 是否会引入大量间接依赖;
- 是否能通过标准库简单完成;
- 许可证是否允许商业使用;
- 升级是否会破坏兼容性。
Go 的标准库能力比较完整,不应为了一个很小的功能引入重量级依赖。
五、项目目录结构
Go 并没有强制规定所有项目必须使用同一种目录结构。
官方建议根据项目类型组织代码,并推荐服务器项目将命令入口放入 cmd,将不希望被其他 Module 引用的实现放入 internal。Organizing a Go module
一个中型 Web 服务可以采用以下结构:
text
order-service
├── cmd
│ ├── api
│ │ └── main.go
│ └── worker
│ └── main.go
├── internal
│ ├── bootstrap
│ │ └── app.go
│ ├── config
│ │ └── config.go
│ ├── order
│ │ ├── entity.go
│ │ ├── errors.go
│ │ ├── repository.go
│ │ └── service.go
│ ├── platform
│ │ ├── database
│ │ ├── cache
│ │ └── observability
│ └── transport
│ └── http
│ ├── handler.go
│ ├── middleware.go
│ └── router.go
├── migrations
├── deployments
├── scripts
├── api
│ └── openapi.yaml
├── testdata
├── Dockerfile
├── Makefile
├── go.mod
├── go.sum
└── README.md
5.1 cmd
cmd 用于保存可执行程序入口。
text
cmd/api/main.go
cmd/worker/main.go
cmd/migrate/main.go
每个目录代表一个独立二进制程序。
main.go 应该尽量简单,只负责:
- 加载配置;
- 初始化日志;
- 创建依赖;
- 启动应用;
- 处理退出信号。
不要把大量业务逻辑写在 main.go 中。
5.2 internal
Go 编译器会限制其他 Module 导入 internal 目录中的包。
因此,服务内部实现可以放在:
text
internal/order
internal/payment
internal/platform
这样可以明确表达:这些包只为当前项目服务,不承诺对外兼容。
5.3 pkg 是否必须存在
pkg 不是 Go 项目的强制目录。
只有当某些包确实希望被其他项目导入时,才需要考虑把它们放在公开位置。对于普通微服务,大部分代码都可以放在 internal 中。
不要仅仅因为某个模板存在 pkg,就在项目中创建空目录。
5.4 migrations
用于保存数据库迁移文件:
text
migrations
├── 000001_create_orders.up.sql
├── 000001_create_orders.down.sql
├── 000002_add_order_status.up.sql
└── 000002_add_order_status.down.sql
数据库结构变化应该版本化,不能依赖开发人员手工修改生产数据库。
5.5 testdata
Go 工具链会忽略名为 testdata 的目录,适合保存测试输入、期望结果和样例文件。
text
testdata
├── valid_order.json
├── invalid_order.json
└── expected_response.json
六、按业务能力组织代码
项目规模较大时,可以按照业务能力划分包:
text
internal
├── order
├── payment
├── customer
├── inventory
└── settlement
以订单模块为例:
text
internal/order
├── entity.go
├── repository.go
├── service.go
├── service_test.go
└── errors.go
6.1 实体
go
package order
import (
"errors"
"time"
)
type Status string
const (
StatusPending Status = "PENDING"
StatusPaid Status = "PAID"
StatusClosed Status = "CLOSED"
)
type Order struct {
ID string
UserID string
Amount int64
Status Status
CreatedAt time.Time
}
func (o *Order) Pay() error {
if o.Status != StatusPending {
return errors.New("only pending order can be paid")
}
o.Status = StatusPaid
return nil
}
6.2 仓储接口
go
package order
import (
"context"
"errors"
)
var ErrNotFound = errors.New("order not found")
type Repository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id string) (*Order, error)
}
6.3 应用服务
go
package order
import (
"context"
"fmt"
)
type Service struct {
repository Repository
}
func NewService(repository Repository) *Service {
return &Service{
repository: repository,
}
}
func (s *Service) Pay(
ctx context.Context,
orderID string,
) error {
entity, err := s.repository.FindByID(ctx, orderID)
if err != nil {
return fmt.Errorf("find order %s: %w", orderID, err)
}
if err := entity.Pay(); err != nil {
return fmt.Errorf("pay order %s: %w", orderID, err)
}
if err := s.repository.Save(ctx, entity); err != nil {
return fmt.Errorf("save order %s: %w", orderID, err)
}
return nil
}
这种结构让订单业务规则不需要了解:
- HTTP 请求;
- JSON 格式;
- MySQL 驱动;
- Redis 客户端;
- 消息队列 SDK。
七、接口应该定义在哪里
Go 接口通常应该由使用方定义,而不是由实现方为了"看起来抽象"而定义。
例如,订单服务需要查询订单:
go
package order
type Repository interface {
FindByID(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, order *Order) error
}
数据库层实现这个接口:
go
package mysqlrepo
type OrderRepository struct {
db *sql.DB
}
func (r *OrderRepository) FindByID(
ctx context.Context,
id string,
) (*order.Order, error) {
// 查询数据库
return nil, nil
}
Go 使用隐式接口实现,不需要显式声明 implements。
可以增加编译期检查:
go
var _ order.Repository = (*OrderRepository)(nil)
7.1 避免大接口
不推荐:
go
type Repository interface {
CreateOrder()
UpdateOrder()
DeleteOrder()
QueryOrder()
CreateUser()
UpdateUser()
SendMessage()
UploadFile()
}
推荐按照调用方需要拆分:
go
type OrderReader interface {
FindByID(ctx context.Context, id string) (*Order, error)
}
type OrderWriter interface {
Save(ctx context.Context, order *Order) error
}
接口越小,测试替身越容易编写,模块耦合也越低。
7.2 不要为每个结构体创建接口
如果一个类型只有一个实现,而且不需要替换或测试隔离,可以直接依赖具体类型。
接口应当来自实际需要,而不是模板规定。
八、依赖注入与应用启动
Go 项目通常可以使用构造函数手工完成依赖注入。
go
func buildApplication(cfg config.Config) (*Application, error) {
db, err := database.Open(cfg.Database)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
orderRepository := mysqlrepo.NewOrderRepository(db)
orderService := order.NewService(orderRepository)
orderHandler := httptransport.NewOrderHandler(orderService)
return NewApplication(
cfg,
db,
orderHandler,
), nil
}
依赖关系清晰可见:
text
数据库连接
↓
订单仓储
↓
订单服务
↓
HTTP Handler
↓
HTTP Server
8.1 避免全局变量
不推荐:
go
var DB *sql.DB
var Logger *slog.Logger
var Config AppConfig
全局变量容易造成:
- 初始化顺序不明确;
- 测试相互污染;
- 依赖不可见;
- 并发修改风险;
- 无法轻松替换实现。
推荐通过结构体传递:
go
type Application struct {
config Config
db *sql.DB
logger *slog.Logger
server *http.Server
}
8.2 构造函数校验依赖
go
func NewService(
repository Repository,
logger *slog.Logger,
) (*Service, error) {
if repository == nil {
return nil, errors.New("repository is required")
}
if logger == nil {
return nil, errors.New("logger is required")
}
return &Service{
repository: repository,
logger: logger,
}, nil
}
对于必须存在的依赖,应在应用启动阶段尽早发现问题,而不是等请求进入后才发生空指针异常。
九、配置管理
配置通常来自:
- 环境变量;
- 配置文件;
- 命令行参数;
- 密钥管理系统;
- Kubernetes ConfigMap;
- Kubernetes Secret。
配置结构:
go
package config
import "time"
type Config struct {
App AppConfig
HTTP HTTPConfig
Database DatabaseConfig
}
type AppConfig struct {
Name string
Environment string
LogLevel string
}
type HTTPConfig struct {
Address string
ReadTimeout time.Duration
WriteTimeout time.Duration
ShutdownTimeout time.Duration
}
type DatabaseConfig struct {
DSN string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
9.1 从环境变量读取
go
func Load() (Config, error) {
cfg := Config{
App: AppConfig{
Name: getEnv("APP_NAME", "order-service"),
Environment: getEnv("APP_ENV", "local"),
LogLevel: getEnv("LOG_LEVEL", "INFO"),
},
HTTP: HTTPConfig{
Address: getEnv("HTTP_ADDRESS", ":8080"),
ReadTimeout: getDuration("HTTP_READ_TIMEOUT", 5*time.Second),
WriteTimeout: getDuration("HTTP_WRITE_TIMEOUT", 10*time.Second),
ShutdownTimeout: getDuration("HTTP_SHUTDOWN_TIMEOUT", 15*time.Second),
},
}
cfg.Database.DSN = os.Getenv("DATABASE_DSN")
if cfg.Database.DSN == "" {
return Config{}, errors.New("DATABASE_DSN is required")
}
return cfg, nil
}
9.2 配置加载后立即校验
go
func (c Config) Validate() error {
if c.App.Name == "" {
return errors.New("app name is required")
}
if c.HTTP.ReadTimeout <= 0 {
return errors.New("HTTP read timeout must be positive")
}
if c.Database.DSN == "" {
return errors.New("database DSN is required")
}
return nil
}
不要等到服务运行一段时间后,才发现数据库地址或关键配置为空。
9.3 不要把密钥写进代码仓库
以下内容不应该直接写在代码或普通配置文件中:
- 数据库密码;
- JWT 私钥;
- API Key;
- 云服务密钥;
- 支付渠道密钥;
- 区块链钱包私钥。
生产环境应使用专门的密钥管理方案,并限制密钥读取权限。
日志中也不能打印完整配置,因为配置中可能包含敏感信息。
十、context 的正确使用
context.Context 用于传递:
- 请求取消信号;
- 截止时间;
- 跨调用链的请求信息。
HTTP 请求中的 Context 应继续传递到业务层、数据库层和外部调用层:
go
func (h *OrderHandler) Get(
w http.ResponseWriter,
r *http.Request,
) {
orderID := r.PathValue("id")
entity, err := h.service.Get(r.Context(), orderID)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, entity)
}
数据库查询:
go
row := db.QueryRowContext(
ctx,
"SELECT id, status FROM orders WHERE id = ?",
orderID,
)
外部请求:
go
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
url,
nil,
)
10.1 context 使用原则
context.Context通常作为第一个参数;- 不要把 Context 保存到长期存在的结构体中;
- 不要传递
nilContext; - 不要使用 Context 传递普通业务参数;
- 不要在底层函数中随意替换成
context.Background(); - 调用
context.WithTimeout后要执行cancel()。
go
ctx, cancel := context.WithTimeout(
parentCtx,
3*time.Second,
)
defer cancel()
10.2 避免丢失取消信号
不推荐:
go
func queryOrder(ctx context.Context) error {
return repository.Query(context.Background())
}
这样会让上层请求取消和超时失效。
推荐:
go
func queryOrder(ctx context.Context) error {
return repository.Query(ctx)
}
十一、错误处理规范
Go 使用显式错误返回:
go
value, err := doSomething()
if err != nil {
return err
}
工程化项目需要进一步统一错误语义。
11.1 使用错误包装保留上下文
go
func (s *Service) Get(
ctx context.Context,
orderID string,
) (*Order, error) {
entity, err := s.repository.FindByID(ctx, orderID)
if err != nil {
return nil, fmt.Errorf(
"find order %s: %w",
orderID,
err,
)
}
return entity, nil
}
使用 %w 包装后,仍然可以通过 errors.Is 判断底层错误:
go
if errors.Is(err, order.ErrNotFound) {
// 订单不存在
}
11.2 定义稳定的业务错误
go
var (
ErrNotFound = errors.New("order not found")
ErrAlreadyPaid = errors.New("order already paid")
ErrInvalidAmount = errors.New("invalid order amount")
)
如果错误需要携带更多信息,可以定义结构体:
go
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Message
}
使用 errors.As:
go
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// 处理参数错误
}
11.3 不要在每一层重复记录错误
错误如果在 Repository、Service 和 Handler 中都记录一次,同一个问题可能产生多条重复日志。
推荐原则:
text
底层负责补充错误上下文
边界层负责记录最终失败
例如:
- Repository:返回错误;
- Service:使用
%w补充业务上下文; - HTTP Handler 或统一中间件:记录一次完整错误。
11.4 不要把内部错误直接返回给客户端
错误响应:
json
{
"code": "ORDER_NOT_FOUND",
"message": "订单不存在",
"requestId": "req-10001"
}
不要返回:
json
{
"message": "dial tcp 10.0.0.12:3306: connection refused"
}
数据库地址、SQL、文件路径和调用栈可能泄漏系统内部信息。
十二、HTTP 服务设计
Go 标准库已经提供了完整的 HTTP 服务能力。
路由初始化示例:
go
func NewRouter(
orderHandler *OrderHandler,
middleware Middleware,
) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(
"GET /health/live",
handleLiveness,
)
mux.HandleFunc(
"GET /health/ready",
handleReadiness,
)
mux.HandleFunc(
"GET /api/orders/{id}",
orderHandler.Get,
)
mux.HandleFunc(
"POST /api/orders",
orderHandler.Create,
)
return middleware.Apply(mux)
}
12.1 Handler 只处理协议转换
Handler 适合负责:
- 解析请求参数;
- 校验基础格式;
- 调用应用服务;
- 转换响应;
- 映射 HTTP 状态码。
不应该把核心业务规则全部写在 Handler 中。
go
func (h *OrderHandler) Get(
w http.ResponseWriter,
r *http.Request,
) {
orderID := r.PathValue("id")
if orderID == "" {
writeProblem(
w,
http.StatusBadRequest,
"INVALID_ORDER_ID",
"订单ID不能为空",
)
return
}
entity, err := h.service.Get(r.Context(), orderID)
if err != nil {
h.writeServiceError(w, r, err)
return
}
writeJSON(w, http.StatusOK, entity)
}
12.2 限制请求体大小
go
r.Body = http.MaxBytesReader(
w,
r.Body,
1<<20,
)
这可以限制请求体最大为 1MB,防止客户端上传超大内容占用内存。
12.3 严格解析 JSON
go
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
writeProblem(
w,
http.StatusBadRequest,
"INVALID_JSON",
"请求格式不正确",
)
return
}
还应防止一个请求体中包含多个 JSON 对象。
12.4 设置 Server 超时
go
server := &http.Server{
Addr: cfg.HTTP.Address,
Handler: router,
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
不要在生产环境中直接使用没有超时配置的:
go
http.ListenAndServe(":8080", handler)
十三、中间件设计
中间件适合处理跨业务模块的公共能力:
- 请求 ID;
- 访问日志;
- Panic 恢复;
- 身份认证;
- 租户识别;
- 限流;
- 跨域配置;
- 指标采集;
- 链路追踪。
中间件类型:
go
type Middleware func(http.Handler) http.Handler
13.1 请求 ID 中间件
go
type contextKey string
const requestIDKey contextKey = "request-id"
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = newRequestID()
}
ctx := context.WithValue(
r.Context(),
requestIDKey,
requestID,
)
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
请求头中的 Request ID 仍然属于外部输入,需要限制格式和长度。
13.2 Panic 恢复
go
func Recovery(
logger *slog.Logger,
next http.Handler,
) http.Handler {
return http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
defer func() {
if recovered := recover(); recovered != nil {
logger.ErrorContext(
r.Context(),
"panic recovered",
"panic", recovered,
"stack", string(debug.Stack()),
)
writeProblem(
w,
http.StatusInternalServerError,
"INTERNAL_ERROR",
"系统内部错误",
)
}
}()
next.ServeHTTP(w, r)
})
}
Panic 恢复是防止单个请求终止整个进程的保护措施,但不能替代正常的错误处理。
13.3 中间件顺序
中间件顺序会影响行为:
text
Recovery
↓
Request ID
↓
Access Log
↓
Tracing
↓
Authentication
↓
Tenant Resolution
↓
Rate Limit
↓
Business Handler
例如,访问日志依赖请求 ID,就应该在 Request ID 中间件之后执行。
十四、结构化日志
生产环境建议使用结构化日志,而不是大量拼接字符串。
Go 标准库提供了 log/slog:
go
logger.Info(
"order created",
"order_id", order.ID,
"user_id", order.UserID,
"amount", order.Amount,
)
输出为 JSON:
json
{
"time": "2026-11-03T10:30:00Z",
"level": "INFO",
"msg": "order created",
"service": "order-service",
"environment": "production",
"order_id": "order-10001",
"user_id": "user-20001",
"amount": 29900
}
14.1 创建 Logger
go
func NewLogger(cfg Config) *slog.Logger {
var level slog.Level
switch strings.ToUpper(cfg.LogLevel) {
case "DEBUG":
level = slog.LevelDebug
case "WARN":
level = slog.LevelWarn
case "ERROR":
level = slog.LevelError
default:
level = slog.LevelInfo
}
handler := slog.NewJSONHandler(
os.Stdout,
&slog.HandlerOptions{
Level: level,
AddSource: cfg.Environment != "production",
},
)
return slog.New(handler).With(
"service", cfg.Name,
"environment", cfg.Environment,
)
}
14.2 日志字段规范
团队应统一常用字段:
text
service
environment
request_id
trace_id
tenant_id
user_id
operation
resource_id
duration_ms
error
不要在不同模块分别使用:
text
requestId
request-id
request_id
reqId
14.3 不记录敏感信息
日志中不应出现:
- 用户密码;
- 完整 Token;
- Cookie;
- 银行卡号;
- 私钥;
- 数据库密码;
- 完整身份证号码;
- 支付密钥。
敏感数据应脱敏或完全不记录。
十五、数据库访问工程化
使用 database/sql 时,需要配置连接池:
go
func OpenDatabase(
ctx context.Context,
cfg DatabaseConfig,
) (*sql.DB, error) {
db, err := sql.Open("mysql", cfg.DSN)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
db.SetConnMaxIdleTime(5 * time.Minute)
pingCtx, cancel := context.WithTimeout(
ctx,
5*time.Second,
)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}
15.1 SQL 必须支持取消
go
rows, err := db.QueryContext(ctx, query, args...)
不要在请求链路中使用不带 Context 的查询方法。
15.2 统一事务边界
可以定义一个事务执行器:
go
type TransactionManager interface {
WithinTransaction(
ctx context.Context,
fn func(ctx context.Context) error,
) error
}
调用示例:
go
err := transactionManager.WithinTransaction(
ctx,
func(txCtx context.Context) error {
if err := orderRepository.Save(txCtx, entity); err != nil {
return err
}
if err := outboxRepository.Add(txCtx, event); err != nil {
return err
}
return nil
},
)
事务边界通常由业务用例决定,不应该在每个 Repository 方法内部自行创建独立事务。
15.3 防止 SQL 注入
正确:
go
row := db.QueryRowContext(
ctx,
"SELECT id FROM orders WHERE order_no = ?",
orderNo,
)
错误:
go
query := "SELECT id FROM orders WHERE order_no = '" +
orderNo + "'"
列名、排序方向等无法通过普通占位符传递的内容,应使用服务端白名单映射。
15.4 数据库迁移独立执行
不建议每个应用副本启动时都自动执行迁移。
更安全的流程是:
text
发布新版本
↓
执行数据库迁移任务
↓
确认迁移完成
↓
逐步启动新应用版本
数据库变更需要支持应用滚动发布,尽量避免立即删除旧字段。
十六、优雅启动与优雅关闭
服务接收到终止信号后,不应该立即退出。
正确流程:
text
收到 SIGTERM
↓
停止接收新请求
↓
等待正在处理的请求结束
↓
停止后台任务
↓
关闭消息消费者
↓
关闭数据库连接
↓
退出进程
示例:
go
func run() error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
logger := NewLogger(cfg.App)
app, err := buildApplication(cfg, logger)
if err != nil {
return fmt.Errorf("build application: %w", err)
}
rootCtx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
serverErr := make(chan error, 1)
go func() {
logger.Info("HTTP server started", "addr", cfg.HTTP.Address)
err := app.Server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
}()
select {
case err := <-serverErr:
return fmt.Errorf("HTTP server failed: %w", err)
case <-rootCtx.Done():
logger.Info("shutdown signal received")
}
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
cfg.HTTP.ShutdownTimeout,
)
defer cancel()
if err := app.Server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown HTTP server: %w", err)
}
if err := app.Close(); err != nil {
return fmt.Errorf("close application: %w", err)
}
logger.Info("application stopped")
return nil
}
func main() {
if err := run(); err != nil {
slog.Error("application exited", "error", err)
os.Exit(1)
}
}
优雅关闭必须与容器平台的终止等待时间配合,否则应用还没有处理完,请求就会被强制终止。
十七、测试体系
Go 内置了测试能力:
bash
go test ./...
完整测试体系通常包括:
text
单元测试
↓
组件测试
↓
集成测试
↓
接口测试
↓
端到端测试
↓
性能测试
↓
模糊测试
17.1 表驱动测试
go
func TestOrderPay(t *testing.T) {
tests := []struct {
name string
status Status
wantStatus Status
wantErr error
}{
{
name: "pending order can be paid",
status: StatusPending,
wantStatus: StatusPaid,
},
{
name: "paid order cannot be paid again",
status: StatusPaid,
wantErr: ErrAlreadyPaid,
},
{
name: "closed order cannot be paid",
status: StatusClosed,
wantErr: ErrAlreadyPaid,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entity := &Order{
ID: "order-10001",
Status: tt.status,
}
err := entity.Pay()
if !errors.Is(err, tt.wantErr) {
t.Fatalf(
"Pay() error = %v, want %v",
err,
tt.wantErr,
)
}
if tt.wantErr == nil &&
entity.Status != tt.wantStatus {
t.Fatalf(
"status = %s, want %s",
entity.Status,
tt.wantStatus,
)
}
})
}
}
表驱动测试适合用统一逻辑覆盖多组输入。
17.2 使用 httptest 测试 Handler
go
func TestOrderHandler_Get(t *testing.T) {
request := httptest.NewRequest(
http.MethodGet,
"/api/orders/order-10001",
nil,
)
request.SetPathValue("id", "order-10001")
response := httptest.NewRecorder()
handler := NewOrderHandler(
&fakeOrderService{
order: OrderDTO{
ID: "order-10001",
Status: "PAID",
},
},
)
handler.Get(response, request)
if response.Code != http.StatusOK {
t.Fatalf(
"status = %d, want %d",
response.Code,
http.StatusOK,
)
}
}
17.3 测试替身
可以手写简单 Fake:
go
type fakeOrderRepository struct {
order *Order
err error
}
func (r *fakeOrderRepository) FindByID(
ctx context.Context,
id string,
) (*Order, error) {
return r.order, r.err
}
func (r *fakeOrderRepository) Save(
ctx context.Context,
entity *Order,
) error {
r.order = entity
return r.err
}
不要为了测试一个只有两个方法的接口,引入过于复杂的 Mock 框架。
17.4 集成测试
集成测试应验证真实组件之间的协作,例如:
- Repository 与真实数据库;
- Redis 缓存读写;
- 数据库事务;
- 消息生产与消费;
- HTTP API 与数据层。
可以使用容器启动临时依赖,测试结束后销毁。
测试之间必须隔离,不能依赖执行顺序。
17.5 覆盖率
bash
go test ./... -cover
生成覆盖率文件:
bash
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out
覆盖率是发现未测试代码的辅助指标,不应成为唯一质量目标。
相比单纯追求百分比,更应该测试:
- 核心业务规则;
- 错误分支;
- 权限边界;
- 金额计算;
- 并发逻辑;
- 状态转换;
- 数据一致性。
17.6 模糊测试
Go 工具链原生支持 Fuzz Testing,通过自动生成输入发现边界条件和崩溃问题。Go Fuzzing
go
func FuzzDecodeCursor(f *testing.F) {
f.Add("eyJpZCI6MTAwfQ")
f.Fuzz(func(t *testing.T, input string) {
_, _ = DecodeCursor(input)
})
}
执行:
bash
go test -fuzz=FuzzDecodeCursor -fuzztime=30s
模糊测试特别适合:
- JSON 解析;
- 游标解析;
- 编解码器;
- 协议解析;
- 文件格式;
- 用户输入校验。
十八、并发测试与 Race Detector
Go 并发代码最常见的问题包括:
- 数据竞争;
- Goroutine 泄漏;
- Channel 永久阻塞;
- 重复关闭 Channel;
- 锁顺序不一致;
- WaitGroup 使用错误;
- Context 取消后任务仍然运行。
运行 Race Detector:
bash
go test -race ./...
也可以构建带竞争检测的程序:
bash
go build -race ./cmd/api
18.1 不要无上限创建 Goroutine
危险写法:
go
for _, item := range items {
go process(item)
}
如果 items 有一百万条,就可能创建大量 Goroutine。
可以使用带上限的并发控制:
go
group, ctx := errgroup.WithContext(ctx)
group.SetLimit(10)
for _, item := range items {
item := item
group.Go(func() error {
return process(ctx, item)
})
}
if err := group.Wait(); err != nil {
return err
}
并发数量应结合:
- CPU;
- 内存;
- 数据库连接池;
- 下游接口限流;
- 单任务资源消耗。
18.2 所有 Goroutine 都应该有退出路径
启动 Goroutine 前需要明确:
text
谁负责取消?
什么时候结束?
发生错误如何返回?
服务关闭时是否退出?
Channel 由谁关闭?
不受管理的后台 Goroutine 很容易造成资源泄漏和服务无法优雅关闭。
十九、代码格式与静态检查
Go 工具链提供了统一格式化能力。
bash
gofmt -w .
也可以使用:
bash
go fmt ./...
格式化不应该依赖开发人员的个人风格,应在编辑器和 CI 中统一执行。
19.1 go vet
bash
go vet ./...
go vet 可以发现一部分可疑代码,例如:
- 格式化参数错误;
- 不正确的结构体标签;
- 复制锁;
- 部分不可达代码;
- 错误的测试签名。
19.2 静态检查工具
团队可以引入统一的 Linter 集合,但需要:
- 固定工具版本;
- 统一配置文件;
- 避免一次开启过多规则;
- 对历史项目逐步治理;
- 不允许每个人使用不同规则。
CI 中可以执行:
text
格式检查
↓
go vet
↓
静态扫描
↓
单元测试
↓
Race Detector
19.3 控制忽略规则
如果确实需要忽略检查,应说明原因:
go
//nolint:errcheck // 进程即将退出,关闭失败不影响结果
defer file.Close()
不建议在整个目录或整个项目范围内无理由关闭规则。
二十、安全工程
Go 工程化不仅包含代码质量,也包括软件供应链安全。
20.1 依赖漏洞扫描
Go 官方提供 govulncheck,它会结合依赖信息和代码调用关系分析已知漏洞。Go Security
安装:
bash
go install golang.org/x/vuln/cmd/govulncheck@latest
扫描:
bash
govulncheck ./...
建议把该步骤加入 CI。
CI 使用的工具版本应该固定,不要每次构建都无条件安装不确定的最新版本。
20.2 输入验证
所有外部输入都不可信,包括:
- HTTP Header;
- Query 参数;
- JSON Body;
- 文件名;
- 上传文件;
- 消息队列消息;
- 游标;
- JWT Claim;
- 第三方接口返回值。
输入校验应考虑:
- 长度;
- 格式;
- 枚举范围;
- 数值上下限;
- 文件大小;
- 文件类型;
- 字符编码;
- 业务权限。
20.3 设置超时
外部调用必须设置超时:
go
client := &http.Client{
Timeout: 5 * time.Second,
}
更复杂的接口可以分别配置:
- 连接超时;
- TLS 握手超时;
- 响应头超时;
- 请求整体超时;
- 空闲连接超时。
没有超时的调用可能无限等待,并最终耗尽 Goroutine、连接池或内存。
20.4 最小权限
服务账号应只拥有必要权限:
- 数据库账号不能拥有管理权限;
- 对象存储只允许访问指定 Bucket;
- 消息消费者只订阅必要 Topic;
- 容器尽量使用非 Root 用户;
- 调试接口不能暴露到公网。
二十一、性能分析与诊断
发生性能问题时,不应该只凭经验猜测。
Go 提供了 Profiling、Tracing 和运行时诊断工具。官方诊断文档介绍了 CPU、Heap、Goroutine、Block 和 Mutex 等 Profile:Go Diagnostics。
21.1 Benchmark
go
func BenchmarkEncodeOrder(b *testing.B) {
entity := Order{
ID: "order-10001",
Status: StatusPaid,
Amount: 29900,
}
for i := 0; i < b.N; i++ {
_, err := json.Marshal(entity)
if err != nil {
b.Fatal(err)
}
}
}
执行:
bash
go test -bench=. -benchmem ./...
输出中的指标通常包括:
- 每次操作耗时;
- 每次操作分配的内存;
- 每次操作的分配次数。
21.2 pprof
可以采集:
- CPU Profile;
- Heap Profile;
- Goroutine Profile;
- Mutex Profile;
- Block Profile。
分析:
bash
go tool pprof profile.out
在生产环境开放 pprof 时必须限制访问:
- 只监听内部管理端口;
- 通过网络策略限制来源;
- 增加身份认证;
- 不直接暴露到公网。
21.3 先测量,再优化
推荐流程:
text
发现性能问题
↓
建立可复现条件
↓
采集指标和 Profile
↓
定位真正瓶颈
↓
完成针对性优化
↓
重新测试和对比
不要为了减少几行代码的内存分配而牺牲整体可读性,除非数据证明这里确实是瓶颈。
二十二、可观测性建设
生产服务应具备三个基础信号:
text
日志 Logs
指标 Metrics
链路 Traces
22.1 日志
用于记录具体事件和错误上下文:
text
订单创建失败
数据库连接超时
用户权限不足
消息消费重试
22.2 指标
用于观察整体趋势:
text
请求数量
错误率
响应时间
Goroutine 数量
数据库连接池状态
消息积压数量
常见 HTTP 指标:
text
http_requests_total
http_request_duration_seconds
http_requests_in_flight
22.3 链路追踪
用于分析一次请求在多个服务之间的耗时:
text
API Gateway:5ms
↓
Order Service:20ms
↓
MySQL:15ms
↓
Payment Service:80ms
22.4 健康检查
存活检查:
http
GET /health/live
只用于判断进程是否仍然运行,不应该执行复杂的下游依赖查询。
就绪检查:
http
GET /health/ready
用于判断服务是否可以接收流量,可以检查必要依赖是否可用。
不要因为一个非核心外部接口短暂失败,就让所有应用实例同时退出就绪状态。
二十三、构建与版本信息
Go 可以直接编译为单个二进制文件:
bash
go build -o bin/order-api ./cmd/api
生产构建建议加入版本信息。
代码:
go
package version
var (
Version = "dev"
Commit = "unknown"
BuildTime = "unknown"
)
构建:
bash
go build \
-trimpath \
-ldflags "\
-s -w \
-X github.com/example/order-service/internal/version.Version=v1.2.0 \
-X github.com/example/order-service/internal/version.Commit=abc123 \
-X github.com/example/order-service/internal/version.BuildTime=2026-11-03T10:00:00Z" \
-o bin/order-api \
./cmd/api
应用启动时可以记录:
json
{
"message": "application starting",
"version": "v1.2.0",
"commit": "abc123",
"build_time": "2026-11-03T10:00:00Z"
}
这样可以快速确认线上运行的是哪个版本。
23.1 可重复构建
为了提高构建一致性,应当:
- 提交
go.mod和go.sum; - 固定 Go 工具链版本;
- 固定代码检查工具版本;
- 固定容器基础镜像;
- 避免构建依赖个人目录;
- 使用
-trimpath去掉本地绝对路径; - 在构建环境中校验测试结果。
二十四、Makefile 统一开发命令
可以通过 Makefile 统一常用操作:
makefile
APP_NAME := order-api
BUILD_DIR := bin
.PHONY: fmt vet test race build run clean check
fmt:
gofmt -w .
vet:
go vet ./...
test:
go test ./... -cover
race:
go test -race ./...
build:
mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 go build \
-trimpath \
-o $(BUILD_DIR)/$(APP_NAME) \
./cmd/api
run:
go run ./cmd/api
check: fmt vet test
clean:
go clean
rm -f $(BUILD_DIR)/$(APP_NAME)
开发人员只需要执行:
bash
make check
CI 也尽量复用相同的检查入口,避免本地命令与流水线不一致。
需要注意,Makefile 中的 Tab 和不同操作系统兼容性需要统一处理。
二十五、Docker 容器化
推荐使用多阶段构建。
dockerfile
ARG GO_VERSION=1.26
FROM golang:${GO_VERSION}-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux \
go build \
-trimpath \
-ldflags="-s -w \
-X github.com/example/order-service/internal/version.Version=${VERSION} \
-X github.com/example/order-service/internal/version.Commit=${COMMIT} \
-X github.com/example/order-service/internal/version.BuildTime=${BUILD_TIME}" \
-o /out/order-api \
./cmd/api
FROM alpine:3.22
RUN addgroup -S app \
&& adduser -S -G app app \
&& apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /out/order-api /app/order-api
USER app
EXPOSE 8080
ENTRYPOINT ["/app/order-api"]
25.1 多阶段构建的作用
构建阶段包含:
- Go 编译器;
- 源代码;
- 依赖缓存;
- 构建工具。
运行阶段只保留:
- 应用二进制;
- CA 证书;
- 必要的时区数据。
这样可以减小镜像体积,并减少运行环境中的攻击面。
25.2 容器化注意事项
- 固定基础镜像版本或摘要;
- 不使用 Root 用户运行;
- 不把密钥复制进镜像;
- 正确处理 SIGTERM;
- 日志输出到标准输出;
- 临时文件写入明确目录;
- 配置通过运行环境注入;
- 镜像构建完成后进行漏洞扫描。
25.3 .dockerignore
text
.git
.idea
.vscode
bin
coverage.out
*.log
.env
README.md
.env 中可能存在密钥,不能被复制到镜像构建上下文中。
二十六、CI 持续集成
一个基础 CI 流程可以包含:
text
检出代码
↓
安装固定版本 Go
↓
恢复依赖缓存
↓
校验格式
↓
go vet
↓
执行单元测试
↓
执行 Race Detector
↓
执行漏洞扫描
↓
构建二进制
↓
构建容器镜像
GitHub Actions 示例:
yaml
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download dependencies
run: go mod download
- name: Verify formatting
run: |
test -z "$(gofmt -l .)"
- name: Vet
run: go vet ./...
- name: Unit tests
run: go test ./... -coverprofile=coverage.out
- name: Race detector
run: go test -race ./...
- name: Build
run: |
CGO_ENABLED=0 go build \
-trimpath \
-o bin/order-api \
./cmd/api
生产流水线还可以增加:
- 许可证检查;
- 依赖漏洞扫描;
- 密钥泄漏扫描;
- 容器漏洞扫描;
- SBOM 生成;
- 镜像签名;
- 集成测试;
- 数据库迁移验证;
- 灰度发布;
- 自动回滚。
第三方 Action 也属于供应链依赖,生产环境可以进一步固定到具体提交摘要。
二十七、CD 持续交付
持续交付需要保证同一个制品依次进入不同环境。
推荐:
text
提交代码
↓
CI 测试
↓
构建一次镜像
↓
推送镜像仓库
↓
部署测试环境
↓
验收测试
↓
灰度部署生产环境
↓
全量发布
不推荐在测试环境和生产环境分别重新构建,因为两次构建得到的内容可能不同。
27.1 镜像版本
建议同时使用:
text
order-service:v1.5.0
order-service:git-abc1234
生产部署不要只使用:
text
order-service:latest
latest 无法明确表示具体版本,也不利于回滚和审计。
27.2 发布策略
常见发布策略:
- 滚动发布;
- 蓝绿发布;
- 金丝雀发布;
- 按租户灰度;
- 按用户比例灰度。
27.3 回滚条件
发布前需要明确:
- 错误率超过多少回滚;
- 延迟增加多少回滚;
- 核心业务成功率如何判断;
- 数据库迁移是否支持旧版本;
- 回滚由系统自动执行还是人工确认。
应用版本可以回滚,但破坏性的数据库变更通常无法简单回滚,因此数据库设计必须兼容滚动发布。
二十八、分支与代码审查规范
团队可以根据规模选择主干开发或短分支开发,但应尽量避免分支长期存在。
推荐流程:
text
从主分支创建短分支
↓
完成小批量改动
↓
提交 Pull Request
↓
自动执行 CI
↓
代码审查
↓
合并主分支
↓
删除开发分支
代码审查重点包括:
- 业务逻辑是否正确;
- 错误是否正确处理;
- Context 是否完整传递;
- 是否存在数据竞争;
- 是否有资源泄漏;
- 数据库查询是否安全;
- 是否缺少测试;
- 日志是否包含敏感信息;
- 是否破坏兼容性;
- 是否引入不必要的依赖。
不要把代码审查变成纯粹的格式讨论。格式问题应该由自动化工具解决。
二十九、常见工程化错误
29.1 过度设计目录
项目刚创建就包含几十个空目录:
text
controller
service
service_impl
dao
repository
domain
entity
model
dto
vo
bo
po
common
utils
helper
Go 项目更适合从业务能力出发,用较少的包表达清晰边界。
29.2 所有代码都放在一个包
text
internal/app
├── order.go
├── user.go
├── payment.go
├── database.go
├── cache.go
└── http.go
随着业务增长,这个包会拥有大量类型和依赖,最终难以理解和测试。
29.3 到处创建 interfaces 包
text
internal/interfaces
所有接口集中在一个包中,会让接口远离真正的使用者。
接口通常应该放在需要它的业务包附近。
29.4 滥用 utils
go
utils.DoSomething()
utils.Handle()
utils.Convert()
包名和方法名都无法表达业务含义。
推荐使用具体名称:
go
money.Parse()
password.Hash()
cursor.Encode()
clock.Now()
29.5 忽略错误
go
data, _ := json.Marshal(value)
file.Close()
如果确实可以忽略错误,应明确说明原因,而不是习惯性使用 _。
29.6 随意使用 panic
业务参数错误、数据库查询失败等可预期问题不应该使用 Panic。
Panic 更适合表示:
- 程序无法继续的初始化错误;
- 违反内部不变量;
- 理论上不应该发生的编程错误。
即便如此,应用启动过程通常也可以返回错误,由 main 统一退出。
29.7 把 Context 当参数容器
不推荐:
go
ctx = context.WithValue(ctx, "order-id", orderID)
普通业务参数应该作为函数参数显式传递。
Context Value 更适合请求 ID、Trace 信息等跨调用链元数据,并且 Key 应使用自定义类型。
29.8 测试只追求覆盖率
覆盖率达到 90%,不代表关键业务一定可靠。
如果测试只是执行代码却没有有效断言,高覆盖率也没有实际意义。
29.9 每个请求创建新的数据库连接池
sql.DB 本身就是连接池,应该在应用级创建并复用。
不要在每个 Repository 方法中重复执行:
go
sql.Open(...)
29.10 pprof 暴露到公网
调试接口可能暴露:
- Goroutine 调用栈;
- 内存数据特征;
- 内部包路径;
- 服务运行状态。
必须通过独立管理端口和访问控制保护。
三十、推荐的开发流程
开发新功能时,可以遵循以下流程:
text
明确业务用例
↓
定义输入、输出和错误语义
↓
设计业务包边界
↓
定义调用方需要的小接口
↓
实现核心业务规则
↓
编写单元测试
↓
实现数据库和外部服务适配
↓
编写集成测试
↓
接入 HTTP 或消息入口
↓
增加日志、指标和追踪
↓
本地执行完整检查
↓
提交代码并由 CI 验证
建议把一次改动控制在容易审查的范围内,不要在一个提交中同时进行:
- 大规模重构;
- 新功能开发;
- 依赖升级;
- 数据库结构调整;
- 代码格式全量修改。
否则出现问题时很难定位具体原因。
三十一、生产级项目检查清单
项目结构
- 可执行程序入口位于
cmd; - 服务内部实现位于
internal; - 包按照业务能力组织;
- 没有无意义的公共包;
-
main.go只负责组装和启动; - 不存在循环依赖。
依赖管理
- 已提交
go.mod和go.sum; - 已执行
go mod tidy; - 没有个人本地路径的
replace; - 第三方依赖经过维护性和安全评估;
- CI 使用固定版本的工具链。
配置与安全
- 配置加载后立即校验;
- 密钥没有写入代码仓库;
- 日志不会打印敏感信息;
- HTTP 请求体存在大小限制;
- 数据库查询使用参数绑定;
- 外部调用设置超时;
- 已执行依赖漏洞扫描。
错误与日志
- 错误使用
%w保留调用链; - 业务错误具有稳定错误码;
- 内部错误不会直接返回客户端;
- 同一个错误不会被重复记录多次;
- 日志使用结构化字段;
- 日志包含 Request ID 和 Trace ID。
并发与资源
- Goroutine 有明确退出条件;
- 并发数量存在上限;
- Context 取消信号能够向下传播;
- 数据库连接池参数合理;
- 文件、Rows 和 Response Body 被正确关闭;
- 已运行 Race Detector。
测试与质量
- 核心业务规则有单元测试;
- 数据库访问有集成测试;
- HTTP Handler 有接口测试;
- 错误分支得到覆盖;
- 已执行
gofmt; - 已执行
go vet; - CI 会自动执行测试和静态检查。
构建与发布
- 构建过程可以重复;
- 二进制包含版本信息;
- 容器使用多阶段构建;
- 容器不使用 Root 用户运行;
- 镜像版本可以追溯到 Git 提交;
- 支持优雅关闭;
- 已准备明确的回滚方案。
可观测性
- 存在存活和就绪检查;
- 已采集请求量、错误率和延迟;
- 日志、指标和 Trace 能够关联;
- 数据库连接池状态可观测;
- pprof 受到访问保护;
- 核心业务异常配置了告警。
三十二、总结
Go 工程化不是简单地创建一套固定目录,也不是引入越多工具越好。
完整的 Go 工程化体系应该覆盖:
text
清晰的项目结构
↓
稳定的依赖方向
↓
显式的依赖注入
↓
统一的配置、错误和日志
↓
完善的测试与静态检查
↓
可重复的构建过程
↓
安全的容器与发布流程
↓
完整的监控、追踪和故障处理
对于一个刚开始的 Go 项目,可以优先做好以下事情:
- 使用 Go Module 管理依赖;
- 按业务能力组织代码;
- 将入口放入
cmd,内部实现放入internal; - 使用构造函数完成依赖注入;
- 正确传递 Context;
- 统一错误包装和结构化日志;
- 编写表驱动测试;
- 在 CI 中执行格式化检查、测试、Race Detector 和漏洞扫描;
- 使用多阶段容器构建;
- 支持优雅关闭和版本追踪。
随着项目规模扩大,再逐步引入:
- 集成测试环境;
- 数据库迁移平台;
- 分布式链路追踪;
- 灰度发布;
- 制品签名;
- SBOM;
- 自动性能分析;
- 更完整的供应链安全治理。
最重要的原则可以概括为:
Go 工程化不是让项目拥有更多目录和工具,而是让任何一次修改都可以被理解、被验证、被构建、被发布,也能够在出现问题时被快速定位和安全回滚。