2026 年 7 月 23 日 · 阅读时长 11 分钟

把工程约束写进代码:一套 Go 服务骨架的设计与迭代

本文根据我开源维护的 Go Skeleton 整理。仓库已移除具体业务,只保留一个 Example 模块,用来展示 HTTP API、异步任务、数据库迁移、可观测性和发布流程如何组合。

让一个 Go HTTP 服务跑起来并不难。路由、数据库连接和几个 handler 很快就能拼出第一个接口,维护成本通常在服务开始迭代后出现:业务规则进入路由层,数据库访问散落在各处,异步任务无法追踪,配置错误直到部署时才暴露,接口文档也渐渐落后于代码。

我在实际项目中反复处理这些问题,后来把共用部分封装成 Go Skeleton。它没有试图预先容纳所有架构模式,主要是把已经确认的工程约束放进代码、生成器和 CI,让越层依赖、契约漂移与错误配置尽早失败。

三个进程划开运行时职责

这套骨架使用 Go 1.26、Gin、GORM、PostgreSQL、Redis 和 Asynq。运行时由三个独立入口组成,每个入口只初始化当前职责需要的资源。

进程职责必需依赖
cmd/api提供 HTTP API、鉴权、指标与健康检查PostgreSQL
cmd/worker消费 Asynq 异步任务Redis,PostgreSQL 可选
cmd/migrate执行版本化 SQL migrationPostgreSQL

三个入口遵循同一套启动顺序:加载配置,初始化进程运行时,创建所需资源,装配应用,收到信号后释放资源。main.go 只负责生命周期,不承载业务逻辑。

进程级资源集中在 bootstrap.Registry,包括配置、数据库、缓存、JWT manager、队列 client 和 Asynq Inspector。API 与 Worker 分别调用 InitAPIInitWorker。初始化中途失败时,已经打开的资源按后开先关的顺序释放;正常退出时,Registry.Close 使用 errors.Join 汇总关闭错误,单个资源释放失败不会阻止后续清理。

迁移因此不会在每个 API 实例启动时重复执行,Worker 也不需要加载 HTTP 路由。部署系统可以分别扩缩 API 与 Worker,migration 则作为一次性任务在发布前运行。

Worker 对 PostgreSQL 的依赖在架构上可选,适合只调用外部服务或只读 Redis 的任务。当前示例任务会通过 repository 写入 PostgreSQL,所以 production 模式没有注入数据库时,processor 检查会直接拒绝启动。可选依赖只有在具体任务不需要它时才成立。

手写 DI 让依赖图保持可见

项目没有引入 Wire、Dig 或 Fx。internal/server.go 是 HTTP 进程的 composition root,集中创建 repository、service 与 handler。

db := reg.DB.DB()
exampleRepository := repository.NewExampleRepository(db)
exampleService := service.NewExampleService(exampleRepository, reg.Queue)
exampleHandler := handler.NewExampleHandler(exampleService)

Registry 管理数据库与 Redis 这类长生命周期资源,server.goworker.go 再把资源装配成业务调用关系。handler、service 与 repository 只声明依赖,不在内部创建下游对象。构造过程可以在一个文件里读完,初始化失败也会在监听端口前返回。

service 使用的 repository 与 queue 接口定义在 service 包内,而且只包含当前业务调用的方法。测试可以直接传入带函数字段的 inline mock,不需要启动容器,也不会被一个覆盖底层全部能力的大接口绑住。

手写装配适合依赖图仍能被一个文件说明的阶段。等模块数量增长到 composition root 明显难读,再评估代码生成式 DI。当前实现没有为尚未出现的复杂度增加工具。

HTTP 框架停在 Handler

同步请求始终沿一个方向流动,context.Context 负责把取消信号、deadline 与 trace 信息传到数据库访问层。

internal/handler 处理参数绑定、service 调用与响应写出。Gin 的 *gin.Context 到这里为止,service 只接收标准库的 context.Context,因此同一个业务方法可以同时由 HTTP handler 和 Worker 调用。

internal/service 保存参数规则、业务流程与错误语义。底层 GORM 错误会在这一层记录,再转换为稳定的业务错误码,数据库结构和驱动信息不会进入客户端响应。

internal/repository 是业务代码中唯一允许使用 GORM 或原生 SQL 的位置。每次查询都使用上游传入的 context,请求取消可以继续传给数据库,日志也能读取同一 context 中的 trace ID。internal/model 只描述持久化结构与字段映射,不保存鉴权、状态机或外部调用。

