使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)

584 阅读9分钟

项目地址:github.com/1111mp/gin-…


一、为什么要做这件事?

用 Node.js 写后端时,我非常喜欢 NestJS。它不仅仅是一个框架,更像是一套强制性的工程规范:模块(Module)、控制器(Controller)、服务(Service)层次分明,依赖注入(DI)让代码解耦得非常漂亮。

转到 Go 之后,我发现生态里缺少这种“开箱即用”的结构化框架。Gin、Echo 很灵活,但一旦项目变大:

  • main.go 塞满了初始化代码。
  • ❌ 包之间互相引用,甚至出现循环依赖。
  • ❌ 路由定义分散在各个角落,找一个 API 像大海捞针。

我并不是要强行把 Go 写成 Java 或 Node,但我希望在 Go 项目中也能拥有清晰的模块边界自动化的依赖管理

于是,我基于 Gin(Web 框架)+ Uber Fx(依赖注入)+ Ent(ORM),摸索出了一套“NestJS 风格”的 Go 工程模板 —— gin-app


二、工程全貌:目录结构一览

在深入代码之前,先看看这套“NestJS 风格”的 Go 项目目录结构长什么样。

cmd/
  app/                 # 应用启动入口
internal/
  app/                 # 服务初始化入口
  config/              # 配置管理
  modules/             # 业务模块(user、post、auth 等)
  router/              # 基础路由定义层
  middleware/          # HTTP 中间件(鉴权、日志、跨域等)
  dto/                 # 数据传输对象(请求/响应模型)
pkg/
  logger/              # 日志模块
  httpserver/          # http server 模块
  postgres/            # PostgreSQL 客户端
  redis/               # Redis 客户端
  jwt/                 # JWT 工具库
  oauth2/              # OAuth2(GitHub / Google 登录)
ent/
  schema/              # Ent ORM 模型定义
  migrate/             # 数据库迁移文件
docs/                  # Swagger 文档

💡 核心设计思路补充

这种结构并非简单的文件堆砌,它背后遵循了几个核心工程原则:

1. 按“业务域”拆分,而不是按“技术层”

在传统的 Go 开发中,我们习惯把所有 Controller 放一起,所有 Service 放一起。

  • 痛点:当项目有 50 个功能时,controllers/ 目录下会有 50 个文件,改一个功能要在三四个顶级目录间反复横跳。
  • 方案internal/modules 下的每个子目录(如 user)都是一个自治单元。它包含了该业务所需的 Controller、Service 和 Repository。
  • 体感:找功能 = 找文件夹。当你需要重构“用户模块”时,你的视野只需锁定在 modules/user 即可。

2. internalpkg 的严格边界

  • internal:存放项目的核心业务逻辑。Go 强制规定 internal 下的代码不能被外部项目引用。这保证了业务逻辑的私密性和安全性,防止你的业务代码被其他项目意外耦合。
  • pkg:存放通用基础设施(如 Logger、Redis 封装)。这些代码是业务无关的,完全可以独立出来甚至抽取成公共仓库。

3. ent/ 带来的类型安全

项目使用了 Facebook 出品的 Ent 作为 ORM。它不像 Gorm 那样依赖反射,而是通过 ent/schema 生成纯 Go 代码。

  • 所有的数据库操作都是强类型的,这意味着你在编写代码时,编译器就能帮你发现 SQL 字段写错的问题。

4. 路由注册的“去中心化”

注意看,internal/router 只负责 Gin 引擎的初始化和全局中间件配置,它并不关心具体的业务路由。

  • 解耦:每个模块(如 user.module.go)利用 Fx 的特性,在自己的文件夹里定义路由。
  • 好处:新增一个业务模块时,你不需要去修改全局的 router.go,真正做到了模块的即插即用

三、核心架构:这是怎么跑起来的?

在这个项目中,我引入了 Uber 开源的 fx 库。很多人只把它当依赖注入工具,但在 gin-app 里,它是整个应用的“心脏”和“调度器”

1. 入口即编排 (cmd/app/main.go)

抛弃传统的一行行手动初始化,现在的启动入口是声明式的。

看看 internal/app/app.go,所有的组件(数据库、Redis、路由、业务模块)都像积木一样被 fx 组装:

// internal/app/app.go

