为什么会写这篇文章

在刚接触 DI 时,最容易混淆的不是语法,而是对象到底在什么时候被创建、依赖到底由谁来传。

很多人之前写项目时,已经在使用“依赖注入”,只是方式是手动的。例如:

  • main 中先创建配置对象
  • 再创建 logger
  • 再创建 service
  • 最后通过 NewUserSrv(...)NewUserAPI(...) 这类函数一层层传下去

这当然也是依赖注入,但它是人工装配。

而使用 DI 容器之后,依赖的装配关系不再由 main 手动维护,而是交给容器在运行时完成。本文会围绕这个核心差异展开说明。

一句话区分两种方式

手动依赖注入:

  • 你自己创建对象
  • 你自己把依赖传进去
  • 你自己维护对象之间的装配顺序

DI 容器管理依赖:

  • 你声明对象需要什么依赖
  • 你把对象注册给容器
  • 容器在运行时创建对象并填充依赖
  • 你只需要从容器中取出装配完成的对象

可以把它概括为一句话:

以前你是自己搬运依赖;现在你是声明依赖,由容器配送。

手动依赖注入是什么

先看最熟悉的方式。

假设有一个删除用户的功能,依赖:

  • 配置 Config
  • 日志 Logger
  • 鉴权模块 AuthHandler

手动注入的常见写法如下:

type AuthHandler struct {
    Config *modelconfig.Config
    Logger *slog.Logger
}

func NewAuthHandler(cfg *modelconfig.Config, logger *slog.Logger) *AuthHandler {
    return &AuthHandler{
        Config: cfg,
        Logger: logger,
    }
}

type UserController struct {
    Config *modelconfig.Config
    Logger *slog.Logger
    Auth   *AuthHandler
}

func NewUserController(
    cfg *modelconfig.Config,
    logger *slog.Logger,
    auth *AuthHandler,
) *UserController {
    return &UserController{
        Config: cfg,
        Logger: logger,
        Auth:   auth,
    }
}

然后在 main 中手动组装:

cfg := loadConfig()
logger := newLogger(cfg)
authHandler := NewAuthHandler(cfg, logger)
userController := NewUserController(cfg, logger, authHandler)

engine.DELETE("/api/user/delete", userController.Delete)

这就是手动依赖注入。

它的问题在哪里

这种方式在小项目里完全可行,但随着模块变多,会有几个问题:

  • main 必须知道所有对象的内部依赖关系
  • 创建顺序必须手动维护
  • 任何对象新增依赖,启动层都要跟着改
  • NewXxx(...) 和参数传递链会越来越长

这时启动层就不再只是“启动程序”,而变成了“手工装配整个系统”。

DI 容器管理依赖是什么

使用 DI 容器时,思路会变成:

  1. 定义一个对象
  2. 在对象上声明它需要哪些依赖
  3. 把这个对象注册到容器
  4. 容器在运行时创建并注入依赖
  5. 使用方只负责获取这个对象

例如:

type UserController struct {
    Config *modelconfig.Config `di.inject:"appConfig"`
    Logger *slog.Logger        `di.inject:"appLogger"`
    Auth   *auth.Handler       `di.inject:"authHandler"`
}

func (c *UserController) Delete(ctx *gin.Context) {
    c.Logger.Info("delete user request received")
}

这里最关键的变化是:

  • UserController 不再通过 NewUserController(...) 接收依赖
  • 它只声明自己需要什么
  • 具体依赖由容器负责填充

然后在容器里注册:

func registerControllerBeans() error {
    if _, err := di.RegisterBean(
        "userController",
        reflect.TypeOf((*controller.UserController)(nil)),
    ); err != nil {
        return err
    }

    return nil
}

如果 authHandlerappConfigappLogger 也都已注册,那么当容器初始化时,UserController 就能被完整装配。

最后在使用处获取:

userControllerInstance, err := di.GetInstanceSafe("userController")
if err != nil {
    return err
}

userController := userControllerInstance.(*controller.UserController)
engine.DELETE("/api/user/delete", userController.Delete)

这里没有手动创建 ConfigLoggerAuth 并塞进 UserController。容器已经做完了这件事。

最容易误解的地方

很多人第一次接触 DI 时,会下意识地觉得:

  • 既然函数要用到依赖
  • 那么总得先创建一个 deps 实例
  • 然后把它作为参数传给下一层

