编写 Node.js 模块

Node.js 的模块系统填了一个 JavaScript 语言本身的一个坑:缺乏一种原生方式来支持把代码组织到不同的自包含单元里面。这种方式的最大好处之一就是可以使用 require()函数把这些模块链接到一起的能力,方法简单却很强大。但是很多新手对此却经常感到困惑,一个常见的问题是:把组件 X 的一个实例传到模块 Y 里面的最好的方法是什么?

有时候这种困惑会让我们急于寻找一种单例模式之类的熟悉的方法来把我们的模块链接起来;另一方面有些人可能会过度使用依赖注入(即使是没有状态的)模式,期望使用它来解决一切依赖问题。

这一章我们将讲解布置模块的各种方法及其优缺点。最重要的几种模式:

  • Hardcoded dependency 硬编码依赖
  • Dependency injection 依赖注入
  • Service locator 服务定位器
  • Dependency injection containers 依赖注入容器

然后我们还会讨论一个紧密相关的问题,如何布置插件。

Modules and dependencies 模块和依赖项

每一个现代应用程序都是由若干个组件聚合而成的,随着应用不断生长,我们用来连接这些组件的方法就成了项目成功或者失败的一大因素。这个问题不仅是跟技术方面相关的,比如可扩展性,而且还关系到我们理解这个系统的方法。一个混乱的依赖图是一种妨碍,并且会增加项目的技术债务;在这种情况下,任何试图在代码中修改或者扩展它的功能都将导致巨大的工作量。

最糟糕的情况是,如果模块都特别紧密地连接在一起,添加或者修改任何东西都会变成不可能的任务,除非对其进行重构或者彻底改写应用程序的整个部分。这并不意味着说从我们最开始的那个模块就要进行过度设计,但是从一开始就找到一种良好的权衡会造成很大的不同。

Node.js 用来组织和编写应用程序模块的优异方法就是 cCommonJS module system。如果一方面它添加了一种方便的 level of indirection在不同的客户端模块和依赖的话,那么另一方面它就可能引入紧密的coupling如果没有正确使用的话。这章我们将讨论在 Node.js 中配置依赖的一些基本面。

Node.js 中最常见的依赖项

在软件架构中,我们可以把任何的实体,状态,或者数据格式,那些影响一个组件的行为或者结构的东西,看做一个依赖。举例来说,一个组件可能会使用由另一个组件所提供的服务,依赖于系统的一个特别的全局状态,或者为了跟其他模块交换信息而实现的一个规定的通信协议,等等。依赖的概念很广,有时候很难评估。

Node.js 里面最容易想到的基本依赖类型就是模块之间的依赖。大系统都会依赖很多模块。把各种模块良好地组织起来可以带来很多好处。实际上一个模块的属性可以被总结成下面这些:

  • 一个模块如果更专注就会更具可读性且更容易理解
  • 使用一个单独的文件来呈现,一个模块就更容易识别
  • 一个可以跨不同的应用程序的模块可能更容易使用

模块代表的是实现信息隐藏的细粒度层次,并且提供了一种有效的仅曝露公共接口的机制。(使用 module.exports)

然而跨不同的模块平铺直叙应用或库的功能并不足以成为成功的设计,必须正确的做才行。我们要在不同的度量下达到正确的平衡。

内聚和耦合

构建模块时最重要的两个需要平衡的属性是 cohesion 和 coupling。内聚和耦合可以定义如下:

  • Cohesion 内聚:这是有关模块内功能之间的相关性的一个测量。举例来说,一个只做一件事情的模块,其所有组成部分都致力于那个唯一的任务就具有高内聚。一个模块,包含把各种类型的对象存到数据库中的函数 – saveProduct(), saveInvoice(), saveUser() 等等 – 具有低内聚。
  • Coupling 耦合:这个衡量的是一个组件在多大程度上依赖于系统中的其他模块。例如,一个模块如果直接读取或者修改其他模块的数据就是跟其他模块紧密耦合的。还有,两个模块如果通过一个全局或者共享的状态进行交互就是紧密耦合的。相反来讲,两个模块如果只通过传递参数进行沟通就是松散耦合的。

理想的场景是具有高内聚和低耦合,这样的模块通常更容易理解,更易重用,同时扩展性更强。

有状态的模块

JavaScript 中的一切事物都是对象。我们没有像纯接口或者类这样的抽象概念;其动态类型系统已经提供了一种天然的机制来解耦 interface(或者叫 policy)从 implementation(或者叫 detail)。这也是为什么很多设计模式在 JavaScript 里面看起来非常不同,而且比传统的实现要简单很多。

在 JS 里面我们几乎没有分离接口和实现的问题,但是通过简单地使用 Node.js 的模块系统,我们已经引入了一种和特定实现相关的硬编码关系。通常情况下这没有什么问题,但是如果使用 require() 导入的是一个导出了一个有状态的实例的模块的话,例如一个 db 句柄,一个 HTTP 服务器实例,一个服务的实例,或者通常是一个非无状态的对象,我们实际上就引用了一个跟 Singleton 非常类似的东西,从而也集成了它的优缺点。

Node.js 中的单例模式

很多新接触 Node.js 的都想知道如何正确地实现单例模式,大多数情况下都只是简单地想在同一个应用程序的不同的模块之间共享一个实例。但是,Node.js 中的答案比我们大多数人认为的都要简单;只要使用 module.exports导出一个实例就足够获得跟单例模式很像的东西了。例如思考一下下面的这行代码:

// 'db.js' 模块
module.exports = new Database('my-app-db');

通过简单地导出一个我们数据库的新的实例,我们就可以认为在当前包里(或者整个应用程序的代码中),我们将只拥有一个 db 模块的实例。这是因为 Node.js 在初次调用 require() 之后会将模块缓存,确保在后面的调用中不会再次执行,而是会返回缓存的实例。比如,我们可以简单地获取一个我们之前定义的 db 模块的共享实例,使用下面这行代码:

var db = require('./db');

需要注意的是,Node.js 使用的是文件的全路径作为查找键,这样只能保证在当前包里面是单例的。我们知道每个包都有自己的 node_modules 作为私有依赖,也就是说这个模块可能会存在于不同的包里面,最终在整个应用中存在多个。加入我们把 db 模块封装到了一个名为 mydb 的包里。下面代码是它的 package.json 文件:

{
    "name": "mydb",
    "main": "db.js"
}

现在考虑一下下面的包依赖树:

app/
`-- node_modules
    |-- packageA
    |   `--node_modules
    |       `-- mydb
    `-- packageB
        `-- node_modules
            `-- mydb

packageA 和 packageB 两者都具有到 mydb 包的依赖;相应的,我们的主程序 app 包,依赖于 packageA 和 packageB。我们刚才描述的场景,将打破我们有关数据库实例唯一性的假设;实际上 packageA 和 packageB 都将使用如下命令来加载数据库实例:

var db = require('mydb');

mydb 在这种情况下会被解析到不同的目录下,因此 packageA 和 packageB 将会加载两个不同的实例。

因此,某种程度上我们可以说单例模式在 Node.js 中是不存在的,除非我们使用一个真正的全局变量来保存它,像下面这样的一些东西:

global.db = new Database('my-app-db');

这将确保该示例是唯一的而且可以跨整个应用程序共享,而不是只在相同的包内。但是大多数情况下,我们并不需要一个纯正的单例模式,而且在很多情况下,我们还可以使用其他模式来在不同的包里面共享一个实例。

放置模块的模式

我们主要看一下如何放置有状态的实例,这是应用程序中最重要的依赖类型。

硬编码依赖

