介绍
基础知识
术语
构建与使用
不同的实现
开发中的模拟实现
提取配置
类的依赖注入
决定提取什么作为依赖
组合根(AKA 容器)
自动 DI 容器
DIY自动DI容器
爱力克斯
循环依赖
编写代码:自上而下与自下而上的方法
最后的考虑
进一步阅读
在我的编程之旅中,在我学到的所有东西中,有一些东西脱颖而出,它们不仅从根本上改变了我的编程方式,而且还改变了我对编程的总体看法。
依赖注入就是其中之一。
依赖注入,又名DI,是一种具有许多好处的强大技术。最明显的(但不仅如此)之一是它可以帮助我们测试那些没有它就很难或不可能做的事情。
最重要的是,一旦你理解了它,就很容易在实践中应用。
但这是棘手的部分:尽管依赖注入是一种有点简单的技术,但根据我的经验,它并不容易掌握,我相信这就是为什么没有多少人使用它的原因。
这是我从经验中可以看出的事情。在阅读了一些关于依赖注入的资源后,我的一位朋友兼导师对这种技术非常有经验,向我解释后才开始理解它。
在本系列中,我们将了解什么是依赖注入,并了解它的一些最重要的技术和应用。
为了使接口明确,示例是用Typescript编写的,它与依赖注入配合得很好。
那么让我们从一个非常具体的例子开始。
想象一下:
您正在编写一个生成介于 0 和某个正数之间的随机数的函数:
// randomNumber.ts
export const randomNumber = (max: number): number => {
return Math.floor(Math.random() * (max + 1));
};
您将如何为此功能编写单元测试?
由于randomNumber
取决于 ,其输出我们无法控制且不确定,因此没有简单的方法来做到这一点。(它们是伪随机的,但从我们的角度来看,无法确定它将生成哪个数字)Math.random
每当我们想让函数的调用者控制函数内部发生的事情时,我们都会参数化那个东西:
// randomNumber.ts
export type RandomGenerator = () => number;
export const randomNumber = (
randomGenerator: RandomGenerator,
max: number
): number => {
return Math.floor(randomGenerator() * (max + 1));
};
现在,我们可以传递任何我们想要的东西,而不是在内部进行硬编码,只要它符合正确的接口,包括它本身。Math.random
randomNumber
randomGenerator
Math.random
这允许我们传递我们控制的模拟版本,现在我们能够为以下内容编写单元测试:Math.random
randomNumber
// randomNumber.test.ts
describe("General case", () => {
it("Produces numbers within the established range", () => {
const randomGeneratorMock = jest
.fn()
.mockReturnValueOnce(0.13445)
.mockReturnValueOnce(0.542)
.mockReturnValueOnce(0.889);
expect(randomNumber(randomGeneratorMock, 10)).toBe(1);
expect(randomNumber(randomGeneratorMock, 10)).toBe(5);
expect(randomNumber(randomGeneratorMock, 10)).toBe(9);
});
});
我们所做的展示了依赖注入背后的主要概念:
依赖注入,本质上就是将之前硬编码在函数/类中的东西参数化,这样我们就可以更大程度地控制这些函数/类。
我想在这里强调“控制”这个词,因为归根结底,这是使用依赖注入的主要目标。
此外,正是通过实现更大程度的控制,我们最终获得了通常归因于依赖注入的所有其他好处。更少的耦合、可测试性、可重用性和可维护性是其中的一部分。但这还不是全部,所以让我们回到我们的示例。
现在我们可以进行单元测试了randomNumber
,但是,当我们解决了一个问题时,我们又创建了另一个问题。为了理解它,假设我们现在需要一个函数来生成预定范围内的随机数列表。为此,我们randomNumber
当然会使用 :
// randomNumberList.ts
import { randomNumber } from "./randomNumber";
export const randomNumberList = (
max: number,
length: number
): Array<number> => {
return Array(length)
.fill(null)
.map(() => randomNumber(Math.random, max));
};
如果您和我一样,这段代码可能会让您感到不舒服,并且有充分的理由:
以前,当被硬编码时,调用者不必关心提供一个,甚至不需要知道它的存在,因为这曾经是一个有目的地封装在 中的实现细节。Math.random
randomNumber
randomGenerator
randomNumber
现在,正如randomNumber
将此实现细节“泄露”给调用者一样,他们有提供randomGenerator
自己的负担,这意味着他们更有可能受到 中更改的影响randomNumber
,就像我们决定更改randomGenerator
的接口一样。
这是抽象与灵活性的难题。我们越抽象实现细节,那段代码变得越不灵活,因为当我们使用抽象来隐藏它们时,它们就超出了调用者的范围。相反,为了使一段代码更灵活,我们必须参数化一些东西,这会将它的一些实现移到它的接口上。
正是这个问题让我很长一段时间都无法理解依赖注入,因为即使我一次又一次地阅读依赖注入,在我看来它总是解决了一个问题只是为了创建另一个可能更糟糕的问题。
所以,我希望你密切关注下一部分,因为它表明这个问题很容易解决:
// randomNumber.ts
export type RandomGenerator = () => number;
// We renamed our previous randomNumber function
// to randomNumberImplementation
export const randomNumberImplementation = (
randomGenerator: RandomGenerator,
max: number
): number => {
return Math.floor(randomGenerator() * (max + 1));
};
// randomNumber now has the randomGenerator
// already injected into it
export const randomNumber = (max: number) =>
randomNumberImplementation(Math.random, max);
// randomNumberList.ts
import { randomNumber } from "./randomNumber";
export const randomNumberList = (
max: number,
length: number
): Array<number> => {
return Array(length)
.fill(null)
.map(() => randomNumber(max));
};
现在,我们拥有两全其美的优势。我们仍然可以randomNumber
通过使用randomNumberImplementation
mocked进行测试randomGenerator
,而且,我们公开了一个randomNumber
已经randomGenerator
注入“库存”的“版本”,这样消费者就不必关心自己提供它了。
我认为一个很好的类比是当我们购买 PC 时,我们有两种选择:
购买预制 PC。
单独购买所有零件并自行组装。
如果我们选择购买预装PC,我们不需要了解任何关于PC组装的知识,我们唯一需要知道的就是如何使用它,当然,我们受到可用预装范围的限制。内置个人电脑。
另一方面,如果我们选择自己购买所有部件,我们就可以更好地控制我们的 PC 的结果,因为我们可以选择要安装到它的每个部件。同时,知道如何使用是不够的,我们还需要一些关于如何使用我们选择的部件来构建它的知识。
我们的代码也是如此。
我们应该将这种将依赖项注入函数/类以生成它们的“完整版本”的过程解释为构建过程。我们的代码中有些部分不必知道它们使用的东西是如何构建的,它们唯一需要知道的是如何使用它们。然而,对于代码的其他部分,比如测试,我们希望对这些东西的行为有更多的控制,但要做到这一点,我们需要知道如何构建它们。
因此,总而言之,依赖注入包括两件事:
将硬编码的依赖项提取为函数/类中的参数,因此我们可以更好地控制它们。
创建这些函数/类的“版本”,并注入它们的依赖项,以便我们可以将它们分发给那些需要使用它们但不需要知道它们是如何构建的人。
这些是依赖注入背后的核心概念。
在我们继续之前,我们需要建立一些术语来更有效地沟通。
在依赖注入的术语中,我们有两个重要的术语:服务(或依赖项)和客户端。
服务(或依赖项)是变量、对象、类、函数或几乎任何语言结构,它们为使用/依赖它们的人提供某种功能。
客户端是可能使用也可能不使用某些服务的函数或类。
请注意,某事物是服务还是客户端的分类取决于上下文。这意味着某些东西可以同时是一种环境中的服务和另一种环境中的客户端。
所以,从现在开始,与其说:
“依赖注入,本质上就是将之前硬编码在函数/类中的东西参数化,这样我们就可以更大程度地控制这些函数/类”。
我们要说:
“依赖注入,本质上就是把之前硬编码在客户端的服务参数化,这样我们就可以更大程度地控制这些客户端”。
早些时候,我们谈到了构建和使用服务之间的区别,但是由于服务依赖项及其参数都是混合的,我们的代码并没有非常清楚地反映这种区别。
为了改变这一点,我们将使用一个函数(在此上下文中称为工厂函数),其唯一目的是通过接收服务的依赖项作为参数来构建我们的服务:
// randomNumber.ts
export type RandomGenerator = () => number;
export const makeRandomNumber =
(randomGenerator: RandomGenerator) =>
(max: number): number => {
return Math.floor(randomGenerator() * (max + 1));
};
// Quick Note:
// The above construct is called a Higher Order Function (HOC),
// because it returns another function.
//
// If it feels weird because of the arrow function notation,
// but it is equivalent to this:
export function makeRandomNumber(randomGenerator: RandomGenerator) {
return function randomNumber(max: number): number {
return Math.floor(randomGenerator() * (max + 1));
};
}
export const randomNumber = makeRandomNumber(Math.random);
以前,randomNumber
的依赖randomGenerator
项 ( ) 与其普通参数 ( max
) 混合在一起,也就是说,参数randomNumber
的客户端必须传递给“构建randomNumber
版本”。
现在,我们使用工厂函数( makeRandomNumber
) 来收集这些依赖关系,并明确地将randomNumber
的构造与其使用分开,这也反映在函数的名称中。
除了概念清晰度的提高之外,这种分离将很快被证明是非常有用的,因为它是围绕 DI 的一些技术的基础。
想象一下,我们虚构的应用程序已经发展壮大,现在我们需要randomNumber
加密安全,也就是说,根据它之前产生的数字,真的很难猜测它接下来会产生哪个数字。
值得庆幸的是,我们找到了一个名为的库(也是虚构的)secureRandomNumber
,它公开了一个具有相同名称 ( secureRandomNumber
) 的函数,该函数具有与 相同的接口randomNumber
,并且正是我们需要的。
这意味着我们可以使用secureRandomNumber
它作为 的直接替代品randomNumber
,因为它还会生成一个介于 0 和某个上限之间的随机数,并将其作为参数,其好处是它以加密安全的方式这样做。
因此,实际上,在我们调用的每个地方,randomNumber
我们都可以调用secureRandomNumber
。
// Before
const someNumber = randomNumber(10);
// After
const someNumber = secureRandomNumber(10);
但有一个问题:secureRandomNumber
运行时间比运行要多得多,所以我们希望在非生产环境randomNumber
中继续使用并且只在生产环境中使用(顺便说一句,我不提倡这样做)。randomNumber
secureRandomNumber
这意味着当 时,我们希望所有使用的模块继续使用它,但是当 时,它们将改为使用。NODE_ENV !== "production"
randomNumber
NODE_ENV === "production"
secureRandomNumber
我们可以做的一件事是randomNumber
根据我们所处的环境更改实现:
// randomNumber.ts
import { secureRandomNumber } from "secureRandomNumber";
export const makeRandomNumber =
(
// From now on, we'll use the
// RandomGenerator type inline instead
// of having is defined as a named type,
// for brevity
randomGenerator: () => number,
secureRandomNumber: (max: number) => number
) =>
(max: number) => {
if (process.NODE_ENV !== "production") {
return Math.floor(randomGenerator() * (max + 1));
}
return secureRandomNumber(max);
};
export const randomNumber = makeRandomNumber(Math.random, secureRandomNumber);
这种方法有很多问题,但现在我只想提请您注意一个问题:
makeRandomNumber
一次做太多事情。
起初,makeRandomNumber
的职责是创建一个生成随机数的函数,现在它仍然这样做,但它还会选择将使用哪种算法来生成随机数。
我们甚至不得不更改它的接口以适应该要求,仅此一项就会破坏其所有单元测试。
因此,我们不这样做,而是考虑另一种方法,一种利用依赖注入的方法。
首先,我们将保持不同的实现randomNumber
分开,在这种情况下,将为每个实现使用不同的文件。
由于secureRandomNumber
来自库,我们只需要为以前的randomNumber
实现创建一个新文件,现在将其命名为fastRandomNumber
.
// fastRandomNumber.ts
// This file holds our original
// randomNumber implementation
export const makeFastRandomNumber =
(randomGenerator: () => number) => (max: number) => {
return Math.floor(randomGenerator() * (max + 1));
};
export const fastRandomNumber = makeFastRandomNumber(Math.random);
然后,在文件中,我们只选择将要用于该服务的实现。randomNumber.ts
randomNumber
// randomNumber.ts
// This file only exports
// the selected random number function
import { fastRandomNumber } from "./fastRandomNumber";
import { secureRandomNumber } from "secureRandomNumber";
export const randomNumber =
process.env.NODE_ENV !== "production" ? fastRandomNumber : secureRandomNumber;
更清洁吧?现在我们不会破坏任何单元测试。
另外,让我们看一下randomNumberList
,它使用randomNumber
:
// randomNumberList.ts
import { randomNumber } from "./randomNumber";
export const makeRandomNumberList =
(randomNumber: (max: number) => number) =>
(max: number, length: number): number => {
return Array(length)
.fill(null)
.map(() => randomNumber(max));
};
// As we currently only have a single randomNumberList
// implementation, we can keep both its implementation
// and service creation in the same file
export const randomNumberList = makeRandomNumberList(randomNumber);
正如我们在上面看到的,消费者randomNumber
可以完全不知道他们正在使用的是哪个实际实现,因为他们都从同一个地方导入它,并且所有实现都遵循同一个接口。
一般来说,每当我们需要使用某个服务的不同实现时,最好使用策略模式。我们不是让服务本身根据某些参数表现不同,而是创建它的不同实现,然后选择合适的实现传递给客户端。
这种技术的另一个有趣的用法是,当我们不仅要在测试期间而且还要在开发期间模拟外部系统(如外部 API)时。
假设我们有一个获取用户的函数,并且在我们应用程序的很多地方使用:
// apiFetchUser.ts
export const makeApiFetchUser = (apiBaseUrl: string) => async () => {
const response = await fetch(`${apiBaseUrl}/users`);
const data = await response.json();
return data;
};
// Using window fetch
export const apiFetchUser = makeApiFetchUser(process.env.API_BASE_URL);
// fetchUser.ts
// This is where the parts of the application that
// use fetchUser import it from.
import { apiFetchUser } from "./apiFetchUser";
export const fetchUser = apiFetchUser;
现在,如果我们想要一个特定的模拟实现,要么是因为 API 还没有准备好使用,要么是因为我们想要一个特定的输出来开发一些特性或重现一些错误,我们可以用一个模拟的实现替换原来的实现:
// inMemoryFetchUser.ts
// In this case, as inMemoryFetchUser
// has no dependencies, we don't need a
// factory function
export const inMemoryFetchUser = () => {
return Promise.resolve([
{
id: "1",
name: "John",
},
{
id: "2",
name: "Fred",
},
]);
};
// fetchUser.ts
// This is where parts of the application that
// use fetchUser import it from.
import { restFetchUser } from "./restFetchUser";
import { inMemoryFetchUser } from "./inMemoryFetchUser";
// We comment this line where we were
// using the original implementation
// export const fetchUser = restFetchUser; void): void => {
const intervalInMs = 5000;
setInterval(() => {
// Data fetching logic
callback(data);
}, intervalInMs);
//);
我们每 5 秒轮询一次,但这个值是硬编码的,这意味着任何时候我们想要更改它都需要更改代码,然后,在大多数情况下,打开一个 PR,让它被审查、批准和合并。
此外,我们可能希望针对不同的环境设置不同的间隔。
也许,我们希望这个值在生产中更高,以避免服务器紧张,但在开发中稍微短一点,以便给我们更快的反馈,在本地开发中甚至更短。
因此,为了实现我们需要的灵活性,我们将这个值提取为环境变量:
export const poll = (callback: (data: Data)): void => {
setInterval(() => {
// Data fetching logic
callback(data);
}, process.env.POLLING_TIME_INTERVAL_IN_MS);
}
现在我们想用这个函数做一些集成测试,看看它在特定时间间隔内的行为,所以我们创建一个我们设置我们想要的值的地方,然后确保我们从加载环境变量。.env.test
POLLING_TIME_INTERVAL_IN_MS
.env.test
那么,我们最终会遇到两个问题:
如果我们想对不同的测试使用不同的时间间隔怎么办?
我们必须确保始终与其他环境变量保持同步。.env.test
出现这些问题是因为poll
知道时间间隔是从哪里来的,但到头来,它并不关心它是从哪里来的,它只需要一个时间间隔的值,那么为什么不将它作为依赖提取出来呢?
export const makePoll = (pollingTimeIntervalInMs: number) => (): void => {
setInterval(() => {
// Data fetching logic
callback(data);
}, pollingTimeInterval);
};
export const poll = makePoll(process.env.POLLING_TIME_INTERVAL);
现在,在测试时,我们可以注入任何pollingTimeIntervalInMs
我们想要的,我们甚至不再需要了。.env.test
另外,另一个好处是,在我们深埋的第一个实现中,依赖环境变量并不明显,所以要发现我们需要查看它的实现。process.env.POLLING_TIME_INTERVAL
poll
poll
在我们的重构示例中,我们将依赖项移至makePoll
的接口,使依赖关系显式化。
这个故事的寓意是:将配置值视为任何其他依赖项并将它们提取为参数,这样使用它们的服务就不需要知道它们来自哪里。这有助于测试,并允许我们更改从何处提取这些值(例如读取文件或异步调用 API),而无需更改服务的实现。
到目前为止,我们已经展示了当我们的客户端和服务是函数时如何使用依赖注入,但也可以使用类来实现:
// FastRandomNumber.ts
export class FastRandomNumber {
constructor(randomGenerator: () => number) {
this.randomGenerator = randomGenerator;
}
public generate(max: number): number {
return Math.floor(randomGenerator() * (max + 1));
}
private randomGenerator: () => number;
}
// randomNumber.ts
// Suppose that now SecureRandomNumber is a class
import { SecureRandomNumber } from "secureRandomNumber";
import { FastRandomNumber } from "./fastRandomNumber";
import { RandomNumber } from "./randomNumber";
export interface RandomNumber {
generate: (max: number) => number;
}
export const randomNumber =
process.env.NODE_ENV !== "production"
? new FastRandomNumber(Math.random)
: new SecureRandomNumber();
// RandomNumberListImpl.ts
export class RandomNumberListImpl {
constructor(randomNumber: RandomNumber) {
this.randomNumber = randomNumber;
}
public generate(max: number, length: number): Array<number> {
return Array(length)
.fill(null)
.map(() => randomNumber(max));
}
private randomNumber: RandomNumber;
}
// randomNumberList.ts
import { randomNumber } from "./randomNumber";
export const randomNumberList = new RandomNumberListImpl(randomNumber);
使用函数时,我们的依赖关系通过使用高阶函数在闭包中捕获。
使用类时,我们的依赖项存储在私有变量中。
当我们在整个应用程序中广泛使用依赖注入时,我们最终会开始怀疑我们将哪些东西提取为依赖关系,哪些东西是硬编码的。
首先让我告诉您,对此没有“一刀切”的规则。
当我们习惯使用依赖注入时,我们可能会倾向于采取“让我们提取所有内容”的姿势,但这行不通,因为我们编写代码的“通用”程度几乎没有限制。
例如,假设我们有以下功能:
export const foo = (value: number): number => {
if (value % 2 === 0) {
return value / 2;
}
return 3 * value + 1;
};
我们可以通过提取其数学运算符使其更通用:
type Dependencies = {
remainder: (dividend: number, divisor: number) => number;
divide: (dividend: number, divisor: number) => number;
multiply: (first: number, second: number) => number;
add: (first: number, second: number) => number;
};
export const makeFoo =
({ remainder, divide, multiply, add }) =>
(value: number): number => {
if (remainder(value, 2) === 0) {
return divide(value, 2);
}
return add(multiply(3, value), 1);
};
我们可以通过提取相等运算符和条件运算符使其更加通用:
type Dependencies = {
remainder: (dividend: number, divisor: number) => number;
divide: (dividend: number, divisor: number) => number;
multiply: (first: number, second: number) => number;
add: (first: number, second: number) => number;
isEqual: <T>(first: T, second: T) => boolean;
ifThenElse: <T, U>(condition: boolean, then: () => T, elseFun: U) => T | U;
};
export const makeFoo =
({ remainder, divide, multiply, add, isEqual, ifThenElse }) =>
(value: number): number => {
return ifThenElse(
isEqual(remainder(value, 2), 0),
() => divide(value, 2),
() => add(multiply(3, value), 1)
);
};
我们可以通过抽象“函数应用”操作使其更通用:
type Dependencies = {
apply: <Args extends Array<unknown>, Return>(
fun: (...args: Args) => Return,
...args: Args
) => Return;
remainder: (dividend: number, divisor: number) => number;
divide: (dividend: number, divisor: number) => number;
multiply: (first: number, second: number) => number;
add: (first: number, second: number) => number;
isEqual: <T>(first: T, second: T) => boolean;
ifThenElse: <T, U>(condition: boolean, then: () => T, elseFun: U) => T | U;
};
export const makeFoo =
({ apply, remainder, divide, multiply, add, isEqual, ifThenElse }) =>
(value: number): number => {
return apply(
ifThenElse,
apply(isEqual, apply(remainder, value, 2), 0),
() => apply(divide, value, 2),
() => apply(add, apply(multiply, 3, value), 1)
);
};
如您所见,这不会为我们的代码增加太多价值,因此在大多数情况下“让我们提取所有内容”并不是一个好主意。
尽管我们没有一些简单的规则来告诉我们哪些东西应该或不应该被提取为依赖关系,但我们确实有一些启发式方法可以帮助我们。
一般来说,我们可能想要提取:
配置值
来自其他单位/模块的服务
全局变量
具有不确定行为的服务(如随机数生成器、uuid 生成器)
直接或间接与程序内存空间之外的事物交互的服务(API、数据库、文件系统、控制台/标准输入/标准输出)
依赖于某些环境特性的服务(例如依赖于浏览器/节点特定变量的代码,例如window
, __dirname
)
有副作用的服务
已经或预计将有多个实现的服务,包括仅用于测试或临时目的的实现
我们可能不想提取的东西:
存在于同一模块/单元上的服务(尽管在某些情况下这可能很有用)
原始语言结构(算术/逻辑运算符、条件)
让我重申一下,这些是指南/启发式,而不是一成不变的规则。
我们工作的最大部分不是盲目遵守规则,而是思考、权衡不同解决方案并选择最合适的解决方案。
另外,请记住,没有必要预先确定哪些东西被提取为依赖项,因为提取以前硬编码的依赖项很容易。
到目前为止,对于我们一直在使用的每个服务,都有一个创建服务然后导出它的文件(它可能包含也可能不包含服务的实现本身),所有使用该服务的客户端都从该文件导入它。
现在,我想向您展示一种不同的方法,我们将使用一个文件来集中创建所有服务,而不是使用单独的文件来创建每个服务。
我们这样做的原因有两个:
分离关注点:因此,定义服务实现的关注点与构建服务的关注点是分开的。
使我们能够更轻松地做事:比如集成测试,或者其他方式不可能做的事情,比如处理循环依赖和异步构建服务。
为了在实践中看到这个过程,让我们回到我们生成随机数的第一个例子:
// randomNumber.ts
// Previously, this is the file where we
// selected the appropriate randomNumber implementation,
// and then exported it as the randomNumber service.
// Now, the only thing we'll do here, is defined
// the RandomNumber interface, to which its implementations
// will adhere.
export type RandomNumber = (max: number) => number;
// fastRandomNumber.ts
import { RandomNumber } from "./randomNumber";
export const makeFastRandomNumber =
(randomGenerator: () => number): RandomNumber =>
(max: number): number => {
return Math.floor(randomGenerator() * (max + 1));
};
// randomNumberList.ts
import { RandomNumber } from "./randomNumber";
export const makeRandomNumberList =
(randomNumber: RandomNumber) =>
(max: number, length: number): Array<number> => {
return Array(length)
.fill(null)
.map(() => randomNumber(max));
};
// Notice that we are not creating the randomNumberList
// service here anymore
然后,我们将有一个文件负责将所有依赖项“插入”在一起:
// container.ts
import { secureRandomNumber } from "secureRandomNumber";
import { makeFastRandomNumber } from "./fastRandomNumber";
import { makeRandomNumberList } from "./randomNumberList";
const randomGenerator = Math.random;
const fastRandomNumber = makeFastRandomNumber(randomGenerator);
const randomNumber =
process.env.NODE_ENV === "production" ? secureRandomNumber : fastRandomNumber;
const randomNumberList = makeRandomNumberList(randomNumber);
export const container = {
randomNumber,
randomNumberList,
};
export type Container = typeof container;
我们集中创建所有服务的这个文件称为composition root或container。
从结构上讲,在一个地方创建服务和在整个应用程序中创建服务之间存在着重要的区别。
当在其文件中创建每个服务时,尤其是当此文件还包含服务的实现时,服务知道它们的依赖项来自何处,因为它们直接导入它们。
例如,之前在我们的示例中,randomNumber
它的实现直接从lib 导入,并且也与它的实现位于同一个文件中,直接从.fastRandomNumber.ts
secureRandomNumber
randomNumberList
randomNumber
randomNumber.ts
现在,当在一个地方创建服务时,这是唯一知道所有服务/依赖项及其来源的地方。
正如您在我们当前的示例中看到的那样,文件(除了组合根)唯一从另一个文件导入的是类型/接口,没有别的,这使得它们尽可能地解耦。
在 JS 中,这似乎不是一个巨大的好处,因为它是一种解释型语言,而且我们没有静态/动态链接之类的东西,在这种情况下,这种结构允许我们减少编译时间并将代码加载为插件,但它确实为围绕 DI 的一些重要技术打开了大门。
回到容器本身,为了组合服务,我们必须注意一些关于我们创建它们的顺序的限制。
当我们考虑服务之间的依赖关系,即谁依赖谁时,它们形成了一个依赖图:
图像上的箭头表示“依赖”关系。
服务必须按照我们所说的反向拓扑顺序创建,也就是说,首先我们必须创建所有本身没有依赖关系的依赖关系,然后我们继续创建仅依赖于已创建的依赖关系的依赖关系,依此类推,直到有没有要创建的服务。
当每个服务都在其文件中创建时,我们不必担心我们创建它们的顺序,因为“导入系统”为我们处理了这个问题。
创建完所有服务后,它们现在就可以使用了,那么问题来了,我们如何使用它们呢?
每当我们有一个组合根时,我们就会将我们的应用程序分成两个阶段:
启动阶段
运行阶段
启动阶段就像一个“运行时构建”阶段,我们通过创建所有服务并将它们插入在一起来组装我们的应用程序。
然后,在运行阶段,我们使用容器中的服务启动应用程序。
回到 PC 的类比,就好像每个服务都是单独制造的 PC 部件,然后有一条装配线(对应于组合根),我们将所有单独的部件组装到 PC 中。
只有当所有部件组合在一起时,我们才能打开 PC。
所以,当我们的容器准备好使用时,我们必须在应用程序的入口点调用它。
根据我们正在处理的应用程序类型,尤其是我们可能使用的框架,应用程序的入口点可能会有所不同,但在最简单的情况下,它是应用程序启动时调用的:index.ts
// index.ts
import { container } from "./container";
const main = () => {
// Do stuff with container
const randomNumberList = container.randomNumberList;
// Reads max and length from command line
const max = Number(process.argv[2]);
const length = Number(process.argv[3]);
console.log(randomNumberList(max, length));
return 0;
};
main();
当然,我们的想法是在我们的入口点只包含启动应用程序所需的最低限度逻辑,以便所有复杂性都隔离在服务内部。
恭喜,如果你已经做到了这一步,这意味着你已经了解什么是依赖注入,如何在实践中使用它,以及它的一些最常见的用例。
从现在开始,我们将讨论如何使依赖注入更易于管理,并探索一些额外的技术和用例。
这种我们自己在组合根中组合依赖项的方法称为手动 DI或纯 DI,但随着依赖项数量的增加,维护组合根并确保我们以正确的顺序创建依赖项的复杂性也随之增加。
为了解决这个问题,我们有自动 DI 容器,它负责自动为我们以正确的顺序创建所有依赖项。
已经有提供自动 DI 容器的现成库,但在我们了解它之前,我想向您展示我们如何自己构建一个自动 DI 容器。
// randomNumber.ts
export type RandomNumber = (max: number) => number;
// fastRandomNumber.ts
import { RandomNumber } from "./randomNumber";
// To be able to construct dependencies
// automatically, we first need to
// start using named arguments to pass
// dependencies to services, that is,
// we'll wrap dependencies in an object
type Dependencies = {
randomGenerator: () => number;
};
export const makeFastRandomNumber =
({ randomGenerator }: Dependencies) =>
(max: number): number => {
return Math.floor(randomGenerator() * (max + 1));
};
// randomNumberList.ts
import { RandomNumber } from "./randomNumber";
type Dependencies = {
randomNumber: RandomNumber;
};
export const makeRandomNumberList =
({ randomNumber }: Dependencies) =>
(max: number, length: number): Array<number> => {
return Array(length)
.fill(null)
.map(() => randomNumber(max));
};
// container.ts
import { makeFastRandomNumber } from "./fastRandomNumber";
import { makeRandomNumberList } from "./randomNumberList";
import { secureRandomNumber } from "secureRandomNumber";
// We first "declare" what our services are
// and their respective factories
const dependenciesFactories = {
randomNumber:
process.env.NODE_ENV !== "production"
? makeFastRandomNumber
: //For this to work,
// we'll need to wrap this in a factory
() => secureRandomNumber,
randomNumberList: makeRandomNumberList,
randomGenerator: () => Math.random,
};
type DependenciesFactories = typeof dependenciesFactories;
// Some type magic to type the container
export type Container = {
[Key in DependenciesFactories]: ReturnValue<DependenciesFactories[Key]>;
};
export const container = {} as Container;
Object.entries(dependenciesFactories).forEach(([dependencyName, factory]) => {
// This is why we need to wrap our dependencies in
// an object, because then we're able to pass
// the entire container to factories, and
// even though we're both passing more dependencies
// than needed and, dependencies that at some
// point in time might be undefined, it doesn't matter.
return Object.defineProperty(container, dependencyName, {
// We're using a getter here to avoid
// executing the factory right away, which
// would break due to some dependencies
// being undefined by the time the factory
// is executed.
// This way, factories are only
//called when the whole container
// is already set up, and then, accessing
// some service triggers the recursive creation
// of all its dependencies
get: () => factory(container),
});
});
这个 DIY 自动 DI 容器构造有点复杂,但不要担心,因为我将它包含在本文中只是为了让您了解自动 DI 容器在引擎盖下是如何工作的,但您不需要完全理解它就可以使用它,特别是因为有图书馆为我们处理这些事情。
这里的要点是我们不必再担心创建依赖项的顺序,因为我们的自动 DI 容器会处理所有事情。我们唯一需要做的就是向适当的解析器注册依赖项。
现在,让我向您展示一个生产级自动 DI 容器。
有一些库为我们提供了自动 DI 容器,在这篇文章中,我们将使用Awilix,它是我的首选 DI 容器,因为它非常强大且易于使用。
// randomNumber.ts
export type RandomNumber = (max: number) => number;
// fastRandomNumber.ts
type Dependencies = {
randomGenerator: () => number;
};
export const makeFastRandomNumber =
({ randomGenerator }: Dependencies) =>
(max: number): number => {
return Math.floor(randomGenerator() * (max + 1));
};
// randomNumberList.ts
import { RandomNumber } from "./randomNumber";
type Dependencies = {
randomNumber: RandomNumber;
};
export const makeRandomNumberList =
({ randomNumber }: Dependencies) =>
(max: number, length: number): Array<number> => {
return Array(length)
.fill(null)
.map(() => randomNumber(max));
};
// For services where we expect to have
// a single implementation, we can also
// export this type which is going to be useful
// for other services
export type RandomNumberList = ReturnType<typeof makeRandomNumberList>;
// container.ts
import {
asValue,
asFunction,
asClass,
InjectionMode,
createContainer as createContainerBuilder,
} from "awilix";
import { AwilixUtils } from "./utils/awilix";
// Here we'll define our dependencies
// and wrap them with the appropriate resolver
// so that they can be instantiated
// automatically by Awilix.
//
// Resolvers are used to tell Awilix
// how a given dependency must be resolved,
// whether it is created using a factory function,
// in which case we use the `asFunction` resolver,
// or whether it is an instance of a class, in which case
// the `asClass` is the correct resolver, or even
// whether it is a value that is ready to be used as is,
// that is, one that doesn't need to be constructed,
// in which case we use the `asValue` resolver.
export const dependenciesResolvers = {
randomGenerator: asValue(Math.random()),
// This .asSingleton() means that we'll be using
// a single instance of this dependency
// throughout the whole application, which
// is how we've been using dependency injection
// so far.
// There are other possible LIFETIMES,
// but this goes out of the scope of this
// article
randomNumber:
process.env.NODE_ENV === "production"
? asFunction(makeSecureRandomNumber).singleton()
: asValue(fastRandomNumber),
randomNumberList: asFunction(makeRandomNumberList),
};
// Everything below here is boilerplate
// This is the awilix container builder,
// where we register the dependencies resolvers and which
// will take care of actually plugging everything
// together
const containerBuilder = createContainerBuilder({
injectionMode: InjectionMode.PROXY,
});
containerBuilder.register(dependencies);
// The actual container that contains all
// our instantiated dependencies
export const container = containerBuilder.cradle;
// utils/awilix.ts
export namespace AwilixUtils {
// Some type magic to make sure our container
// is typed correctly
type ExtractResolverType = T extends Resolver
? U
: T extends BuildResolver
? U
: T extends DisposableResolver
? U
: never;
export type Container = {
[Key in keyof Dependencies]: ExtractResolverType;
};
}
让我们更深入地了解 Awilix:
最终,我们希望得到一个实例化了所有服务的容器。为此,我们需要告诉 Awilix 两件事:
我们的依赖。
如何创建它们。
这是通过在 中注册依赖项及其解析器来完成的containerBuilder
,其中每种“种类”的依赖项都必须使用适当的解析器进行包装。
如果我们直接注入一些东西,也就是说,不需要任何类型的构造/创建,我们使用asValue
解析器。
如果我们注入的内容是使用工厂函数(我们到目前为止一直在使用)创建的,那么我们将使用asFunction
解析器。
最后,如果我们的依赖项是使用类创建的,我们将使用asClass
解析器。
Awilix 使用 JSProxy
发挥它的魔力,所以每当我们访问容器中的某些服务时,它都会拦截它并递归地创建所有依赖项,以创建我们正在访问的服务所需的方式。
我真正喜欢 Awilix 的一件事是它的不打扰性。要在我们的应用程序中使用它,我们不必更改代码中的任何内容。唯一改变的地方是容器本身。
其他一切都保持不变:我们的测试、我们的服务的实现,以及我们如何在应用程序的入口点调用容器。
每当服务直接或间接依赖于自身时,我们就会产生所谓的循环或循环依赖,这就是处理递归函数时会发生的情况。
// Without dependency injection
// a.ts
import { b } from "./b";
const a = (value: number): void => {
console.log("a", value);
if (value === 0) {
return;
}
b(value - 1);
};
// b.ts
import { a } from "./b";
const b = (value: number): void => {
console.log("b", value);
if (value === 0) {
return;
}
a(value - 1);
};
// index.ts
a(2);
// Logs:
// a 2
// b 1
// a 0
在这个说明性的例子中,a
取决于b
并且b
也取决于a
,这意味着a
它间接地依赖于它自己。
因此,如果我们尝试在没有 DI 容器(手动或自动)的情况下将 DI 与这些函数一起使用,它是行不通的:
// a.ts
import { makeA } from "./aImpl";
import { b } from "./b";
export type A = (value: number) => void;
export const a = makeA({ b });
// b.ts
import { makeB } from "./bImpl";
import { a } from "./a";
export type B = (val: number) => void;
export const b = makeB({ a });
// aImpl.ts
import { A } from "./a";
import { B } from "./b";
type Dependencies = {
b: B;
};
export const makeA =
({ b }): A =>
(value: number) => {
console.log("a", value);
if (value === 0) {
return;
}
b(value - 1);
};
// bImpl.ts
import { A } from "./a";
import { B } from "./b";
type Dependencies = {
a: A;
};
export const makeB =
({ a }: Dependencies): B =>
(value: number) => {
console.log("b", value);
if (value === 0) {
return;
}
a(value - 1);
};
// index.ts
import { a } from "./a";
a(2);
// Logs:
// a 2
// b 1
// Error: a is not a function
这是我们执行时发生的事情:index.ts
解释器遇到一个语句,所以它“转到” 。import { a } from "./a"
a.ts
解释器遇到一个语句,所以它“转到” 。import { b } from "./b"
b.ts
解释器遇到一条语句,但它知道它已经“去了” ,所以它忽略这条语句并继续解析。import { a } from "./a"
a.ts
b.ts
b
通过注入创建a
,但请注意我们还没有达到创建的地步a
,所以a
传递给b
的实际上是undefined
。
解析后,它“回到”并创建,并注入其中,它已经创建。b.ts
a.ts
a
b
最后,在解析and之后,它“回到”并调用,这工作正常,然后调用,这也工作正常,但是当调用时,由于它的引用未定义,一切都崩溃了。a.ts
b.ts
index.ts
a
a
b
b
a
a
我不会深入研究这个主题,因为它会让我们偏离正轨,所以如果你想阅读更多相关信息,我推荐这篇文章,它专门讨论如何处理依赖注入中的循环依赖。
但是,我将向您展示我们如何使用 Awilix 解决这个问题。
由于 Awilix 会自动创建依赖项,因此我们不需要更改容器本身的任何内容,我们唯一需要做的就是以下内容:
// aImpl.ts
import { A } from "./a";
import { B } from "./b";
type Dependencies = {
b: B;
};
// Notice we're not destructuring our dependencies
// in the function's signature anymore
export const makeA =
(deps: Dependencies): A =>
(value: number) => {
// We now must destructure them in the
// resulting function
const { b } = deps;
console.log("a", value);
if (value === 0) {
return;
}
b(value - 1);
};
// bImpl.ts
import { A } from "./a";
import { B } from "./b";
type Dependencies = {
a: A;
};
export const makeB =
(deps: Dependencies): B =>
(value: number) => {
const { a } = deps;
console.log("b", value);
if (value === 0) {
return;
}
a(value - 1);
};
瞧,我们可以安全地使用具有循环依赖性的服务。
没有依赖注入,我们所能做的就是以自下而上的方式编写代码。我们从编写没有依赖关系的模块开始,然后编写依赖于我们已经编写的模块的其他模块,依此类推,一直到“表面”模块。
此外,由于缺乏模拟功能,我们所有的测试都将是集成测试(这本身并不是一件坏事)。
但是,如果我们想做相反的事情怎么办。如果想开始开发更接近“表面”的模块,然后一直深入到底部怎么办?
使用依赖注入,我们可以很容易地做到这一点,正如我将向您展示的那样。
假设我们正在开发一个命令行来执行应用程序(我知道,非常有创意,对吧?),而不是考虑我们将如何存储待办事项,所有逻辑将如何工作,我们将从用户界面。
因此,我们首先要开发一个run
函数,它是应用程序的入口点:
type Dependencies = {
// Instead of reading directly from stdin
// and writing to stdout, we'll depend on the
// `read` and `write` abstractions, which gives us
// greater flexibility and allows us to mock them
read: () => Promise;
write: (data: string) => Promise;
showTodos: () => Promise;
toggleTodo: () => Promise;
addTodo: () => Promise;
editTodo: () => Promise;
deleteTodo: () => Promise;
invalidCommand: () => Promise;
};
export const makeRun =
({
read,
write,
showTodos,
toggleTodo,
addTodo,
editTodo,
deleteTodo,
invalidCommand,
}: Dependencies) =>
async () => {
await write(
`Welcome to the To Do App!
Commands:
"show" - Show todos.
"toggle" - Toggle todo.
"add" - Add todo.
"edit" - Edit todo.
"delete" - Delete todo.
`
);
while (true) {
const command = (await read()).trim();
switch (command) {
case Command.Show:
showTodos();
break;
case Command.Toggle:
toggleTodo();
break;
case Command.Add:
addTodo();
break;
case Command.Edit:
editTodo();
break;
case Command.Delete:
deleteTodo();
break;
case Command.Quit:
return 0;
default:
invalidCommand();
}
}
};
export const enum Command {
Show = "show",
Toggle = "toggle",
Add = "add",
Edit = "edit",
Delete = "delete",
Quit = "quit",
}
export type Run = ReturnType;
该run
函数将欢迎文本写入终端,然后进入循环,读取用户发出的命令并调用相应的例程。
由于read
,write
并且所有调用的例程都被提取为参数,我们现在不需要关心它们的实现,我们唯一需要知道的是我们期望它们做什么,而“如何”可以推迟。
请注意,此时我们无法运行该应用程序,因为我们没有编写该run
函数将使用的实现,那么我们如何确定我们正在编写“正确的东西”呢?
答案是测试run
:通过为函数编写单元测试,我们可以在不运行应用程序的情况下对其进行测试,因为我们可以模拟所有缺失的依赖项。
//run.test.ts
// Here we have a sample test
// I won't include others for the sake of
// brevity, but we could easily exercise other
// execution paths by simulating different user
// inputs with the mocked read function.
describe("When initializing", () => {
it("Displays the correct message", async () => {
const read = jest.fn().mockReturnValueOnce(Promise.resolve(Command.Quit));
const write = jest.fn();
const showTodos = jest.fn();
const toggleTodo = jest.fn();
const addTodo = jest.fn();
const editTodo = jest.fn();
const deleteTodo = jest.fn();
const invalidCommand = jest.fn();
const run = makeRun({
read,
ViewTodoDomainMapper: ViewTodoDomainMapperMock,
createViewTodosStore,
loadTodos,
addTodo,
deleteTodo,
editTodo,
invalidCommand,
showTodos,
toggleTodo,
write,
});
await run();
expect(write).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenNthCalledWith(
1,
`Welcome to the To Do App!
Commands:
"show" - Show todos.
"toggle" - Toggle todo.
"add" - Add todo.
"edit" - Edit todo.
"delete" - Delete todo.
`
);
});
});
然后,一旦我们对我们的实现和测试感到满意run
,我们就可以前进到它的直接依赖项,例如showTodos
例程:
// todo.ts
// Even though we might not need to have our dependencies
// implementations at a certain point in time, we might need
// their interface, which is the case here
export type Todo = {
id: string;
text: string;
status: TodoStatus;
};
export const enum TodoStatus {
Complete = "Complete",
Incomplete = "Incomplete",
}
// showTodos.ts
import { Todo } from "./todo";
type Dependencies = {
write: (data: string) => Promise;
};
export const makeShowTodos =
({ write }: Dependencies) =>
async (todos: Array) => {
if (todos.length === 0) {
write("No todos yet!");
return;
}
const formattedTodos = todos.reduce((string, todo, index) => {
const formattedStatus =
todo.getStatus() === ViewTodoStatus.Complete ? "[x]" : "[ ]";
const todoNumber = index + 1;
const formattedTodo = `${todoNumber}. ${formattedStatus} ${todo.getText()}n`;
return string + formattedTodo;
}, "");
write(formattedTodos);
};
export type ShowTodos = ReturnType;
// showTodos.test.ts
import { makeShowTodos } from "./showTodos";
import { TodoStatus } from "./todo";
describe("When there are NO todos", () => {
it("Displays the appropriate message", async () => {
const write = jest.fn();
const showTodos = makeShowTodos({
write,
});
await showTodos([]);
expect(write).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenNthCalledWith(1, "No todos yet!");
});
});
describe("When there are todos", () => {
it("Displays todos formatted correctly", async () => {
const write = jest.fn();
const todos: Array = [
{
id: "234",
status: TodoStatus.Complete,
text: "Walk dog",
},
{
id: "323345",
status: TodoStatus.Incomplete,
text: "Wash dishes",
},
];
const showTodos = makeShowTodos({
write,
});
await showTodos(todos);
expect(write).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenNthCalledWith(
1,
`1. [x] Walk dogn2. [ ] Wash dishesn`
);
});
});
然后我们继续,直到所有的依赖项都被有效地实现。
这种方法的好处是我们可以推迟考虑应用程序的内部工作,并专注于其可观察到的行为。
最后一个考虑因素是,自上而下和自下而上的方法都位于一条“线”的两端,但在这两个端点之间存在一个整体梯度。我们可以从编写一些“表面”模块开始,然后编写一些“核心”模块,它们最终会遇到“中间”。
这就是依赖注入赋予我们的力量,使模块非常松耦合,我们可以针对接口编程而不是针对实现编程,这为我们编写软件的方式提供了很大的灵活性。
在这篇文章中,我们了解了什么是依赖注入、如何实现它以及它的一些应用。
您可能注意到,在整个帖子中,我们用于依赖注入的实现逐渐变得更加复杂,因为我们必须处理更复杂的问题。
基于此,我想给出一条建议,特别是如果您是依赖注入的新手:您不需要从最复杂的方法开始。我建议您从满足您需求的最简单的实现开始,然后,随着您的需求增长,您可以逐渐转向更复杂的实现。
原文:https://blog.codeminer42.com/dependency-injection-in-js-ts-part-1/