这个想法来自手动依赖注入的经验,本身没有错,但它不适用于容器式 DI 的核心模型。

在 DI 中,你写下来的 struct 更像是一个“对象模板”或者“依赖声明模板”:

type UserController struct {
    Config *modelconfig.Config `di.inject:"appConfig"`
    Logger *slog.Logger        `di.inject:"appLogger"`
    Auth   *auth.Handler       `di.inject:"authHandler"`
}

你在写代码时,并没有真的创建这个对象。

真正的对象创建发生在程序运行时:

  • 容器读取注册信息
  • 容器创建 UserController 实例
  • 容器按 di.inject 标签填充字段
  • 最后把这个完整对象交给你

所以真正的关键不是“我该怎么手动造一个 deps”,而是:

userControllerInstance, err := di.GetInstanceSafe("userController")
userController := userControllerInstance.(*controller.UserController)

这两行的意义是:

  • 从容器中取出已经装配完成的 userController
  • 将返回的 interface{} 断言成具体类型
  • 后续直接调用这个对象的方法

这里不是类型断言在“智能填充依赖”,而是容器在此之前已经完成了填充;类型断言只是为了让 Go 知道这个对象的具体类型。

为什么说依赖像对象的成员变量

从 OOP 的角度看,DI 注入后的依赖非常像一个对象的成员变量。

例如:

type UserController struct {
    Config *modelconfig.Config `di.inject:"appConfig"`
    Logger *slog.Logger        `di.inject:"appLogger"`
    Auth   *auth.Handler       `di.inject:"authHandler"`
}

func (c *UserController) Delete(ctx *gin.Context) {
    c.Logger.Info("start delete user")
}

在方法内部,ConfigLoggerAuth 的使用体验和普通成员变量完全一样。

差别只在于:

  • 普通对象的成员变量通常由你手动赋值
  • DI 对象的成员变量由容器在运行时自动填充

因此可以这样理解:

  • 方法只依赖自己的成员变量
  • DI 容器负责提前把这些成员变量填好
  • 你拿到对象后,直接调用它的方法即可

main 中的 runtimeDeps 和控制器 Bean 有什么区别

这是另一个很容易混淆的问题。

在当前项目里,main 中有这样一层:

type runtimeDeps struct {
    logger         *slog.Logger
    logHandler     *logx.Handler
    webHandler     *web.Handler
    testController *controller.TestController
}

然后通过类似这样的函数集中获取:

func loadRuntimeDeps() (*runtimeDeps, error) {
    loggerInstance, err := di.GetInstanceSafe("appLogger")
    if err != nil {
        return nil, err
    }

    webHandlerInstance, err := di.GetInstanceSafe("webHandler")
    if err != nil {
        return nil, err
    }

    return &runtimeDeps{
        logger:     loggerInstance.(*slog.Logger),
        webHandler: webHandlerInstance.(*web.Handler),
    }, nil
}

这和 userController := di.GetInstanceSafe("userController") 看起来很像,但角色不同。

runtimeDeps 是启动层的手动聚合

它的作用是:

  • main 启动流程直接要用到的几个顶层 Bean 聚合起来
  • run() 只面对一个统一的依赖入口

这是一种启动层便利封装,不是 DI 自动注入 run()

换句话说:

  • DI 负责把 loggerwebHandlertestController 都准备好
  • loadRuntimeDeps() 再把这些“已经注入完成的对象”聚合成一个 struct

userController 是容器直接交付的业务对象

userController 不一样。

它是一个真正的消费者 Bean。容器会直接为它完成内部依赖注入。你拿到它时,它已经是完整对象:

userControllerInstance, err := di.GetInstanceSafe("userController")
userController := userControllerInstance.(*controller.UserController)

所以这两者的本质区别是:

  • runtimeDeps:启动层手动聚合多个已注入对象
  • userController:容器直接交付的已注入对象

是否还需要单独定义 UserDeps

很多人会想写成这样:

type UserDeps struct {
    Config *modelconfig.Config `di.inject:"appConfig"`
    Logger *slog.Logger        `di.inject:"appLogger"`
    Auth   *auth.Handler       `di.inject:"authHandler"`
}

func Delete(deps UserDeps) {
}

这不是完全不行,但通常不推荐。

更推荐的写法是:

type UserController struct {
    Config *modelconfig.Config `di.inject:"appConfig"`
    Logger *slog.Logger        `di.inject:"appLogger"`
    Auth   *auth.Handler       `di.inject:"authHandler"`
}

