写 Go 项目,最头疼的事情不是语法------语法一天就能上手。真正让人纠结的是"代码往哪放"。
Go 官方不像 Rails 或 Django 那样给你规定目录结构。你搜"Go project layout",能搜出来一堆方案,什么 golang-standards/project-layout、clean architecture、hexagonal architecture......每个都有道理,但每个都不能直接拿来就用。
我也纠结过一阵。最早写 Go 就一个 main.go 打天下,后来项目大了开始拆文件夹,拆的过程中踩了不少坑------层太多了写起来烦,层太少了改起来痛。折腾了几个项目之后,慢慢沉淀出一套自己用着顺手的结构。
不敢说这是最优解,但至少是在实际项目里磨合过的。这篇就聊聊这套结构长什么样,以及每个设计决策背后的想法。
整体目录结构
先把全貌亮出来:
go
cmd/
api/ API 服务入口
worker/ 异步队列消费者入口
migrate/ 数据库迁移入口
config/ 环境变量加载与类型化配置
internal/
server.go HTTP 服务装配
worker.go Worker 服务装配
bootstrap/ 进程级资源初始化和关闭
errcode/ 错误码定义
handler/ HTTP handler
service/ 业务逻辑
repository/ 数据访问层
model/ 数据模型
middleware/ Gin 中间件
router/ 路由注册
task/ 异步任务定义
taskqueue/ 异步队列边界
worker/ 异步队列运行时组件
pkg/
auth/ JWT 鉴权
cache/ Redis 封装
database/ 数据库初始化
log/ 日志初始化
response/ 统一 JSON 响应
validator/ 参数校验
tests/
integration/ PostgreSQL、Redis、Asynq 集成测试
.github/
workflows/
ci.yml 自动化测试、lint 和容器验证
Dockerfile 三个进程入口的多阶段镜像构建
.dockerignore Docker 构建上下文排除规则
compose.yaml 本地完整依赖栈与启动顺序
Makefile 开发、验证和容器命令入口
几个顶层目录的职责:
- cmd/ :进程入口。每种进程一个子目录,
main.go只负责进程级编排:API 和 Worker 管理启动与关闭,Migrate 执行一次性迁移后退出 - config/:只负责从环境变量加载类型化配置,不在这里创建数据库、Redis 或 JWT
- internal/bootstrap/:初始化 API 和 Worker 的共享进程资源,组装 Registry,并统一负责关闭;Migrate 作为一次性入口直接管理自己的数据库连接
- internal/ :模块私有的应用代码、进程装配和运行时组件。Go 的
internal约定保证这些包不会被外部项目直接引用 - pkg/:基础设施辅助包。大部分保持业务无关,少数包(例如统一响应)仍服务于当前应用契约
- tests/integration/:连接真实 PostgreSQL 和 Redis,并通过 Asynq client 验证任务持久化;使用独立 build tag 与默认单元测试隔离
- 根目录工程文件 :
Dockerfile、Compose、Makefile和 GitHub Actions 共同负责本地运行、构建与自动化验证,不承载业务逻辑
这不是照搬 golang-standards/project-layout。那个仓库里有些目录我觉得没必要(比如 api/、third_party/),有些是我后来根据实际需要加的(比如拆了三个 cmd)。项目结构这种东西,合适比"标准"重要。
三个入口:API / Worker / Migrate
最早我只有一个 cmd/main.go,API 服务、数据库迁移、异步任务全塞在一起。能跑是能跑,但问题很快就来了:
- 部署的时候只想跑 API,但启动时 Worker 的依赖也得初始化,Redis 没配好整个服务就起不来
- 想单独跑数据库迁移,得把整个 API 服务的依赖都拉起来
- API 和 Worker 需要的环境变量不一样,混在一起
.env文件越来越乱
所以拆了三个入口:
cmd/api/ --- HTTP 服务。最核心的入口,可以多实例水平部署。
cmd/worker/ --- Asynq 队列消费者。用的是 Asynq,一个基于 Redis 的 Go 异步任务库。Worker 和 API 可以复用 model、service、repository 等应用代码,但各自独立初始化依赖------Worker 不启动 HTTP server,API 也不启动 Asynq server。当前骨架只注册了一个最小示例任务,还没有接入定时调度。
cmd/migrate/ --- 数据库迁移。独立进程,只连接 PostgreSQL,不依赖 Redis 和 JWT。当前骨架用 GORM 的 AutoMigrate 创建或更新 examples 表,没有维护独立 SQL migration 文件。真实项目如果需要可审计、可回滚的迁移历史,再替换成专门的迁移工具。
每个 main.go 都只做进程级工作,不写业务逻辑。API 和 Worker 加载配置、初始化依赖、启动服务并处理退出信号;Migrate 执行完迁移后直接退出。
go
// cmd/api/main.go(保留关键启动链路)
func main() {
config.LoadEnv("cmd/api/.env")
cfg := config.Load()
if err := bootstrap.InitRuntime(cfg, "api"); err != nil {
panic(fmt.Sprintf("init runtime: %v", err))
}
defer func() { _ = applog.Sync() }()
registry, err := bootstrap.InitAPI(cfg)
if err != nil {
applog.L().Fatal("initialize application", zap.Error(err))
}
defer func() { _ = registry.Close() }()
server, err := app.NewServer(registry)
if err != nil {
applog.L().Fatal("assemble http server", zap.Error(err))
}
ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
syscall.SIGTERM,
)
defer stop()
errCh := make(chan error, 1)
go func() { errCh <- server.Run() }()
select {
case err := <-errCh:
if err != nil {
applog.L().Fatal("web server error", zap.Error(err))
}
return
case <-ctx.Done():
}
if _, err := shutdownWebServer(
server,
errCh,
gracefulShutdownTimeout,
serverExitWaitTimeout,
); err != nil {
applog.L().Error("web server shutdown failed", zap.Error(err))
}
}
代码不多,但结构很清楚。实际实现收到 SIGINT 或 SIGTERM 后,会给 HTTP server 10 秒的优雅关闭窗口;如果优雅关闭失败,再调用 Close 强制释放连接,最后由 Registry 关闭队列、Redis 和数据库资源。
分层设计:handler → service → repository
这是整个项目结构的核心。调用链是这样的:
handler → service → repository → DB
每一层有明确的职责边界,不能越级:
handler:HTTP 协议适配层。做三件事------绑定参数、调用 service、格式化响应。不写任何业务逻辑。
go
// internal/handler/example.go
type ExampleHandler struct {
svc *service.ExampleService
}
func (h *ExampleHandler) Create(c *gin.Context) {
var req service.CreateExampleReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(200, response.BuildValidationErrorResponse(c, err))
return
}
example, err := h.svc.Create(c.Request.Context(), &req)
if err != nil {
response.WriteError(c, err)
return
}
response.WriteSuccess(c, example)
}
service :业务逻辑层。接收普通参数或请求 DTO(不是 gin.Context),返回业务数据或错误码。service 在自己所在的包里定义所需的最小接口,不直接依赖具体 repository 类型,单元测试时可以很容易替换成 fake。
go
// internal/service/example.go
type ExampleRepository interface {
Create(ctx context.Context, example *model.Example) error
List(ctx context.Context, limit, offset int) ([]model.Example, int64, error)
}
type ExampleService struct {
repo ExampleRepository
queue ExampleQueue
}
func (s *ExampleService) Create(ctx context.Context, req *CreateExampleReq) (*model.Example, error) {
example := model.Example{Name: req.Name}
if err := s.repo.Create(ctx, &example); err != nil {
applog.FromContext(ctx).Error("failed to create example", zap.Error(err))
return nil, errcode.DatabaseError
}
return &example, nil
}
注意 service 接收的是 context.Context,不是 *gin.Context。它不依赖 Gin 类型,只通过标准 context 接收请求取消、超时和 trace 等信号,同样的 service 后续可以由 handler 或 worker 调用。为了让示例保持紧凑,当前请求 DTO 仍放在 service 包并带有 json、form、binding 标签,所以这里追求的是实用的协议隔离,不是假装已经做成纯领域模型。
service 会把需要暴露给客户端的失败映射成 errcode 包里的稳定错误,不直接返回 "数据库错误" 这种字符串。handler 拿到错误后,通过统一的 response.WriteError() 转成 JSON 响应。后面会详细说这块。
repository :业务数据访问层。业务查询和写入集中在这里;pkg/database 的连接初始化与 cmd/migrate 的迁移代码属于基础设施例外。
go
// internal/repository/example.go
type ExampleRepository struct {
db *gorm.DB
}
func (r *ExampleRepository) Create(ctx context.Context, example *model.Example) error {
return dbFromContext(ctx, r.db).WithContext(ctx).Create(example).Error
}
func (r *ExampleRepository) List(ctx context.Context, limit, offset int) ([]model.Example, int64, error) {
db := dbFromContext(ctx, r.db).WithContext(ctx)
var total int64
if err := db.Model(&model.Example{}).Count(&total).Error; err != nil {
return nil, 0, err
}
var examples []model.Example
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&examples).Error; err != nil {
return nil, 0, err
}
return examples, total, nil
}
repository 还提供了一个基于 context.Context 的事务辅助:如果 context 中已经携带 GORM transaction,repository 会复用它;否则使用默认 DB。这样 service 不需要知道每个 repository 怎样取事务连接。
model :GORM 数据模型。保持为数据结构,不放业务逻辑;像 TableName 这种数据库映射方法是例外。
go
// internal/model/example.go
type Example struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Name string `gorm:"column:name;type:varchar(255);not null" json:"name"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;default:CURRENT_TIMESTAMP;not null" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;default:CURRENT_TIMESTAMP;not null" json:"updated_at"`
}
func (Example) TableName() string {
return "examples"
}
为什么不直接 handler → repository?
小项目确实可以跳过 service 层,handler 直接调 repository。代码少、路径短,改起来快。
但业务一复杂就后悔了。比如"创建订单"这个操作,可能需要:检查库存 → 计算价格 → 扣减库存 → 创建订单 → 发送通知。这些逻辑如果写在 handler 里,handler 就变成了一个几百行的大函数,HTTP 协议适配和业务逻辑搅在一起,谁来改都头疼。
service 层就是为了把"这个接口做什么"和"HTTP 请求怎么处理"分开。
当前骨架没有 usecase 层。如果业务进一步复杂,涉及跨域编排(比如订单支付要协调用户服务、库存服务、支付网关),再考虑在 service 上面加一层:
handler → usecase → service → repository → DB
usecase 负责编排多个 service 的调用流程,service 保持聚焦在单个领域。但这是在业务确实需要的时候才加,不要一上来就四五层架空。
手写依赖注入
Go 社区有几个流行的 DI 方案:Google 的 Wire(编译期代码生成)、Uber 的 Dig 和 Fx(运行时反射注入)。
我试过 Wire,用了一阵之后放弃了。原因几个:
- 多一个代码生成步骤(
wire gen),每次改依赖都要重新跑,CI 也得加这步 - 生成的代码是个黑盒,出了问题排查不直观
- 对于中小型项目,手写 DI 的代码量其实没有想象中大
Dig / Fx 用运行时反射注入,出错信息更不友好。而且 Go 社区整体更偏好"编译期能发现的问题就别留到运行时",反射注入跟这个哲学有点冲突。
所以最终选了手写。核心是一个 Registry 模式:
go
// internal/bootstrap/registry.go
type Registry struct {
Cfg *config.Config
DB *database.DBManager
Cache *cache.Client
Auth *auth.JWTManager
Queue *taskqueue.Queue
queueClient *asynq.Client
}
config 只加载配置,internal/bootstrap 按进程初始化资源并组装 Registry。以 API 为例,PostgreSQL 是必需的,Redis 和 JWT 则按配置启用:
go
// internal/bootstrap/api.go(精简版)
func InitAPI(cfg *config.Config) (*Registry, error) {
if cfg == nil {
return nil, errors.New("config is nil")
}
dbMgr, err := initDatabase(cfg)
if err != nil {
return nil, fmt.Errorf("init database: %w", err)
}
if dbMgr.DB() == nil {
return nil, errors.New("postgres dsn is required for api")
}
cacheClient, err := initCache(cfg)
if err != nil {
return nil, fmt.Errorf("init cache: %w", err)
}
authManager, err := initAuth(cfg)
if err != nil {
return nil, fmt.Errorf("init auth: %w", err)
}
validator.InitValidator()
queueClient := newAsynqClient(cfg)
return &Registry{
Cfg: cfg,
DB: dbMgr,
Cache: cacheClient,
Auth: authManager,
Queue: taskqueue.NewQueue(queueClient),
queueClient: queueClient,
}, nil
}
Registry 自己实现 Close(),按队列客户端、Redis、数据库的顺序回收资源,并用 errors.Join 汇总关闭错误。然后 internal/server.go 从 Registry 装配业务调用链:
go
// internal/server.go
func newHTTPHandlers(reg *bootstrap.Registry) *HTTPHandlers {
db := reg.DB.DB()
exampleRepository := repository.NewExampleRepository(db)
exampleService := service.NewExampleService(exampleRepository, reg.Queue)
return &HTTPHandlers{
Auth: handler.NewAuthHandler(reg.Auth),
Health: handler.NewHealthHandler(reg.DB, reg.Cache),
Example: handler.NewExampleHandler(exampleService),
}
}
装配分成两层:internal/bootstrap 管 API 和 Worker 的共享进程资源,internal/server.go 和 internal/worker.go 分别装配 HTTP 与异步应用对象;Migrate 则直接管理一次性迁移所需的数据库连接。handler、service、repository 自己不 new 其他业务对象------它们只声明自己需要什么,由上层传进来。
这样做的好处是:打开 bootstrap 和 server,资源生命周期与业务依赖关系都一目了然。谁依赖谁,什么时候创建、什么时候销毁,都能直接沿构造函数追下去,不需要理解 DI 框架的"魔法"。
代码多吗?确实比 Wire 生成的要多一些。但这点"啰嗦"换来的是完全透明的依赖关系,我觉得值。
路由设计
router 包只干一件事------注册 /api/v1 下 URL 和 handler 的映射关系。不构造依赖,不初始化服务。/health 不属于业务 API,由 internal/server.go 在装配 HTTP engine 时直接注册。
go
// internal/router/router.go
type Dependencies struct {
Auth *handler.AuthHandler
AuthRequired gin.HandlerFunc
Example *handler.ExampleHandler
}
func RegisterRoutes(r *gin.RouterGroup, deps Dependencies) error {
registerAuthRoutes(r, deps)
registerExampleRoutes(r, deps)
return nil
}
func registerAuthRoutes(r *gin.RouterGroup, deps Dependencies) {
if deps.Auth == nil {
return
}
authRoutes := r.Group("/auth")
authRoutes.POST("/token", deps.Auth.CreateToken)
if deps.AuthRequired != nil {
authRoutes.GET("/me", deps.AuthRequired, deps.Auth.Me)
}
}
func registerExampleRoutes(r *gin.RouterGroup, deps Dependencies) {
if deps.Example == nil {
return
}
examples := r.Group("/examples")
examples.GET("", deps.Example.List)
examples.POST("", deps.Example.Create)
examples.POST("/tasks", deps.Example.EnqueueTask)
}
几个设计决策:
按模块拆私有函数 。当前是 registerAuthRoutes 和 registerExampleRoutes,后面增加模块时继续沿用这一模式,避免 router.go 变成一个几百行的大文件。
Dependencies 由外部传入。router 不关心 handler 是怎么创建的,它只接收现成的、可以直接用的 handler。这就保证了路由注册和依赖装配的解耦------改装配逻辑不用动 router,改路由不用动装配。
认证中间件作为依赖传入 。受保护路由只接收一个 gin.HandlerFunc,不在 router 里创建 JWT manager。当前 JWT_SECRET 为空时,Auth handler 和认证中间件都不会创建,整个 /api/v1/auth 示例路由也不会注册。以后更换认证中间件时,路由分组不用跟着改;令牌签发 handler 仍要按新的认证方案调整。
当前完整路由很少:GET /health,JWT 启用后的 POST /api/v1/auth/token 与 GET /api/v1/auth/me,以及三个公开的 Example 路由。POST /api/v1/examples/tasks 即使没有配置 Redis 也会注册,但调用时会返回 QUEUE_UNAVAILABLE。
HTTP 中间件和健康检查
HTTP engine 使用固定的中间件顺序:
TraceLogger → Recovery → Timeout → CORS → IP RateLimit → Handler
TraceLogger 先建立 trace context,保证后面的 panic、超时和业务日志都能带上同一个 request ID;Recovery 捕获 panic,并按统一业务错误协议返回 INTERNAL_ERROR;Timeout 只给请求 context 加 deadline,不会强行杀掉 handler,所以 repository 和外部调用仍要正确使用传下来的 context。
CORS 使用显式 origin allowlist,空配置不会返回跨域允许头。限流默认关闭,启用后是单进程、按客户端 IP 的内存 token bucket;它适合保护单实例或做第一层削峰,不是多实例共享的全局配额。
/health 会在两秒窗口内检查依赖。PostgreSQL 是 API 的必需依赖,异常时返回 HTTP 503;Redis 没配置时标记为 not_configured 但不影响健康,配置后不可用才会把整体状态变成 unhealthy。
统一响应协议和错误码
这块是我纠结比较久才定下来的。
固定 HTTP 200
先说一个可能有争议的决策:业务 API 固定返回 HTTP 200 ,用 JSON body 里的 code 字段区分成功和失败。
成功响应:
json
{
"code": 0,
"msg": "success",
"data": { "id": 1, "name": "demo" }
}
失败响应:
json
{
"code": 1001,
"reason": "INVALID_PARAMS",
"msg": "invalid request parameters",
"metadata": {
"trace_id": "550e8400-..."
}
}
为什么不用 HTTP 状态码区分?主要是项目场景决定的------我做的项目 APP 端占大头。移动端网络环境复杂,中间经过的 CDN、网关、代理层可能会对非 200 的响应做各种"好心"的处理(重试、缓存、改写)。固定 200 能减少很多奇怪的中间层问题。前端拿到响应后统一看 code 字段就行,处理逻辑一致。
探针接口 /health 是例外,它返回真实的 HTTP 状态码(200 或 503),因为负载均衡器和 k8s 靠状态码判断服务是否健康。
错误码设计
业务错误用 errcode 包集中定义:
go
// internal/errcode/type.go
type Error struct {
code int
reason string
}
func newError(code int, reason string) Error {
return Error{code: code, reason: reason}
}
func (e Error) Code() int {
if e.code <= 0 {
return 0
}
return e.code
}
func (e Error) Reason() string {
if e.reason == "" {
return "UNKNOWN_ERROR"
}
return e.reason
}
func (e Error) Error() string {
return e.Reason()
}
// internal/errcode/common.go
var (
InvalidParams = newError(1001, "INVALID_PARAMS")
Unauthorized = newError(1002, "UNAUTHORIZED")
PermissionDenied = newError(1003, "PERMISSION_DENIED")
TooManyRequests = newError(1004, "TOO_MANY_REQUESTS")
RequestTimeout = newError(1005, "REQUEST_TIMEOUT")
InternalError = newError(9001, "INTERNAL_ERROR")
DatabaseError = newError(9002, "DATABASE_ERROR")
QueueUnavailable = newError(9003, "QUEUE_UNAVAILABLE")
QueueError = newError(9004, "QUEUE_ERROR")
)
Code 是稳定的数字,方便客户端做分支;Reason 是稳定的大写蛇形标识,方便日志检索和报警规则匹配。错误码层不保存展示文案,当前英文 msg 由 response 层根据 Reason 统一映射。
service 层不直接构造 JSON 响应 。需要暴露给客户端的数据库、队列等失败会先映射成 errcode;handler 再用统一的 response.WriteError() 转成响应,未知错误统一收敛成 INTERNAL_ERROR:
go
// pkg/response/response.go
type Response struct {
Code int `json:"code"`
Message string `json:"msg"`
Reason string `json:"reason,omitempty"`
Data any `json:"data,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
func WriteSuccess(c *gin.Context, data any) {
c.JSON(http.StatusOK, SuccessResponse(data))
}
func WriteError(c *gin.Context, err error) {
var ec errcode.Error
if errors.As(err, &ec) {
c.JSON(http.StatusOK, ErrorResponse(c, ec))
return
}
c.JSON(http.StatusOK, ErrorResponse(c, errcode.InternalError))
}
请求头
HTTP 中间件会设置一个自定义响应头:
X-Request-ID:如果上游已经传入就沿用,否则生成 UUID。它会进入标准context.Context、结构化日志以及错误响应的metadata.trace_id,用于跨层排查同一次请求
当前没有返回 X-Process-Time;服务端耗时记录在审计日志的 latency 字段里。浏览器端如果需要直接读取 X-Request-ID,还应根据跨域场景补充 Access-Control-Expose-Headers。
多语言:当前骨架没有内置
我做的很多项目确实有多语言需求,但这次把骨架公开出来时,选择先保留最小闭环,没有接入 go-i18n,也没有 config/i18n/locales 目录或 Accept-Language 处理。
当前 Reason 仍然是稳定的机器标识,response 层把它映射成固定英文 msg。pkg/validator 也只把第一条 binding 错误整理成简短英文提示。这意味着现有代码的错误协议是稳定的,但展示文案尚未国际化。
如果具体项目需要 i18n,扩展点仍然清楚:在 response 层按 Reason 翻译业务错误,在 validator 层翻译参数错误。等真实需求出现时再加入翻译文件和语言协商,避免让不需要多语言的服务先承担一套未使用的初始化与维护成本。
JWT 鉴权:保持最小可验证边界
当前骨架里的 JWT 是一个通用示例,只处理最基础、可以独立验证的边界:使用 HS256 签发和验签。默认配置会写入 sub、iss、iat、nbf、exp;自定义配置中,只有 TTL 大于零才写 exp,只有 issuer 非空才在解析时校验签发者。
go
// pkg/auth/jwt.go
type Claims struct {
jwt.RegisteredClaims
}
options := []jwt.ParserOption{
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithTimeFunc(func() time.Time { return m.now().UTC() }),
}
if m.issuer != "" {
options = append(options, jwt.WithIssuer(m.issuer))
}
认证中间件从 Bearer Token 中解析 Claims,只把 subject 写进 Gin context。JWT_SECRET 为空时 JWT manager 不会创建,/api/v1/auth/token 和 /api/v1/auth/me 也不会注册。这里的 /auth/token 是为了演示完整调用链,不是可以直接拿去生产使用的登录接口。
主动登出需要 JTI 黑名单,改密码后批量失效通常需要 token_version。这些能力当前都没有实现,也没有预留一个看似通用但未经业务验证的 Store 接口。具体项目真正需要时,再把撤销策略放到认证应用层,并明确 Redis 或数据库不可用时应该放行还是拒绝。
异步队列
不是所有操作都适合在 HTTP 请求里同步完成。发邮件、推送通知、生成报表这些耗时操作,应该扔到异步队列里处理。
我用的是 Asynq,一个基于 Redis 的 Go 异步任务库。选它的原因很简单:
- 项目本来就用 Redis,不想再引入 RabbitMQ 或 Kafka 这种重依赖
- 纯 Go 实现,API 简洁,文档清晰
- 自带重试、优先级队列和任务运行元数据,足够覆盖常见后台任务
Asynq 本身还提供 Scheduler,也可以配合 Asynqmon 查看任务状态,但当前骨架没有启动这两个组件,只实现任务发布和消费。
任务定义放在 internal/task/ 包里:
go
// internal/task/example.go
const TypeExampleTask = "example:run"
type ExamplePayload struct {
Name string `json:"name"`
TraceID string `json:"trace_id,omitempty"`
}
func NewExampleTask(name string, traceID ...string) (*asynq.Task, error) {
payload := ExamplePayload{Name: name}
if len(traceID) > 0 {
payload.TraceID = strings.TrimSpace(traceID[0])
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal example payload: %w", err)
}
return asynq.NewTask(TypeExampleTask, data, asynq.MaxRetry(5)), nil
}
API 端通过 service 投递任务。Redis 没配置时 Queue 本身就是 nil,service 会返回稳定的 QUEUE_UNAVAILABLE,不会把 nil pointer 留到运行时才暴露:
go
func (s *ExampleService) EnqueueTask(ctx context.Context, req *EnqueueExampleTaskReq) (*EnqueueExampleTaskRes, error) {
if s.queue == nil || !s.queue.Available() {
return nil, errcode.QueueUnavailable
}
t, err := task.NewExampleTask(req.Name, applog.TraceIDFrom(ctx))
if err != nil {
return nil, errcode.QueueError
}
if _, err := s.queue.Enqueue(ctx, t); err != nil {
return nil, errcode.QueueError
}
return &EnqueueExampleTaskRes{Queued: true}, nil
}
Worker 端消费任务:
go
// internal/worker/handler.go
func (d *Deps) HandleExampleTask(ctx context.Context, t *asynq.Task) error {
var payload task.ExamplePayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("unmarshal example payload: %w", err)
}
applog.FromContext(ctx).Info("example task executed",
zap.String("name", payload.Name),
zap.Bool("db_available", d != nil && d.DB != nil),
)
return nil
}
这条异步链路还延续了 HTTP 请求的 trace ID:API 把它写进任务 payload,Worker 中间件恢复到标准 context,再记录任务开始、完成、失败和重试日志。如果 payload 没有 trace ID,Worker 会用 asynq:<task_id> 生成兜底值,并把 trace_source 标成 asynq_task。Worker 默认并发数是 10,按 critical:default:low = 6:3:1 分配队列权重,重试采用指数退避并封顶一小时。
当前示例 Worker 只解析 payload 并记录日志,还没有实际调用 service / repository。不过两边都使用标准 context.Context,Worker 也已经能注入可选 DB,以及 Redis 提供的 Cache 和 Queue;真实任务需要复用业务逻辑时,可以直接装配对应 service,而不必把 Gin 概念带进来。
其他约定
pkg 的定位
pkg/ 主要放跨层复用的基础设施辅助包,但我不再把"位于 pkg 就一定能原样抽成外部库"当成硬规则。当前 pkg/response 会使用 internal/errcode 来实现本项目的响应契约;如果某个包真的要跨仓库复用,再单独收紧它的依赖边界。
pkg/auth:JWT 签发和校验pkg/cache:Redis client 封装pkg/database:GORM 初始化和健康检查pkg/log:zap logger 初始化,trace_id 上下文 helperpkg/response:统一 JSON 响应函数pkg/validator:自定义校验规则入口,以及简短的英文 binding 错误整理
环境变量
每个进程都可以提供自己的 .env,同时使用根目录 .env 作为 fallback。三个进程的真实依赖边界是:
- API:PostgreSQL 必需;Redis 和 JWT 可选。只有
REDIS_ADDR为空时 Redis 才会跳过,只有JWT_SECRET为空时鉴权示例才会跳过 - Worker:Redis 必需;PostgreSQL 可选
- Migrate:只需要 PostgreSQL
bash
cmd/api/.env API 专属配置
cmd/worker/.env Worker 专属配置
cmd/migrate/.env 迁移专属配置
.env 共享 fallback
加载优先级:真实环境变量 > 进程专属 .env > 根目录 .env 。生产环境通过容器环境变量注入,本地开发用 .env 文件。所有 .env 文件都在 .gitignore 里,仓库里只保留 .env.example 做参考。
本地运行、容器与 CI
我现在更愿意把"能被稳定地构建、启动和验证"也算作项目结构的一部分。根目录的 Dockerfile 使用多阶段构建,分别编译 API、Worker 和 Migrate;运行镜像不再包含 Go 工具链和源码,只把三个业务二进制复制进 Alpine 基础镜像,补上证书与时区数据,并切换到非 root 用户。compose.yaml 则把 PostgreSQL、Redis、Migrate、API 和 Worker 组装成完整的本地环境:先等待 PostgreSQL 健康并完成迁移,再启动 API 和 Worker。PostgreSQL 与 Redis 发布到宿主机的端口只绑定在 127.0.0.1,避免把本地开发凭据暴露到其他网络接口。
Makefile 是本地开发和 CI 的共同命令入口。make ci 依次检查格式、模块状态、go vet、race test,并构建三个入口;lint 还会带上 integration build tag,确保集成测试代码也进入静态检查。需要真实依赖时,可以通过独立的 integration Compose 项目启动 PostgreSQL 和 Redis;测试完成后执行 make integration-down,会同时删除容器、网络和测试数据卷,不影响普通开发栈保留的数据。
tests/integration 验证的是基础设施边界,而不是再写一套 mock:PostgreSQL 测试为每次运行创建独立 schema,覆盖 repository 的创建、分页和事务提交与回滚;Redis cache 使用随机 key 前缀验证读写与 TTL;Asynq 使用随机 queue 验证任务确实写入 Redis。这个测试包本身不启动 Worker,真实的消费链路由 CI 的 container job 负责:它构建非 root 镜像、启动完整 Compose 栈、检查 /health、写入数据库、发布任务,并确认 Worker 实际消费成功。这样验证的不只是代码能编译,还包括三个进程的启动顺序和真实依赖是否接得起来。
审计日志
API 请求默认会输出一条结构化的审计日志,用 zap 写。包含请求方法、路径、状态码、耗时、客户端 IP、trace_id 这些核心信息。可以通过 AUDIT_LOG_ENABLED 整体关闭,也可以用 AUDIT_LOG_EXCLUDE_PATHS 排除 /health 这类高频路径。
当前采用的是 metadata-only 审计:中间件不主动读取 Authorization、Cookie、请求 body 或响应 body,降低了常见敏感字段和大块内容进入审计日志的风险。这里不是完整的数据安全保证------客户端传入的 request ID 和 URL path 仍会进入日志。以后如果业务确实需要采集 body,再把字段脱敏、大小上限和内容类型判断一起设计,不能只加一条打印语句。
context 传递
有一条硬性约定:请求级的 context.Context 沿 handler → service → repository 一路传下去 ,业务层不能用 context.Background() 替换已经收到的有效 context。进程入口创建根 context,以及基础设施对 nil context 做防御性兜底,是另外两种情况。
为什么?因为 context 里带着 trace_id、超时控制、取消信号这些东西。如果 service 层自己 context.Background() 一下,上游的超时取消就传不下去了------请求 deadline 已经过期或客户端已经断开,数据库查询却还在傻跑。
总结
这套结构不是银弹。它是在特定场景下------Go + Gin + PostgreSQL + Redis 的中小型后端服务------经过几个项目打磨出来的方案。
如果要提炼几个核心原则的话:
- 显式优于隐式:手写 DI、显式错误处理、每一层的职责都摆在明面上
- 职责边界清晰:每一层只干自己该干的事,不越级、不混搭
- 依赖关系单向:handler → service → repository,不反向依赖
- 为变化留余地:API 的 Redis 和 JWT 按配置启用,Worker 则必须配置 Redis;usecase、i18n、token 撤销策略等能力等到真实需求出现再增加,Worker 和 API 仍可独立部署
- 让结构可验证 :本地通过 Make targets 统一日常检查和真实依赖测试,CI 在此基础上再验证带
integrationtag 的 lint 与完整容器 smoke test
项目小的时候这套结构可能觉得有点"重"------就几个接口至于分这么多层吗?但项目一旦开始长大,你会庆幸一开始就把边界画好了。改一个 service 不用担心影响 handler 的逻辑,加一个新模块只需要加对应的 handler/service/repository 然后在 router 里注册一下。
欢迎大佬们,批评指正。