mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5406 字
15 分钟
聊聊 Go 项目结构
2023-06-16
2026-07-15

一、为什么 Go 项目结构值得专门聊#

写 Go 项目,最头疼的事情不是语法,语法一天就能上手。真正让人纠结的是「代码往哪放」。Go 官方不像 Rails 或 Django 那样给你规定目录结构。搜「Go project layout」能搜出一堆方案,golang-standards/project-layout、clean architecture、hexagonal architecture,每个都有道理,但每个都不能直接拿来就用。

我也纠结过一阵。最早写 Go 就一个 main.go 打天下,后来项目大了开始拆文件夹,拆的过程中踩了不少坑:层太多了写起来烦,层太少了改起来痛。折腾了几个项目之后,慢慢沉淀出一套自己用着顺手的结构。不敢说这是最优解,但至少是在实际项目里磨合过的。

这套结构的背景是 Go + Gin + PostgreSQL + Redis 的中小型后端服务,APP 端占大头。现在很多微服务架构也采用类似的分层方案。这篇聊聊这套结构长什么样,以及每个设计决策背后的想法。

二、整体目录结构#

先把全貌亮出来,后面的章节再逐个拆解每个部分:

cmd

apiAPI 服务入口

worker异步队列消费者入口

migrate数据库迁移入口

config配置读取、运行时初始化、i18n

i18n

locales

en.json英文翻译文件

zh.json中文翻译文件

internal业务代码

server.goHTTP 服务装配

worker.goWorker 服务装配

errcode错误码定义

handlerHTTP handler

service业务逻辑

repository数据访问层

model数据模型

middlewareGin 中间件

router路由注册

task异步任务定义

worker异步队列运行时组件

migrationsSQL 迁移文件

pkg通用工具包

authJWT 鉴权

cacheRedis 封装

database数据库初始化

log日志初始化

response统一 JSON 响应

validator参数校验

四个顶层目录各有定位:

目录职责谁能引用
cmd/进程入口,每种进程一个子目录,各自的 main.go 只管启动和关闭只被构建引用
config/加载配置、初始化基础依赖(DB、Redis、JWT 等),组装成 Registry 供上层使用cmdinternal
internal/业务代码,Go 的 internal 约定保证不被外部项目引用cmd、自身
pkg/跟业务无关的通用工具包,理论上可被别的项目引用任意,但不能反向引用 internal

migrations/ 单独拎出来不放 internal 里,是因为迁移工具需要直接读取这些 SQL 文件,放 internal 反而碍事。

这不是照搬 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 队列消费者加定时调度,跟 API 共享 service、repository 层的代码,但各自独立初始化依赖:Worker 不需要启动 HTTP server,API 不需要启动 Asynq server。cmd/migrate/ 是数据库迁移,独立进程,只连数据库执行 SQL,不依赖 Redis、不依赖 JWT,启动快、依赖少。CI/CD 里先跑 migrate 再起 API,职责清晰。

每个 main.go 的职责很简单:加载配置、初始化依赖、启动服务、处理信号优雅关闭。不在 main 里写任何业务逻辑。下面是 API 入口的精简版:

// cmd/api/main.go(精简版)
func main() {
// 1. 加载配置
cfg := config.Load()
// 2. 初始化基础依赖
registry, cleanup, err := config.InitAPI(cfg)
if err != nil {
log.Fatal("init failed", zap.Error(err))
}
defer cleanup()
// 3. 创建并启动 HTTP 服务
srv := internal.NewServer(registry)
go func() {
if err := srv.Start(); err != nil {
log.Error("server stopped", zap.Error(err))
}
}()
// 4. 等待信号,优雅关闭
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
}

代码不多,但结构很清楚。收到 SIGINTSIGTERM 后,给 HTTP server 10 秒的优雅关闭窗口,等正在处理的请求结束,再释放资源退出。cleanup 函数由 InitAPI 返回,负责关闭数据库连接、Redis 连接这些基础依赖,用 defer 保证一定会执行。

四、分层设计:handler → service → repository#

这是整个项目结构的核心。调用链是一条单向路径:

graph LR H["handler<br/>HTTP 协议适配"] --> S["service<br/>业务逻辑"] S --> R["repository<br/>数据访问"] R --> DB[("DB")]

每一层有明确的职责边界,不能越级。下面逐层看。

handler 是 HTTP 协议适配层,做三件事:绑定参数、调用 service、格式化响应,不写任何业务逻辑。

