一、写在前面:这是一次真实的工程探索经历
这篇文章并不是一篇"架构设计说明文档",而更像是一次个人工程探索的记录。
我在使用 Node.js 写后端服务的过程中,一直很喜欢 NestJS 那种工程化的代码组织方式:
- 模块化结构清晰
- 项目层次分明
- 依赖关系明确
- 工程规范天然统一
当项目规模变大时,这种结构非常"稳",不容易乱。
后来在转向 Go 做后端项目时,我发现 Go 生态并没有类似 NestJS 这种"开箱即用"的工程型框架。更多是:
- Gin / Echo / Fiber 解决 Web
- 各种库组合日志、配置、数据库、鉴权
- 架构自由,但缺少统一范式
一开始我也尝试过很多组合方式,说实话:项目规模小的时候没问题,一旦项目开始复杂,结构很容易失控:
- 目录开始变乱
- 模块边界开始模糊
- 依赖开始互相引用
- 测试越来越难写
- 新人越来越难上手
于是我开始有意识地去思考一个问题:
Go 里能不能也做一套"工程级结构模板"?
不追求框架能力,而是解决工程组织问题。
在这个过程中,我也参考过一些优秀项目,比如:
它在分层思想和依赖方向控制上给了我很多启发,但整体更偏理论型架构设计,并不是我想要的那种"工程模板型结构"。
后来逐步尝试将 Gin + Uber Fx 组合在一起,一边写项目,一边调整结构,慢慢形成了一套相对稳定的工程组织方式,也就是现在的:
gin-app
github.com/1111mp/gin-...
它不是一个框架项目,更像是:
我在真实项目中一步步试出来的一套 Go 工程模板结构
二、我想解决的,其实不是"技术问题"
说实话,这个项目一开始并不是为了追求技术复杂度,而是为了解决几个很现实的问题:
- 项目一大,结构就乱
- 人一多,风格就乱
- 模块多了之后,依赖就开始乱
- 没有统一结构规范,重构成本极高
所以 gin-app 的核心目标其实很简单:
让 Go 项目在规模增长时,依然能保持结构清晰
而不是:
写代码更炫、技术更复杂
三、为什么选择 Gin + Fx
Gin
这个没什么花哨理由:成熟、稳定、生态好,用起来顺手。
Fx
Fx 一开始我也只是当作 DI 工具在用,但真正用到工程里之后发现:
它更像是一个项目启动调度器:
- 统一管理组件初始化
- 统一管理启动流程
- 统一管理生命周期
- 自动处理依赖顺序
慢慢发现,它更适合做"工程结构工具",而不只是"依赖注入工具"。
四、现在的工程结构
目前项目结构是这样组织的:
csharp
cmd/
app/ # 应用启动入口
internal/
config/ # 配置管理
modules/ # 业务模块(user、post、auth 等)
router/ # 路由层
middleware/ # HTTP 中间件(鉴权、日志、跨域等)
dto/ # 数据传输对象(请求/响应模型)
pkg/
logger/ # 日志模块
postgres/ # PostgreSQL 客户端
redis/ # Redis 客户端
jwt/ # JWT 工具库
oauth2/ # OAuth2(GitHub / Google 登录)
ent/
schema/ # Ent ORM 模型定义
migrate/ # 数据库迁移文件
docs/ # Swagger 文档
我现在的核心习惯是:
- 按业务域拆模块,而不是按技术拆层
- router 只做路由绑定
- modules 只做业务逻辑
- middleware 独立
- dto 单独管理输入输出模型
- pkg 放通用基础设施能力
模块结构保持类似 NestJS 的风格:
go
modules/user
user.controller.go
user.service.go
user.repository.go
user.module.go
这种结构的好处很直接:
找功能 = 找模块,不是翻目录
user.controller.go这样的文件命名方式应该是不符合 Go 的开发规范的,但是目前好像也没遇到什么问题,所以...😊
五、Fx 在工程里的真实作用
现在在这个项目里,Fx 更像是:
一个"工程启动调度器"
它帮我解决的是:
- 谁先初始化
- 谁依赖谁
- 谁什么时候启动
- 谁什么时候关闭
而不是"我怎么 new 一个对象"。
在 gin-app 里,Fx 的使用方式也比较偏工程化,而不是零散注入。
1. 启动入口统一调度
整个系统的启动入口集中在 internal/app/app.go 的 Run() 方法中,这里不是简单地 main() + 一堆初始化函数调用,而是通过 fx.New() 作为系统总调度中心:
go
fx.New(
fx.Supply(...),
fx.Provide(
NewLogger,
NewPostgresDB,
NewRedis,
NewOauth2Client,
...
),
modules.APIModule,
fx.Invoke(startHTTPServer),
).Run()
这里的结构非常清晰:
(1)fx.Supply ------ 外部配置注入
go
fx.Supply(
fx.Annotate(cfg, fx.As(new(config.ConfigInterface))),
)
配置对象作为"外部输入"进入系统容器,而不是在内部到处读取配置文件。
(2)fx.Provide ------ 基础设施统一注册
包括:
- Logger
- Postgres
- Redis
- JWT
- OAuth2(Github / Google)
- Gin Router
- API Router
- OpenAPI Router
- HTTP Server
全部集中注册在一个地方,形成:
基础设施层统一初始化中心
例如数据库注册:
go
fx.Annotate(
func(cfg config.ConfigInterface, logger logger.Interface) (*postgres.Postgres, error) {
pg, err := postgres.New(...)
...
return pg, nil
},
fx.OnStop(func(logger logger.Interface, pg *postgres.Postgres) error {
pg.Close()
return nil
}),
)
同时把生命周期管理也绑定在注册阶段完成。
(3)modules.APIModule ------ 业务系统整体挂载
modules.APIModule,
业务模块不是散落注册,而是作为一个完整模块树挂载进系统容器。
(4)fx.Invoke(startHTTPServer) ------ 启动行为声明
go
fx.Invoke(startHTTPServer)
系统"启动做什么事"通过声明式绑定:
- 不在
main里写启动逻辑 - 不在构造函数里启动服务
- 不靠 init() 隐式行为
而是通过 Invoke 显式声明系统启动动作。
配合 Lifecycle:
php
lc.Append(fx.Hook{
OnStart: ...
OnStop: ...
})
形成完整的:
构建期(Provide) → 装配期(Module) → 启动期(Invoke) → 生命周期管理(Lifecycle)
这使整个系统从"过程式启动"变成了声明式启动模型。
不是"我在 main 里手动控制顺序",而是:
系统结构决定启动顺序
依赖关系决定初始化顺序
Fx 在这里更像是一个:工程启动调度框架,而不仅是依赖注入工具。
2. 模块化注册方式(业务模块即路由单元)
在这个项目里,模块不仅是业务单元,同时也是路由注册单元。
每个业务模块都有自己的 module.go,不仅负责依赖注册,也负责路由挂载,例如 user.module.go:
go
var Module = fx.Module(
"user",
fx.Provide(
// user repository
fx.Annotate(
NewUserRepository,
fx.As(new(UserRepository)),
),
// user service
fx.Annotate(
NewUserService,
fx.As(new(UserService)),
),
// user controller
NewUserController,
),
// register router
fx.Invoke(func(
router api_router.APIRouterParams,
userController *UserController,
) {
// public routers
{
userGroup := router.Public.Group("/users")
userGroup.POST("", userController.CreateOne)
}
// private routers
{
userGroup := router.Private.Group("/users")
userGroup.GET(":id", userController.GetById)
}
}),
)
这里有一个非常重要的设计点:
路由注册跟着业务模块走,而不是集中在 router 目录统一管理。
也就是说:
- 一个模块 = 一组业务能力
- 一个模块 = 一组路由定义
- 一个模块 = 一组 Controller + Service + Repository
模块本身就是一个完整业务闭环单元。
这样带来的效果非常直观:
- 新增功能 = 新增模块
- 删除功能 = 删除模块
- 查功能 = 查模块
而不是在 router.go 里翻几十个路由定义文件。
从工程体验上来说,这种方式非常接近 NestJS 的 Module + Controller 组织模式,只不过是用 Fx 的 Module 机制实现。
3. 路由系统的初始化解耦设计
internal/router 目录只负责一件事:Gin 本身的初始化与中间件装配,不关心任何业务路由。
Gin 初始化(系统层)
go
func NewRouter(
cfg config.ConfigInterface,
logger logger.Interface,
) *gin.Engine {
app := gin.Default()
// middlewares
app.Use(requestid.New())
app.Use(cors.New(...))
app.Use(ginzap.GinzapWithConfig(...))
app.Use(ginzap.RecoveryWithZap(...))
app.Use(timeout.Timeout(...))
app.Use(middleware.ErrorHandler(logger))
// Swagger
if cfg.Swagger().Enabled {
app.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
}
return app
}
这一层只做系统级能力:
- 中间件装配
- 日志系统
- 跨域
- 超时控制
- Swagger
完全不出现任何业务路由。
API Router 抽象层
通过 Fx 构造 API Router:
go
type APIRouter struct {
fx.Out
Public *gin.RouterGroup `name:"api:public"`
Private *gin.RouterGroup `name:"api:private"`
}
func NewAPIRouter(
cfg config.ConfigInterface,
jwt jwt.JWTManagerInterface,
app *gin.Engine,
) APIRouter {
public := app.Group("/api/v1")
private := public.Group("/")
private.Use(middleware.APIAuthHandler(jwt, cfg.HTTP().CookieName))
return APIRouter{
Public: public,
Private: private,
}
}
通过 fx.Out + name tag:
- 系统级 Router 负责分组
- 模块级 Router 只消费分组
业务模块只关心:
go
router.Public
router.Private
而不关心:
- Gin Engine 从哪里来
- 中间件怎么装
- JWT 鉴权怎么挂
形成清晰分层:
系统层:Router 初始化 + 中间件 + 分组策略
模块层:消费 RouterGroup,注册业务路由
这让整个系统的路由组织方式变成:
系统提供"路由能力"
模块声明"路由行为"
而不是传统的:
一个 router 目录管理全世界路由
每个业务模块都有自己的 module.go,类似:
go
var Module = fx.Module(
"user",
fx.Provide(
NewUserService,
NewUserRepository,
NewUserController,
),
)
模块只关心:
- 自己提供什么能力
- 自己依赖什么能力
不关心:
- 谁来调用我
- 谁先启动系统
3. 路由注册解耦
路由层通过 fx.Invoke 统一注册:
go
fx.Invoke(RegisterRouter)
Router 层只依赖 Controller,不反向依赖业务模块实现,形成清晰依赖方向。
4. 生命周期统一管理
通过 Fx 的 Lifecycle 机制统一管理服务生命周期:
go
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return server.Start()
},
OnStop: func(ctx context.Context) error {
return server.Stop()
},
})
这让:
- 服务启动顺序可控
- 资源初始化有序
- 支持优雅关闭
- 系统状态统一管理
在实际使用中,Fx 带来的最大价值不是"省几行代码",而是:
把系统结构从"手动组织"变成"声明式组织"
系统从"人脑调度"变成"系统调度"。
六、模块化的真实体验
模块化之后,一个很直观的感受是:
- 模块边界清晰
- 改代码不容易误伤其他模块
- 重构更安全
- 依赖关系更清楚
模块不再是"文件分类",而是业务单元。
七、这套结构带来的真实变化
用这套结构写项目之后,最大的变化不是"写得更快",而是:
- 项目结构更稳
- 维护压力更小
- 重构成本更低
- 新人更容易理解项目
- 心理负担更小(笑)
八、写在最后
gin-app 对我来说,不是一个"作品",而更像是:
一套在真实项目中不断试错、调整、演化出来的工程结构
它不是标准答案,也不是最佳实践,只是一个:
当前阶段让我用起来最舒服的一种 Go 工程组织方式
如果你也在做 Go 项目工程化,可能你也会遇到类似的问题。
这篇文章只是一次个人实践分享,希望能提供一些思路参考,而不是范式标准。
项目地址
GitHub:
github.com/1111mp/gin-...
如果你也在做 Go 工程化探索,欢迎交流实践经验 🙌