当 IoC 遇见了 Node.js

  • 2014-06-04
  • 本文字数:4966 字

    阅读完需:约 16 分钟

没有 IoC 的年代

一个简单的例子:


var Engine = require('./engine');
var Wheel = require('./wheel');

var Car = function() {
 this.engine = new Engine();
 this.wheel = new Wheel();
}

Car.prototype.run = function() {
 this.engine.run();
 this.wheel.run();
 console.log('run car...');
}

module.exports = Car;

在例子中,汽车(car)需要依赖轮子(wheel)和发动机(engine)才能跑起来。为了处理好这一关系,必须首先人为的通过 require 把 engine 和 wheel 引入进来,然后通过 **new** 操作实例化,这样 car 才能真正的 run 起来。

这个例子非常简单,但是存在着一些问题:

  1. require 的时候,需要直接知道 engine 和 wheel 的文件位置、文件名、以及所 exports 的是什么。一旦 engine 或者 wheel 的文件名、所在位置、exports 方式发生了变化,require 操作必须做出相应的改变
  2. car 直接依赖于 engine 和 wheel,因此如果我们试图做单元测试,则会发现 mock engine 或者 wheel 非常困难,要么就是修改 engine、wheel 的代码,要么就是修改 car 的代码

这个例子只有 3 个对象,读者可能觉得也没啥要紧的,这样直接做也没多大问题。但是一旦系统里面的对象数量变大了呢?复杂的依赖关系可能就是这样的:

这样的系统紧密耦合,往往会造成难以维护、难以重构、难以做单元测试,尤其是当一个新人加入团队的时候,也会因为这份复杂性变得举步维艰,看不明白也改不动。

步入 IoC

使用 IoC 之后,car 的代码就会变成如下所示:


var Car = function(engine) {
 this.engine = engine;
 this.wheel = null;
}

Car.prototype.run = function() {
 this.engine.run();
 this.wheel.run();
 console.log('run car...');
}

module.exports = Car;

car 无需知道 engine、wheel 的具体所在以 require 进来,也无需知道 engine 和 wheel 什么时候实例化以调用 run 方法跑起来,一切都变得如此简单与美好!

  1. 去除了 engine 和 wheel 的直接依赖,随便 engine 和 wheel 叫什么名字,写在哪里(甚至可以是一个 remote 对象),重构变得轻而易举
  2. 想对 car 进行单元测试,只需要依赖注入一个 mock 的 engine 和 wheel 对象即可完成,再也不需要直接修改 car 或者 engine、wheel 的代码了

让 IoC 发挥作用

本文通过 Bearcat 所提供的 IoC 容器来让 IoC 在 Node.js 中发挥作用。

Bearcat IoC 使用非常简单,只需要提供一个简单的配置文件即可让 IoC 容器管理下的系统运转起来:


{
 "name": "simple_inject",
 "beans": [{
   "id": "car",
   "func": "car",
   "props": [{
     "name": "wheel",
     "ref": "wheel"
   }],
   "args": [{
     "name": "engine",
     "ref": "engine"
   }]
 }, {
   "id": "wheel",
   "func": "wheel"
 }, {
   "id": "engine",
   "func": "engine"
 }]
}

这里就通过一个简单的 context.json 配置文件来对 IoC 进行了描述:告知容器中有一个 car,依赖于 wheel 和 engine,wheel 通过对象属性的方式注入,engine 通过构造函数参数的方式注入,容器中还有一个 wheel 和一个 engine。

启动容器跑起来,只需要把 context.json 的路径传递给 bearcat 即可:


var Bearcat = require('bearcat');
var contextPath = require.resolve('./context.json');

var bearcat = Bearcat.createApp([contextPath]);
bearcat.start(function(){
  var car = bearcat.getBean('car'); // get bean
  car.run(); // call the method
});

运行结果


[2014-05-05 18:50:41.996] [INFO] bearcat - [app] Bearcat startup in 6 ms
run engine...
run wheel...
run car...

更多 IoC 的功能

scope 定义