internal/handler/article.go
type ArticleHandler struct {
svc *service.ArticleService
}
func (h *ArticleHandler) Create(c *gin.Context) {
var req struct {
Title string `json:"title" binding:"required,max=200"`
Content string `json:"content" binding:"required"`
Tags []string `json:"tags"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.ParamError(c, err)
return
}
article, err := h.svc.CreateArticle(c.Request.Context(), req.Title, req.Content, req.Tags)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, article)
}

service 是业务逻辑层,接收普通参数,返回业务数据或错误码。注意它接收的是 context.Context,不是 *gin.Context,这一步很关键,后面会展开说。

internal/service/article.go
type ArticleService struct {
repo *repository.ArticleRepository
}
func (s *ArticleService) CreateArticle(ctx context.Context, title, content string, tags []string) (*model.Article, error) {
// 业务规则校验
if len(tags) > 10 {
return nil, errcode.ErrTooManyTags
}
article := &model.Article{
Title: title,
Content: content,
Tags: tags,
}
if err := s.repo.Create(ctx, article); err != nil {
return nil, errcode.ErrDatabase
}
return article, nil
}

service 接收 context.Context 而不是 *gin.Context,是为了跟 HTTP 协议解耦。同样的 service 可以被 handler 调用,也可以被 worker 调用,不用改一行代码。service 返回的错误是 errcode 包里定义的业务错误码,不是直接返回「数据库错误」这种字符串。handler 拿到错误码之后,通过统一的 response.Error() 转成 JSON 响应,这块后面详细说。

repository 是数据访问层,整个项目里唯一允许写 GORM 或原生 SQL 的地方。

internal/repository/article.go
type ArticleRepository struct {
db *gorm.DB
}
func (r *ArticleRepository) Create(ctx context.Context, article *model.Article) error {
return r.db.WithContext(ctx).Create(article).Error
}
func (r *ArticleRepository) FindByID(ctx context.Context, id uint) (*model.Article, error) {
var article model.Article
err := r.db.WithContext(ctx).First(&article, id).Error
if err != nil {
return nil, err
}
return &article, nil
}

model 是 GORM 数据模型,纯数据结构,不包含任何方法:

internal/model/article.go
type Article struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Tags []string `gorm:"type:text[];default:'{}'" json:"tags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

为什么不直接 handler → repository#

小项目确实可以跳过 service 层,handler 直接调 repository,代码少、路径短、改起来快。但业务一复杂就后悔了。比如「创建订单」这个操作,可能需要检查库存、计算价格、扣减库存、创建订单、发送通知。这些逻辑如果写在 handler 里,handler 就变成了一个几百行的大函数,HTTP 协议适配和业务逻辑搅在一起,谁来改都头疼。service 层就是为了把「这个接口做什么」和「HTTP 请求怎么处理」分开。

如果业务进一步复杂,涉及跨域编排(比如订单支付要协调用户服务、库存服务、支付网关),可以在 service 上面再加一层 usecase:

graph LR H["handler"] --> U["usecase<br/>跨域编排"] --> S["service<br/>单领域"] --> R["repository"] --> DB[("DB")]

usecase 负责编排多个 service 的调用流程,service 保持聚焦在单个领域。但这是在业务确实需要的时候才加,不要一上来就四五层架空。

五、手写依赖注入:Registry 模式#

依赖注入(DI)的核心目的只有一个:让每一层只声明自己需要什么,由上层把依赖传进来,自己不 new 别人。Go 社区有几个流行的 DI 方案:Google 的 Wire(编译期代码生成)、Uber 的 Dig 和 Fx(运行时反射注入)。

我试过 Wire,用了一阵之后放弃了,原因有几个:

  • 多一个代码生成步骤(wire gen),每次改依赖都要重新跑,CI 也得加这步
  • 生成的代码是个黑盒,出了问题排查不直观
  • 对于中小型项目,手写 DI 的代码量其实没有想象中大

Dig 和 Fx 用运行时反射注入,出错信息更不友好。而且 Go 社区整体偏好「编译期能发现的问题就别留到运行时」,反射注入跟这个哲学有点冲突。所以最终选了手写。

核心是一个 Registry 模式,把所有基础依赖集中放在一个结构体里:

config/registry.go
type Registry struct {
DB *gorm.DB
Cache *redis.Client
JWT *auth.JWTManager
QueueClient *asynq.Client
I18n *i18n.Bundle
// 其他基础依赖
}

config 包负责初始化这些基础依赖,组装成 Registry,同时返回一个 cleanup 函数用于资源释放:

config/init.go
func InitAPI(cfg *Config) (*Registry, func(), error) {
db, err := database.Open(cfg.DatabaseURL)
if err != nil {
return nil, nil, err
}
cache := cache.NewClient(cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
jwt := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTExpiresAt)
registry := &Registry{
DB: db,
Cache: cache,
JWT: jwt,
}
cleanup := func() {
cache.Close()
sqlDB, _ := db.DB()
sqlDB.Close()
}
return registry, cleanup, nil
}

然后 internal/server.go 从 Registry 装配完整的调用链:

internal/server.go
func NewServer(reg *config.Registry) *Server {
// 装配调用链:repo → service → handler
articleRepo := repository.NewArticleRepository(reg.DB)
articleSvc := service.NewArticleService(articleRepo)
articleHandler := handler.NewArticleHandler(articleSvc)
// 装配路由
engine := gin.New()
router.RegisterRoutes(engine, router.Dependencies{
Article: articleHandler,
Auth: middleware.JWTAuth(reg.JWT, reg.Cache),
})
return &Server{
engine: engine,
addr: reg.Config.ServerPort,
}
}

所有的依赖装配集中在 server.go 这一个地方。handler、service、repository 自己不 new 任何其他业务对象,它们只声明自己需要什么,由上层传进来。这样做的好处是:打开 server.go,整个应用的依赖关系一目了然,谁依赖谁、什么时候创建什么时候销毁,全在这一个文件里。IDE 可以直接跳转到每个构造函数,不需要理解任何 DI 框架的「魔法」。

代码多吗?确实比 Wire 生成的要多一些。但这点「啰嗦」换来的是完全透明的依赖关系,我觉得值。

六、路由设计#

router 包只干一件事:注册 URL 和 handler 的映射关系。不构造依赖,不初始化服务。

internal/router/router.go
type Dependencies struct {
Article *handler.ArticleHandler
User *handler.UserHandler
Auth gin.HandlerFunc
}
func RegisterRoutes(r *gin.Engine, deps Dependencies) {
// 健康检查,不需要认证
r.GET("/health", handler.Health)
// API 路由组
v1 := r.Group("/api/v1")
registerArticleRoutes(v1, deps)
registerUserRoutes(v1, deps)
}
func registerArticleRoutes(r *gin.RouterGroup, deps Dependencies) {
articles := r.Group("/articles")
articles.Use(deps.Auth)
articles.GET("", deps.Article.List)
articles.POST("", deps.Article.Create)
articles.GET("/:id", deps.Article.Get)
}
func registerUserRoutes(r *gin.RouterGroup, deps Dependencies) {
users := r.Group("/users")
// 公开接口
users.POST("/login", deps.User.Login)
users.POST("/register", deps.User.Register)
// 需要认证的接口
authed := users.Group("")
authed.Use(deps.Auth)
authed.GET("/me", deps.User.Me)
}

这里有几个设计决策值得说。

第一,按模块拆私有函数。每个业务模块一个 registerXxxRoutes,模块内自己决定哪些接口公开、哪些需要认证。模块多了也不会让 router.go 变成一个几百行的大文件。

第二,Dependencies 由外部传入。router 不关心 handler 是怎么创建的,它只接收现成的、可以直接用的 handler。这就保证了路由注册和依赖装配的解耦:改装配逻辑不用动 router,改路由不用动装配。

第三,认证中间件作为依赖传入。Auth 中间件也是从外面传进来的,router 不知道具体的认证实现。以后换了认证方式(比如从 JWT 换成 OAuth),只改 server.go 里的装配代码就行。

七、统一响应协议和错误码#

这块是我纠结比较久才定下来的。

固定 HTTP 200#

先说一个可能有争议的决策:业务 API 固定返回 HTTP 200,用 JSON body 里的 code 字段区分成功和失败。成功和失败的响应结构长这样:

{
"code": 0,
"message": "success",
"data": { "id": 1, "title": "..." }
}
{
"code": 4001,
"reason": "INVALID_PARAMS",
"message": "标题不能为空",
"metadata": {
"trace_id": "550e8400-..."
}
}

为什么不用 HTTP 状态码区分?主要是项目场景决定的,我做的项目 APP 端占大头。移动端网络环境复杂,中间经过的 CDN、网关、代理层可能会对非 200 的响应做各种「好心」的处理:重试、缓存、改写。固定 200 能减少很多奇怪的中间层问题,前端拿到响应后统一看 code 字段就行,处理逻辑一致。

探针接口 /health 是例外,它返回真实的 HTTP 状态码(200 或 503),因为负载均衡器和 k8s 靠状态码判断服务是否健康。

错误码设计#

业务错误用 errcode 包集中定义:

internal/errcode/errcode.go
type AppError struct {
Code int // 数字错误码
Reason string // 机器可读的错误标识
Message string // 人类可读的错误信息
}
func (e *AppError) Error() string {
return e.Message
}
// 通用错误
var (
ErrInvalidParams = &AppError{Code: 4001, Reason: "INVALID_PARAMS", Message: "请求参数不合法"}
ErrUnauthorized = &AppError{Code: 4010, Reason: "UNAUTHORIZED", Message: "未登录或登录已过期"}
ErrForbidden = &AppError{Code: 4030, Reason: "FORBIDDEN", Message: "没有权限"}
ErrNotFound = &AppError{Code: 4040, Reason: "NOT_FOUND", Message: "资源不存在"}
ErrDatabase = &AppError{Code: 9002, Reason: "DATABASE_ERROR", Message: "系统繁忙,请稍后重试"}
)
// 业务错误
var (
ErrTooManyTags = &AppError{Code: 5001, Reason: "TOO_MANY_TAGS", Message: "标签数量超过限制"}
ErrDuplicateTitle = &AppError{Code: 5002, Reason: "DUPLICATE_TITLE", Message: "标题已存在"}
)

三个字段各有用处。Code 是数字,方便前端做 switch-case。Reason 是大写蛇形字符串,方便日志检索和报警规则匹配。Message 是给用户看的,如果接了多语言,这个字段会根据请求头里的语言偏好返回对应的翻译。

service 层只返回 errcode 里的错误,不直接构造 JSON 响应。handler 拿到错误后,用统一的 response.Error() 转成 JSON:

pkg/response/response.go
func Success(c *gin.Context, data interface{}) {
c.JSON(200, gin.H{
"code": 0,
"message": "success",
"data": data,
})
}
func Error(c *gin.Context, err error) {
var appErr *errcode.AppError
if errors.As(err, &appErr) {
c.JSON(200, gin.H{
"code": appErr.Code,
"reason": appErr.Reason,
"message": appErr.Message,
"metadata": gin.H{
"trace_id": c.GetString("trace_id"),
},
})
return
}
// 兜底:未知错误
c.JSON(200, gin.H{
"code": 9999,
"reason": "INTERNAL_ERROR",
"message": "系统繁忙,请稍后重试",
})
}

errors.As 把层层包裹的错误里的 AppError 扒出来,未知错误兜底返回一个固定的内部错误码,避免把底层异常细节泄漏给前端。

请求头#

每个响应都会带两个自定义头。X-Request-ID 是请求的唯一标识,在中间件里生成(UUID),贯穿整个请求链路,写进日志、写进错误响应的 metadata.trace_id。排查问题的时候客户端把 ID 给你,就能在日志里定位到完整的请求信息。X-Process-Time 是服务端处理耗时(毫秒),方便客户端做性能监控。

八、多语言(i18n)#

我做的项目基本都有多语言需求,所以从项目结构设计的时候就得把 i18n 考虑进去。很多框架虽然支持 i18n,但不会告诉你翻译文件该放哪、错误信息怎么跟多语言串起来。这块得自己规划。

翻译文件放在哪#

翻译文件放在 config/i18n/locales/ 下,按语言分文件,用的是 go-i18n 这个库。每个 JSON 文件里是 message ID 到翻译文本的映射:

{
"INVALID_PARAMS": "请求参数不合法",
"UNAUTHORIZED": "未登录或登录已过期",
"TOO_MANY_TAGS": "标签数量超过限制",
"DATABASE_ERROR": "系统繁忙,请稍后重试"
}
{
"INVALID_PARAMS": "Invalid request parameters",
"UNAUTHORIZED": "Not logged in or session expired",
"TOO_MANY_TAGS": "Too many tags",
"DATABASE_ERROR": "System busy, please try again later"
}

错误信息怎么翻译#

还记得前面错误码里的 Reason 字段吗?它就是 i18n 的 message ID。response.Error() 在构造响应时,根据请求头里的 Accept-Language 查对应的翻译文本,替换掉 message 字段:

func Error(c *gin.Context, err error) {
var appErr *errcode.AppError
if errors.As(err, &appErr) {
// 根据请求语言偏好翻译 message
lang := c.GetHeader("Accept-Language")
message := i18n.Translate(lang, appErr.Reason, appErr.Message)
c.JSON(200, gin.H{
"code": appErr.Code,
"reason": appErr.Reason,
"message": message,
"metadata": gin.H{
"trace_id": c.GetString("trace_id"),
},
})
return
}
// ...
}

Reason 是固定的英文标识(TOO_MANY_TAGS),不会变,前端可以拿它做逻辑判断。Message 是翻译后的文本,给用户看。同一个错误码,中文用户看到「标签数量超过限制」,英文用户看到「Too many tags」。

参数校验错误也要翻译#

Gin 的 ShouldBind 报出来的校验错误默认是英文的,类似 Field validation for 'Title' failed on the 'required' tag,直接返给前端不太合适。pkg/validator 里做了一层翻译封装,把 binding 错误转成可读的多语言提示:required 规则在中文下翻译成「不能为空」,max=200 翻译成「长度不能超过 200」。这样参数校验错误和业务错误的多语言体验是一致的。

为什么从一开始就考虑 i18n#

i18n 如果不从项目结构层面规划好,后期补是很痛苦的。你会发现错误信息散落在各种地方:有的写在 handler 里,有的写在 service 里,有的直接硬编码在 c.JSON() 调用里。要把它们全部收拢到翻译文件里,改动面很大。从一开始就约定好「所有面向用户的文案都走 i18n」,代码里只写 Reason 标识,翻译文本统一放在 locales 文件里,后面加语言只需要加一个 JSON 文件就行,不用改业务代码。

九、JWT 鉴权:三层校验#

JWT 鉴权不只是「验个签名」就完事了。我设计了三层校验链,每层解决不同的问题:

graph TD REQ["请求进来"] --> L1 L1["Layer 1:HS256 签名校验 + 过期检查 + 签发者校验"] --> L2 L2["Layer 2:Redis JTI 黑名单检查"] --> L3 L3["Layer 3:token_version 比对(可选)"] --> OK["通过,设置 currentUser"] L1 -->|不通过| E1["401"] L2 -->|命中黑名单| E1 L3 -->|版本不匹配| E1

Layer 1 是标配,所有 JWT 库都做。签名验证确保 token 没被篡改,exp 确保 token 没过期,iss 校验确保 token 是我们自己签发的。

Layer 2 解决「主动登出」的问题。JWT 是无状态的,签发出去之后你没法「撤回」它,只要没过期它就有效。所以用户主动登出的时候,把这个 token 的 JTI(JWT ID,每个 token 的唯一标识)加到 Redis 黑名单里。中间件每次请求都查一下 Redis,命中黑名单就拒绝。黑名单的 TTL 设成跟 token 过期时间一样,过期了自然失效,不会无限膨胀。

Layer 3 解决「批量失效」的问题。用户改了密码、管理员踢人、账号被封,这些场景需要让某个用户的所有 token 都失效。一个个加黑名单不现实,你不知道用户一共有多少有效 token。所以 token 里带一个 token_version 字段,数据库或缓存里存着这个用户当前的版本号。改密码时把版本号加 1,所有旧 token 的版本号就对不上了,一次性全部失效:

// Token Claims 结构
type Claims struct {
UserID uint64 `json:"user_id"`
TokenVersion uint64 `json:"token_version"`
jwt.RegisteredClaims
}

Layer 3 是可选的,骨架里默认不启用。如果你的项目需要,实现一个 TokenVersionStore 接口注入进去就行:

type TokenVersionStore interface {
GetTokenVersion(ctx context.Context, userID uint64) (uint64, error)
}

不需要这个功能的项目就不注入,中间件自动跳过 Layer 3 的检查。这样既不增加简单项目的复杂度,又给需要的项目留了扩展口。

十、异步队列#

不是所有操作都适合在 HTTP 请求里同步完成。发邮件、推送通知、生成报表这些耗时操作,应该扔到异步队列里处理。我用的是 Asynq,一个基于 Redis 的 Go 异步任务库。

选它的原因很简单:项目本来就用 Redis,不想再引入 RabbitMQ 或 Kafka 这种重依赖;纯 Go 实现,API 简洁,文档清晰;自带定时任务调度(Scheduler),不用再接 cron;有 Web UI(Asynqmon)可以看任务状态。

任务定义放在 internal/task/ 包里,只负责描述任务的类型和 payload,不包含处理逻辑:

internal/task/email.go
const TypeSendEmail = "email:send"
type SendEmailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
func NewSendEmailTask(payload SendEmailPayload) (*asynq.Task, error) {
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return asynq.NewTask(TypeSendEmail, data), nil
}

API 端在 service 层投递任务:

// 在 service 层
func (s *UserService) Register(ctx context.Context, ...) error {
// ... 创建用户 ...
// 异步发送欢迎邮件
t, _ := task.NewSendEmailTask(task.SendEmailPayload{
To: user.Email,
Subject: "欢迎注册",
Body: "...",
})
s.queueClient.Enqueue(t)
return nil
}

Worker 端消费任务:

// internal/worker/ 下面的 handler
func HandleSendEmail(ctx context.Context, t *asynq.Task) error {
var payload task.SendEmailPayload
json.Unmarshal(t.Payload(), &payload)
return sendEmail(payload.To, payload.Subject, payload.Body)
}

Worker 和 API 共享 service、repository 层的代码。两者的区别只是入口不同:API 通过 HTTP handler 进入业务逻辑,Worker 通过 Asynq handler 进入。这就是为什么前面强调 service 层不依赖 *gin.Context 很重要,如果 service 层耦合了 HTTP 概念,Worker 就没法复用了。

十一、其他约定#

pkg 的定位#

pkg/ 放的是跟业务无关的通用工具包。这些包理论上可以抽出去给其他项目用,所以它们不应该 import internal/ 里的任何东西,否则就反向依赖了。

职责
pkg/authJWT 签发和校验
pkg/cacheRedis client 封装
pkg/databaseGORM 初始化和健康检查
pkg/logzap logger 初始化,trace_id 上下文 helper
pkg/response统一 JSON 响应函数
pkg/validator请求参数校验,binding 错误多语言翻译

环境变量#

每个进程的 .env 是独立的。API 需要 JWT_SECRET,Worker 不需要,Migrate 只需要数据库连接串:

cmd/api/.envAPI 专属配置

cmd/worker/.envWorker 专属配置

cmd/migrate/.env迁移专属配置

.env共享 fallback

加载优先级是:真实环境变量 > 进程专属 .env > 根目录 .env。生产环境通过容器环境变量注入,本地开发用 .env 文件。所有 .env 文件都在 .gitignore 里,仓库里只保留 .env.example 做参考。

审计日志#

API 请求默认会输出一条结构化的审计日志,用 zap 写,包含请求方法、路径、状态码、耗时、客户端 IP、trace_id 这些核心信息。有两个设计细节值得说。

自动脱敏:AuthorizationCookie 这类头部,以及请求/响应 body 里的 passwordtoken 等字段,日志里会自动替换成 ***,不用担心敏感信息被打到日志系统里。大小控制:请求和响应 body 最大只记录 64KB,超出部分截断并标记;文件下载、SSE 流式响应这类非文本响应只记录摘要,不会把二进制内容塞进日志。

context 传递#

有一条硬性约定:请求级的 context.Context 沿 handler → service → repository 一路传下去,业务层禁止用 context.Background() 替换。

为什么?因为 context 里带着 trace_id、超时控制、取消信号这些东西。如果 service 层自己 context.Background() 一下,上游的超时取消就传不下去了,HTTP 请求已经超时断开,数据库查询还在傻跑。

十二、不是银弹#

这套结构不是银弹。它是在特定场景下,Go + Gin + PostgreSQL + Redis 的中小型后端服务,经过几个项目打磨出来的方案。如果要提炼几个核心原则的话:

  • 显式优于隐式:手写 DI、显式错误处理、每一层的职责都摆在明面上
  • 职责边界清晰:每一层只干自己该干的事,不越级、不混搭
  • 依赖关系单向:handler → service → repository,不反向依赖
  • 为变化留余地:JWT 的三层校验可以按需开关,分层可以根据复杂度增减,Worker 和 API 可以独立部署

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

当然,如果你的项目就是一个简单的内部工具或者原型验证,该 main.go 一把梭就一把梭,别被「最佳实践」束缚住了。结构是为项目服务的,不是反过来。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

聊聊 Go 项目结构
https://blog.souloss.cn/posts/golang/go-project-layout/
作者
Souloss
发布于
2023-06-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时