问题

为什么有很多人说 Go 语言不需要依赖注入?

回答
很多人说 Go 语言不需要依赖注入,这背后其实有一些非常深刻的原因,而且并非空穴来风。要理解这一点,我们需要先回顾一下依赖注入(Dependency Injection,简称 DI)这个概念本身,以及 Go 语言在设计上的独特之处。

首先,我们得明白什么是依赖注入?

简单来说,依赖注入是一种设计模式,它的核心思想是:将一个对象(服务)所需的其他对象(依赖)的创建和管理,从对象本身移交给外部来完成。 换句话说,不是你的服务自己去 new(创建)它需要的其他服务,而是有人(比如一个 DI 容器)帮你把这些服务“注入”到你的服务中。

这样做的主要好处包括:

解耦(Decoupling): 你的服务不再直接依赖于具体实现,而是依赖于抽象(接口)。这使得替换具体的实现变得非常容易,为单元测试提供了便利。
可测试性(Testability): 在测试时,你可以轻松地将真实的依赖替换成 mock(模拟)对象,从而隔离被测试的代码。
可维护性(Maintainability)和可重用性(Reusability): 代码更容易理解、修改和复用,因为组件之间的耦合度降低了。

为什么 Go 语言的拥趸们认为它“不需要”DI?

这里的“不需要”并不是说 Go 不能 使用 DI,而是说,在很多情况下,Go 的语言特性和最佳实践本身就能够达到类似 DI 的效果,或者提供了更直接、更符合 Go 哲学的方式来管理依赖。

主要有以下几个层面的原因:

1. 接口(Interfaces)的强大作用:
Go 的接口是其最核心的特性之一。Go 的接口是“隐式”实现的,这意味着你不需要显式地声明一个类型实现了某个接口。只要一个类型提供了接口定义的所有方法,它就自动实现了该接口。

这与 DI 的解耦目标不谋而合。 在 Go 中,当你需要一个服务时,你通常会定义一个接口来描述这个服务的功能,而不是直接依赖于一个具体的实现类型。

举个例子: 假设你需要一个日志服务。你不会定义一个 `MySpecificLogger` 类型的变量,而是定义一个 `Logger` 接口:

```go
type Logger interface {
Info(message string)
Error(message string)
}
```

然后,你的业务逻辑代码只需要使用 `Logger` 接口,而不需要知道具体是哪个日志实现(比如 `ConsoleLogger`、`FileLogger` 或 `NilLogger`)在背后工作。

在创建对象时: 当你创建一个需要 `Logger` 的对象时,你只需在构造函数中接收一个 `Logger` 接口类型的参数:

```go
type UserService struct {
logger Logger
}

func NewUserService(logger Logger) UserService {
return &UserService{logger: logger}
}
```

这就是一种“注入”! 你通过函数参数把 `Logger` 实例传给了 `UserService`。这种方式非常直接,不需要额外的框架或容器。在测试时,你可以轻松地传入一个 `MockLogger`。

2. 函数式编程的风格和初始化函数(Constructors):
Go 语言鼓励使用构造函数(或者更准确地说是“工厂函数”或“创建函数”)来初始化对象,并在这些函数中传入所有必要的依赖。

构造函数作为注入点: 如上例所示,`NewUserService(logger Logger)` 就是一个典型的构造函数。它接收了 `logger` 依赖,并将它“注入”到 `UserService` 实例中。这种方式非常自然,代码可读性也很高。

避免全局变量和单例: Go 的标准库和社区倾向于避免使用全局变量来存储服务实例,因为全局变量会引入隐式的依赖,难以管理和测试。通过构造函数传递依赖,可以清晰地揭示对象所依赖的服务,并方便地进行替换。

3. 显式和清晰的依赖关系:
Go 的设计哲学是“显式优于隐式”。在 Go 中,依赖关系通过函数签名或结构体字段清晰地声明出来。

一目了然: 当你看到 `func NewUserService(logger Logger)` 时,你立刻就知道 `UserService` 需要一个 `Logger`。这种显式性使得代码更容易理解和维护,尤其是在大型项目中。

无需猜测: 你不需要去查找一个 DI 容器的配置,来了解一个对象是如何被创建和配置的。依赖关系就在那里,直接可见。

4. Go 的包管理和组合:
Go 的包(package)系统本身就提供了一种模块化的方式来组织代码。通过将相关的功能封装在包中,并暴露接口,可以实现良好的代码组织。

组合优于继承: Go 遵循组合优于继承的原则。一个类型可以通过包含其他类型(包括接口类型)的实例来实现其功能。这种组合方式本身就是一种管理依赖的手段。

