TypeScript 中的依赖注入
SOLID 中的字母 D 代表依赖倒置原则。它有助于将模块彼此解耦,以便您可以轻松地将代码的一部分替换为另一部分。
有助于遵循这一原则的技术之一是依赖注入。
这篇文章的灵感来自Sasha Bespoyasov 的文章,部分内容是其翻译。
什么是依赖关系?
为了便于参考,我们将依赖项定义为我们的模块使用的任何模块。
让我们看一个接受两个数字并返回一定范围内的随机数的函数:
const getRandomInRange = (min: number, max: number): number =>
Math.random() * (max - min) + min;
该函数取决于两个参数:min
和max
。
但你可以看到,函数不仅依赖于参数,还依赖于Math.random
函数本身。如果Math.random
没有定义,我们的getRandomInRange
函数也无法运行。也就是说,它getRandomInRange
依赖于另一个模块的功能。因此,Math.random
它也是一个依赖项。
让我们通过参数明确传递依赖关系:
const getRandomInRange = (
min: number,
max: number,
random: () => number,
): number => random() * (max - min) + min;
现在该函数不仅使用两个数字,而且还使用一个random
返回数字的函数。我们将getRandomInRange
像这样调用它:
const result = getRandomInRange(1, 10, Math.random);
为了避免Math.random
一直传递,我们可以将其作为最后一个参数的默认值。
const getRandomInRange = (
min: number,
max: number,
random: () => number = Math.random
): number => random() * (max - min) + min;
这是依赖倒置的原始实现。我们将模块运行所需的所有依赖项传递给它。
为什么需要它?
真的,为什么要把Math.random
参数放进去然后直接用呢?直接在函数内部用有什么问题吗?原因有两个。
可测试性
当所有依赖项都明确声明后,模块测试起来会更容易。我们可以看到运行测试需要准备什么。我们知道哪些部分会影响该模块的运行,因此可以根据需要用一些简单的实现或模拟实现来替换它们。
Mock 实现让测试变得简单很多,有时候没有它们你根本测试不了任何东西。就像我们的函数一样getRandomInRange
,我们无法测试它返回的最终结果,因为它是……随机的。
/*
* We can create a mock function
* that will always return 0.1 instead of a random number:
*/
const mockRandom = () => 0.1;
/* Next, we call our function by passing the mock object as its last argument: */
const result = getRandomInRange(1, 10, mockRandom);
/*
* Now, since the algorithm within the function is known and deterministic,
* the result will always be the same:
*/
console.log(result === 1); // -> true
用另一个依赖项替换一个依赖项
在测试期间交换依赖项是一种特殊情况。通常,我们可能出于其他原因想要将一个模块交换为另一个模块。
如果新模块的行为与旧模块相同,我们可以用一个模块替换另一个模块:
const otherRandom = (): number => {
/* Another implementation of getting a random number... */
};
const result = getRandomInRange(1, 10, otherRandom);
但是我们能保证新模块的行为与旧模块相同吗?是的,我们可以,因为我们使用了() => number
参数类型。这就是我们使用 TypeScript 而不是 JavaScript 的原因。类型和接口是模块之间的纽带。
对抽象的依赖
乍一看,这可能有点过于复杂。但事实上,采用这种方法:
- 模块之间的相互依赖性降低;
- 在开始编写代码之前,我们必须设计行为。
当我们提前设计行为时,我们会使用抽象约定。在这些约定下,我们设计自己的模块或第三方模块的适配器。这使我们能够替换系统的某些部分,而无需完全重写。
当模块比上面的示例更复杂时,这尤其有用。
依赖注入
我们再写一个计数器。我们的计数器可以增加或减少,并且还能记录……的状态。
class Counter {
public state: number = 0;
public increase(): void {
this.state += 1;
console.log(`State increased. Current state is ${this.state}.`);
}
public decrease(): void {
this.state -= 1;
console.log(`State decreased. Current state is ${this.state}.`);
}
}
这里我们遇到了和之前一样的问题——我们不仅使用了类实例的内部状态,还使用了另一个模块—— console
。我们必须注入这种依赖关系。
如果在函数中我们通过参数传递依赖项,那么在类中我们将通过构造函数注入依赖项。
我们的Counter
类使用了 console 对象的 log 方法。这意味着该类需要传递某个包含该log
方法的对象作为依赖项。其实并非必须如此console
——我们只需要考虑模块的可测试性和可替换性。
interface Logger {
log(message: string): void;
}
class Counter {
constructor(
private logger: Logger,
) {}
public state: number = 0;
public increase(): void {
this.state += 1;
this.logger.log(`State increased. Current state is ${this.state}.`);
}
public decrease(): void {
this.state -= 1;
this.logger.log(`State decreased. Current state is ${this.state}.`);
}
}
然后我们将创建此类的实例,如下所示:
const counter = new Counter(console);
如果我们想console
用其他东西替换,我们只需要确保新的依赖项实现了Logger
接口:
const alertLogger: Logger = {
log: (message: string): void => {
alert(message);
},
};
const counter = new Counter(alertLogger);
自动注射和 DI 容器
现在该类不再使用隐式依赖项了。这很好,但这种注入方式仍然很尴尬:每次你都必须手动添加对象的引用,如果有多个对象,还要尽量避免顺序混乱等等……
这应该由一个特殊的东西来处理——DI 容器。一般来说,DI 容器是一个只向其他模块提供依赖关系的模块。
容器知道哪个模块需要哪些依赖项,并在需要时创建并注入它们。我们将对象从跟踪其依赖项的义务中解放出来,控制权转移到其他地方,正如 SOLID 中的字母 S 和 D 所暗示的那样。
容器会知道每个模块需要哪些对象以及哪些接口。它还会知道哪些对象实现了这些接口。当创建依赖于这些接口的对象时,容器会自动创建并访问它们。
自动注射实践
我们将使用Brandi DI 容器,它的功能与我们上面描述的完全一致。
让我们首先声明Logger
接口并创建其ConsoleLogger
实现:
/* Logger.ts */
export interface Logger {
log(message: string): void;
}
export class ConsoleLogger implements Logger {
public log(message: string): void {
console.log(message)
}
}
现在我们需要了解令牌。由于我们将 TypeScript 编译成 JavaScript,因此代码中不会包含接口和类型。Brandi 使用令牌将依赖项绑定到 JavaScript 运行时中的实现。
/* tokens.ts */
import { token } from 'brandi';
import { Logger } from './Logger';
import { Counter } from './Counter';
export const TOKENS = {
logger: token<Logger>('logger'),
counter: token<Counter>('counter'),
};
上面的代码提到了Counter
。我们看看依赖项是如何注入的:
/* Counter.ts */
import { injected } from 'brandi';
import { TOKENS } from './tokens';
import { Logger } from './Logger';
export class Counter {
constructor(
private logger: Logger,
) {}
/* Other code... */
}
injected(Counter, TOKENS.logger);
我们使用该injected
函数来指定应该通过哪个令牌注入依赖项。
由于令牌是有类型的,因此无法注入具有不同接口的依赖项,这将在编译时引发错误。
最后,我们来配置容器:
/* container.ts */
import { Container } from 'brandi';
import { TOKENS } from './tokens';
import { ConsoleLogger } from './logger';
import { Counter } from './counter';
export const container = new Container();
container
.bind(TOKENS.logger)
.toInstance(ConsoleLogger)
.inTransientScope();
container
.bind(TOKENS.counter)
.toInstance(Counter)
.inTransientScope();
我们将令牌与它们的实现绑定在一起。
现在,当我们从容器中获取一个实例时,它的依赖项将被自动注入:
/* index.ts */
import { TOKENS } from './tokens';
import { container } from './container';
const counter = container.get(TOKENS.counter);
counter.increase() // -> State increased. Current state is 1.
啥是inTransientScope()
?
瞬态是容器将创建的一种实例生命周期。
inTransientScope()
— 每次从容器中获取实例时都会创建一个新实例;inSingletonScope()
— 每次获取都会返回相同的实例。
Brandi 还允许您在容器和解析范围内创建实例。
容器的好处
第一个好处是,我们可以用一行代码更改所有模块的实现。这样就实现了 SOLID 最后一个字母所说的控制反转。
例如,如果我们想Logger
在所有依赖此接口的地方更改实现,我们只需要更改容器中的绑定:
/* The new implementation. */
class AlertLogger implements Logger {
public log(message: string): void {
alert(message);
}
}
/*
* With this implementation we replace the old `ConsoleLogger`.
* This only needs to be done in one place, at the token binding:
*/
container
.bind(TOKENS.logger)
.toInstance(AlertLogger)
.inTransientScope();
另外,我们不需要手动传递依赖项,我们不需要遵循构造函数中的枚举顺序,模块之间的耦合度变得更低。
您应该使用 DI 吗?
花点时间了解一下使用 DI 的潜在利弊。虽然你需要编写一些基础代码,但你的代码耦合度会更低,更灵活,也更易于测试。
鏂囩珷鏉ユ簮锛�https://dev.to/vovaspace/dependency-injection-in-typescript-4mbf