IoC容器(IoC Container)

本文翻译自IoCContainer in nodejs

本文会讨论下列问题

  • IoC容器
  • 为什么需要IoC容器
  • 怎样实现IoC容器
  • 最佳实践(什么能做/什么不能做)

本文假设读者已经了解依赖注入相关的知识,具体阐述可以查阅前一篇文章(依赖注入)[//www.greatytc.com/p/cf26a8dbc16e]

What is an IoCcontainer

IoC全称为inversion of control(控制反转),依赖注入是一种IOC的表现形式,主要表现为让第三方来管理自己的依赖。IOC容器的价值在于让依赖管理和使用更为便捷。简单地说,容器是一个申请依赖、保存依赖的对象。容器的声明性正式它强大的地方,这个特性可以让开发者无需去特别指定什么时间创建依赖的实例,申明后直接通过容器来获取相应的实例,容器内部来处理相关的逻辑。

Why use an IoCcontainer

在分析如何实现IOC容器之前,我们先要搞清楚,IOC容器是解决哪类问题的。在上一篇关于动态注入的文章中,我们提到了,动态注入最终会将依赖引入的层级推至顶层,随着应用复杂性的增加,相应的依赖管理会变得更为复杂。开发者不得不面临各种依赖初始化的问题。

代码可能会变成如下所示:

const config = require('./config');
const Database = require('./Database');
const UserRepo = require('./UserRepo');
const TodoRepo = require('./TodoRepo');
const Emailer = require('./Emailer');
const UserRoute = require('./UserRoute');
const TodoRoute = require('./TodoRoute');
let emailer = new Emailer(config.EMAIL_CONFIG);
let db = new Database(config.DB_CONFIG_MYSQL);
let userRepo = new UserRepo(db, emailer);
let todoRepo = new TodoRepo(db);
let userRoute = new UserRoute(userRepo);
let todoRoute = new TodoRoute(todoRepo);

这段代码是一个不太好的示例,当开发者添加新的依赖时,这里的混乱度会产生滚雪球效应。需要注意的是,依赖的实例化顺序也是不能出错的,因为依赖间还存在依赖关系。上述的代码必须严格保证从config->db->userRepo->userRoute的顺序。这种开发体验真的是够糟糕的。

Implementing the container

NPM市场上有一些IoC容器的包,本文将会以实现一个最简单的容器来讲解其中的思路。要牢记一个IOC容器是声明性的(或者叫响应式的)。假设已经有一个容器,现在要声明UserRepo,示例代码如下:

let c = new Container();
c.service('UserRepo', c => new UserRepo(c.Database, c.Emailer));

备注:serviceIoC容器常见的典型api命名,可以根据自己的喜好和理解使用别的方法名,比如declare等近义词

上述的代码是一个声明,将字符串UserRepo声明为一个可以返回UserRepo实例的工厂方法,而UserRepo依赖于DatabaseEmailer,所以也需要在容器上声明。把其他的依赖都声明出来后,示例代码如下:

c.service('config', c => config);

// Database & Emailer used first
c.service('UserRepo', c => new UserRepo(c.Database, c.Emailer));
c.service('UserRoute', c => new UserRoute(c.UserRepo));

c.service('TodoRepo', c => new TodoRepo(c.Database));
c.service('TodoRoute', c => new TodoRoute(c.TodoRepo));

// Database & Emailer declared last
c.service('Database', c => new Database(c.config.DB_CONFIG_MYSQL));
c.service('Emailer', c => new Emailer(c.config.EMAIL_CONFIG));

这段示例代码故意把DatabaseEmailer的定义放在了使用的后面,这也是我们所期望的,无需关注依赖何时被实例化,只要声明过就可以保证获取到相应的实例。

依赖声明之后该怎么使用呢?通常,我们会使用某种App对象或类似的对象来启动整个应用程序,作为示例可能会启动一个httpserver并监听某个端口。本示例中只假设在UserRoute上运行一些方法。使用UserRoute实例的代码如下:

// 获取userRoute实例
let userRoute = c.UserRoute;
userRoute.addUser({name: 'cluo'});

如果这也是你心目中的IoC容器的模样,那就一起看看它的实现代码:

class Container {
    constructor(){
        // 存储依赖实例的对象
        this.services = {};
    }

    service(name, cb){
        // 使用响应式的方式,使用.获取实例时,触发getter,在getter的hook中返回合法的实例
        Object.defineProperty(this, name, {
            get: () => {
                if(!this.services.hasOwnProperty(name)){
                    this.services[name] = cb(this);
                }

                return this.services[name];
            },
            configurable: true,
            enumerable: true
        });

        return this;
    }
}

通过短短的20行代码,实现了

  • 声明式依赖解析器
  • 延迟初始化
  • 一个看起来像常规对象的容器

整个实现的精髓在于使用Object.definePropertyhook容器的读属性操作,点运算符的使用也是容器看上去像是一个常规的对象的原因。这个不需要纠结,有很多IoC容器的实现使用的是方法来获取实例属性,比如container.get(‘UserRepo’)

声明式和延迟性也很好理解,一起看看下面UserRepo的声明回调函数

c => new UserRepo(c.Database, c.Emailer)

执行回调方法时,c.Database, c.Emailer在执行点运算符时,又会触发各自的创建实例的逻辑,这个逻辑就会执行各自声明的代码逻辑。这也意味着,如果一个依赖没有被使用,它也不会被实例化,真正做到按需实例化依赖。

另外,需要注意的是,由于在内部services对象上缓存了创建的实例,回调函数只会执行一次。只要是使用同一个声明获取到的实例一定是相同的。

Providers

通过上面的方法,开发者不需要再关注初始化依赖的顺序,这非常棒,但同时所有的东西都在一个文件中。为了解决这个问题,提出了providers的概念。provider负责设置与应用程序或库的特定部分相关的所有依赖项。比如,现在需要一个构建loggerprovider

provider在代码表现上本质是一个方法

// providers/userProvider.js
module.exports = function(c){
    c.service('UserRepo', c => new UserRepo(c.Database, c.Emailer));
    c.service('UserRoute', c => new UserRoute(c.UserRepo));
};

通过provider创建的容器示例代码如下:

// createContainer.js
module.exports = function(){
    let container = new Container();

    require('./providers/loggerProvider')(container);
    require('./providers/dbProvider')(container);
    require('./providers/userProvider')(container);
    require('./providers/todoProvider')(container);
    require('./providers/appProvider')(container);

    return container;
};

容器的使用示例代码如下:

// index.js
let createContainer = require('./createContainer');

let c = createContainer();
let app = c.App;

app.start();
testing

使用动态注入会让应用的单元测试变得简单,而IoC容器使得运行应用其他部分的测试更为简单,因为容器可以做到方便地替换依赖。比如下面的示例,使用sqlite替换database,进而对整个app进行测试:

// tests/testApp.js
let createContainer = require('./createContainer');

let c = createContainer();

// 在声明时使用sqlite来替换database,使整个测试可行
c.service('Database', c => new SqliteTestDatabase());

let app = c.App;

app.start();

// Run http requests on app, and check the results
What to be careful with(avoid)

使用IoC容器也有需要特别注意的一些点

避免对同一个依赖声明多个类型名

容器有很多工厂方法,这些方法在调用的时候会返回一个所需要的依赖的新实例。因为实例的类型和返回实例的工厂方法都是可以在容器上声明的,所以有可能会出现不同的类型名会对应到同样类型的实例上。比如下面的代码

// 3个类型声明,但返回的实例是同一个类型的,通常会让开发者迷惑
c.service('UserRoute', c => new UserRoute(c.UserRepo));
c.service('Route', c => new UserRoute(c.UserRepo));
c.service('AppRoute', c => new UserRoute(c.UserRepo));

即使有特殊的逻辑需要处理,也不要通过添加类型名的方式解决,而只能通过一个类型名来获取实例,然后通过其他方法来完成开发逻辑。

避免传递容器

在应用中传递容器将会让IoC容器变成service locator(服务定位器),原先对具体实例的依赖变成对服务定位器的依赖,这种转变会隐藏真正的依赖关系。示例代码如下:

// index.js
c.service('config', c => config);

// userRepo.js
class UserRepo {
        constructor(c) {
            this._container = c;
        }
        doSomething() {
                // config才是真正的依赖,这里却把container变成了依赖
                const config = this._container.config;
        }
}
避免在容器中放过多的东西

比如,lodash是不需要放到容器中的,一些小的没有依赖的工具函数也不需要放进来。时刻要记住容器是帮助管理依赖的。

避免使用装饰器或注释作为依赖项声明

诸如inversifyjs之类的库允许开发者通过装饰器声明依赖项让我非常困扰,这样既把类强绑定到支持此方法的特定容器上,也完全违背依赖反转的原则。使用装饰器,意味着当前模块明确指定一个依赖,这是自己在控制所需的依赖,这与把依赖的注入交予第三方(往往是顶层)的初衷是背离的。

编后语:本文尽量去理解原文思路,进行表达,如有偏差请不吝指正。笔者根据自己的理解为傍边读者更好地体会,加入一些示例代码。文章的最后关于inversifyjs等类提供装饰器来做动态注入的能力的观点纯属原文作者的观点,仁者见仁智者见智。

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