为什么会写这篇文章
在刚接触 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 容器时,思路会变成:
- 定义一个对象
- 在对象上声明它需要哪些依赖
- 把这个对象注册到容器
- 容器在运行时创建并注入依赖
- 使用方只负责获取这个对象
例如:
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
}如果 authHandler、appConfig、appLogger 也都已注册,那么当容器初始化时,UserController 就能被完整装配。
最后在使用处获取:
userControllerInstance, err := di.GetInstanceSafe("userController")
if err != nil {
return err
}
userController := userControllerInstance.(*controller.UserController)
engine.DELETE("/api/user/delete", userController.Delete)这里没有手动创建 Config、Logger、Auth 并塞进 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")
}在方法内部,Config、Logger、Auth 的使用体验和普通成员变量完全一样。
差别只在于:
- 普通对象的成员变量通常由你手动赋值
- 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 负责把
logger、webHandler、testController都准备好 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 只应该获取“启动流程要直接使用的顶层对象”,例如:
webHandlertestControlleruserControllerappLogger
main 不应该获取什么
main 不应该为了组装某个控制器,再单独去拿它的内部依赖,例如:
appConfigauthHandlerdbService
然后自己拼成一个 UserController。
如果 main 开始知道 UserController 依赖什么、怎么创建,那就说明启动层又回到了手动装配模式。
DI 与手动注入的关系
DI 不是对手动注入的否定,而是对手动注入的进一步抽象。
两者的关系可以这样理解:
- 手动依赖注入也是依赖注入
- DI 容器管理依赖是“由容器执行的依赖注入”
- 两者目标相同,都是让对象依赖外部提供,而不是自己在内部硬编码创建
- 两者差别在于:由谁来负责装配
所以如果非要用一句最严谨的话概括:
DI 容器并没有消灭依赖注入,它只是把“注入动作”从开发者手里接管过去。
最后总结
如果只保留最核心的结论,那么有下面几条:
- 手动依赖注入与 DI 容器管理依赖的本质差别,不在于“有没有注入”,而在于“谁来装配”。
- 在 DI 模式中,你写下的 struct 更像是对象模板;真正的实例是在程序运行时由容器创建的。
- 依赖字段可以理解为对象的成员变量,方法只依赖自己的成员变量工作。
di.GetInstanceSafe("userController")的意义是“取出已装配完成的对象”,不是“现场组装对象”。main只获取顶层对象,不手动拼装它们的内部依赖。runtimeDeps是启动层的手动聚合;userController是容器直接交付的业务对象,这两者不要混淆。
当你真正接受“我写的是依赖声明,容器负责运行时装配”这个前提之后,DI 的理解就会突然变得清晰很多。
评论区(暂无评论)