在nodejs的编程中,DI(依赖注入)很明显并没有被广泛使用,即使它理应得到较高的重视。就我个人观点而言,DI是解耦并带给开发者福音的最重要的模式。本文将为读者展示,我为什么会这样认为。
W(hat is dependency injection?)
DI在形式上是接收参数替代直接构造。有多种形式
// Constructor injection
class Car{
constructor(engine){
this.engine = engine;
}
}
// Setter injection
class Car{
constructor(){
}
setEngine(engine){
this.engine = engine;
}
}
// Taking an input argument and binding a function
function createCarWithEngine(engine){
// create a car with the engine
}
let createCar = createCarWithEngine.bind(null, engine);
请注意我们如何忽略engine
的一切信息只单纯地关注ca
r的功能。
关键的点是我们不关注我们的依赖是在什么地方、这些依赖是如何构建、甚至依赖是啥。这么做对于代码的解耦、重用性、可测性、可读性有巨大的好处。
我们先看看实际应用的案例...
案例1: 用户信息资料库
假设我们有一个用户资料库,简而言之,我们有一个用户信息表,我们想针对这张表做一些操作。在这个case中,我们需要一些组件
- 数据库的配置信息
- 数据库的连接
- 用户表
从UserRepo
的角度来看,我们可以有多种方法获取上述组件。可以使用已有的数据库连接、可以自行创建数据库连接、或者可以让其他的开发者提供创建的连接并传入。
详细地看看具体方案的实现:
引入数据库连接
示例代码如下
const db = require('./db');
class UserRepo{
getUsers(){
return db.query('SELECT * FROM users');
}
}
看上去不赖。
但./db
文件中具体发生了什么?既然我们要使用数据库连接,那必不可少地需要先创建它。因此,示例的代码会存在如下问题:
- 当我们通过
require
引入./db
文件时,数据库连接就被创建出来。这样的话,我们就无法把控连接的创建时机。 - 如果
./db
文件的路径发生了改变又会怎样呢?我们不得更新项目中所有对其的引用路径。(在现代IDE
中,通常会由此类提醒功能,但依然是有风险) - 比如有一个
TodoRepo
,它要使用一个单独的数据库连接,require(./db)
就不能满足诉求,只能重新创建一个同样功能的文件,来实现需求。 - 当我们要对上述代码进行测试时,只能对
require
的结果进行mock
,但这相当于在测试之前已经修改了代码。 - 如果我们要在一个库或者其他文件中重用
db
的代码,只能在依赖中通过硬编码./db
,无法原样使用代码。
自行创建数据库连接
示例代码如下
const createDb = require('./createDb');
const dbConfig = require('./dbConfig');
const db = createDb(dbConfig);
// OR
// const db = createDb(process.env.CONNECTION_STRING);
class UserRepo{
getUsers(){
return db.query('SELECT * FROM users');
}
}
这种场景下,我们完全可以控制数据库连接的初始化,因此可以在任何想要的地方创建数据库连接。如果需要的话,也可以创建多个数据库连接。但是如果要重用连接的话就需要自己创建连接池或者使用缓存来处理。
即便如此,我们依然还有前述的一些问题(可测性更差)无法解决,UserRepo
文件更加臃肿。
代码修改成上述的写法,与直接引入.db
会有相同的问题,因为UserRepo
需要关注./config
,它同样需要在代码中hardcode
路径。
使用环境变量是否可以解决路径hardcode
的问题吗?
尝试从process.env
中 获取数据库连接的地址对解决本问题是有效的,这样可以让配置更加灵活。但将配置文件的路径放到环境变量中,也同样会引发一系列的问题。UserRep
o需要提出变量命名的方案以保证这个命名不与环境变量上的其他变量名发生冲突,但这显然不应该是UserRepo
所应该关注的事情,违背单一责任
原则。给变量一个比较泛义的命名会更加容易,但这么做丧失了灵活性。像这样使用环境变量的形式,其实是一种隐形的依赖引入。当我们实例化UserRepo
时,我们无法立即看到应该设置的env
变量。
此外,想象一下在npm
包中使用process.env
这种场景,是不是不可思议?但令人惊讶的是,很多npm
包都依赖了环境变量,这通常是由于这些包本身的调试模块依赖于多环境变量导致的。这样做的主要问题是鼓励库作者不要提供任何接口来注入自定义debug-logger
,而本场景下接口就是环境变量。这意味着,如果我们想将日志发送到调试模块不支持的地方,则不能(或者必须monkey patch)实现。另一个问题是不同的库中间极有可能会产生命名冲突。
接收DB实例(又名:依赖注入)
最终,我们选择使用依赖注入的方案。比如使用最简单的构造函数注入的方法,示例代码如下:
class UserRepo{
constructor(db){
this._db = db;
}
getUsers(){
return this._db.query('SELECT * FROM users');
}
}
当我们使用动态注入的方案时,db实例如何创建?有多少个实例?如何配置数据库连接信息?创建db实例的模块文件在什么位置?统统这些信息对于UserRepo
都是透明的,它不需要关心。当然,对于db
模块而言,如何创建实例仍然需要解决,示例代码如下:
// Database.js
class Database{
constructor(config){
this.config = config;
}
connect(){
// create database
}
query(){
}
}
// config.js
const config = {
DB_CONFIG_MYSQL: {
server: process.env.DB_SERVER,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PW
},
DB_CONFIG_POSTGRES: {
// ...
}
};
// index.js
const config = require('./config');
const Database = require('./Database');
const UserRepo = require('./UserRepo');
let db = new Database(config.DB_CONFIG_MYSQL);
db.connect();
let userRepo = new UserRepo(db);
从上面的示例代码中,通过使用构造函数注入所需的依赖,UserRepo
跟Database
解耦,Database
跟config
解耦。
细心的读者可能会注意到,config
依然使用了环境变量。但当我们把config
的使用放到了app
的顶层,并且对于config
的使用者(Database
)并不需要关注它,这样也是OK的。在app
顶层使用环境变量,就可以统一管理环境变量,避免命名冲突,并且对环境变量的依赖也并非隐式的。db.js
就可以作为一个纯净的无依赖的库在app
中被任何地方引入复用。
通过依赖注入,很好地解决了前述的几个问题
- 掌控何时连接数据库
- 掌控如何管理文件,模块的引入只需要在一个地方修改(app顶层)
- 创建多个
db
连接(或者复用单个连接) - 可测性高(将
db
的连接mock
出来传入)
将UserRepo
、Database
、config
进行解耦后,UserRepo
的代码该怎么写也就很简单了。编写UserRepo
的代码时,希望db
提供任何api
都可以在这里写上,然后在具体的db
模块中实现该api
。如果这个api
已经存在,你仍然可以做任何想做的事情,因为没有直接依赖任何东西。如果有更好的api
,则只需要在传入实例前对db
添加适配器即可。但重要的是,UserRepo
不需要知道该适配器的任何细节。
小结
依赖注入是非常棒的方法,但也并非是解决所有场景问题的银弹。依赖注入和大多数啊软件设计模式相同,它主要的缺点也来源于对事物的抽象。对事物进行抽象也意味着对其理解上就存在一定的难度。在之前的示例代码中,通过依赖注入的话,我们是不能直接看出db
实例是在哪里创建的,需要阅读其他部分的代码,才能确定。因此,对于这样的依赖注入来说,抽象是值得的,它们必须大量抵消抽象的成本。而且即使在小型应用程序中,依赖注入也几乎总是可取的,仅仅只是简化测试这一项就值得了。
不适合使用依赖注入的场景
是否使用依赖注入是一个偏主观的决策。如果被引入的模块跟当前模块是强耦合的也是可以直接引入。比如,开发者可以在UserRepo
中引入User
模块,毕竟UserRepo
中存储的就是User
的集合。然而,需要注意的地方就是开发者往往事后会对代码做解耦。假设用户模块的初始化需要一些默认的配置,这种情况下更好的方法是UserRepo
使用UserFactory
来创建User
的实例。从这个角度来看,需要把配置信息注入到User
,而UserRepo
不必关注相关逻辑。
如果依赖的包不需要任何配置或存在被替换的可能性,也可以不用动态注入,比如依赖lodash
,直接引入即可。
类型智能提示
使用动态注入编写代码时,会在IDE
中丢失类型的智能提示。但如果使用ES6
的类构造函数配合注释就可以解决这个类型提示的问题,示例代码如下:
class UserRepo{
/**
* @param {Database} db
*/
constructor(db){
this._db = db;
}
}
IOC
(控制反转)容器
动态注入会推进依赖引入所处的代码层次结构,组织依赖和注入依赖的逻辑会被逐步向上层转移,往往是app
的入口文件(index.js
或app.js
等)。这会让依赖的管理比较混乱,而这也是IOC
容器诞生的契机。
下一篇文章会讨论IOC
容器 ,不要担心,不会让读者去安装任何包、框架或xml
文件。
网友评论