IoC 中可以定义 scope,可支持 singleton 和 prototype 两种 scope,默认情况下 scope 是 singleton 的。scope 其实对应着常见的两种设计模式,即单例模式(singleton)和多例模式(prototype)。

singleton:


{
   "name": "simple",
   "beans": [{
       "id": "car",
       "func": "car",
       "scope": "singleton"
   }]
}

var car1 = bearcat.getBean('car');
var car2 = bearcat.getBean('car');
// car1 与 car2 是同一个实例对象

prototype:


{
   "name": "simple",
   "beans": [{
       "id": "car",
       "func": "car",
       "scope": "prototype"
   }]
}

var car1 = bearcat.getBean('car');
var car2 = bearcat.getBean('car');
// car1 与 car2 不是同一个实例对象 

生命周期回调

初始化与销毁操作在 Node.js 开发中是非常常见的。

初始化方法


var Car = function() {
   this.num = 0;
}

Car.prototype.init = function() {
   console.log('init car...');
   this.num = 1;
   return 'init car';
}

Car.prototype.run = function() {
   console.log('run car...');
   return 'car ' + this.num;
}

module.exports = Car;

car 现在需要在实例化之后执行一个 init 方法来做些初始化的工作,那么在 IoC 中可以如下定义:


{
   "name": "simple_init_method",
   "beans": [{
       "id": "car",
       "func": "car",
       "scope": "prototype",
       "init": "init"
   }]
}

销毁方法

销毁方法在处理数据库链接等场景时非常有用。一个系统在 shutdown 的时候,平滑优雅的关闭就需要处理一些资源释放、完成未完成的任务等工作:


var Car = function() {

};

Car.prototype.destroy = function() {
   console.log('destroy car...');
   return 'destroy car';
};

Car.prototype.run = function() {
   console.log('run car...');
   return 'car';
};

module.exports = Car;

当 car 结束生命的时候,需要执行一个 destroy 方法来释放资源,那么在 IoC 中可以如下定义:


{
   "name": "simple_destroy_method",
   "beans": [{
       "id": "car",
       "func": "car",
       "destroy": "destroy"
   }]
}

异步初始化方法

众所周知,Node.js 中异步调用是非常平常的,比如初始化一个 MySQL 或者 Redis 的连接都是异步的,那么异步的初始化方法也就不可避免。而且在某些场景下,必须要求异步操作完成后,才能继续另外一个操作,这就要求保证两者之间的顺序性。在 Bearcat IoC 中,你可以配置 **orderasync** 来完成这样的初始化需求:


var Car = function() {
   this.num = 0;
}

Car.prototype.init = function() {
   console.log('init car...');
   this.num = 1;
}

Car.prototype.run = function() {
   console.log('run car...');
   return 'car ' + this.num;
}

module.exports = Car;

var Wheel = function() {}

Wheel.prototype.init = function(cb) {
   console.log('init wheel...');
   setTimeout(function() {
       console.log('asyncInit setTimeout');
       cb();
   }, 1000);
}

Wheel.prototype.run = function() {
   console.log('run wheel...');
   return 'wheel';
}

module.exports = Wheel;

在这个简单的例子中,wheel 有一个异步的初始化方法,它必须在 car 初始化之前调用,那么你就可以在 context.json 配置中配置 wheel 为 async 的,且 order 的值比 car 的要小,以表明 wheel 要在 car 之前初始化:


{
   "name": "simple_async_init",
   "beans": [{
       "id": "car",
       "func": "car",
       "init": "init",
       "order": 2
   }, {
       "id": "wheel",
       "func": "wheel",
       "async": true,
       "init": "init",
       "order": 1
   }]
}

IoC 实战

随心所欲的单元测试

在单元测试中,很多情况下需要构造一个对象的 mock 对象出来,然后被测试的对象调用这个 mock 对象来进行单元测试。但是在没有 IoC 之前,由于测试对象和 mock 原对象之间往往是紧密耦合的,那么要完成这样的操作,要么就是修改 mock 原对象的代码,要么就是修改测试对象的代码,但这样都不是最佳的实践。比如说一个基于 express 的 web 应用里面有一个 userController,它依赖于 userService:


var userService = require('../service/user-service');

exports.allUsers = function (req, res, next) {
 userService.getUsers(function (err, users) {
   if (err) {
     return next(err);
   }
   res.json(users);
 });
};

这时,如果想构造一个 userService 的 mock 对象 mockUserService,并且在 userController 里面 require 进来进行测试的话,就需要修改 userController 里面的代码,比如这样子:


//var userService = require('../service/user-service');
var userService = require('../service/mock-user-service');

exports.allUsers = function (req, res, next) {
 userService.getUsers(function (err, users) {
   if (err) {
     return next(err);
   }
   res.json(users);
 });
};

而通过 IoC,这一切就变得非常的简单,无需修改代码即可完成。

在 IoC 容器管理下,依赖关系是通过对象给 IoC 容器的描述来完成的,因此,只需要修改 context.json 元数据配置,即可完成原始对象和 mock 对象之间的 **无缝** 切换:


{
   "name": "simple_unit_test",
   "beans": [{
       "id": "userController",
       "func": "userController",
       "props": [{
         "name": "userService",
         "ref": "userService"
       }]
   }]
}

改成如下所示的 test-context.json 即可。单元测试的时候,使用 test-context.json 来作为 IoC 容器的配置,既不影响开发,也可以完成测试工作,相当的便捷:


{
   "name": "simple_unit_test",
   "beans": [{
       "id": "userController",
       "func": "userController",
       "props": [{
         "name": "userService",
         "ref": "mockUserService"
       }]
   }]
}

一致性配置

在 Node.js 开发中,系统需要配置的参数本质上其实就是设置函数的参数或者对象的属性。

比如要创建一个 Redis 连接,就是传入一个 Redis 的 host,port 参数:


var serverConfig = require('../../config/server');
var redis = require("redis");
var client = redis.createClient(serverConfig['redisPort'], serverConfig['redisHost']);

client.on("error", function(err) {
 console.error("redis error " + err);
});

client.on("ready", function() {
 console.log("redis is ready");
});

module.exports = client;

上面的做法简单粗暴,但是配置往往是与环境相关的。开发环境、测试环境、线上环境的 redis port、host 都不一样,因此这样的做法就无法解决环境切换的问题,要么就只能根据不同环境来对 config/server 文件进行替换,做法相当的粗暴,很容易出现问题。

通过 IoC,配置问题就将的变得非常简单,环境切换也变得自然无缝。比如说 car 里面一个 num 属性需要进行配置:


var Car = function() {
 this.num = null;
}

Car.prototype.run = function() {
 console.log('run car' + this.num);
 return 'car' + this.num;
}

module.exports = Car;

在 context.json 中,可以配置 num 为一个 ${car.num} 的占位符:


{
 "name": "simple",
 "beans": [{
   "id": "car",
   "func": "car",
   "props": [{
     "name": "num",
     "value": "${car.num}"
   }]
 }]
}

${car.num} 占位符最终会被特定环境下的值所替代。在 config 文件夹下面,对不同环境分不同的子目录,开发环境对应于 dev,生产环境对应于 prod,里面有 car.num 具体的配置:


├─┬ placeholderSample/
│ ├─┬ config/
│ │ └─┬ dev/
│ │ │ └── car.json
│ │ └─┬ prod/
│ │   └── car.json
│ └── car.js
└── context.json

{
   "car.num": 100
}

通过启动参数指定 env 的值来部署到不同的环境中。部署到生产环境中的示例如下:


node app.js env=prod

总结

本文中深入介绍了 IoC 在 Node.js 中的应用以及所给 Node.js 开发带来的便捷与好处。IoC 可以去除代码之间的直接依赖关系,降低了耦合性。通过灵活可配置可重用的元数据配置,开发者在进行开发的时候面对的就不仅仅是一个个对象个体,而是弹性可配置的整体。IoC 同时使得根据环境进行配置变得简单与无缝。

参考资料


感谢田永强对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。