func (c *UserController) Delete(ctx *gin.Context) {
}

原因很简单:

  • 路由最终绑定的是一个对象的方法
  • 对象本身就是依赖载体
  • 没必要再人为拆一个 UserDeps

因此在容器式 DI 中,更自然的组织方式是:

  • 用对象表达业务角色,例如 UserController
  • 用对象字段表达它的依赖
  • 用对象方法表达它的行为

一个完整的运作流程

假设现在有接口 /api/user/delete,它需要:

  • 配置
  • 日志
  • 鉴权模块

推荐的实现路径如下。

第一步:定义依赖消费者

文件:internal/controller/user.go

package controller

import (
    "log/slog"

    "github.com/gin-gonic/gin"

    "github.com/jrnitre/blackshores/internal/auth"
    modelconfig "github.com/jrnitre/blackshores/model/config"
)

type UserController struct {
    Config *modelconfig.Config `di.inject:"appConfig"`
    Logger *slog.Logger        `di.inject:"appLogger"`
    Auth   *auth.Handler       `di.inject:"authHandler"`
}

func (c *UserController) Delete(ctx *gin.Context) {
    c.Logger.Info("delete user request received")
    ctx.JSON(200, gin.H{
        "message": "ok",
    })
}

第二步:注册 Bean

文件:internal/container/controller.go

func registerControllerBeans() error {
    if _, err := di.RegisterBean(
        "userController",
        reflect.TypeOf((*controller.UserController)(nil)),
    ); err != nil {
        return err
    }

    return nil
}

文件:internal/container/auth.go

func registerAuthBeans() error {
    if _, err := di.RegisterBean(
        "authHandler",
        reflect.TypeOf((*auth.Handler)(nil)),
    ); err != nil {
        return err
    }

    return nil
}

第三步:初始化容器

文件:cmd/main.go

if err := container.Init(); err != nil {
    return err
}

第四步:获取顶层对象

userControllerInstance, err := di.GetInstanceSafe("userController")
if err != nil {
    return err
}

userController := userControllerInstance.(*controller.UserController)

第五步:绑定到 API

engine.DELETE("/api/user/delete", userController.Delete)

到这里,依赖链就已经打通了。

注意:main 不需要手动这样做:

cfg := ...
logger := ...
authHandler := ...
userController := ...

这些装配工作已经交给 DI 容器处理。

应该由谁获取依赖

这是实践中最重要的边界之一。

main 应该获取什么

main 只应该获取“启动流程要直接使用的顶层对象”,例如:

  • webHandler
  • testController
  • userController
  • appLogger

main 不应该获取什么

main 不应该为了组装某个控制器,再单独去拿它的内部依赖,例如:

  • appConfig
  • authHandler
  • dbService

然后自己拼成一个 UserController

如果 main 开始知道 UserController 依赖什么、怎么创建,那就说明启动层又回到了手动装配模式。

DI 与手动注入的关系

DI 不是对手动注入的否定,而是对手动注入的进一步抽象。

两者的关系可以这样理解:

  • 手动依赖注入也是依赖注入
  • DI 容器管理依赖是“由容器执行的依赖注入”
  • 两者目标相同,都是让对象依赖外部提供,而不是自己在内部硬编码创建
  • 两者差别在于:由谁来负责装配

所以如果非要用一句最严谨的话概括:

DI 容器并没有消灭依赖注入,它只是把“注入动作”从开发者手里接管过去。

最后总结

如果只保留最核心的结论,那么有下面几条:

  1. 手动依赖注入与 DI 容器管理依赖的本质差别,不在于“有没有注入”,而在于“谁来装配”。
  2. 在 DI 模式中,你写下的 struct 更像是对象模板;真正的实例是在程序运行时由容器创建的。
  3. 依赖字段可以理解为对象的成员变量,方法只依赖自己的成员变量工作。
  4. di.GetInstanceSafe("userController") 的意义是“取出已装配完成的对象”,不是“现场组装对象”。
  5. main 只获取顶层对象,不手动拼装它们的内部依赖。
  6. runtimeDeps 是启动层的手动聚合;userController 是容器直接交付的业务对象,这两者不要混淆。

当你真正接受“我写的是依赖声明,容器负责运行时装配”这个前提之后,DI 的理解就会突然变得清晰很多。