依赖注入(Dependency injection)

本文翻译自:Dependency Injection in nodejs

在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的一切信息只单纯地关注car的功能。
关键的点是我们不关注我们的依赖是在什么地方、这些依赖是如何构建、甚至依赖是啥。这么做对于代码的解耦、重用性、可测性、可读性有巨大的好处。

我们先看看实际应用的案例...

案例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中 获取数据库连接的地址对解决本问题是有效的,这样可以让配置更加灵活。但将配置文件的路径放到环境变量中,也同样会引发一系列的问题。UserRepo需要提出变量命名的方案以保证这个命名不与环境变量上的其他变量名发生冲突,但这显然不应该是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);

从上面的示例代码中,通过使用构造函数注入所需的依赖,UserRepoDatabase解耦,Databaseconfig解耦。

细心的读者可能会注意到,config依然使用了环境变量。但当我们把config的使用放到了app的顶层,并且对于config的使用者(Database)并不需要关注它,这样也是OK的。在app顶层使用环境变量,就可以统一管理环境变量,避免命名冲突,并且对环境变量的依赖也并非隐式的。db.js就可以作为一个纯净的无依赖的库在app中被任何地方引入复用。

通过依赖注入,很好地解决了前述的几个问题

  • 掌控何时连接数据库
  • 掌控如何管理文件,模块的引入只需要在一个地方修改(app顶层)
  • 创建多个db连接(或者复用单个连接)
  • 可测性高(将db的连接mock出来传入)

UserRepoDatabaseconfig进行解耦后,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.jsapp.js等)。这会让依赖的管理比较混乱,而这也是IOC容器诞生的契机。

下一篇文章会讨论IOC容器 ,不要担心,不会让读者去安装任何包、框架或xml文件。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,128评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,316评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,737评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,283评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,384评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,458评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,467评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,251评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,688评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,980评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,155评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,818评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,492评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,142评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,382评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,020评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,044评论 2 352

推荐阅读更多精彩内容