func Run(cfg *config.Config) {
    fx.New(
        // 1. 注入全局配置
        fx.Supply(fx.Annotate(cfg, fx.As(new(config.ConfigInterface)))),

        // 2. 基础设施层 (Infrastructure)
        // 这里的 NewLogger, NewPostgresDB 等都会自动执行并注册到容器中
        fx.Provide(
            logger.NewLogger,           // 日志
            postgres.NewPostgresDB,     // 数据库 (Ent)
            redis.NewRedis,             // Redis
            jwt.NewJWTManager,          // JWT
            router.NewRouter,           // Gin 基础实例
            router.NewAPIRouter,        // 路由分组 (Public/Private)
            server.NewHTTPServer,       // HTTP Server
        ),

        // 3. 业务模块层 (Modules)
        // 类似于 NestJS 的 imports: [UserModule, AuthModule]
        modules.APIModule, 

        // 4. 启动层 (Bootstrap)
        // 只有被 Invoke 的函数才会被执行,这里显式启动 HTTP 服务和注册路由
        fx.Invoke(
            func(s *server.HTTPServer) {
                // 启动服务 (生命周期管理)
                s.Run()
            },
        ),
    ).Run()
}

这一段代码解决了什么?

  • 你不需要关心 NewPostgresDB 需要什么参数,fx 会自动从容器里找。
  • 你不需要关心启动顺序,fx 会根据依赖关系自动构建图谱。

四、像 NestJS 一样写业务模块

这是我最喜欢的部分。我将传统的 MVC 目录打散,改为按业务域拆分。每个模块目录包含自己的一切。

目录结构如下:

internal/modules/user/
├── user.controller.go  # 处理 HTTP 请求
├── user.service.go     # 业务逻辑
├── user.repository.go  # 数据交互
└── user.module.go      # 模块定义 (核心!)

最大的特点是:拒绝按技术分层(Controller/Service 文件夹),而是按业务分模块(User/Auth 文件夹)

你看,当你想修改用户相关的功能时,只需要盯着 internal/modules/user 这一个文件夹就够了,不用在 controllersservices 文件夹之间来回横跳。


1. 模块定义 (user.module.go)

gin-app 中,一个文件就是一个独立的“插件”。

// internal/modules/user/user.module.go

var Module = fx.Module(
    "user", // 模块名称

    // 1. 提供本模块的能力 (Service, Controller, Repo)
    fx.Provide(
        NewUserRepository,
        NewUserService,
        NewUserController,
    ),

    // 2. 注册本模块的路由
    // 注意:这里我们请求了 api_router.APIRouterParams,这是系统提供的路由能力
    fx.Invoke(func(
        router api_router.APIRouterParams, 
        userController *UserController,
    ) {
        // 公开路由
        userGroup := router.Public.Group("/users")
        {
            userGroup.POST("", userController.Create)
        }

        // 私有路由 (需要鉴权)
        privateGroup := router.Private.Group("/users")
        {
            privateGroup.GET(":id", userController.GetByID)
        }
    }),
)

2. 控制器实现 (user.controller.go)

控制器变得非常纯粹,它不需要知道 Service 是怎么来的,只要结构体里声明了,fx 就会填进去。

type UserController struct {
    logger logger.Interface
    svc    UserService // 依赖 Service 接口
}

// 构造函数:Fx 会自动注入依赖
func NewUserController(logger logger.Interface, svc UserService) *UserController {
    return &UserController{
        logger: logger,
        svc:    svc,
    }
}

func (c *UserController) GetByID(ctx *gin.Context) {
    // 纯粹的业务调用
    user, err := c.svc.GetByID(ctx, id)
    if err != nil {
        // 统一错误处理
        response.Error(ctx, err)
        return
    }
    response.Success(ctx, user)
}

🛠️ 你可以这样尝试

如果你想在本地体验这套结构,可以尝试:

  1. modules 下新建一个 order 文件夹。
  2. 按照 user 的模式编写 order.module.go
  3. internal/modules/modules.go 中把 order.Module 挂载上去。 你会发现,整个过程不需要修改任何现有的业务代码,这种纵向扩展的丝滑感正是这套模板的精髓。
// internal/modules/modules.go

var APIModule = fx.Module(
    "api",

    // auth module
    auth.Module,
    // user module
    user.Module,
    // post module
    post.Module,
    // access-token module
    accesstoken.Module,
    // common module
    common.Module,
    // order module
    order.Module
)

五、巧妙的路由设计:Public 与 Private

gin-app 里,我不想在每个 Controller 里手动加 Use(AuthMiddleware)。我在基础设施层把路由分成了两组:

// internal/router/api_router.go

