从目录分层到交付验证:我的 Go 后端工程实践

写 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))
    }
}

代码不多,但结构很清楚。实际实现收到 SIGINTSIGTERM 后,会给 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 包并带有 jsonformbinding 标签,所以这里追求的是实用的协议隔离,不是假装已经做成纯领域模型。

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 的 DigFx(运行时反射注入)。

我试过 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.gointernal/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)
}

几个设计决策:

按模块拆私有函数 。当前是 registerAuthRoutesregisterExampleRoutes,后面增加模块时继续沿用这一模式,避免 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/tokenGET /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_ERRORTimeout 只给请求 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 层把它映射成固定英文 msgpkg/validator 也只把第一条 binding 错误整理成简短英文提示。这意味着现有代码的错误协议是稳定的,但展示文案尚未国际化。

如果具体项目需要 i18n,扩展点仍然清楚:在 response 层按 Reason 翻译业务错误,在 validator 层翻译参数错误。等真实需求出现时再加入翻译文件和语言协商,避免让不需要多语言的服务先承担一套未使用的初始化与维护成本。

JWT 鉴权:保持最小可验证边界

当前骨架里的 JWT 是一个通用示例,只处理最基础、可以独立验证的边界:使用 HS256 签发和验签。默认配置会写入 subissiatnbfexp;自定义配置中,只有 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 上下文 helper
  • pkg/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 在此基础上再验证带 integration tag 的 lint 与完整容器 smoke test

项目小的时候这套结构可能觉得有点"重"------就几个接口至于分这么多层吗?但项目一旦开始长大,你会庆幸一开始就把边界画好了。改一个 service 不用担心影响 handler 的逻辑,加一个新模块只需要加对应的 handler/service/repository 然后在 router 里注册一下。

欢迎大佬们,批评指正。

相关推荐
ArixBit4 小时前
用 Go 手撸 Agent 框架 #9:把最小 Agent 抽成 Runtime
go·agent
江畔柳前堤6 小时前
Go04-控制结构
大数据·人工智能·面试·职场和发展·go·业界资讯
用户6757049885021 天前
别再乱用了!var arr []int 和 arr := []int{} 给整懵了?不就声明个空数组,项目居然崩了?
后端·go
江畔柳前堤2 天前
GO01-Go 语言与主流编程语言深度对比
开发语言·人工智能·后端·微服务·云原生·golang·go
名字还没想好☜2 天前
Go slice 的 append 陷阱:共享底层数组导致的数据串改
开发语言·后端·golang·go·slice
妙码生花2 天前
从 PHP 到 AI + Golang,程序员自救转型手记(三十五):控制台静态页面、深入研究时间格式化方案
后端·go·ai编程
HokKeung2 天前
Go 项目难测试,问题通常出在代码结构
单元测试·go
妙码生花3 天前
从 PHP 到 AI + Golang,程序员自救转型手记(三十四):后台初始化请求实现
前端·javascript·go
妙码生花3 天前
从 PHP 到 AI + Golang,程序员自救转型手记(三十三):权限规则管理器
后端·go·gin