当IoC遇见了Node.js

没有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遇见了Node.js_第1张图片

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

步入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同时使得根据环境进行配置变得简单与无缝。

参考资料

  • bearcat 一个基于POJOs的应用层框架,提供了IoC、AOP、一致性配置等特性
  • bearcat-IoC容器详解 bearcat IoC容器各种特性详细介绍

你可能感兴趣的:(当IoC遇见了Node.js)