在 Node.js 里面,如果一个客户模块使用 require() 显式载入了另一个模块,我们就叫做hardcoded dependency。这种建立模块依赖的方法简单且高效,但是我们要额外注意处理有状态实例的硬编码依赖,它可能会限制我们模块的重用性。

使用硬编码依赖构建一个鉴权服务器

Building an authentication server using hardcoded dependencies

我们通过看下面的这张图来开始我们的分析: 

上图是一个简单的鉴权系统的结构。AuthController接受来自客户的输入,从请求中解释出登录信息,并执行一次初步的校验。然后它会依赖于 AuthService 来检查提供的凭证是否跟数据库中储存的信息相匹配;这通过使用 DB 句柄跟数据库通讯来执行一些特定的查询实现的。这三种模块被连接到一起的方法将会决定它们在可重用性,可测试性,以及可维护性方面的级别。

放置这些组件最自然的方法就是在 AuthService 里面引入 DB 模块,然后从 AuthController 里面引入 AuthService模块。这就是我们要讨论的 hardcoded dependency。

我们通过真正实现来演示一下这种方法。我们实现一个 鉴权服务器,它会导出下面两个 HTTP API:

  • POST ‘/login’:这将接受一个 JSON 对象,包含一个用户名密码对来进行验证。如果成功的话,它会返回一个 JSON Web Token(JWT),这个将被在后续请求中用作验证用户的身份。
  • GET ‘/checkToken’:将从一个 GET 查询参数读取一个 token 并验证其有效性。

