阅读视图

发现新文章,点击刷新页面。
🔲 ⭐

【译】用依赖注入解耦你的代码

用依赖注入解耦你的代码

无需第三方框架

[Icons8 团队](https://unsplash.com/@icons8?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 摄于 [Unsplash](https://unsplash.com/s/photos/ingredients?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)

没有多少组件是能够独立存在而不依赖于其它组件的。除了创建紧密耦合的组件,我们还可以利用依赖注入(DI)来改善 关注点的分离

这篇文章将会脱离第三方框架向你介绍依赖注入的核心概念。所有的示例代码都将使用 Java,但所介绍的一般原则也适用于其它任何语言。


示例:数据处理器

为了让如何使用依赖注入更加形象化,我们将从一个简单的类型开始:

public class DataProcessor {

private final DbManager manager = new SqliteDbManager("db.sqlite");
private final Calculator calculator = new HighPrecisionCalculator(5);

public void processData() {
this.manager.processData();
}

public BigDecimal calc(BigDecimal input) {
return this.calculator.expensiveCalculation(input);
}
}

DataProcessor 有两个依赖项:DbManagerCalculator。直接在我们的类型中创建它们有几个明显的缺点:

  • 调用构造函数时可能发生崩溃
  • 构造函数签名可能会改变
  • 紧密绑定到显式实现类型

是时候改进它了!


依赖注入

《敏捷开发的艺术》 的作者 James Shore 很好地指出

「依赖注入听起来复杂,实际上它的概念却十分简单。」

依赖注入的概念实际上非常简单:为组件提供完成其工作所需的一切。

通常,这意味着通过从外部提供组件的依赖关系来解耦组件,而非直接在组件内创建依赖,让组件间过度耦合。

我们可以通过多种方式为实例提供必要的依赖关系:

  • 构造函数注入
  • 属性注入
  • 方法注入

构造函数注入

构造函数注入,或称基于初始化器的依赖注入,意味着在实例初始化期间提供所有必需的依赖项,将其作为构造函数的参数:

public class DataProcessor {

private final DbManager manager;
private final Calculator calculator;

public DataProcessor(DbManager manager, Calculator calculator) {
this.manager = manager;
this.calculator = calculator;
}

// ...
}

由于这一简单的改变,我们可以弥补大多数最开始的缺点:

  • 易于替换:DbManagerCalculator 不再被具体的实现所束缚,现在可以模拟单元测试了。
  • 已经初始化并且「准备就绪」:我们不必担心依赖项所需要的任何子依赖项(例如,数据库文件名、有效数字(译者注)等),也不必担心它们可在初始化期间发生崩溃的可能性。
  • 强制要求:调用方确切地知道创建 DataProcessor 的所需内容。
  • 不变性:依赖关系始终如初。

尽管构造函数注入是许多依赖注入框架的首选方法,但它也有明显的缺点。其中最大的缺点是:必须在初始化时提供所有依赖项。

有时,我们无法自己初始化一个组件,或者在某个时刻我们无法提供组件的所有依赖关系。或者我们需要使用另外一个构造函数。一旦设置了依赖项,我们就无法再改变它们了。

但是我们可以使用其它注入类型来缓解这些问题。

属性注入

有时,我们无法访问类型实际的初始化方法,只能访问一个已经初始化的实例。或者在初始化时,所需要的依赖关系并不像之后那样明确。

在这些情况下,我们可以使用属性注入而不是依赖于构造函数:

public class DataProcessor {

public DbManager manager = null;
public Calculator calculator = null;

// ...

public void processData() {
// WARNING: Possible NPE
this.manager.processData();
}

public BigDecimal calc(BigDecimal input) {
// WARNING: Possible NPE
return this.calculator.expensiveCalculation(input);
}
}

我们不再需要构造函数了,在初始化后我们可以随时提供依赖项。但这种注入方式也有缺点:易变性

在初始化后,我们不再保证 DataProcessor 是「随时可用」的。能够随意更改依赖关系可能会给我们带来更大的灵活性,但同时也会带来运行时检查过多的缺点。

现在,我们必须在访问依赖项时处理出现 NullPointerException 的可能性。

方法注入

即使我们将依赖项与构造函数注入与/或属性注入分离,我们也仍然只有一个选择。如果在某些情况下我们需要另一个 Calculator 该怎么办呢?

我们不想为第二个 Calculator 类添加额外的属性或构造函数参数,因为将来可能会出现第三个这样的类。而且在每次调用 calc(...) 前更改属性也不可行,并且很可能因为使用错误的属性而导致 bug。

更好的方法是参数化调用方法本身及其依赖项:

public class DataProcessor {

// ...

public BigDecimal calc(Calculator calculator, BigDecimal input) {
return calculator.expensiveCalculation(input);
}
}

现在,calc(...) 的调用者负责提供一个合适的 Calculator 实例,并且 DataProcessor 类与之完全分离。

通过混合使用不同的注入类型来提供一个默认的 Calculator,这样可以获得更大的灵活性:

public class DataProcessor {

// ...

private final Calculator defaultCalculator;

public DataProcessor(Calculator calculator) {
this.defaultCalculator = calculator;
}

// ...

public BigDecimal calc(Calculator calculator, BigDecimal input) {
return Optional.ofNullable(calculator)
.orElse(this.calculator)
.expensiveCalculation(input);
}
}

调用者可以提供另一种类型的 Calculator,但这不是必须的。我们仍然有一个解耦的、随时可用的 DataProcessor,它能够适应特定的场景。

选择哪种注入方式?

每种依赖注入类型都有自己的优点,并没有一种「正确的方法」。具体的选择完全取决于你的实际需求和情况。

构造函数注入

构造函数注入是我的最爱,它也常受依赖注入框架的青睐。

它清楚地告诉我们创建特定组件所需的所有依赖关系,并且这些依赖不是可选的,这些依赖关系在整个组件中应该都是必需的。

属性注入

属性注入更适合可选参数,例如监听或委托。又或是我们无法在初始化时提供依赖关系。

其它编程语言,例如 Swift,大量使用了带属性的 委托模式。因此,使用属性注入将使其它语言的开发人员更熟悉我们的代码。

方法注入

如果在每次调用时依赖项可能不同,那么使用方法注入最好不过了。方法注入进一步解耦组件,它使方法本身持有依赖项,而非整个组件。

请记住,这不是非此即彼。我们可以根据需要自由组合各种注入类型。

控制反转容器

这些简单的依赖注入实现可以覆盖很多用例。依赖注入是很好的解耦工具,但事实上我们仍然需要在某些时候创建依赖项。

但随着应用程序和代码库的增长,我们可能还需要一个更完整的解决方案来简化依赖注入的创建和组装过程。

控制反转(IoC)是 控制流 的抽象原理。依赖注入是控制反转的具体实现之一。

控制反转容器是一种特殊类型的对象,它知道如何实例化和配置其它对象,它也知道如何帮助你执行依赖注入。

有些容器可以通过反射来检测关系,而另一些必须手动配置。有些容器基于运行时,而有些则在编译时生成所需要的所有代码。

比较所有容器的不同之处超出了本文的讨论范围,但是让我通过一个小示例来更好地理解这个概念。

示例: Dagger 2

Dagger 是一个轻量级、编译时进行依赖注入的框架。我们需要创建一个 Module,它就知道如何构建我们的依赖项,稍后我们只要添加 @Inject 注释就可以注入这个 Module

@Module
public class InjectionModule {

@Provides
@Singleton
static DbManager provideManager() {
return manager;
}

@Provides
@Singleton
static Calculator provideCalculator() {
return new HighPrecisionCalculator(5);
}
}

@Singleton 确保只能创建一个依赖项的实例。

要注入依赖项,我们只需要将 @Inject 添加到构造函数、字段或方法中。

public class DataProcessor {

@Inject
DbManager manager;

@Inject
Calculator calculator;

// ...
}

这些仅仅是一些基础知识,乍一看不可能会给人留下深刻的印象。但是控制反转容器和框架不仅解耦了组件,也让创建依赖关系的灵活性得以最大化。

由于提供了高级特性,创建过程的可配置性变得更强,并且支持了使用依赖项的新方法。

高级特性

这些特性在不同类型的控制反转容器和底层语言之间差异很大,比如:

  • 代理模式 和延迟加载。
  • 生命周期(例如:单例模式与每个线程一个实例)。
  • 自动绑定。
  • 单一类型的多种实现。
  • 循环依赖。

这些特性是控制反转容器真正的能力。你可能会认为诸如「循环依赖」这样的特性并非好的主意,确实如此。

但是,如果由于遗留代码或是过去不可更改的错误设计而需要这种奇怪的代码构造,那么我们现在有能力可以这样做。

总结

我们应该根据抽象(例如接口)而不是具体的实现来设计代码,这样可以帮助我们减少代码耦合。

接口必须提供我们代码所需要的唯一信息,我们不能对实际实现情况做任何假设。

「程序应当依赖抽象,而非具体的实现」
—— Robert C. Martin (2000), 《设计原则与设计模式》

依赖注入是通过解耦组件来实现这一点的好办法。它使我们能够编写更简洁明了、更易于维护和重构的代码。

选择三种依赖注入类型中的哪种很大程度上取决于环境和需求,但是我们也可以混合使用三种类型使收益最大化。

控制反转容器有时几乎以一种神奇的方式通过简化组件创建过程来提供另一种便利的布局。

我们应该处处使用它吗?当然不是。

就像其它模式和概念一样,我们应该在适当的时候应用它们,而不是能用则用。

永远不要把自己局限在一种做事的方式上。也许 工厂模式 甚至是广为厌恶的 单例模式 是能够满足你需求的更好的解决方案。


资料


控制反转容器

Java

Kotlin

Swift

C

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

🔲 ⭐

《Head First 设计模式》学习笔记 | 策略模式

前言

我最近在看大名鼎鼎的《Head First 设计模式》。这本「OO 圣经」用 Java 实现各类设计模式,对于我 —— 一个非 Java 爱好者而言,读起来并不过瘾。

有人读完这本书可能会误解设计模式就是设计 Interface,而事实并非如此。在知乎的一个问题《Python 里没有接口,如何写设计模式?》中,vczh 轮子哥是这样回答的:

设计模式搞了那么多东西就是在告诉你如何在各种情况下解耦你的代码,让你的代码在运行时可以互相组合。这就跟兵法一样。难道有了飞机大炮,兵法就没有用了吗?

我觉得这个比喻很好,不同的语言就像不同的兵器,各有各的特点与使用方式,而设计模式就是那套「兵法」,无论你使用何种兵器,不过是「纵横不出方圆,万变不离其宗」。而只看书中一种「兵器」未免太少,不如我们多试几样?

本篇就来看一看第一章「兵法」 —— 策略模式(Strategy Pattern)。

定义

书中对策略模式的定义如下:

策略模式定义了算法族,分别封装起来,让它们之间可以互相替换,此模式让算法的变化独立于使用算法的客户。

下面以书中的「模拟鸭子应用」为例。

继承的弊端

你要设计一个鸭子游戏,游戏里有各种各样的鸭子,它们会游泳(swim()),还会呱呱叫(quack()),每种鸭子拥有不同的外观(display())。

一开始,你可能会设计一个鸭子的超类 Duck,然后让所有不同种类的鸭子继承它:

设计一个鸭子超类(Superclass)

如果此时我们想让鸭子飞起来,就要在超类中增加一个 fly() 方法:

让鸭子飞

此时,鸭子家族来了一只擅于代码调试工作的小黄鸭。

此时,一切都乱套了,这位代码调试工作者会发出「吱吱」的叫声,但却不会飞,然而它却从鸭子超类继承了 quack()fly() 方法。为了让它尊重客观事实,我们需要在小黄鸭类中覆盖超类的 quack()fly() 方法,让它变得不会叫也不会飞。

在小黄鸭中覆盖原有的方法

虽然我们用「覆盖方法」的手段解决了小黄鸭的问题,但未来我们可能还会制造更多奇奇怪怪的鸭子。例如周黑鸭或北京烤鸭,它们显然既不会叫,也不会游泳,还不会飞,这时我们又要为它们重写所有的行为吗?利用继承的方式来为不同种类的鸭子提供行为显然不够灵活。

抽离可变行为

不同的鸭子具有不同的行为,鸭子的行为应当是灵活可变的

设计原则一:找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。

因此,利用上述原则,我们把「鸭子的行为」从鸭子类(Duck)中抽离出来。

取出容易变化的行为

实现被抽离的行为

设计原则二:针对接口编程,而不是针对实现编程。

我们将这些被抽离出的行为归类:

  • 所有具体的飞行行为属于飞行策略
  • 所有具体的叫声行为属于叫声策略
  • 所有具体的游泳行为属于游泳策略
  • ……

我们可以利用接口或抽象类代表这些策略,然后让特定的具体行为来实现这些策略中的方法

例如,我们的飞行策略名为 FlyBehavior,我们将它设计为一个抽象类(当然也可以是接口)。然后,我们有两种具体的飞行方式 FlyWithWings(会飞)和 FlyNoWay(不会飞),它们需要实现飞行策略中的 fly() 方法:

整合

此时,我们已经将可变的行为从鸭子超类(Duck)中抽离,并把它们用具体的「行为类」进行表示。我们希望:如果鸭子要执行某个行为,它不需要自己处理,而是将这一行为委托给具体的「行为类」

因此,我们可以在鸭子超类(Duck)中加入「行为类」的实例变量,从而通过这些实例变量来调用具体的行为方法。

Class Duckfly() 方法中,我们可以使用实例 flyBehavior 调用具体的行为方法,从而达成「委托」的目的:

public function fly() 
{
$this->flyBehavior->fly();
}

具体实现

下面来看看不同语言的具体实现:

PHP

PHP 有抽象类也有接口,语法和 Java 比较接近。实现方法中规中矩,和书中的并无二致。只不过这里我把行为接口改成了抽象类。类图如下:

UML 类图关系

具体实现:

<?php
// 飞行行为类
abstract class FlyBehavior
{
abstract public function fly();
}

// 「飞」的具体行为
class FlyWithWings extends FlyBehavior
{
public function fly()
{
echo "会飞\n";
}
}

class FlyNoWay extends FlyBehavior
{
public function fly()
{
echo "不会飞\n";
}
}

// 叫声行为类
abstract class QuackBehavior
{
abstract public function quack();
}

// 「叫」的具体行为
class Quack extends QuackBehavior
{
public function quack()
{
echo "呱呱\n";
}
}

class Squeak extends QuackBehavior
{
public function quack()
{
echo "吱吱\n";
}
}

class MuteQuack extends QuackBehavior
{
public function quack()
{
echo "不会叫\n";
}
}

// 鸭子类
abstract class Duck
{
protected $flyStrategy;
protected $quackStrategy;

public function fly()
{
$this->flyStrategy->fly();
}

public function quack()
{
$this->quackStrategy->quack();
}
}

// 有只小黄鸭
class YellowDuck extends Duck
{
public function __construct($flyStrategy, $quackStrategy)
{
$this->flyStrategy = $flyStrategy;
$this->quackStrategy = $quackStrategy;
}
}

$yellowDuck = new YellowDuck(new FlyNoWay(), new Squeak());
$yellowDuck->fly();
$yellowDuck->quack();

/* Output:
不会飞
吱吱
*/
?>

Python

Python 就没有所谓的抽象类和接口了,当然你也可以通过 abc 模块来实现这些功能。

比较简单的做法是:将具体行为直接定义为函数,在初始化鸭子时通过构造函数传入行为函数,赋值给对应的变量。当执行具体行为时,将直接调用被赋值的变量,这时具体的行为动作就被委托给了传入的行为函数,达到了「委托」的效果。

class Duck:
def __init__(self, fly_strategy, quack_strategy):
self.fly_strategy = fly_strategy
self.quack_strategy = quack_strategy

def fly(self):
self.fly_strategy()

def quack(self):
self.quack_strategy()

def fly_with_wings():
print("会飞")

def fly_no_way():
print("不会飞")

def quack():
print("呱呱")

def squeak():
print("吱吱")

def mute_quack():
print("不会叫")

# 一只会飞也不会叫的小黄鸭
yellow_duck = Duck(fly_no_way, mute_quack)
yellow_duck.fly()
yellow_duck.quack()

# Output:
# 不会飞
# 不会叫

Golang

在 Go 语言中没有 extends 关键字,但可以通过在结构体中内嵌匿名类型的方式实现继承关系。此处,将 FlyBehavior 飞行行为和 QuackBehavior 行为声明为接口。

package main

import "fmt"

// FlyBehavior 飞行行为接口
type FlyBehavior interface {
fly()
}

// QuackBehavior 呱呱叫行为接口
type QuackBehavior interface {
quack()
}

// FlyWithWings 会飞的类
type FlyWithWings struct {
}

func (flyWithWings FlyWithWings) fly() {
fmt.Println("会飞")
}

// FlyWithWings 不会飞的类
type FlyNoWay struct{}

func (flyNoWay FlyNoWay) fly() {
fmt.Println("不会飞")
}

// Quack 呱呱叫
type Quack struct{}

func (quack Quack) quack() {
fmt.Println("呱呱")
}

// Squeak 吱吱叫
type Squeak struct{}

func (squeak Squeak) quack() {
fmt.Println("吱吱")
}

// MuteQuack 不会叫
type MuteQuack struct{}

func (muteQuack MuteQuack) quack() {
fmt.Println("不会叫")
}

// Duck 鸭子类
type Duck struct {
FlyBehavior FlyBehavior
QuackBehavior QuackBehavior
}

func (d *Duck) fly() {
d.FlyBehavior.fly() // 委托给飞行行为
}

func (d *Duck) quack() {
d.QuackBehavior.quack() // 委托给呱呱叫行为
}

func main() {
yellowDuck := Duck{FlyNoWay{}, Squeak{}}
yellowDuck.fly()
yellowDuck.quack()
}

/* Output:
不会飞
吱吱
*/

总结

三种设计原则:

  1. 封装变化
  2. 多用组合,少用继承
  3. 针对接口编程,不针对实现编程

注意此处的「针对接口编程」,书中也有强调:

「针对接口编程」真正的意思是「针对超类型(supertype)编程」。这里所谓的「接口」有多个含义,接口是一个「概念」,也是一种 Java 的 interface 构造。你可以在不涉及 Java interface 的情况下「针对接口编程」,关键就在多态。利用多态,程序可以针对超类型编程,执行时会根据实际状况执行到真正的行为。

因此,你不用拘泥于 interface,你所用的语言就算没有 interface 也能实现设计模式。

❌