Dependency Injection 这个概念是我之前在实习的时候做 Java 开发的时候接触的。Dependency Injection 可以大大降低模块之间的耦合度,提高系统的可扩展性和鲁棒性,不过这个概念对于新人来说理解起来还是存在比较大的障碍。由于当时实习的时间比较短,对于这个概念我并没有吃透。这次学习 Node.js 的时候,又在 awilix 这个库里面遇到了这个概念。以此为契机就来好好学习一些 Dependency Injection 和其后的设计逻辑与方法。

下面的内容翻译自:Dependency Injection in Node.js。这篇文章浅显地介绍了 Dependency Injection 的基本理念。选择这篇文章是因为我在阅读 awilix 模块作者关于 Dependency Injection 的系列文章中时,作者在开篇提议阅读此文。

不过这篇文章毕竟是 2015 年的文章,在 js 的一些语法和模块细节上和今时今日的有些不同,但是并不妨碍我们对于其核心理念的理解。

1 使用 Dependency Injection 的理由

1.1 解耦 (Decoupling)

Dependency Injection 使你的模块耦合度降低,从而提升代码的可维护性。

1.2 更简单的单元测试

比起需要硬编码的依赖关系,你可以将依赖关系传输进入你要用的模块。在大多数场合下使用这种范式你不必要使用 proxyquire 这样的模块。

这一段作者写的比较含糊。其实意思是在使用 Dependency Injection 场景下,我们在独立测试一些单元功能的时候,对于其他模块可以通过注入 Mock 对象,从而将待测试的模块独立出来进行测试。

1.3 更快速的开发

在使用了 Dependency Injection 的场景下,在接口定义好了以后,开发会更加容易,Merge conflict 会更少。

2 如何在 Node.js 中使用 Dependency Injection

下面我们来看看如何在不适用 Dependency Injection 的前提下开发应用,然后看看如何进行转化。

2.1 不使用 Dependency Injection 的例子

下面是一段简单的没有使用 Dependency Injection 的代码:

1
2
3
4
5
6
7
8
// team.js
var User = require("./user");

function getTeam(teamId) {
return User.find({ teamId: teamId });
}

module.exports.getTeam = getTeam;

对应的测试可能是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// team.spec.js
var Team = require("./team");
var User = require("/user");

describe("Team", function () {
it("#getTeam", function* () {
var users = [{ id: 1, id: 2 }];

this.sandbox.stub(User, find, function () {
return Promise.resolve(users);
});

var team = yield team.getTeam();

expect(team).to.eql(users);
});
});

在上面的代码中我们做的是创建了一个名为team.js的模块,该模块可以返回属于一个 team 的用户列表。为了实现这一功能,我们导入User模块,然后我们再调用其find方法返回用户列表。

看起来不错,是吗?但是当我们需要进行测试时,我们必须要使用sinon的 test stubs.

在测试文件中,我们需要引入 User 模块,为其 stub 一个find方法。注意,我们在这里要使用 sandbox 功能,这样我们不需在测试完成后回复find的原函数。

注意:如果原始对象使用了Object.freeze,那么 stubs 将不会起作用。

2.2 使用 Dependency Injection 的例子

1
2
3
4
5
6
7
8
9
10
11
12
// team.js
function Team(options) {
this.options = options;
}

Team.prototype.getTeam = function (teamId) {
return this.options.User.find({ teamId: teamId });
};

function create(options) {
return new Team(options);
}

你可以使用下面的这个文件来进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// team.spec.js
var Team = -require("./team");

describe("Team", function () {
it("#getTeam", function* () {
var users = [{ id: 1, id: 2 }];

var fakeUser = {
find: function () {
return Promise.resolve(users);
},
};

var team = Team.create({
User: fakeUser,
});

var team = yield team.getTeam();

expect(team).to.eql(users);
});
});

那么,使用了 Dependency Injection 的版本同之前的版本有什么区别呢?首先你可能注意到的是这里使用了工厂模式:我们使用这种设计模式来将 options/dependencies inject 到新创建的对象中 - 这里是我们注入User模块的方法。

在测试文件中我们还需要创建一个 fake model 来代表User模块,然后将这个伪造的模块传递给工厂函数。很简单,不是吗?

3 Dependency Injection in Real Projects

你可以在非常多的开源项目中发现 Dependency Injection 的例子。例如,你在日常工作中常常用到的 Express/Koa 的大部分中间件都使用了这种技术。

3.1 Express Middlewares

1
2
3
4
5
6
7
var express = require('express');
var app = express();
var session = require('express-session');

app.use(session({
store: require('connect-session-knex');
}))

上面的代码片段使用了基于工厂模式的 Dependency Injection:对应 session 中间件我们传递了一个connect-session-knex模块。这个模块需要实现session模块调用需要的借口。

在这个例子中,connect-session-knex模块需要实现下面的方法:

  • store.destroy(sid, callback)
  • store.get(sid, callback)
  • store.set(sid, session, callback)

3.2 Hapi plugins

Dependency Injection 的概念还可以在 Hapi 中找到。下面的例子中,handlebars模块被作为 view engine 注入给 Hapi 使用:

1
2
3
4
5
6
7
server.views({
engines: {
html: require('handlebars`)
},
relativeTo: __dirname,
path: 'templates'
})