当前骨架没有 usecase 层。一次操作需要协调多个领域或至少三个 service 时,可以在 service 之上增加 usecase;简单 CRUD 提前增加一层,只会把调用过程拉长。

事务边界由业务操作决定

跨多个 repository 的事务由 service 发起。repository.InTx 把活跃的 *gorm.DB 放进 context,repository 再通过 dbFromContext 取得事务句柄。业务接口始终只有一套,不需要为每个方法维护 CreateInTxUpdateInTx 之类的事务版本。

这个设计把事务决策留给掌握完整业务过程的 service,同时让 repository 继续只关心数据访问。context 在这里承担的是请求范围内的事务句柄传递,不用作任意参数容器。

分页查询还保证了结果一致性:CountFind 放在 REPEATABLE READ 的只读事务中,total 和当前页数据来自同一个快照。需要一致分页结果时可以采用这种方式,普通读操作没有必要一律增加同级别事务。

异步消息也有自己的契约

HTTP 接口通过 taskqueue.Queue 投递任务。Worker 收到消息后完成反序列化与版本检查,再调用同一个 service 的业务方法。Worker 不直接持有 GORM,也不复制一套处理逻辑。

任务 payload 嵌入统一的 Header,其中包含 schema version 与 trace_id。API 产生的请求 ID 会随消息进入 Redis,Worker middleware 再把它恢复到 context.Context。没有上游 trace 时,Worker 使用 Asynq task ID 生成稳定标识,同一任务的多次重试仍能按一个 ID 查询日志。

payload version 会在业务处理前检查。增加兼容字段时可以保持版本不变;删除字段或改变语义时提升版本,并在滚动发布期间同时接收旧版与新版消息。无法识别的版本进入重试与 archived 队列,不会被静默确认。

任务工厂统一设置最大重试次数与单次超时,也区分两类去重语义:稳定业务键使用 TaskID,短时间防重复使用 Unique(ttl)。Worker 采用指数退避,并对异常重试次数做溢出保护。任何 processor 在 production 模式下缺少真实实现,Worker 都会拒绝启动,避免 noop processor 把任务直接确认。

Asynq 提供 at-least-once 处理语义,业务 handler 仍需保证幂等。PostgreSQL 事务与 Redis 入队也不是一个原子操作,因此项目禁止在数据库事务回调中直接投递任务。业务需要严格保证数据库提交后消息一定发出时,应增加 outbox。

OpenAPI 同时约束实现与脚手架

api/openapi.yaml 定义路径、请求、响应、安全要求与错误结构。oapi-codegen 根据它生成 Gin server interface、transport model 和内嵌 spec,生成文件进入版本控制。

两道检查负责维护契约一致性:APIServer 在编译期声明实现生成的 oapi.ServerInterface,方法签名缺失会直接构建失败;make oapi-verify 重新生成代码并检查 git diff,防止 YAML 已修改但生成文件没有更新。

新增资源时,make new-endpoint NAME=Order 会读取 OpenAPI,生成 handler、service、repository、model、task 与测试模板,并更新集中装配点。新方法会返回明确的 NOT_IMPLEMENTED_YET,仓库仍能编译并执行校验,运行时也不会用空成功响应掩盖缺失实现。

脚手架只处理高频、结构稳定的工作。复杂的多 path 参数、组合 schema 和跨多个根路径的资源仍需手写,make new-endpoint-check 则检查 YAML、路由与 handler 之间的缺失、残留和不一致。

当前基线使用 oapi-codegen v2.7.0。该版本对 OpenAPI 3.1 的支持仍有限,现有 spec 已经通过生成与测试,但新增 3.1 特性前仍要确认生成器能否正确处理。

Migration 是独立的发布动作