在这个示例中,我们将使用 express(https://npmjs.org/package/express) 来实现 Web API,并且使用 levelup(https://npmjs.org/package/levelup) 来存储用户数据。

db 模块

从头构建该应用所需要的第一个模块是能够导出 levelUp 数据库实例。我们创建一个新的名为 lib/db.js 的文件并且在其中包含下列内容:

var level = require('level');
var sublevel = require('level-sublevel');

module.exports = sublevel(
    level('example-db', {valueEncoding: 'json'})
);

上述代码简单地创建了一个到 LevelDB 数据库的链接,数据库存储在 ./example-db 目录下,然后使用 sublevel 插件(https://npmjs.org/package/level-sublevel)来装饰该实例,增加了创建和查询单独的数据库 section (类似 SQL 数据库的表和 MongoDB 数据库的集合)的支持。由模块所导出的对象是数据库句柄本身,这是一个有状态的实例,这样我们就创建了一个单例。

authService 模块

现在我们有了一个 db 单例了,我们可以使用它来实现 lib/authService.js 模块了,该模块负责依据数据库中的信息来检查用户的凭证。代码如下(只显示相关部分):

[...]
*var db = require('./db');*
var users = db.sublevel('users');

var tokenSecret = 'SHHH!';

exports.login = function(username, password, callback) {
    users.get(username, function(err, user) {
        [...]
    });
};

exports.checkToken = function(token, callback) {
    [...]
    users.get(userData.username, function(err, user) {
        [...]
    });
};

authService 模块实现了负责根据数据中内容检查用户名/密码对的 login() 服务,以及接受一个 token 并验证其有效性的 checkToken() 服务。

上述代码也演示了使用一个带状态模块的硬编码依赖的第一个示例。我们说的就是 db 模块,我们通过简单地引入它进行了加载。作为结果的 db 变量包含一个我们可以直接使用的已经经过初始化的数据库句柄,用于执行查询操作。

这个时候,我们可以看到我们为 authService 模块创建的全部代码并不强制依赖一个特定的 db 模块的实例 – 任何一个实例都可以工作。然而,我们把依赖硬编码到了某一个特定的 db 实例,这意味着我们将无法在不修改代码的情况下将 authService 重用来跟另一个数据库实例进行组合。

authController 模块

继续走向应用的更高层,我们现在看一下 lib/authController.js 模块什么样子。这个模块负责处理 HTTP 请求,并且本质上是一个 express 路由的集合;该模块的代码如下所示:

var authService = require('./authService');

exports.login = function(req, res, next) {
    authService.login(req.body.username, req.body.password, function(err, result) {
        [...]
    });
};

exports.checkToken = function (req, res, next) {
    authService.checkToken(req.query.token, function(err, result) {
        [...]
    });
};

authController模块实现了两个 express 路由:一个用来执行登录和返回相应的验证token(login()),另一个用于检查 token 的有效性(checkToken())。两个路由都把其大部分的逻辑委托给了 authService,所以它们的工作就是处理 HTTP 请求和响应。

我们可以看到,在这种情况下,我们又硬编码了一个依赖与有状态模块 authService。因为它直接依赖 db 模块,通过传递,authServie 模块也是有状态的。通过这个示例,我们就能开始理解硬编码依赖是如何轻易地扩展蔓延到整个应用程序的结构上的:authController 模块依赖于 authService 模块,而 authService 模块又相应地依赖于 db 模块。

app 模块

最后,我们可以通过实现应用程序的入口点来把所有这些片段放到一起。依照惯例,我将把这些逻辑放在一个名为 app.js 的模块中,位于项目根目录下,如下所示:

var express = require('express');
var bodyParser = require('body-parser');
var errorHandler = require('errorhandler');
var http = require('http');

*var authController = require('./lib/authController');*

var app = module.exports = express();
app.use(bodyParser.json());

app.post('/login', authController.login);
app.get('/checkToken', authController.checkToken);
app.use(errorHandler());
http.createServer(app).listen(3000, function(){
    console.log('Express server started');
});

我们的 app 模块是很基础的;它包含一个简单的 express 服务器,注册了几个中间件和两个由 authController 导出的路由。当然,这里面最重要的是我们通过 require authController 来跟它的有状态示例产生了一个硬编码依赖。

运行验证服务器

我们可以使用示例代码中的 populate_db.js 脚本来填充一些示例数据。之后,我们可以通过运行下列命令来启动服务器了:

node app

然后我们就可以尝试调用我们创建的这两个服务;我们可以使用 REST 客户端实现或者也可以使用旧的好的 curl 命令。例如,要执行登录操作,我们可以运行如下命令:

curl -X POST -d '{"username": "alice", "password": "secret"}' http://localhost:3000/login -H "Content-Type: application/json"

上述命令应该返回一个 token,我们可以用于 /checkLogin web 接口(只要在下列命令中把 替掉即可): curl -X GET -H “Accept: application/json” http://localhost:3000/checkToken?token=

上述命令应该返回一个类似下面的字符串,这可以确认服务器是如预期般工作的:

{"ok": "true", "user": {"username": "alice"}}

硬编码依赖的优缺点

我们刚才实现的这个示例,演示了在 Node.js 中放置模块的常规方法,利用其模块系统的力量在应用程序的各个组件之间管理依赖。我们从我们的模块中导出了有状态的实例,让 Node.js 来管理它们的生命周期,然后我们从应用的其他部分直接 require 它们。结果是一个非常直观的组织,易于理解和调试,在这里每一个模块都是自行初始化和放置,无需任何额外的干扰。

然后从另一方面来看,在一个有状态的实例上面硬编码依赖限制了把该模块和其他实例配置的可能性,这让其变得重用性差,而且难以进行单元测试。例如,要把 authService 和另一个数据库实例组合使用几乎是不可能的,因为它的依赖被硬编码为一个特定的实例。同样地,单独测试 authService 也变成一个困难的任务,因为我们无法轻松模拟由该模块所使用的数据库。

作为一个最后的考量,意识到使用硬编码的大部分不好的地方都跟有状态的实例有关是非常重要的。这意味着如果我们使用 require() 来加载一个无状态的模块,例如,一个工厂,构造器,或者一组无状态的函数,我们就不会招致同样的问题。我们仍然会跟一个特定实现紧密耦合,但是在 Node.js 里面,这不会影响组件的重用,因为它不会引入跟一个特定的状态的耦合。

依赖注入

依赖注入模式可能是软件设计中被误解最多的概念之一。很多人把这个词跟依赖注入容器联系在一起,像(Java和C#)中的 Spring或者 PHP 中的 Pimple,但实际上它是很简单的一个概念。依赖注入模式的主要思想是一个模块的依赖项是由一个外部实体作为一个输入项提供的。

这样的一个实体可以是一个客户组件或者是一个全局容器,在那里集中摆放系统的所有模块。该方法的主要益处是一个改进了的解耦合,尤其是针对那些依赖有状态实例的模块。使用 DI,每一个依赖,不再是被硬编码到模块中,而是被从外部接收。这意味着模块可以被配置成使用任意依赖并且因此可以在不同的上下文中得以重用。

下面我们来演示一下这种方法,通过重构我们刚才构建的鉴权服务器,这次使用依赖注入来配置模块。

重构鉴权服务器使其使用依赖注入

把我们的模块重构成使用依赖注入只要围绕使用一个非常简单的秘诀即可:针对有状态的实例不使用硬编码依赖,我们将使用一个工厂替代,该工厂将使用一组依赖作为参数。

开始重构了,首先处理 lib/db.js 模块:

var level = require('level');
var sublevel = require('level-sublevel');

module.exports = function(dbName) {
    return sublevel(
        level(dbName, {valueEncoding: 'json'})
    );
};

我们重构过程的第一步是把 db 模块转换成了一个工厂。这样做的结果就是我们现在可以想要多少数据库实例就用它来创建多少;这意味着整个模块现在都是可以重用以及无状态的了。

让我们继续前进实现一个新版的 lib/authService.js 模块:

var jwt = require('jwt-simple');
var bcrypt = require('bcrypt');

module.exports = function(db, tokenSecret) {
    var users = db.sublevel('users');
    var authService = {};

    authService.login = function(username, password, callback) {
        // ...跟之前版本一样
    };

    authService.checkToken = function(token, callback) {
        // ... 跟之前版本相同
    };

    return authService;
};

同样地,authService 模块现在也是无状态的了;它不再导出任何特定实例了,只是一个简单的工厂。最重要的细节是我们让 db 依赖作为一个工厂函数的参数可注入了,把之前的硬编码依赖去除了。这个简单的改变能够让我们通过把它跟任何一个数据库实例摆放在一起从而生成一个新的 authService 模块。

我们可以类似的方法重构 lib/authController.js 模块:

module.exports = function(authService) {
    var authController = {};

    authController.login = function(req, res, next) {
        // ... 跟之前版本相同
    };

    authController.checkToken = function(req, res, next) {
        // ... 跟之前版本相同
    };

    return authController;
};

authController 模块现在不再有任何硬编码依赖了,甚至连无状态的都没有了。唯一的依赖,那个 authService 模块,现在是作为它被调用时的作为工厂的输入提供了。

好吧,现在我们看一下这些模块实际上都是在哪里创建以及摆放在一起的;答案在于 app.js 模块,该模块作为我们应用的最顶层;其代码如下:

[...]
var dbFactory = require('./lib/db');    // [1]
var authServiceFactory = require('./lib/authService');
var authControllerFactory = require('./lib/authController');

var db = dbFactory('example-db');       // [2]
var authService = authServiceFactory(db, 'SHHH!');
var authController = authControllerFactory(authService);

app.post('/login', authController.login);       // [3]
app.get('/checkToken', authController.checkToken);
[...]

上面的代码可以被归结为下面几点:

  1. 首先,我们载入我们服务的各个工厂;此时,它们仍然是无状态的对象。
  2. 接着,我们通过提供它们所需的依赖项对每个服务进行了实例化。在此阶段全部模块被创建并且被配置上了。
  3. 最后,我们像通常一样在 express 服务上注册了 authController 模块的路由。

我们的验证服务器现在就是使用依赖注入配置的了,而且准备好了被再次使用。

依赖注入的不同类型

我们刚才演示的示例只是依赖注入的一种类型(工厂注入),但其实还有几个值得一提的:

  • 构造器注入:在这种类型的 DI 中,依赖项在它创建时是被传给一个构造器的;下面是一种可能的示例:var service = new Service(dependencyA, dependencyB);
  • 属性注入:在这种 DI 类型中,依赖在一个对象生成后被附加给该对象,如下面所演示的:var service = new Service(); // 工厂也适用 service.dependencyA = anInstanceOfDependencyA;

属性注入隐含的是该对象被以一种不完整的状态创建出来的,因为它没有配置依赖项,所以它是最不鲁棒的,但在依赖间存在循环引用的情况却可能是有用的。举个例子,如果我们有两个组件,A 和 B,两者都使用工厂或者构造器注入,而且两者都彼此依赖,我们无法实例化它们的任何一个,因为两者要被生成的话都需要对方是存在的。我们来思考一下像下面这样的一个简单示例:

function Afactory(b) {
    return {
        foo: function() {
            b.say();
        },
        what: function() {
            return 'Hello!';
        }
    }
}

function Bfactory(a) {
    return {
        a: a,
        say: function() {
            console.log('I say: ' + a.what);
        }
    }
}

在上述两个工厂之间的依赖死锁只能使用属性注入来解决,比如,通过首先生成一个不完整的 B 的实例,这个实例然后就可以被用于生成 A。最终,我们将通过如下设置相对的属性把 A 注入到 B:

var b = Bfactory(null);
var a = Afactory(b);
b.a = a;

要注意的是虽然有时候循环依赖是不可避免的,但是通常情况下这预示着一个不好的设计。

依赖注入的优缺点

在鉴权服务器的示例中,通过使用依赖注入我们可以把我们的模块从特定的依赖实例进行解耦。结果是我们现在可以以最小的努力和无需修改它们的代码就可以重用每个模块。使用依赖注入模式的模块测试起来也很容易;我们可以轻松模拟依赖项,同时可以把我们的模块跟系统其他部分隔离开来进行单独测试。

从之前我们呈现的例子中,另一个需要高亮的重要方面是我们把依赖配置的责任从结构的底部上升到了顶部。思路在于高层组件相对底层组件天生是较小被重用的,同时还因为我们更多地走向一个应用程序的上级层面,一个组件就会变得更加明确和具体。

从此假定出发,我们就可以理解我们看一个应用程序架构的有效方法,高层组件拥有它们的底层依赖,可以被反转过来(依赖反转?),这样底层组件就只依赖一个接口了(在 JavaScript 里面,它只是一个我们从一个依赖期望的接口),同时把定义依赖的具体实现的所有权给与了高层组件。在我们的鉴权服务器中,实际上,所有的依赖都是在顶层组件中被实例化和配置的,在 app 模块中,该模块是最小可重用的,从而就耦合来讲也是最奢侈的。

所有这些对解耦合重用性来说的益处,然而还是要有所付出的。通常情况下,在编码阶段对解决依赖的无能为力会让理解系统中不同模块间的关系变得更加困难。同样,如果我们看一下我们在 app 模块中实例化所有依赖的方法,我们能够得出我们必须遵循一定的顺序;我们实际上必须要手动构建一个整个应用的依赖图谱。这可能会在需要配置的模块数量变大的时候变得不可管理。

一种可行的方案是把依赖的所有权在几个组件之间进行分割,而不是全部集中在一个地方。这可以降低管理以指数增长依赖的复杂性,因为每个组件都将只负责其专门的依赖子图。当然,我们也可以选择只在本地使用依赖注入,只在必要的时候,而不是把整个应用程序构建在它上面。

我们将在这一章看另外一种在复杂结构中简化模块配置的可能的解决方案,使用依赖注入容器,一个负责实例化和配置一个应用程序所有依赖项的专门模块。

使用依赖注入确实增加了我们模块的复杂度和冗长度,但是正如你在前面所看到的,这样做是有很多原因的。选择正确的方法取决于我们自己,取决于我们想要达到的在简单性和重用性之间的一个平衡。

注意:依赖注入(Dependency injection)通常被和依赖反转原则(Dependency Inversion principle )和控制反转(Inversion of Contorl)一起提及;然而,它们都是不同的概念(即使是相关的)。

服务定位器

在上一部分,我们学习了依赖注入是如何真正地转变了我们配置依赖的方法,获得了可重用的并且是解耦了的模块。另一种具有非常类似意图的模式是服务定位器(Service Locator)。它的核心原则是有一个核心注册表(registry),用来管理系统的组件,并在无论何时一个模块需要加载一个依赖的时候扮演一个中介者。其思路就是向服务定位器请求依赖,而不是对依赖进行硬编码,如下图所示:

Service Locator

通过使用服务定位器我们就引入了对它的一个依赖,理解这一点是非常重要的,所以我们把它放到我们模块中的方法就决定了它们的耦合级别,以及因此的重用性。在 Node.js 里面,我们可以定义三类服务定位器,取决于它们被配置到系统中各个组件的方式:

  • 对服务定位器的硬编码依赖
  • 注入式服务定位器
  • 全局服务定位器

第一种从解耦来说肯定是提供好处最少的,因为它包括使用 require() 的对服务定位器实例的直接引用。在 Node.js 里面,这可以被认为是一种反模式,因为本来是要提供更好解耦的组件却引入了一个紧耦合。在这种情况下,一个服务定位器明显地无法提供重用性方面的任何价值,而只是增加了非直接的一个水平和复杂性。

而不同的是,一个注入式服务定位器则是通过依赖注入由一个组件来引用的。这可以被认为是立即注意一整套依赖的更恰当的方法,而不是一个一个地提供它们,但是正如我们所将看到的,它的好处还不止如此。

引用一个服务定位器的第三种方法是直接从一个全局作用域。这和硬编码服务定位器具有一样的不好的地方,但是因为它是全局的,它是一个真正的单例,从而它可以很轻松地用作在包间共享实例的一种模式。我们稍后将会看一下这是如何工作的,但是目前为止我们还是得说使用一个全局服务定位器的理由其实很少。

注意:Node.js 的模块系统已经实现了一个服务定位器模式的变种,require() 本身就代表了一个服务定位器的全局实例。

我们在这里讨论的一切在我们用服务定位器模式真正做一个例子之后就会变得比较清楚。现在用我们所学到的来重构鉴权服务器吧。

重构鉴权服务器使其使用服务定位器

我们现在要把鉴权服务器转换成使用一个注入服务定位器。要实现这个,第一步是实现服务定位器自身;我们将使用一个新的模块,’lib/serviceLocator.js‘:

module.exports = function() {
    var dependencies = {};
    var factories = {};
    var serviceLocator = {};

    serviceLocator.factory = function(name, factory) {  //[1]
        factories[name] = factory;
    };

    serviceLocator.register = function(name, instance) {    //[2]
        dependencies[name] = instance;
    };

    serviceLocator.get = function(name) {   //[3]
        if(!dependencies[name]) {
            var factory = factories[name];
            dependencies[name] = factory && factory(serviceLocator); // 接受 serviceLocator 的构造器?
            if(!dependencies[name]) {
                throw new Error('Cannot find module: ' + name);
            }
        }
        return dependencies[name];
    };

    return serviceLocator;
};

我们的 serviceLocator 模块是一个返回一个具有三个方法的对象的一个工厂:

  • factory() 被用于关联一个组件名称和一个工厂。
  • register() 被用于直接关联一个组件名称和一个实例。
  • get() 根据名称提取一个组件。如果一个实例已经可用,它就简单地把它返回;否则,它会尝试调用注册的工厂来获取一个新的实例。非常重要的是要观察到模块工厂被调用,是通过注入服务定位器的当前实例(serviceLocator)。这是该模式的核心机制,能够让我们系统的依赖图谱在需要的时候自动构建。我们马上就会看到这是如何运行的。

旁白: 一个简单的模式,接近效仿一个服务定位器,是使用一个对象为一组依赖作为命名空间:

var dependencies = {};
var db = require('./lib/db');
var authService = require('./lib/authService');

dependencies.db = db();
dependencies.authService = authService(dependencies);

现在我们来转换’lib/db.js’模块直接演示一下我们的 serviceLocator 是如何工作的;

var level = require('level');
var sublevel = require('level-sublevel');

module.exports = function(serviceLocator) {
    var dbName = serviceLocator.get('dbName');

    return sublevel(
        level(dbName, {valueEncoding: 'json'})
    );
}

db 模块使用从输入获得的服务定位器来提取要实例化的数据库名称。这是一个要大书特书一下的有趣的点;一个服务定位器不光可以用来返回我们希望创建的组件实例,还可以用来提供定义了整个依赖图谱行为的配置参数。(上面的register(name, instance) 中的 instance 也可以是一个字符串,表示数据库名称)

下一步是转换’lib/authService.js’模块:

[...]
module.exports = function(serviceLocator) {
    var db = serviceLocator.get('db');
    var tokenSecret = serviceLocator.get('tokenSecret');

    var users = db.sublevel('users');
    var authService = {};

    authService.login = function(username, password, callback) {
        // ... 跟上一个版本相同
    }

    authService.checkToken = function(token, callback) {
        // ... 跟上一个版本相同
    }

    return authService;
};

同样地,authService 模块也是一个把服务定位器作为输入的工厂。模块的两个依赖,db 句柄和 tokenSecret(这是另一个配置参数)通过使用服务定位器的 get() 方法进行了提取。

以同样的方式,我们可以转换’lib/authController.js’模块:

module.exports = function(serviceLocator) {
    var authService = serviceLocator.get('authService');
    var authController = {};

    authController.login = function(req, res, next) {
        // ... 跟之前代码相同
    };

    authController.checkToken = function(req, res, next) {
        // ... 跟之前代码相同
    };

    return authController;
}

现在我们已经准备好了看看服务定位是如何实例化和配置的了。当然还是发生在’app.js’模块里面:

[...]
var svcLoc = require('./lib/serviceLocator')();     //[1]

svcLoc.register('dbName', 'example-db');        //[2]
svcLoc.register('tokenSecret', 'SHHH!');
svcLoc.factory('db', require('./lib/db'));
svcLoc.factory('authService', require('./lib/authService'));
svcLoc.factory('authController', require('./lib/authController'));

var authController = svcLoc.get('authController');      //[3]

app.post('/login', authController.login);
app.all('/checkToken', authController.checkToken);
[...]

这就是配置使用我们新的服务定位器如何工作的:

  1. 我们通过调用服务定位器的工厂实例化了一个新的出来。
  2. 我们在服务定位器上注册了配置参数和模块工厂。此时,我们所有的依赖尚未实例化,我们只是注册了它们的工厂。(db 也没有实例化吗?)
  3. 我们从服务定位器中加载了 authController;这是触发我们应用程序整个依赖图谱实例化的入口点。当我们请求实例化 authController 组件时,服务定位器通过注入一个自身的实例调用关联的工厂,然后 authController 的工厂会尝试加载 authService 模块,authService 也会接着实例化 db 模块。

发现了服务定位器的延迟特性还是挺有趣的;每个实例只在需要的时候才会被创建。但是这里还有另外一个重要的含义,我们可以看到,实际上,全部依赖都是被自动配置的,而无需实现手动来做。好处就是我们不需要事先知道实力化和配置模块的正确顺序,全部都是自动发生的而且是按需的。这相比简单的依赖注入模式要方便得多。

旁白: 另外一种常见的模式是使用一个 express 服务器实例作为一个简单的服务定位器。这可以通过使用 expressApp.set(name, instance) 进行服务注册,接着 expressApp.get(name) 然后获取它,来实现。这种模式便捷的地方在于 server 实例,它扮演一个服务定位器,已经注入到了每个中间件里面去了,而且可以通过 request.app 属性时可访问的。我们也带了一个使用这种模式的示例。

服务定位器的优缺点

服务定位器和依赖注入有很多一致的地方;两者都把依赖的所有权提升到了一个组件外的实体中。但是我们配置服务定位器的方法决定了我们整个架构的灵活性。我们并不偶然的选择了一个注入的服务定位器来实现我们的示例,作为跟一个硬编码和全局服务定位器相反的。后两个变种几乎抵消了此模式的益处。实际上,结果可能会是,并没有使用 require() 把一个组件直接跟他的依赖相耦合,我们反而会把它跟一个服务定位器的特定实例相耦合了。确实,一个硬编码服务定位器仍然可以提供更多的灵活性在配置哪个组件跟哪个特定名称相关联上,但是这在可重用性方面依然没有给出一个很大的益处。

同样,跟依赖注入类似,使用一个服务定位器让辨别组件间的关系变得更难,因为它们都是在运行时决定的。但是除此之外,它还让确切知道一个特定组件到底会需要哪个依赖变得更加困难。使用依赖注入的话,这个问题被以一种非常清晰的方式进行了表达,通过在工厂或者构造器参数中声明依赖。使用服务定位器,这个问题就没那么清晰,可能会需要代码检查或者是在文档中的一个显式声明来解释某个模块会试图加载哪些依赖。

最后一个说明,一个服务定位器经常不正确地被误解为一个依赖注入容器,认识到这一点是很重要的,因为它们共享了同样的service registry 角色;然而,这两者之间是有巨大区别的。使用一个服务定位器,每一个组件都显式地从它的服务定位器本身加载其依赖项,而使用一个 DI 容器的时候却不是,这时组件并不知道容器的存在。

这两种方法之间的区别值的注意是因为这两个原因:

  • 重用性:一个依赖于服务定位器的组件重用性较差,因为它要求系统中必须存在一个服务器定位器。
  • 可读性:如上所述,一个服务定位器使一个模块的依赖要求变得混乱。

就可重用性来说,我们可以说服务定位器模式介于硬编码依赖和DI之间。就便捷性和简单性而言,它绝对比手动依赖注入要好,因为我们不再需要手动小心构建整个依赖图谱。

在这些假定下,一个依赖注入容器绝对提供了最好的模块可重用性和便捷性方面的承诺。我们将在下一章分析一下这个模式。

依赖注入容器

把一个服务定位器变换成一个依赖注入容器的步骤并不大,但是正如我们已经提到的,就解耦来说却会造成大不同。使用这种模式,实际上,每一个模块都不再需要继续依赖服务定位器了,而是可以简单地表达它对依赖项的要求,同时依赖注入容器会无缝地完成剩下的工作。如我们将要看到的,这种机制带来的巨大飞跃就在于全部模块都可以在即使没有容器的情况下得以重用。

给一个依赖注入容器声明一套依赖

依赖注入容器本质上是一个有着一个额外功能的服务定位器:它能够在实例化一个模块之前识别出它的依赖要求。为了让这个变得可能,一个模块必须以某种方式来声明其依赖,正如我们即将看到的,我们有好几个选项来做这个。

第一个也可能是最常见的技术包括注入一套依赖,基于在工厂或者构造器中使用的参数名称。我们假设一下 authService 模块:

module.exports = function(db, tokenSecret) {
    // ...
}

就像我们定义它的一样,上面的模块将被我们的依赖注入控制器使用名为 db 和 tokenSecret 的依赖进行实例化,一种非常简单且直观的机制。然而,为了能够读取参数或者函数的名称,必须使用一点小手段。在 JavaScript 中,我们有序列化一个函数的可能性,在运行时获取它的源代码;这就跟在函数引用上调用 toString() 一样简单。然后,使用以下正则表达式,获取参数列表就当然不是什么黑魔法了。

Light: 使用一个函数的参数的名称来注入一套依赖的技术由 AngularJS 变得流行,这是一个客户端的JavaScript框架,由 Google 开发并且是整个构建在一个 DI 容器之上的。

此方法最大的问题在于它不能很好地跟 minification 一起工作,minification 是一种在客户端很常用的实践,包括应用特定的代码变形来减少源代码的最小大小。很多 minificator 采用一种名为 name mangling 的技术,本质上会重命名本地变量以减少它的长度,通常是减少到一个字节。坏消息是函数参数都是本地变量,从而通常会被这种过程所影响,会造成我们描述的用于声明依赖的机制支离破碎。即使 minification 在服务端代码中并不是真的有必要,重要的是要考量 Node.js 的模块经常被跟浏览器所共享,从而这是考量我们分析的一个重要因素。(那么 AngularJS 是怎么解决这个问题的呢?)

幸运的是,一个 DI 容器可以使用其他技术来知道哪些依赖需要注入。这些技术给出如下:

  • 我们可以使用附加到工厂函数的一个特别属性,比如,一个显式列出了需要注入的全部依赖的一个数组:module.exports = function(a, b) {}; module.exports._inject = ['db', 'another/dependency'];
  • 我们可以把模块指定为一个数组,依赖名称后面跟着工厂函数:module.exports = ['db', 'another/dependency', function(a, b) {}];
  • 我们可以使用一个注释注解,附加到一个函数的每一个参数后面(但是,这可能也没法跟 minification 愉快地玩耍):module.exports = function(a /*db*/, b /*another/dependency*/) { };

所有这些技术都是非常武断的,所以对于我们的示例而言,我们将使用最简单和最常见的,也就是使用函数参数来获得依赖项名称。

重构鉴权服务器以使用一个依赖注入容器

为了演示依赖注入容器相比服务定位器是如何具有更小的入侵性,我们将再次重构我们的鉴权服务器,而且为了做这个我们将从使用一个纯依赖注入模式的版本作为起点。实际上,我们要做的只是除了 app.js 模块之外其他所有组件都放着不动,app.js 是用来负责初始化该容器的模块。

但是首先,我们需要实现我们的依赖注入容器。让我们通过创建一个新的叫做 diContainer.js 的模块来实现这点,位于 lib/ 目录下。这是它的初始化部分:

var argsList = require('args-list');

module.exports = function() {
    var dependencies = {};
    var factories = {};
    var diContainer = {};

    diContainer.factory = function(name, factory) {
        factories[name] = factory;
    };

    diContainer.register = function(name, dep) {
        dependencies[name] = dep;
    };

    diContainer.get = function(name) {
        if(!dependencies[name]) {
            var factory = factories[name];
            dependencies[name] = factory && diContainer.inject(factory);
            if(!dependencies[name]) {
                throw new Error('Cannot find module: ' + name);
            }
        }
        return dependencies[name];
    };
    // ... to be continued

diContainer 模块的第一部分功能上跟我们之前看到的服务定位器完全一致。唯一需要注意的区别是:

  • 我们引入了一个叫做 args-list(https://npmjs.org/package/args-list)的新的 npm 模块,我们将使用它来提取函数的参数名。
  • 这一次,我们不直接调用模块工厂,我们要依赖 diContainer 模块的另一个名为 inject() 的方法,该方法将解析一个模块的依赖项,并且使用它们来调用工厂。

让我们看一下 diContainer.inject() 方法看起来是什么样子的:

    diContainer.inject = function(factory) {
        var args = argsList(factory)
            .map(function(dependency) {
                return diContainer.get(dependency);
            });
        // args 应该是一个依赖项的数组,apply 到 factory 什么作用?第二个参数是作为参数
        return factory.apply(null, args);
    };
}; // end of module.exports = function() {

就是上面的方法让依赖注入容器和服务定位器不同的。它的逻辑非常明确:

  1. 使用 args-list 库,从一个工厂函数中我们接受的输入中提取参数列表。
  2. 然后我们把每一个参数名映射到使用 get() 方法提取到的相应的依赖项实例。
  3. 最后,我们要做的就是通过提供我们刚生成的依赖项列表来调用工厂。

这确实就是我们的 diContainer。我们看到它跟服务定位器并没有太大区别,但是通过注入它的依赖项的方式来实例化一个模块的简单步骤,能形成非常巨大的差异(跟注入整个服务定位器相比)。

要完成鉴权服务器的重构,我们还需要调整’app.js’模块:

[...]
var diContainer = require('./lib/diContainer')();

diContainer.register('dbName', 'example-db');
diContainer.register('tokenSecret', 'SHHH!');
diContainer.factory('db', require('./lib/db'));
diContainer.factory('authService', require('./lib/authService'));
diContainer.factory('authController', require('./lib/authController'));

var authController = diContainer.get('authController');

app.post('/login', authController.login);
app.get('/checkToken', authController.checkToken);
[...]

正如我们所见,上面的 app 模块的代码跟我们之前章节中用来初始化服务定位器的完全一样。我们也注意到依赖注入容器的引导程序,我们仍然得像一个服务定位器一样使用调用 diContainer.get(‘authController’) 。从这一点起,使用依赖注入容器注册的全部模块都将被自动实例化和配置。

依赖注入容器的优缺点

一个依赖注入容器假定我们的模块使用了依赖注入模式,从而也继承了其大多数的优点和缺点。尤其是,我们有了一个改进的解耦和可测试性,但是在另一方面也更复杂了,因为我们的依赖在运行时决定。一个依赖注入容易也和服务定位器模式共享了很多属性,但是它不需要强制模块去依赖一个外部的服务,除了它真正需要依赖的之外。这是一个巨大的好处,因为它能够让每个模块被使用即使没有依赖注入容器,使用了一种简单的手动注入。

我们在这一部分所演示的都是很基本的:我们使用了基本依赖注入模式的鉴权服务器,然后没有修改其自身的任何组件(除了 app 模块之外),我们可以自动化每一个依赖的注入。

Light: 在 NPM 上面,我们可以找到一大堆依赖注入容器,用来重用或者从中受到启发:https://www.npmjs.org/search?q=dependency%20injection.

编写插件

一个软件工程师的梦幻架构是一个具有小的,最小内核,通过使用 plugins 在需要的时候可以进行扩展的。不幸的是,这总是不容易实现,因为大多数时间它都有一个时间上,资源上和复杂度上的消耗。更不用说,总是希望支持某种类型的外部可扩展性,即使限制到仅系统的某些部分。这一部分,我们将投入到这个迷人的世界并且会聚焦在一个二元问题上:

  • 把应用的服务导出到一个插件
  • 集成一个插件到父应用的流程中

以包形式存在的插件

在 Node.js 中很常见的是,应用程序的插件被作为包安装到项目的 node_modules 目录下。这样做有两个好处。首先,我们可以利用 npm 的强大力量来分发我们的插件和管理其依赖。第二,一个包可以有它自己的私有依赖图谱,这就降低了依赖之间产生冲突和不兼容性,跟让插件使用父项目的依赖相比。

下面的目录结构给出了一个带有作为包进行发布的两个插件的应用程序的示例:

application
`--node_modules
    |-- pluginA
    `-- pluginB

在 Node.js 世界中这是一种常见的实践,一些常见的示例包括 express(http://expressjs.com) 和它的中间件,gulp(http://gulpjs.com), grunt(http://gruntjs.com), nodebb(http://nodebb.org)和 docpad(http://docpad.org)。

然而,使用包的好处并不仅仅局限于外部插件。实际上,一种流行的模式是通过把应用的模块都封装成包来构建整个应用,就好像它们是内部插件。所以,与其在应用的主要包里面组织模块,我们也可以为每个大块的功能创建一个独立的包,并且把它们安装到 node_modules 目录下。

Light: 包也可以是私有的,而不必在公用 npm 注册。我们总是可以在 ‘package.json’ 中设置 ‘private’ 标记来防止意外公开到 npm。然后我们可以把这个包提交到一个像git这样的版本控制器系统,或者使用一个私有 npm 服务器来在团队成员之间共享。

为什么要跟随这种模式?所有便捷之首:人们经常发现使用相对路径标记来引用一个包里面的本地模块不太实际或者太冗长。举个例子,比如下面这个目录结构:

application
|-- componentA
|   `-- subdir
|       `-- moduleA
`-- componentB
    `-- moduleB

如果你要想从 moduleA 来引用 moduleB,你必须得这样写:

require('../../componentB/moduleB');

作为一种替代,我们可以利用 require() 解析算法的属性并且把整个 componentB 目录放到一个包里。通过把它安装到 node_modules 目录下,我们接着就可以写一些类似下面的东西(从应用程序主包的任何地方):

require('componentB/module')

把一个项目切分到包的第二个原因当然是重用性了。一个包可以有自己的私有依赖,并且它强制开发者去思考,哪些是需要暴露给主应用程序的,哪些是相反要保持私有的,对整个应用在解耦合信息隐藏方面具有有益的影响。

Lights: Pattern: 使用包作为组织应用程序的方法,而不只是用于和 npm 结合来分发代码。

我们刚才描述的用例利用了一个包,不只是一个无状态的可重用的库(像 npm 上的大多数包一样),而更是一个特定应用的内部部分,提供服务,扩展其功能,或者修改其行为。主要的区别在于这些类型的包是被集成到一个应用程序的内部,而不只是用到了。

Notes: 为了简化,我们将使用 plugin 这个词来描述任何意味着要集成到一个特定应用程序的包。

正如我们将会看到的,在决定支持这种类型的架构时我们会面临的一个常见问题是,导出部分主应用给插件。实际上,我们不能只考虑无状态插件,这是理所当然的,一个完美地可扩展性目标-但是有时候插件不得不使用一些父应用的服务以实现它的任务。这方面可能很大程度上依赖于父应用中配置模块所使用的技术。

扩展点

能够让一个应用可扩展的方法简直有无数种。例如,我们在前面学到的有些设计模式正好是针对这个的:使用代理模式或者装饰者模式我们可以改变或者增强一个服务的功能;使用策略模式我么可以切换一个算法的一部分;使用中间件模式我们可以在一个已有的管道中插入处理单元。同样,流模式也可以提供很多好的可扩展性,感谢其组合天性。

另一方面,EventEmitter 能够让我们使用事件和发布者/订阅者模式来解耦我们的模块。另一种重要的是技术是在应用程序中显式定义一些点,在这些点上新的功能可以被附加上,或者已有的点可以被修改;这些在应用程序中的点通常被称为钩子(hooks)。概括一下,支持插件最重要的因素是一组扩展点(extension points)。

但同样我们配置组件的方法也扮演至关重要的角色,因为它可以影响我们导出应用程序到插件的服务的方法。在这一节中,我们主要集中在这方面。

由插件控制的扩展 vs 由应用控制的扩展

在我们前行并呈现一些示例之前,理解我们即将使用的技术的背景是非常重要的。用于扩展一个应用的组件主要有两种方法:

  • 显式扩展
  • 通过控制反转(Inversion of Control – IoC) 的扩展

在第一种情况下,我们有了一个更特定的显式扩展了基础设施的组件(提供一个新的功能),而在第二种情况下,是由基础设施来控制扩展,通过加载,安装,或者执行新的特定组件。在第二种情况下,控制流是被反转了的,如下图所示:

Plugin-controlled vs Application-controlled extension

控制反转是一个非常广的原则,不仅可以被用于应用程序扩展的问题。实际上,在更通常的概念上,可以说通过实现某种形式的 IoC,不再是定制代码控制基础设施,而是基础设施控制定制代码。使用 IoC,一个应用程序的各个组件交替它们的控制流程的力量,从而实现了一种经过改进的解耦层次。这通常也被称为好莱坞原则或者是“dont’t call us, we’ll call you”。

举例来说,一个依赖注入容器是一个控制反转原则应用于依赖管理这个特定案例的一种演示。观察者模式则是IoC应用于状态管理的另一示例。模板模式,策略模式,状态模式,和中间件模式也是同一原则更本地化的表现。浏览器在分发 UI 事件到 JavaScript 代码时也实现了 IoC 原则(不是 JavaScript 代码不停地向浏览器轮询有无事件)。并且你猜怎么着,Node.js 它自己在控制各种回调的执行的时候也遵循 IoC 原则。

Notes: 要了解更多有关IoC 原则,我建议你研究一下它的大师主题 Martin Fowler 位于:http://martinfowler.com/bliki/InversionOfControl.html

把这个概念应用到指定情形的插件,我们就可以识别两类扩展:

  • 插件控制的扩展
  • 应用程序控制的扩展(IoC)

在第一种情况下,是插件进入应用程序的组件来在需要的时候扩展它们,而第二种情形,控制是在应用程序手中,它会把插件集成到它的一个扩展点中。

要快速出一个例子,我们思考这样一个使用一个新的路由来扩展 express 应用的插件。通过使用一个插件控制的扩展,这可能看起来像下面这样:

// in the application
var app = express();
require('thePlugin')(app);

// in the plugin:
module.exports = function plugin(app) {
    app.get('/newRoute', function(req, res) { ... })
};

如果与此相反,我们要使用控制反转呢,上面这个相同的例子看起来应该会是这样的:

// in the application
var app = express();
var plugin = require('thePlugin')();
app[plugin.method](plugin.route, plugin.handler);

// in the plugin:
module.exports = function plugin() {
    return {
        method: 'get',
        route: '/newRoute',
        handler: function(req, res) { ... }
    }
}

在后面这个代码片段中,我们看到插件在扩展过程中如何只是一个被动玩家;控制权是在应用程序手中,它实现了接受插件的框架。

基于上面的示例,我们能够立即识别出两种方法之间的一些重要的差异:

  • 插件控制的扩展更强大,更灵活,就像经常我们访问应用程序内部那样,而且我们可以像插件就是应用程序一部分那样自由移动。然而,这有时会编程一种债务,而不是一种益处。实际上,在应用程序中的任何改动可能都会很容易地影响到插件,在主程序演进的时候就需要不断地进行更新。
  • 应用程序控制的扩展要求在主程序中有一个插件基础设施。使用一个插件控制的扩展,唯一要求的就是应用的组件在某种程度上是可扩展的。
  • 使用一个插件控制的扩展,和插件共享应用的内部服务变得重要(在上面的小例子中,服务要共享的是 app 实例),否则我们就无法扩展它们。使用应用控制的扩展,访问应用的某些服务可能仍是必要的,不是去扩展而是使用它们。例如,你可能想要在我们的插件中查询 db 实例,或者利用主应用的 logger,只是要命名一些场景。

最后一点应该让我们思考暴露一个应用程序的服务到插件的重要性,而且这正是我们探索所主要感兴趣的地方。做这个的最好的方法是显示一个实际的插件控制扩展的例子,这需要在基础设施上的一小点努力,并且我们可以把重点更多地放在跟插件共享应用状态上。

实现一个登出插件

现在我们开始给鉴权服务器做一个小插件。使用我们最初创建应用的方法,显式使一个 token 无效是不能的,它只会在过期时变成无效的。现在,我们希望增加对这个功能的支持,命名为 logout,并且我们希望通过不修改主程序的代码而只是把这个任务委托给一个外部插件来实现。

为了实现这个功能,我们需要把每一个 token 在创建完之后都保存在数据库中,然后每次我们想要验证它的时候再检查他们是否存在。要无效一个 token,我们只需要简单地把它从数据库中删除即可。

要实现该功能,我们打算使用一个插件控制的扩展来代理到 authService.login() 和 authService.checkToken() 的调用。我们然后需要使用一个名为 logout() 的新方法来装饰 authService。在此之后,我们还想要根据主 express 服务器注册一个新的路由,导出一个新的断点(‘/logout’),我们可以用此通过一个 HTTP 请求来无效化一个 token。

我们将使用四种变种来实现我们刚刚描述的这个插件:

  • 使用硬编码依赖
  • 使用依赖注入
  • 使用服务定位器
  • 使用依赖注入容器

使用硬编码依赖

我们即将实现的第一个插件变种覆盖了我们的应用程序主要使用的,用来编写无状态模块的硬编码依赖这种情况。在这种情况下,如果我们的插件位于 node_moduels 目录下的包里,要使用主应用的服务,我们必须获得到父包的访问。我们可以通过两种方法实现:

  • 使用 require() 并使用相对或者绝对路径导航到应用程序的根。
  • 使用 require() 通过扮演一个在父应用中的模块-通常是实例化这个插件的模块。这将让我们通过使用 require() 轻松获得到应用程序中的所有服务的访问,就好像是由父应用调用的而不是从插件中。

第一种技术不够鲁棒,因为它假定包知晓主应用程序的位置。而模块扮演模式则,不管包从哪里被 require 都可以被使用,因为这一点我们就用这种技术来实现我们的下一个 demo。

要构建我们的插件,我们首先需要在 node_modules 目录下创建一个新的包,命名为 authsrv-plugin-logout。在我们开始编码之前,我们需要创建一个最小的 package.json 来描述该包,只用最基本的参数来填充它(该文件的完整路径为:node_modules/authsrv-plugin-logout/package.json):

{
    "name": "authsrv-plugin-logout",
    "version": "0.0.0"
}

现在,我们已经准备好来创建插件的主模块了,我们将使用文件 ‘index.js’ 因为它是 Node.js 试图加载的默认模块。注意我们是怎么 require 它们的(文件:’node_modules/authsrv-plugin-logout/index.js’:

var parentRequires = module.parent.require;

var authService = parentRequire('./lib/authService');
var db = parentRequire('./lib/db');
var app = parentRequire('./app');

var tokensDb = db.sublevel('tokens');

代码的第一行是创造不同的所在。我们获取了一个到父模块的 require() 函数的引用,就是它加载的该插件。在我们的情况下,这个父模块应该就是主程序中的 app 模块,这意味着每次我们使用 parentRequire() 我们就仿佛是在从 ‘app.js’ 中做这个一样。

下一步是为 authService.login() 方法生成一个代理。学过“设计模式”中的代理模式的话,我们就应该知道这是如何工作的:

var oldLogin = authService.login;       //[1]
authService.login = function(username, password, callback) {
    oldLogin(username, password, function(err, token) {     // [2]
        if(err) return callback(err);                       // [3]

        tokensDb.put(token, {username: username}, function() {
            callback(null, token);
        });
    });
}

在上述代码中,采用的步骤解释如下:

  1. 我们首先保存了一个到旧的 login() 方法的应用,然后我们使用我们的代理版本对其进行了重写。
  2. 在代理函数中,我们通过提供一个自定义回调援引了最初的 login() 方法,这样我们就可以拦截最初的返回值。
  3. 如果最初的 login() 返回一个错误,我们就简单地把它前转给回调,否则我们就把 token 保存进数据库。

类似地,我们需要拦截到 checkToken() 的回调,这样我们才能添加我们自定义的逻辑:

var oldCheckToken = authService.checkToken;

authService.checkToken = function(token, callback) {
    tokensDb.get(token, function(err, res) {
        if(err) return callback(err);

        oldCheckToken(token, callback);
    });
}

这一次,我们希望在把控制权交给原始 checkToken() 之前首先检查 token 在数据库中是否存在。如果 token 没有找到,get() 操作返回一个错误;这意味着我们的 token 是无效的,从而我们立马返回一个错误给回调。

要完结 authService 的扩展,我们现在需要使用一个新的方法来装饰它,我们将用它来无效一个 token:

authService.logout = function(token, callback) {
    tokensDb.del(token, callback);
}

logout() 方法非常简单:我们只是从数据库中删除了该 token。

最后,我们可以附加一个新的路由到 express 服务器来通过 web service 导出一个新的功能:

app.get('/logout', function(req, res, next) {
    authService.logout(req.query.token, function() {
        res.status(200).send({ok: true});
    });
});

现在,我们的插件已经准备好可以附加到主程序上了,所以为了实现这个我们只需要回到应用程序主目录,然后编辑’app.js’模块即可:

[...]
var app = module.exports = express();
app.use(bodyParser.json());

require('authsrv-plugin-logout');

app.post('/login', authController.login);
app.all('/checkToken', authController.checkToken);
[...]

正如我们所见,要附加此插件我们只需要 require 它。只要一 require 它 — 在应用程序启动过程中 — 控制流程就被交给了插件,插件因而将扩展 authService 和 app 模块,如我们之前所见。

现在我们的鉴权服务器一也支持 token 的无效了。我们通过可重用的方式实现了这一点,应用程序的核心几乎保持没变,并且我们能够简单地应用代理和装饰者模式来扩展它的功能。

现在我们可以试着再次启动该应用:

node app

然后我们可以验证新的 /logout web service 是否存在以及是否如预期般工作了。使用 curl 我们可以试着使用 /login 来获取一个新的 token:

curl -X POST -d '{"username": "alice", "password": "secret"}' http://localhost:3000/login -H "Content-Type: application/json"

然后,我们可以使用 /checkToken 来检查 token 是否有效:

curl -X GET -H "Accept: application/json" http://localhost:3000/checkToken?token=<TOKEN HERE>

接着,我们可以把 token 传给 /logout 端点让它变得无效;使用 curl 可以像这样实现:

curl - X GET -H "Accept: application/json" http://localhost:3000/logout?token=<TOKEN HERE>

现在如果我们再试图检查token的有效性,我们应该得到一个负面的响应,由此确认我们的插件工作正常。

即使使用这样一个我们刚才实现的很小的插件,支持基于插件的扩展的好处也是显而易见的。我们还学习了如何获取到主程序服务的访问,使用模块扮演从另一个包里面。

Notes: module impersonation pattern 模块扮演模式被很多 NodeBB 的插件所使用;你如果想看一下他们是怎么在真实应用里面使用的。可以看一下这几个示例:

模块扮演当然也是一种硬编码依赖形式,同样也共享它的长处和弱点。从一方面来说,它让我们可以访问主应用的任何服务,只需要很少的努力和很小的基础设施要求,但是从另一方面,它生成了一种紧耦合,不仅是根一个服务的特定实例,还跟它的位置,这很容易在主应用中导出插件来改变和重构。

使用服务定位器导出服务

跟模块扮演类似,如果我们想要把一个应用的所有组件都暴露给它的插件,服务定位器也是一个好的选择,但是在它之上呢,还有一个主要的益处,因为一个插件可以使用服务定位器来暴露它自己的服务给应用或者甚至是其他模块。

让我们再次重构我们的 logout 插件来使用一个服务定位器。我们将重构 node_modules/authsrv-plugin-logout/index.js 文件中的插件的主模块:

module.exports = function(serviceLocator) {
    var authService = serviceLocator.get('authService');
    var db = serviceLocator.get('db');
    var app = serviceLocator.get('app');

    var tokensDB = db.sublevel('tokens');

    var oldLogin = authService.login;
    authService.login = function(username, password, callback) {
        // ... 跟之前版本相同
    }

    var oldCheckToken = authService.checkToken;
    authService.checkToken = function(token, callback) {
        // ... 跟之前版本相同
    }

    authService.logout = function(token, callback) {
        // ... 之前版本相同
    }

    app.get('/logout', function(req, res, next) {
        // ... 之前版本相同
    });
};

现在我们的插件接受父应用的服务定位器作为输入,它可以在需要的时候访问它的服务。这意味着应用不需要事先知道插件将会需要什么依赖;这肯定是在实现一个插件控制的扩展时的一个主要好处。

下一步是从主应用中执行该插件,为了达到这个目的,我们必须修改 app.js 模块。我们将使用已经基于服务定位器模式的鉴权服务器版本。需要做的修改如下所示:

[...]
var svcLoc = require('./lib/serviceLocator')();
svcLoc.register(...);
[...]

svcLoc.register('app', app);
var plugin = require('authsrv-plugin-logout');
plugin(svcLoc);

[...]

这些修改让我们能够:

  • 把 app 模块自身注册到服务控制器,因为插件可能需要访问它
  • require 插件
  • 通过提供服务定位器作为一个参数调用了插件的主函数

正像我们已经说过的,服务定位器的主要长处是它提供了一种简单的方法来暴露出一个应用的所有服务给它的插件,但是它也能被用作一种从插件到父应用甚至其他插件之间共享服务的机制。最后这个考量可能是在基于插件的扩展相关环境下最大的好处了。

使用依赖注入导出服务

使用依赖注入来扩展服务到一个插件就好像在应用本身中一样简单。如果主应用就是用这种模式来配置模块的,那么插件几乎必然要用这种模式,但是在普通的依赖管理形式是硬编码依赖或者服务定位器的情况下我们也可以用这种方式。DI 在我们想要支持一种应用程序控制的扩展时是一种理想的选择,因为它对跟插件共享什么提供了更好的控制。

要测试这些假设,我们立马使用依赖注入来重构 logout 插件。对改变的要求很少,所以我们从插件的主模块开始:

module.exports = function(app, authService, db) {
    var tokensDb = db.sublevel('tokens');

    var oldLogin = authService.login;
    authService.login = function(username, password, callback) {
        // ... same as in the preview version
    }

    var oldCheckToken = authService.checkToken;
    authService.checkToken = function(token, callback) {
        // ... same as in the previous version
    }

    authService.logout = function(token, callback) {
        // ... same as in the previous version
    }

    app.get('/logout', function(req, res, next) {
        // ... same as in the previous version
    });
};

我们要做的就是把插件代码封装到了一个接受父应用服务作为输入的工厂里面;剩下的保持不变。

要完成我们的重构,我们还需要修改我们把插件附加到父应用的方式。让我们该一行 app.js 模块中的代码:

[...]
var plugin = require('authsrv-plugin-logout');
plugin(app, authService, authController, db);
[...]

(所有这些示例如果能用类图描述一下会更好)

Notes: Grunt(http://gruntjs.com) 使用依赖注入来提供给每个插件一个核心 grunt 服务的实例。每个插件然后就可以通过附加新的任务来扩展它,使用它来获取配置参数,或者运行其他任务。一个 grunt 插件看起来像是下面这样:

module.exports = function(grunt) {
    grunt.registerMultiTask('taskName', 'description', function(...) { ... });
};

使用依赖注入容器导出服务

小结

回复