一、为什么 Go 项目结构值得专门聊
写 Go 项目,最头疼的事情不是语法,语法一天就能上手。真正让人纠结的是「代码往哪放」。Go 官方不像 Rails 或 Django 那样给你规定目录结构。搜「Go project layout」能搜出一堆方案,golang-standards/project-layout、clean architecture、hexagonal architecture,每个都有道理,但每个都不能直接拿来就用。
我也纠结过一阵。最早写 Go 就一个 main.go 打天下,后来项目大了开始拆文件夹,拆的过程中踩了不少坑:层太多了写起来烦,层太少了改起来痛。折腾了几个项目之后,慢慢沉淀出一套自己用着顺手的结构。不敢说这是最优解,但至少是在实际项目里磨合过的。
这套结构的背景是 Go + Gin + PostgreSQL + Redis 的中小型后端服务,APP 端占大头。现在很多微服务架构也采用类似的分层方案。这篇聊聊这套结构长什么样,以及每个设计决策背后的想法。
二、整体目录结构
先把全貌亮出来,后面的章节再逐个拆解每个部分:
i18n
locales
四个顶层目录各有定位:
| 目录 | 职责 | 谁能引用 |
|---|---|---|
cmd/ | 进程入口,每种进程一个子目录,各自的 main.go 只管启动和关闭 | 只被构建引用 |
config/ | 加载配置、初始化基础依赖(DB、Redis、JWT 等),组装成 Registry 供上层使用 | cmd、internal |
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)}代码不多,但结构很清楚。收到 SIGINT 或 SIGTERM 后,给 HTTP server 10 秒的优雅关闭窗口,等正在处理的请求结束,再释放资源退出。cleanup 函数由 InitAPI 返回,负责关闭数据库连接、Redis 连接这些基础依赖,用 defer 保证一定会执行。
四、分层设计:handler → service → repository
这是整个项目结构的核心。调用链是一条单向路径:
每一层有明确的职责边界,不能越级。下面逐层看。
handler 是 HTTP 协议适配层,做三件事:绑定参数、调用 service、格式化响应,不写任何业务逻辑。
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,这一步很关键,后面会展开说。
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 的地方。
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 数据模型,纯数据结构,不包含任何方法:
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:
usecase 负责编排多个 service 的调用流程,service 保持聚焦在单个领域。但这是在业务确实需要的时候才加,不要一上来就四五层架空。
五、手写依赖注入:Registry 模式
依赖注入(DI)的核心目的只有一个:让每一层只声明自己需要什么,由上层把依赖传进来,自己不 new 别人。Go 社区有几个流行的 DI 方案:Google 的 Wire(编译期代码生成)、Uber 的 Dig 和 Fx(运行时反射注入)。
我试过 Wire,用了一阵之后放弃了,原因有几个:
- 多一个代码生成步骤(
wire gen),每次改依赖都要重新跑,CI 也得加这步 - 生成的代码是个黑盒,出了问题排查不直观
- 对于中小型项目,手写 DI 的代码量其实没有想象中大
Dig 和 Fx 用运行时反射注入,出错信息更不友好。而且 Go 社区整体偏好「编译期能发现的问题就别留到运行时」,反射注入跟这个哲学有点冲突。所以最终选了手写。
核心是一个 Registry 模式,把所有基础依赖集中放在一个结构体里:
type Registry struct { DB *gorm.DB Cache *redis.Client JWT *auth.JWTManager QueueClient *asynq.Client I18n *i18n.Bundle // 其他基础依赖}config 包负责初始化这些基础依赖,组装成 Registry,同时返回一个 cleanup 函数用于资源释放:
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 装配完整的调用链:
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 的映射关系。不构造依赖,不初始化服务。
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": "..." }}为什么不用 HTTP 状态码区分?主要是项目场景决定的,我做的项目 APP 端占大头。移动端网络环境复杂,中间经过的 CDN、网关、代理层可能会对非 200 的响应做各种「好心」的处理:重试、缓存、改写。固定 200 能减少很多奇怪的中间层问题,前端拿到响应后统一看 code 字段就行,处理逻辑一致。
探针接口 /health 是例外,它返回真实的 HTTP 状态码(200 或 503),因为负载均衡器和 k8s 靠状态码判断服务是否健康。
错误码设计
业务错误用 errcode 包集中定义:
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:
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": "系统繁忙,请稍后重试"}错误信息怎么翻译
还记得前面错误码里的 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 鉴权不只是「验个签名」就完事了。我设计了三层校验链,每层解决不同的问题:
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,不包含处理逻辑:
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/ 下面的 handlerfunc 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/auth | JWT 签发和校验 |
pkg/cache | Redis client 封装 |
pkg/database | GORM 初始化和健康检查 |
pkg/log | zap logger 初始化,trace_id 上下文 helper |
pkg/response | 统一 JSON 响应函数 |
pkg/validator | 请求参数校验,binding 错误多语言翻译 |
环境变量
每个进程的 .env 是独立的。API 需要 JWT_SECRET,Worker 不需要,Migrate 只需要数据库连接串:
加载优先级是:真实环境变量 > 进程专属 .env > 根目录 .env。生产环境通过容器环境变量注入,本地开发用 .env 文件。所有 .env 文件都在 .gitignore 里,仓库里只保留 .env.example 做参考。
审计日志
API 请求默认会输出一条结构化的审计日志,用 zap 写,包含请求方法、路径、状态码、耗时、客户端 IP、trace_id 这些核心信息。有两个设计细节值得说。
自动脱敏:Authorization、Cookie 这类头部,以及请求/响应 body 里的 password、token 等字段,日志里会自动替换成 ***,不用担心敏感信息被打到日志系统里。大小控制:请求和响应 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 一把梭就一把梭,别被「最佳实践」束缚住了。结构是为项目服务的,不是反过来。
参考资料
- Asynq - 基于 Redis 的 Go 异步任务库
- Wire - Google 的编译期依赖注入工具
- Dig - Uber 的运行时依赖注入容器
- Fx - 基于 Dig 的应用框架
- go-i18n - Go 国际化库
- golang-standards/project-layout - Go 项目目录结构参考
- arixbit/go-example - 本文结构的参考实现仓库
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时








