本文会讨论下列问题
- 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));
备注:
service
是IoC
容器常见的典型api命名,可以根据自己的喜好和理解使用别的方法名,比如declare
等近义词
上述的代码是一个声明,将字符串UserRepo
声明为一个可以返回UserRepo
实例的工厂方法,而UserRepo
依赖于Database
和Emailer
,所以也需要在容器上声明。把其他的依赖都声明出来后,示例代码如下:
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));
这段示例代码故意把Database
和Emailer
的定义放在了使用的后面,这也是我们所期望的,无需关注依赖何时被实例化,只要声明过就可以保证获取到相应的实例。
依赖声明之后该怎么使用呢?通常,我们会使用某种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.defineProperty
来hook
容器的读属性操作,点运算符的使用也是容器看上去像是一个常规的对象的原因。这个不需要纠结,有很多IoC
容器的实现使用的是方法来获取实例属性,比如container.get(‘UserRepo’)
。
声明式和延迟性也很好理解,一起看看下面UserRepo
的声明回调函数
c => new UserRepo(c.Database, c.Emailer)
执行回调方法时,c.Database, c.Emailer
在执行点运算符时,又会触发各自的创建实例的逻辑,这个逻辑就会执行各自声明的代码逻辑。这也意味着,如果一个依赖没有被使用,它也不会被实例化,真正做到按需实例化依赖。
另外,需要注意的是,由于在内部services
对象上缓存了创建的实例,回调函数只会执行一次。只要是使用同一个声明获取到的实例一定是相同的。
Providers
通过上面的方法,开发者不需要再关注初始化依赖的顺序,这非常棒,但同时所有的东西都在一个文件中。为了解决这个问题,提出了providers
的概念。provider
负责设置与应用程序或库的特定部分相关的所有依赖项。比如,现在需要一个构建logger
的provider
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等类提供装饰器来做动态注入的能力的观点纯属原文作者的观点,仁者见仁智者见智。