type APIRouter struct {
    fx.Out // 告诉 Fx 这是一个输出对象

    // 使用 Named Tag 区分不同的路由组
    Public  *gin.RouterGroup `name:"api:public"`
    Private *gin.RouterGroup `name:"api:private"`
}

func NewAPIRouter(app *gin.Engine, jwt jwt.JWTManager) APIRouter {
    root := app.Group("/api/v1")

    // 1. 公开组:无中间件
    public := root.Group("/")

    // 2. 私有组:挂载鉴权中间件
    private := root.Group("/")
    private.Use(middleware.Auth(jwt)) 

    return APIRouter{
        Public:  public,
        Private: private,
    }
}

这样做的好处是: 业务模块开发时(如上面的 user.module.go),只需要选择要把路由挂在 router.Public 还是 router.Private 下,鉴权逻辑完全被封装在底层,业务层完全无感。


六、生命周期管理:优雅的启动与关闭

Fx 提供了 Lifecycle 钩子,这在 Go 服务中至关重要(比如等待数据库连接关闭后再停机)。

internal/app/app.go 中:

func startHTTPServer(
    lc fx.Lifecycle,
    sd fx.Shutdowner,
    httpServer httpserver.Server,
    logger logger.Logger,
) {
    lc.Append(
        fx.Hook{
            // start
            OnStart: func(ctx context.Context) error {
                logger.Info("http server - Starting HTTP Server...")

                // 异步启动,不阻塞主进程
                go func() {
                    if err := httpServer.Start(); err != nil && err != http.ErrServerClosed {
                        logger.Errorf("http server - Start HTTP Server error: %v", err)
                        sd.Shutdown()
                    }
                }()

                logger.Info("http server - Listening on ", httpServer.GetAddress())
                return nil
            },
            // stop
            OnStop: func(ctx context.Context) error {
                logger.Info("http server - Stopping HTTP Server...")

                // 优雅关闭:处理完当前请求再退出
                err := httpServer.Shutdown()
                if err != nil {
                    return err
                }

                logger.Info("http server - Server - Shutting down")
                return nil
            },
        },
    )
}

这意味着当你按下 Ctrl+C 时,Fx 会按依赖的反向顺序(先停 HTTP 服务,再断数据库连接)依次执行清理工作,保证数据安全。


七、进阶思考:这不仅仅是 Gin + Ent

虽然这个项目叫 gin-app,但我必须强调:这套架构的灵魂是 Uber Fx 构建的依赖注入体系,而非具体的 Web 框架或 ORM。

在软件工程中,铁打的架构,流水的库。

1. 它是“框架无关”的 (Framework Agnostic)

Gin 和 Ent 只是我在这个模板中选择的“插件”。得益于 Fx 的模块化设计和 Go 的接口机制,你可以轻松地进行组件替换,而不用推翻重来:

  • 不喜欢 Gin? 你完全可以将 pkg/httpserverinternal/router 替换为 EchoFiber 的实现。只要保持 fx.Provide 的注入方式不变,业务层的 ServiceRepository 代码甚至不需要改动。
  • 习惯用 Gorm? 只需重写 pkg/postgres 的初始化逻辑,将其注册到 Fx 容器中。Repository 层直接注入 *gorm.DB 即可,模块定义的结构(Module -> Controller -> Service)依然稳固。

2. 真正的价值:标准化的“骨架”

这套模板最大的价值在于它定义了一套**“标准骨架”**:

  • 它规定了代码放在哪里(目录结构)。
  • 它规定了组件如何通信(依赖注入)。
  • 它规定了应用如何启停(生命周期)。

无论你填充进这副骨架的是 Gin、Echo 还是 gRPC,是 MySQL 还是 MongoDB,项目的可维护性清晰度都不会降低。这就是架构设计的魅力。


八、总结

这套架构不是为了炫技,而是为了解决我在真实开发中遇到的“乱”:

  1. 目录结构清晰modules 目录下全是业务,pkg 目录下全是通用工具。
  2. 依赖反转:彻底告别全局变量和复杂的初始化链。
  3. 高内聚:删除一个功能,只需删除对应的 module 文件夹,不会残留路由配置。

它可以不适合极其简单的 CRUD 小工具,但如果你正准备开始一个中型 Go 后端项目,无论你最终选择 Gin 还是 Echo,这套基于依赖注入的“模块化思维”都能帮你构建出更健壮的系统。

Talk is cheap, star the code: 👇

GitHub 仓库github.com/1111mp/gin-…

如果你也在做 Go 工程化探索,欢迎 Issue 或 PR 交流!