5. 测试的便捷性:
前面已经提到,Go 的接口设计使得单元测试非常容易。你不需要引入一个复杂的 DI 框架来为测试环境准备 mock。

简单 Mock: 创建一个接口的 mock 实现非常简单。你只需要定义一个实现相同接口但逻辑简单的结构体即可:

```go
type MockLogger struct{}

func (m MockLogger) Info(message string) {
fmt.Println("Mock Info:", message)
}
func (m MockLogger) Error(message string) {
fmt.Println("Mock Error:", message)
}
```

直接传入: 在测试函数中,你只需要创建 `MockLogger` 的实例,然后将其传递给 `NewUserService` 即可。

```go
func TestUserService(t testing.T) {
mockLogger := &MockLogger{}
userService := NewUserService(mockLogger)
// ... 进行断言 ...
}
```

那么,是不是 Go 真的完全不需要 DI 框架?

这取决于项目的大小、复杂度和团队的偏好。

小型项目和简单场景: 对于大多数 Go 项目,特别是那些不涉及大量服务、复杂依赖图或者需要高度灵活的配置场景,上面的“原生”依赖管理方式(构造函数注入、接口)已经足够了,并且更加符合 Go 的简洁和高效的哲学。许多 Go 的核心库和知名的第三方库都遵循这种模式。

大型、复杂或企业级应用: 在一些非常庞大、组件数量极多、依赖关系错综复杂、或者需要非常灵活地配置不同环境下的服务实现的项目中,一个成熟的 DI 框架(例如 Google Guice, Spring in Java,或者 Go 社区的一些 DI 库,如 `wire`,`fx` 等)可能会带来一些额外的便利:
集中化配置: 所有的依赖关系和对象的创建逻辑可以集中在一个地方管理,更容易审计和维护。
生命周期管理: 框架可以帮助管理对象的生命周期(例如单例、请求作用域等)。
自动装配: 在某些框架中,可以通过反射或代码生成自动扫描并装配依赖,减少手动编写构造函数和注入代码的工作量。
元编程/代码生成: 像 Google 的 `wire` 库,它不是运行时反射,而是编译时代码生成,这非常符合 Go 的效率和可预测性。它会分析你的依赖关系,然后生成你需要的构造代码,避免了反射的性能损耗和运行时错误。

总结一下:

当人们说 Go 语言“不需要”依赖注入时,他们通常指的是:

1. Go 语言的核心特性(接口、构造函数)提供了一种非常自然且有效的方式来管理依赖,能够实现 DI 的主要好处(解耦、可测试性)。
2. 使用这些“原生”方式通常比引入一个重量级的 DI 框架更符合 Go 的简洁、直接和高性能的哲学。
3. Go 的开发者社区普遍推崇这种更“手动”但更清晰的依赖管理方式。

这并不意味着 DI 框架在 Go 中毫无用处,但在很多场景下,Go 的内建能力已经足够强大,使得第三方 DI 框架显得不是那么“必要”。选择哪种方式,更多地取决于项目的具体需求和团队的权衡。

网友意见

user avatar

更新下:其实我觉得依赖倒置原则是思路,是系统构建的大方向和哲学,最接近这个哲学的工具是First Class Module和Existance Type。而很多主牛语言根本没这种东西。那么一个比较通用的工具就是DI.


可能文章写得太长了。。。 其实简化来说就两点:

建议把2个逻辑分开:

  • 各个components自己的逻辑
  • 怎么把多个小components组合起来形成高一层组件的组合逻辑(也就是控制组合流,你可以手动DI,或者用任何类型的controller,或者用DI Framework,或者你使用的语言直接支持,或者利用隐式环境,关键是单独来完成这件事,别把怎么组合自己和自己的dependency这件事情,让自己来决定;也就是别把components的逻辑和components怎么和别人的实例组合的逻辑放在一起)
  • 做到这点需要component不能把自己的依赖写死(不能自己去创建自己需要的dependency的实例),而是只是声明自己需要的spec(一般是interface;如果只存这个spec/interface的一种实例,那么懒得定义interface用实例的类型做依赖声明也可以,需要存在不同实例的时候再重构抽出interface就行了,但是别自己create自己的依赖。比如sort的实现把要sort的compareTo的实例固定,那么sort就只能sort一种T)这就是广义的DI。 无法把广义DI和DI framework分开思考的人就别评论了。谢。

    只有很少几种情况components可以直接决定自己的依赖,比如这个依赖绝对不会更改(这意味着你想连UT都把这个components和依赖必须一起测,因为写死了),或者这个实例在世界上只存在一种(比如你依赖于一些数学函数,Math.abs) 这些东西只存在一种实现,绝对不会变,即使放在UT里。


    注意:如果需要component控制dependency生成的时机timing,可以inject进来dependency的factory。那么component虽然控制dependency instance生成的时机,但是不知道生成的具体实例类型,同样松耦合了各个Components

    再次注意:我个人不喜欢DI Framework,是手动DI,或者用Reader Monad一派的,友军请别乱开枪。


    看了很多答案,感觉都没提到依赖注入对系统设计的重要性,所以其实我更想说一下依赖倒置原则和系统设计…

    不过先答正题:Go当然也可以做依赖注入:"Dependency Injection" in Golang

    因为依赖注入也可以手动完成,而根本不需要DI framework。所以任何支持subtyping或者first class function的语言都完全可以做dependency injection;甚至在C里也可以用函数指针做DI。


    有位答主问:

    我更好奇的是,这世上有一离开“依赖注入”就玩不转的项目吗?

    我想就这个问题讲一下依赖注入,依赖倒置,和系统设计。


    依赖注入的一般应用

    依赖注入可以让让“提供功能类A”和“使用功能类B”解耦合;A注入在B里,那么如果A有subtyping的变换A_xA_y...时,而整体系统调整(比如新feature,测试环境和prod环境)需要B在不同的场景/时间注入其他具体功能实现类A_x...时; B都不需要改,且系统可以通过不同的配置轻松实现在不同场景的多版本系统。最常见的就是业务类使用Dao类,不要在业务类里new具体Dao类,这样就绑死了关系,而注入Dao类,这样则任何Dao的subtyping,业务类都可以接受。


    依赖注入与依赖倒置

    其实依赖注入最重要的作用之一是实现依赖倒置;(控制反转和依赖倒置不同,最后说)

    <划重点>让任何B调A的关系,不都产生B必须绑死依赖于A的效果</划重点>。

    当B必须用到A的功能的时候,控制流是从B到A,A不需要知道B(最直观的proof A不需要import B),而B必须知道A,或者说B直接依赖于A(直观proof就是B需要import A才能用A),依赖关系箭头从B到A;这样会导致类依赖图甚至包依赖图(如果A和B在不同包)是从B指向(依赖)A。

    但是如果有一个interface类C在B的包里(甚至自已的一个interface包里),让A实现C(注意这样A import C依赖于C),让B使用C的interface(B也依赖C)来间接使用C的implementation A,A的实例通过依赖注入到B里供B使用;这样依赖关系就变成了A依赖C,而如果C在B的包里(为什么C放在B的包里合理请往下看),依赖关系就变成了A所在的包依赖于B所在的包(注意本来是B包依赖A包)。从而实现了依赖的倒置。


    依赖倒置原则与系统设计

    为什么要这么麻烦做依赖倒置?如果你看过我写的这篇关于系统分层的文章:用谁都能看懂的方式来解释系统设计中的分层,那么应该明白一个稳定易拓展的系统应该让底层依赖高层,而不是相反(高层依赖底层),一个合理的系统,应该由高层按照自己的需求,指定底层应该满足的specification(比如B包是一个高层业务包,那么B里的interface C就是B对于底层的命令式的Specification,然后B里的所有逻辑都应该基于这个specification来写,因为B和B的Specification, 也就是interface C都是在高层抽象讨论问题,所以B和Interface C放在一个高层包里是一种合理设计),底层应该想方设法来满足这个specification(也就是底层类A需要实现interface C);而不应该让业务类扭曲自己来迎合底层类的需要(非依赖倒置的情况);最终实现整个系统的高层底层配合;

    用DDD的话来说就是,业务决定技术实现, 而不是相反

    不使用依赖反转的系统构架,控制流和依赖关系流的依赖箭头是一个方向的,由高层指向底层,也就是高层依赖底层,最明显的特点就是高层包需要import很多底层包里的类,这样的话任何的底层小改动,都可能产生影响高层业务逻辑类的改动的蝴蝶效应;这样的系统耦合严重,维护,模拟,测试,实验和拓展都很困难(想想你一个业务类绑死了数据库类,你怎么做UT?怎么在Continuous Delivery的pipeline里模拟数据库做integration test?怎么能简单的把业务数据写替换成写到SQS里,换成写到S3里,写到Kinesis里? 或者能同时都写),可能动不动就要重构和re-architecture;让程序员苦不堪言。而使用依赖倒置,使得底层包依赖高层包。高层包里不会import任何一个底层类。只要interface设计的好,底层的任何变动,都不会影响高层一行code。

    严重注意⚠️:一个系统绝不仅仅只有高层和底层2层,而是可以有很多层,业务类也可以并需要分为多层,同时保证依赖倒置!


    关于控制反转

    顺便提一下控制反转,比较容易和依赖倒置原则弄混(太早学的这些,我在开始写这个答案的时候也弄混了这些名词。。。),控制反转是EJB,Spring这种框架平台兴起的时候提起的一个概念。相对于Lib来说,我们总是自己application的逻辑调用Lib的逻辑,控制流由我们的application规定,EJB,Spring是让Framework调用我们的逻辑。在这个控制流的方向上,体现了控制反转。这个原则也称为Hollywood Principle - "Don't call us, we'll call you"。

    其实Template Method这个设计模式也体现了控制反转,父类实现一些框架方法并定义控制流如何走,控制流会按照定义好的顺序调用一些abstract方法,然后留给子类去实现这些abstract方法。JUnit框架就是一个好例子。

    控制反转是当系统的很多东西都能够模版化的时候,而业务逻辑只是其中的某一步或者几步,那么framework一般需要DI的方式来管理你的领域(业务)类,来inject到自己的控制流里,成为framework规定的控制流的一部分,而你的业务类则不需要关心控制流。可以看出,当控制流可以framework化和模版化的时候,控制反转相当重要(EJB,Spring,JUnit都是例子)。


    DI Framework

    Dependency Framework, 就是Spring,GoogleGuice,MacWire(一个scala DI framework), 这种通过config file或者annotation等标记来帮助你自动DI的框架,那么到底是手动wire手动DI好,还是用DI framework好呢?

    要评价这个就不免带个人意见色彩了。。。 我个人不是很喜欢DI framework,因为我喜欢函数风格的程序,DI对于函数式来说不过是high order function罢了。我个人更倾向于MacWire的作者的意见, In today’s post-OO world, is dependency injection still relevant? 就是DI以后可能会设计为语言自己的功能,由语言来提供一个隐式“environmental” parameter。

    Java,Scala里的DI framework有两个好处:

  • 可以把wiring各个组件,类的code变成config,这样严格区分了“组件功能逻辑”和“控制组合逻辑”,降低了功能逻辑 泄漏到 “控制组合逻辑”的类里的风险(其实这很经常发生,我review code就经常看到新手SDE在组合逻辑里放业务逻辑...) 而使用DI framework,比如Spring配置文件,根本就没办法写逻辑,只能老老实实指定谁怎么实例化和谁跟谁组合,所以避开了这些风险。
  • 可以用反射等实例化非public可见,而是包可见,甚至private的类,而手动wire则需要所有需要实例化的类是public可见的。比如我们的例子里底层的功能类A必须是public可见,我们的手动wire所有高层底层组件的逻辑才能够实例化A。使用DI framework使得我们可以把A设为包可见甚至private,这样除了DI的wire logic,没有任何类能够实例化A,保证了A不被滥用。当然,如果你上了java9,你就可以用modular功能指定A只能被wire logic所在的包看到,所以上了java9的项目,在这点上DI framework就毫无优势了。

  • 依赖注入,可说是任何程序员必掌握的技巧之一,系统设计入门基础技能之一。而依赖倒置原则作为一条原则,自从被人发现以来,从来都没被颠覆过,可以算得上软件设计艺术的真理之一。

    学会一门语言的语法,并不代表你“真的会写程序”了。正如你学会了铅笔的构造原理,你也没法像画家一样画画。真正的画家用任何画笔都能画画,只是有些画笔用着更顺手罢了。

    (最后推荐Clean Architecture这本书,除了依赖倒置原则,老司机还介绍了好几种其他系统构架的小技巧,推荐阅读。)


    最后:在container,虚拟化,微服务,甚至serverless,FaaS盛行的今天,几百行code就可以是一个service,遵循依赖倒置原则来设计稳定的易拓展的系统,跟代码行数超过多少行有什么必然联系么???





    ====== 小尾巴 =====

    我是阿莱克西斯,10+年技术经验关于高并发,大数据,与内部科学家合作搭建预测优化后段黑魔法系统的Amazon Sr. SDE,每天看技术书和paper到夜里1点的读书狂魔兼猫奴。

    关注我,关注我的读书专栏,和解释分布式系统paper的专栏,带你成为程序艺术家,玩转分布式系统~

    用谁都能看懂的方法解释分布式系统

    一个书魔程序员的读书简评

    类似的话题

    本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

    © 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有