仓库已经移除 AutoMigratemigrations/*.sql 是数据库 schema 的唯一来源。SQL 通过 go:embed 打入 migrate 二进制,部署时不需要额外复制 migration 目录。

cmd/migrate 使用 goose Provider API,并固定为 PostgreSQL dialect。执行前先取得 PostgreSQL advisory lock,多实例同时启动 migrate 时只会有一个进程执行 DDL,其余进程等待,避免 schema 与版本表发生竞争。

migration 测试会检查文件名是否采用 14 位时间戳与 snake_case,是否同时包含 UpDown 段,以及破坏性 DDL 是否带有原因标记。删除表、删除列、修改字段类型、增加 NOT NULL、重命名与 TRUNCATE 都会触发检查。

自动校验只能拦住能够确定识别的问题。model 与 SQL 是否一致、数据回填是否可承受、Down 是否会丢数据,仍需代码评审和真实数据库测试。

生命周期与可观测性一起设计

API 启动时先探测 PostgreSQL 和已经配置的 Redis,必需依赖不可达就停止启动。production 配置会进一步检查 JWT secret、开发 token 端点、Gin 模式与日志格式。限流未启用、trusted proxies 为空、指标仍挂在业务端口等情况只产生 warning,因为这些职责也可能由上游网关或部署网络承担。

健康检查分成两个端点。/livez 只表示进程仍能响应,不访问外部依赖;/health 表示实例是否适合接收流量,PostgreSQL 不可用时返回 503,Redis 不可用时返回 degraded 与 200。

收到 SIGTERM 后,API 先把 draining 标志设为 true,/health 随即返回 503。等负载均衡停止发送新请求,再执行 graceful shutdown。Worker 先调用 Asynq Stop 停止拉取任务,再调用 Shutdown 等待正在处理的任务结束。

中间件顺序也属于生命周期设计。请求日志位于最外层,随后是 metrics、recovery、安全响应头、body 限制、timeout、CORS 与限流。被后续中间件拒绝的请求仍会计入入口流量,panic 可以转换为稳定响应,限流判断前也不会执行多余的业务工作。

Prometheus 使用独立 Registry,指标 label 记录路由模板而不是原始 URL,避免动态 path 产生高基数。production 环境可以把 /metrics 放到独立 listener,再用 Kubernetes NetworkPolicy 限制访问。pprof 同样使用独立 server,production 模式绑定非 loopback 地址时会给出配置 warning。

架构规则由 AST 和 CI 执行

README 能解释规则,却无法阻止一次错误 import。仓库里的 architecture-verify 使用 Go AST 检查四条边界。

规则拦截的问题
service、repository、model、task 与 worker 禁止 import Gintransport 框架进入业务层
GORM 只允许出现在 repository、model、bootstrap 与 database package数据访问越层
pkg/ 禁止反向依赖 internal/通用 package 被业务实现绑定
service 与 handler 禁止调用 context.Background()请求取消、deadline 与 trace 丢失

make verify 还会执行格式化、go vet、单元测试、lint、环境变量校验、go mod tidy 漂移检查、OpenAPI 生成校验,以及文档与 shell 脚本检查。CI 在此基础上增加 race detector、真实 PostgreSQL 与 Redis 集成测试、OpenAPI breaking change 检查,以及 systemd 和 Kubernetes manifest 校验。

发布流程从 tag 触发,构建 Linux amd64 与 arm64 静态二进制,生成 SHA256 校验文件和 SPDX SBOM,再使用 cosign keyless 签名。Docker 路径使用 multi-stage build 与 distroless nonroot runtime;Kubernetes 模板包含 readiness、liveness、HPA、PodDisruptionBudget、NetworkPolicy 和只读根文件系统。

这些规则把评审中反复出现的确定性要求变成失败条件。代码一旦越过既定边界,开发环境或 CI 会立即给出结果。

有意保留的限制

这套骨架适合以 PostgreSQL 为主要数据源,同时需要 HTTP API 与后台任务的中小型服务。它提供清晰的分层、基础设施接入与验证入口,新模块可以沿现有结构增加。

当前实现也保留了几项限制。内存 per-IP limiter 不能覆盖多副本的全局限流;Prometheus 已满足现阶段的观测需求,所以没有预先引入 OpenTelemetry;手写 DI 在依赖数量持续增长时会变长;handler、service 与 repository 分层也不等于完整的领域模型。

扩展方向由已经出现的问题决定。需要跨实例限流时,可以把限制放到网关或改用 Redis limiter;需要可靠事件发布时增加 outbox;需要分布式 trace 时接入 OpenTelemetry;跨多个领域的流程明显增多时再增加 usecase。现有接口边界可以让这些替换集中在少数模块内。

我在持续封装和迭代 Go Skeleton 时,主要关注契约、资源生命周期、错误语义、异步消息、数据库变更和发布验证。目录结构只是这些设计的外观,可执行的约束才决定服务迭代后是否仍然容易理解和修改。

延伸阅读

参考源码

参考资料

CO

我是 Cooper,一名生活在中国的开发者,多年来一直在家远程工作。

主要使用 Go、PHP 和 Rust,从事移动端、Web、跨境支付与资金系统开发。目前主要采用 Vibe Coding 构建产品,并以真实运行结果完成验证与交付。