Node.js 适配器模式

适配器模式

Adapter

适配器模式可以让我们使用一个不同的接口来访问一个对象的功能。它用来适配一个对象,来让期望一个不同接口的组件可以使用它。如下图所示: 

Adapter 实际就是 Adaptee 的封装,导出一个不同的接口。如图所示,Adapter 的接口也可以是 Adaptee 中一个或多个方法的组合。 这个模式非常直观,我们直接来看一个例子。

从文件系统API使用 LevelUP

我们围绕 LevelUP API 来构建一个适配器,把它变成一个兼容核心 fs 模块的接口。 尤其是,我们将确保每次调用 readFile() 和 writeFile() 都将转成到 db.get() 和 db.put() 的调用;这样我们就可以把 LevelUP 数据库当做简单文件系统操作的一个存储后台。

我们从创建一个名为 fsAdapter.js 的模块开始。我们将从加载依赖开始,然后导出我们将用来构建适配器的 createFsAdapter() 工厂:

var path = require('path');

module.exports = function createFsAdapter(db) {
	var fs = {};
		//... 继续
}

下面,我们来实现工厂内部的 readFile() 函数,并确保它的接口跟 fs 模块的原生接口之一是兼容的:

fs.readFile = function(filename, options, callback) {
	if (typeof options === 'function') {
		callback = option;
		options = {};
	} else if (typeof options === 'string') {
		options = {encoding: options};
	}

	db.get(path.resolve(filename), {	//[1]
		valueEncoding: options.encoding
		},
		function(err, value) {
			if (err) {
				if (err.type === 'NotFoundError') {	//[2]
					err = new Error('ENOENT, open \'' + filename + '\'');
					err.code = 'ENOENT';
					err.errno = 34;
					err.path = filename;
				}
				return callback && callback(err);
			}
			callback && callback(null, value);	//[3]
		}
	);
};

在上述代码中,我们必须做一些额外工作确保我们新函数的行为跟原始的 fs.readFile() 函数要尽可能接近。各步骤描述如下:

  1. 要从 db 类获取一个文件,我们使用 filename 作为索引来调用 db.get(),并确保总是使用它的全路径(通过使用 path.resolve())。我们把数据库使用的 valueEncoding 设置成跟从输入拿到的 encoding 选项相同。
  2. 如果key在数据库中没找到,我们就使用 ENOENT 作为错误代码来生成一个错误,这个是 fs 模块用来通知一个缺失文件的。其他任何类型的错误都被前转给 callback(我们在这个例子中,只处理了最常见的错误情况)
  3. 如果 key-value 对被成功从数据库获取,我们将使用 callback 把这些值返回给调用者。

实现粗燥,大致可用。

我们再来实现 writeFile() 函数:

fs.writeFile = function(filename, contents, options, callback) {
	if (typeof options === 'function') {
		callback = options;
		options = {};
	} else if (typeof options === 'string') {
		options = {encoding: options};
	}

	db.put(path.resolve(filename), contents, {
		valueEncoding: options.encoding
	}, callback);
}

最后,返回 fs 对象就好:

return fs;

我们可以写一个小的测试模块来试一下:

var fs = require('fs');

fs.writeFile('file.txt', 'Hello', function() {
	fs.readFile('file.txt', {encoding: 'utf8'}, function(err, res) {
		console.log(res);
	});
});

// try to read a missing file
fs.readFile('missing.txt', {encoding: 'utf8'}, function(err, res) {
	console.log(err);
});

我是不是可以搞一章 fs module 和 Adapter 模式

现在我们可以把 fs 模块换成我们的适配器了,如下:

var levelup = require('level');
var fsAdapter = require('./fsAdapter');
var db = levelup('./fsDB', {valueEncoding: 'binary'});
var fs = fsAdapter(db);

Adapter 模式在跟浏览器共享代码中扮演非常重要的角色。

实践中

现实世界中使用适配器的例子数不胜数,可以去探索和分析下列值得关注的示例:

跟不同的翻译引擎进行适配,跟不同的短信网关进行适配

* 示例的完整实现是 level-filesystem(https://www.npmjs.org/package/level-filesystem)。

从核心 API 里面分组,并和模式结合进行讲解,术道并重。

Node.js 异步流程控制模式

Node.js 平台上 continuation-passing style 和异步 API 是规范。一些在同步代码中很简单的操作在异步代码中可能就需要特别注意,比如:

  • 遍历一组文件
  • 按顺序执行一系列任务
  • 等待一组操作完成

这些都要求开发者们采用新的方法和技术,首先要避免错误的实现,然后还要考虑执行效率和代码可读性;还要避免陷入 callback hell。(如果你的代码一直在横着长,而不是竖着长,就有问题了)

使用模式和纪律来驯服回调。像 async 这样的流程控制库可以极大地简化流程控制的问题,同时 CPS 也不是实现异步 API 的唯一方法。还有 Promises 和 ECMAScript 6 generators,很强大,也很灵活。

异步编程的困难所在

JavaScript 大量使用闭包和异步函数的 in-place 定义让编程体验非常平滑顺畅,我们不需要跳到代码的其他地方再编写新的函数,但同时也损失了一定的模块化,可重用性以及可维护性,最终会导致回调嵌套不受控制地扩散,函数大小不断增长,代码组织也会越来越差。大多数情况下,回调并不是功能上需要的,更多的是跟纪律相关,而不是异步编程的问题。【异步编程,编程纪律,绝对是根代码风格有关,仔细研读 google 和 airbnb 的 js 风格】预先知道我们的代码会变得非常笨重,并且给我们准备了非常好的预案和足够多的解决方案,是新手和专家的区别。

创建一个简单的网络蜘蛛

a little web spider – 一个命令行小程序,接受一个 web URL 作为输入,下载其内容到本地文件。用到了两个 npm 依赖:

  • request: 提高 HTTP 调用效率的一个库
  • mkdirp: 递归创建目录的一个小功能

spider.js

var request = require('request');
var fs = require('fs');
var mkdirp = require('mkdirp');
var path = require('path');
var utilities = require('./utilities');

function spider(url, callback) {
    var filename = utilities.urlToFilename(url);
    fs.exists(filename, function(exists) {      //[1]
        if (!exists) {
            console.log('Downloading ' + url);
            request(url, function(err, response, body) {    //[2]
                if (err) {
                    callback(err);
                } else {
                    mkdirp(path.dirname(filename), function(err) {  //[3]
                        if (err) {
                            callback(err);
                        } else {
                            fs.writeFile(filename, body, function(err) {    //[4]
                                if(err) {
                                    callback(err);
                                } else {
                                    callback(null, filename, true);
                                }
                            });
                        }
                    });
                }
            });
        } else {
            callback(null, filename, false);
        }
    });
}

上述代码执行的主要任务:

  1. 通过验证相应的文件是否应经被创建来检查URL是否已经被下载过了 fs.exists(filename, function(exists) ...
  2. 如果文件没有找到,就使用下列代码行来下载 URL:request(url, function(err, response, body) ...
  3. 然后确认将要包含该文件的目录是否存在: mkdirp(path.dirname(filename), function(err)...
  4. 最后,我们把HTTP响应包体写到文件系统:fs.writeFile(filename, body, function(err)...

从命令行参数读取 URL 并调用我们的 spider() 函数:

spider(process.argv[2], function(err, filename, downloaded) {
    if (err) {
        console.log(err);
    } else if (downloaded) {
        console.log('Completed the download of "' + filename + '"');
    } else {
        console.log('"' + filename + '" was already downloaded');
    }
});

安装依赖

node install

运行:

node spider http://www.example.com

回调地狱

回头看一下我们刚才的代码,虽然我们的逻辑很简单,实现也很直观,但还是有很多层的缩进,而且阅读起来也比较困难。如果我们用同步阻塞API来实现的话,代码看起来会直观很多。

callback hell可能是 node.js 里面最被大家熟知的反模式之一了。受这个问题影响的代码结构看起来像是下面这样:

asyncFoo(function(err) {
    asyncBar(function(err) {
        asyncFooBar(function(err) {
            [...]
        });
    });
});

形状看起来像是金字塔,因此也被口语化地称为 pyramid of doom。

上面这样的代码最明显的问题是可读性,最后你往往分不出一个函数是从哪里开始的,又是从哪里结束的。这种重叠引起的另一个问题是在每一个作用域中使用的变量名。通常情况下,我们会使用相似的或者是一致的名称来描述一个变量的内容。最好的例子就是由每一个回调所接受的 error 参数。有些人呢,就在不同的作用域里面使用相同名字的变种来区分 — 例如,err, error, err1, err2等等;还有的人就总是用相同的名字来隐藏作用域中变量的定义,例如只用 err。两种方法都不完美,都容易造成混淆以及增加引入错误的可能性。

闭包会带来性能上的代价和内存消耗。

注意:有关闭包是如何在 V8 中运行的一个非常棒的介绍,Vyacheslav Egorov ,Google 的一个 V8 工程师写的一篇博客:http://mrale.ph/blog/2012/09/23/grokking-v8-closures-for-fun.html

我们刚才的 spider() 函数就是一个回调地狱:不容易阅读,分不清函数的起止,变量名跨作用域混乱,…下面我们继续看一下有哪些方法来修复这个问题。

使用纯 JavaScript

回调地狱只是写异步代码会碰到的问题之一。比如在一个集合上遍历并按顺序应用一个异步操作,并不像在数组上调用一个 forEach() 那么简单,它实际上要求的是类似回调的技术。我们除了避免回调地狱之外,还有学习一些常见的控制流模式,只使用简单的纯 JavaScript。

回调纪律

写异步代码的第一条原则就是要记住在定义回调的时候不要滥用闭包。大多数情况下,修复回调地狱并不需要任何库,也不需要多么高级的技术,或者范式的改变,而只需要一些常识即可。

这里是一些基本原则,帮助我们保持嵌套层次在一个比较低的水平,同时改进我们的代码组织:

  • 尽早退出。根据上下文使用 return, continue, 或者 break,而不是写完整的 if/else 语句。这将帮助保持代码在一个比较浅的水平。
  • 为回调创建具名函数,把它们放在回调之外,并把中间结果作为参数传入。给函数命名也将有助于堆栈调试。
  • 代码模块化。把代码切成尽量小的,可以重用的函数。

使用回调纪律

第一步,去掉 else 语句,收到 error 之后马上从函数返回。从这样的代码:

if (err) {
    callback(err);
} else {
    // code to execute when there are no errors
}

改进到:

if (err) {
    return callback(err);
}
// code to execute when there are no errors

这样就减少了一些嵌套的层次,同时也不需要复杂的重构。

注意:一个常见的错误是忘记返回只调用:callback(err),而不是 return callback(err)。

第二个优化,甄别可以重用的代码。我们把写字符串到文件重构为一个单独的函数:

function saveFile(filename, contents, callback) {
    mkdirp(path.dirname(filename), function(err) {
        if (err) {
            return callback(err);
        }
        fs.writeFile(filename, contents, callback);
    });
}

依据相同的原则,我们可以创建一个名为 download() 的通用函数,接受一个 URL 和文件名作为输入,把 URL 下载到给定的文件中。在内部我们可以使用刚创建的 saveFile() 函数:

function download(url, filename, callback) {
    console.log("Downloading " + url);
    request(url, function(err, response, body) {
        if (err) {
            return callback(err);
        }
        saveFile(filename, body, function(err) {
            console.log('Downloaded and saved: ' + url);
            if (err) {
                return callback(err);
            }
            callback(null, body)
        });
    });
}

最后一步,修改 spider() 函数,感谢我们前面的这些修改,这个函数现在看起来像是这样了:

function spider(url, callback) {
    var filename = utilities.urlToFilename(url);
    fs.exists(filename, function(exists) {
        if (exists) {
            return callback(null, filename, false);
        }
        download(url, filename, function(err) {
            if (err) {
                return callback(err);
            }
            callback(null, filename, true);
        })
    });
}

功能和接口都保持没变,唯一改变的就是我们组织代码的方式。通过应用我们刚才讨论的这些基本原则,我们减少了嵌套,同时增加了代码的可读性和可测试性。总而言之,记住不要滥用闭包和匿名函数。

顺序执行

从分析 sequential execution flow 开始探索异步控制流程模式。

顺序执行一组任务意味着每次运行一个,一个接着一个。执行顺序是有关系的,而且必须保持,因为列表中任务的结果可能会影响后面任务的执行。这种流程有几个变种:

  • 顺序执行一组已知的任务,不需要链式也不需要传播结果
  • 使用一个任务的输出作为下一项任务的输入(通常也被称为 chain, pipline 或者 waterfall)
  • 在一个集合上迭代,同时在每个元素上运行一个异步任务,一个完成之后再下一个

顺序执行,在使用异步 CPS 的时候,通常是callback hell问题的主要原因。

顺序地执行一组已知任务

根据前面的示例,使用下面的模式来归纳一下解决方案:

function task1(callback) {
    asyncOperation(function() {
        task2(callback);
    });
}

function task2(callback) {
    asyncOperation(function(result) {
        task3(callback);
    });
}

function task3(callback) {
    asyncOperation(function() {
        callback();
    });
}

task1(function() {
    // task1, task2, task3 completed
});

顺序性迭代

刚讨论的模式只在我们事先知道有多少任务要执行的情况下会工作良好。这让我们可以按顺序硬编码下一个任务的调用;但是如果我们想在一个集合的每一个元素上执行一个异步操作怎么办?这种情况就没办法硬编码了,我们必须得动态构建。

网络蜘蛛版本 2

为了演示顺序迭代,我们给 web spider 引入一个新功能。我们现在希望循环下载包含在页面中的全部链接。我们将从页面中提取全部链接,然后在每一个上面循环调用网络蜘蛛,并且是按顺序的。

第一步是修改我们的 spider() 函数让它可以触发一个循环下载我们页面全部链接,通过使用一个名为 spiderLinks() 的函数。

还有,我们现在不在检查一个文件是否已经存在,而是尝试去读取它,然后开始爬它的链接;通过这种方式,我们可以继续打断的下载。最后一项修改,我们传递一个新的参数,nesting,用来帮助我们限定循环的深度,最终代码如下:

function spider(url, nesting, callback) {
    var filename = utilities.urlToFilename(url);
    fs.readFile(filename, 'utf8', function(err, body) {
        if(err) {
            if(err.code !== 'ENOENT') {
                return callback(err);
            }

            return download(url, filename, function(err, body) {
                if(err) {
                    return callback(err);
                }
                spiderLinks(url, body, nesting, callback);
            });
        }

        spiderLinks(url, body, nesting, callback);
    });
}

链接的顺序爬取

现在我们可以创建这个版本的爬虫的核心了,spiderLinks() 函数,使用顺序异步迭代算法下载一个 HTML 页面中的全部链接。

function spiderLinks(currentUrl, body, nesting, callback) {
    if(nesting === 0) {
        return process.nextTick(callback);
    }

    var links = utilities.getPageLinks(currentUrl, body);   //[1]
    function iterate(index) {       //[2]
        if(index === links.length) {
            return callback();
        }
        spider(links[index], nesting - 1, function(err) {   //[3]
            if (err) {
                return callback(err);
            }
            iterate(index + 1);
        });
    }
    iterate(0);     //[4]
}

新函数中比较重要的几步:

  1. 使用 utilities.getPageLinks() 函数获得包含在该页面中的全部链接的列表。这个函数只返回指向内部目标的链接(同一个域)
  2. 我们使用一个名为 iterate() 的本地函数迭代 links 集合,它接受下一个要进行分析的链接的 index。在此函数中,我们做的第一件事是检查索引是否等于 links 数组的长度,如果是的话,我们立马调用 callback() 函数,因为这意味着我们处理完全部项目了。
  3. 到这个时候,处理链接的准备工作都完成了。我们通过减少 nesting 级别来调用 spider() 函数,并且在操作完成时调用迭代的下一步。
  4. 作为 spiderLinks() 函数的最后一个步骤,我们通过调用 iterate(0) 来启动整个迭代过程。

我们刚才演示的算法可以让我们在一个数组上迭代,顺序执行一个异步操作,在这里就是 spider() 函数。

模式

spiderLinks() 函数说明迭代一个集合的同时对其应用一个异步操作是可能的。顺序异步迭代一个集合的该模式可以归纳如下:

function iterate(index) {
    if(index === tasks.length) {
        return finish();
    }
    var task = tasks[index];
    task(function() {
        iterate(index + 1);
    });
}

function finish() {
    // iteration completed
}

iterate(0);

我们可以上面的算法为基础实现 map 和 reduce 算法。(好像跟 underscore.js 很像啊)我们可以把它封装成一个如下的具有签名的函数:

iterateSeries(collection, iteratorCallback, finalCallback)

这个可以当做一个练习。

Notes: 模式(顺序迭代):通过创建一个名为 iterator 的函数顺序执行一列任务,该函数调用集合中下一个有效的任务,并确保在当前任务结束时调用迭代的下一步。

并行执行

有些情况下,一组异步任务的执行顺序并不重要,我们只需要的全部任务完成以后通知我们即可。这种情况更适合由并行执行流。

网络蜘蛛版本3

网络蜘蛛更适合并行下载。修改网络蜘蛛 spiderLinks(),让所有下载一下子全都开始(技巧主要是在回调函数 done 里面):

function spiderLinks(currentUrl, body, nesting, callback) {
    if(nesting === 0) {
        return process.nextTick(callback);
    }

    var links = utilities.getPageLinks(currentUrl, body);
    if(links.length === 0) {
        return process.nextTick(callback);
    }

    var completed = 0, errored = false;

    function done(err) {
        if (err) {
            errored = true;
            return callback(err);
        }
        if(++completed === links.length && !errored) {
            return callback();
        }
    }

    links.forEach(function(link) {
        spider(link, nesting - 1, done);
    });
}

spider() 任务现在是一下子全都开始,这可以通过简单地遍历 links 数组然后开始每一个任务开始。

links.forEach(function(link) {
    spider(link, nesting - 1, done);
});

让我们的应用等待全部任务都完成的技巧就在于给 spider() 函数提供了一个特殊的回调,我们叫做 done()。当一个 spider 任务完成的时候,done() 函数就会增加一个计数器。当下载完成的次数到达 links 数组的大小时,最终的callback 会被调用:

function done(err) {
    if (err) {
        errored = true;
        return callback(err);
    }
    if(++completed === links.length && !errored) {
        callback();
    }
}

模式

并行模式归纳:

var tasks = [...];
var completed = 0;
tasks.forEach(function(task) {
    task(function() {
        if (++completed === tasks.length) {
            finish();
        }
    });
});

function finish() {
    // all the tasks completed
}

稍微修改一下,我们就可以实现把每个任务的结果积累起来,来过滤或者映射一个数组的元素,或者一个给定数量的任务完成就来调用 finishe() 回调。【这部分到底算是函数式编程,还是怎么着?】最后一种情形被称为 competitive race。

模式(不受限的并行执行):通过立即全部派生来并行执行一组异步任务,然后通过技术它们的回调被调用的次数来等待所有任务完成。

在并发任务出现时纠正竞争状态(race conditions)

Node.js 的单线程并不意味着我们并不会有竞争状态,相反还会很普遍,主要是因为异步操作调用和他的结果通知之间的延迟。举个例子,我们最新版本的网络蜘蛛里面就有一个竞争状态。假如两个 spider 任务同时操作同一个 URL,可能就会在同一个文件上调用 fs.readFile(),一个没有找到开始下载去了,在这期间另一个也检查说没有文件,也去下载去了。

怎么修复呢?答案很简单,我们用一个数组变量做标记,动手之前先检查一下标识:

var spidering = {};
function spider(url, nesting, callback) {
    if (spidering[url]) {
        return process.nextTick(callback);
    }
    spidering[url] = true;
    [...]
}

竞争条件在单线程中也会引起很多问题,并且很难调试,运行并行任务的时候要仔细检查这种类型的情况。

受限的并发执行

实际上,并发一般都是受限的,好的工作流程应该是:

  • 初始化的时候,我们派生出不超过并发数限制的那么多任务
  • 每次一个任务完成的时候,再派生出一个或多个任务直到没有再次达到限制

限制并发

现在我们来提供一种模式使用受限的并发来执行一套给定的任务:

var tasks = [...];
var concurrency = 2, running = 0, completed = 0, index = 0;
function next() {   //[1]
    while(running < concurrency && index < tasks.length) {
        task = tasks[index++];
        task(function() {   //[2]
            if(completed === tasks.length) {
                return finish();
            }
            completed++, running--;
            next();
        });
        running++;
    }
}
next();

function finish() {
    // all tasks finished
}

这可以被认为是一个顺序执行和并发执行的混合。 任务没有全部完成,且运行数少于限制数的情况下,派生一个任务,每派生一个,运行数+1;有任务完成的时候,完成数+1,运行数-1,如果完成数等于任务总数,全部任务完成;循环以上

【这里面有一个问题,cpu 会很高吧?】

全局性地限制并发

注意 node.js 本身的 maxsockets 数量限制。0.11版本及之后的不同。

我们可以把刚才的限定模式应用到 spiderLinks() 上,但是问题是这里的限定是针对一个单一页面的。因为我们可以下载多个页面,所以每个页面又会派生出另外两个下载,导致全部加起来的下载数很多。

使用队列进行拯救

我们真正想要的是能够限制并行运行的全部数量,我们可以稍微修改一下之前的例子,但是这里我们决定引入一个新的机制 queues,来限制多个任务的并发。

TaskQueue 组合了一个队列和之前演示的算法。先从定义一个下面这样的构造器开始:

function TaskQueue(concurrency) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
}

这个构造器的参数只有并发数限制,但是在此之外,它初始化了其他我们稍后需要的实例变量。

实现 pushTask() 方法:

TaskQueue.prototype.pushTask = function(task, callback) {
    this.queue.push(task);
    this.next();
}

上面函数简单地添加了一个新的任务到队列然后通过调用 this.next() 启动了工作者的运行。

next() 方法的角色是从队列派生一组任务,但不超过并发限制:

TaskQueue.prototype.next = function() {
    var self = this;
    while(self.running < self.concurrency && self.queue.length) {
        var task = self.queue.shift();
        task(function(err) {
            self.running--;
            self.next();
        });
        self.running++;
    }
}

这里面有两个好处,一个是我们可以在运行时动态修改并发限制数,另一个是我们现在有了一个共享的集中式限制并发实体,可以在所有函数运行中共享。

网络蜘蛛版本 4

加载依赖,创建 TaskQueue 实例:

var TaskQueue = require('./taskQueue');
var downloadQueue = new TaskQueue(2);

更新 spiderLinks() 函数:

function spiderLinks(currentUrl, body, nesting, callback) {
    if (nesting === 0) {
        return process.nextTick(callback);
    }

    var links = utilities.getPageLinks(currentUrl, body);
    if (links.length === 0) {
        return process.nextTick(callback);
    }

    var completed = 0, errored = fasle;
    links.forEach(function(link) {
        downloadQueue.pushTask(function(done) {
            spider(link, nesting - 1, function(err) {
                if (err) {
                    errored = true;
                    return callback(err);
                }
                if (++completed === links.length && !errored) {
                    callback();
                }
                done();
            });
        });
    });
}
  • We run the spider() function by providing a custom callback.
  • In the callback, we check if all the tasks relative to this execution of the spiderLinks() function are completed. When this condition is true, we invoke the final callback of the spiderLinks() function.
  • At the end of our task, we invoke the done() callback so that the queue can continue its execution.

async 库

我们目前为止分析的这些控制流程都可以作为一个基础,来构建一个可以重用的,并且更通用的解决方案。我们可以把顺序执行,顺序执行并把上一步的输出作为下一步的输入,顺序执行进行map/reduce,并行执行,限定并发数的并行执行等这些算法都封装成可以重用的函数,这正是 async(https://npmjs.org/package/async)库所做的工作。async 在 Node.js 里面非常的流行,不仅提供了一组简化任务执行控制流的函数,还可以对集合进行处理。

顺序执行

async 提供的方法太多,因此针对要解决的问题挑选正确的方法反而成了一个问题,需要一定的经验和实践。我们从网络蜘蛛版本2来改造成使用 async。首先我们先安装一下 async:

npm install async	

然后我们从 spider.js 模块中加载一个新的依赖:

var async = require('async');

顺序执行已知的一组任务

我们先来修改 download() 函数。我们知道它主要是顺序执行下面三个任务:

  1. 下载一个 URL 的内容。
  2. 如果目录不存在的话则创建一个新的。
  3. 把 URl 的内容保存到文件里面。

async 中最适合的函数是 async.series(),签名如下:

async.series(tasks, [callback])

它接受一个任务列表和一个回调函数,在全部任务结束后会调用该回调函数。每个任务都是一个接受一个回调函数的函数,在任务完成后必须调用该回调函数:

function task(callback) {}

async 使用跟 Node.js 相同约定的回调,它会自动处理错误的传递。所以,如果任何一个任务调用其回调函数时如果带着一个错误,async 将忽略列表中剩下的任务而直接跳到最后一个回调。

根据这种思想,我们看一下使用 async 的 download() 函数怎么改变的:

function download(url, filename, callback) {
	console.log('Downloading' + url );
	var body;
	
	async.series([
		function(callback) {	//[1]
			request(url, function(err, response, resBody) {
				if (err) {
					return callback(err);
				}
				body = resBody;
				callback();
			});
		},
		mkdirp.bind(null, path.dirname(filename)),	//[2]
		function(callback) {	//[3]
			fs.writeFile(filename, body, callback);
		}
	], function(err) {	//[4]
		console.log('Downloaded and saved: ' + url);
		if (err) {
			return callback(err);
		}
		callback(null, body);
	});
}

使用 async 我们不再需要嵌套 callback,只需要提供一组扁平化的任务列表即可,通常每个任务就是一个异步操作,async 将会顺序执行它们,这样就不会有 callback hell 了。我们是这样定义每一个任务的:

  1. 第一个任务参与的是 URl 的下载。同样我么还把响应包体保存到了一个闭包变量(body)里面,这样我们就可以在和其他任务共享了。
  2. 在第二个任务里面,我们希望创建一个承载下载页面的目录。我们通过mkdirp()函数的部分应用来实现,绑定了要创建的目录的路径,我们能省一些代码行并且增加了它的可读性。
  3. 最后,我们把下载的 URL 的内容写到了一个文件。这种情况下我们不能像任务2那样执行部分应用了,因为 body 只有在第一个任务执行完成之后才有效。然而我们仍然可以节省一些代码行,通过发挥 async 的自动化错误管理,通过简单地传入任务回调给 fs.writeFile() 函数。
  4. 所有任务都完成以后,async.series() 的最终的回调将被调用。在我们这个例子里,我们只是简单做了一些错误管理,然后就把 body 变量返回给 download() 函数的回调了。

我们在开发过程中顺序执行碰到很多的还有一种就是需要把上一步的输出作为下一步的输入,这时候可以使用 async.waterfall()。在我们这个例子中,我们可以使用该功能来传递 body 变量直到序列结束。这个可以作为我们的一个练习,然后看一下区别。

顺序迭代

我们可以使用 async 的 async.eachSeries() 来改造我们的 spiderLinks() 函数,也就是在集合上进行迭代的方法,如下:

function spiderLinks(currentUrl, body, nesting, callback) {
	if (nesting === 0) {
		return process.nextTick(callback);
	}
	
	var links = utilities.getPageLinks(currentUrl, body);
	if (links.length === 0) {
		return process.nextTick(callback);
	}
	
	async.eachSeries(links, function(link, callback) {
		spider(link, nesting - 1, callback);
	}, callback);
}

和我们自己使用纯 JavaScript 实现的同样的函数相比,我们能够注意到 async 给我们带来的在代码组织和可读性方面巨大的益处。

并行执行

我们使用 async 的函数来改造我们的网络蜘蛛版本3,也就是无受限并行流程。用 async.each:

function spiderLinks(currentUrl, body, nesting, callback) {
	[...]
	async.each(links, function(link, callback) {
		spider(link, nesting - 1, callback);
	}, callback);
}

代码跟我们的顺序版本是一样的,我们只是把 async.eachSeries() 换成了 async.each()。也就是说我们的重点放在应用程序逻辑即可,控制流程的工作可以都交给 async 来做。

受限的并行执行

受限类的 async 函数,eachLimit(), mapLimit(), parallelLimit(), queue() 和 cargo()。async 的 async.queue() 函数工作方式跟 TaskQueue 类很类似:

var q = async.queue(worker, concurrency);

worker() 函数接受一个 task 来运行,以及一个 callback 来调用,在任务完成的时候:

function worker(task, callback)

往队列里面添加新任务使用 q.push(task, callback)。跟一个任务相关的 callback 必须被 worker 回调,在任务处理完成之后。我们使用 async.queue() 实现版本4。首先创建一个新的队列:

var downloadQueue = async.queue(function(taskData, callback) {
	spider(taskData.link, taskData.nesting - 1, callback);
}, 2);

代码是比较直观的,我们创建了一个新的队列,设置并发限制为2,还有一个简单地调用我们的 spider() 函数的 worker,采用一个跟任务相关联的数据。然后,我们实现 spiderLinks() 函数:

function spiderLinks(currentUrl, body, nesting, callback) {
	if (nesting === 0) {
		return process.nextTick(callback);
	}
	var links = utilities.getPageLinks(currentUrl, body);
	if (links.length === 0) {
		return process.nextTick(callback);
	}
	var completed = 0, errored = false;
	links.forEach(function(link) {
		var taskData = {link: link, nesting: nesting};
		downloadQueue.push(taskData, function(err) {
			if (err) {
				errored = true;
				return callback(err);
			}
			if (++completed === links.length && !errored) {
				callback();
			}
		});
	});
}

使用 async 我们避免了从零开始写异步控制流程模式,从而可以为我们节省大量的时间和精力,还有代码行数。

Promises

CPS 并不是编写异步代码的唯一方法,promises 及其实现,遵照 Promises/A+ 规范(https://promisesaplus.com)。

什么是 promise?

promises 是允许异步函数返回称为一个 promise 对象的抽象,它代表的是该操作的最终结果。用 promises 的话来说,如果该异步操作尚未完成,我们就说一个 promise 是 pending 的,当操作成功结束的时候我们说它是 fulfilled 的,并且当操作随着一个错误终止的时候我们说它是 rejected 的。一旦一个 promise 是 fulfilled 或者是 rejected,都被认为是 settled。

要接受 fufillment 的值或者跟 rejection 相关连的error(reason)的话,可以使用promise的 then() 方法。下面是它的签名:

promise.then([onFulfilled], [onRejection])

这里的 onFulfilled() 是一个最终将接受promise的 fulfillment 值的函数,而 onRejected() 是另一个将接受 rejection 原因的函数。两个都是可选的。要想了解 Promise 会如何转换我们的代码,让我们考虑下面的例子:

asyncOperation(arg, function(err, result) {
	if (err) {
		// handle error
	}
	// do stuff with result
});

Promise 能够转换这种典型的 CPS 代码到一种更好的结构和更优雅的代码,如下所示:

asyncOperation(arg)
	.then(function(result) {
		// do stuff with result
	}, function(err) {
		// handle error
	});

then() 方法一个至关重要的属性是它同步返回另一个 promise。如果任何一个 onFulfilled() 或者 onRejected() 方法返回一个值 x,那么由 then() 方法返回的 promise 将按照下面这样:

  • 如果 x 是一个值的话,则带着 x Fulfill
  • 如果 x 是一个 promise 或者一个 thenable 的话,使用 x 的 fulfillment 值进行 Fulfill
  • 如果 x 是一个 promise 或者一个 thenable 的话,使用 x 的最终 rejection reason 来 Reject

Notes: 一个 thenable 是一个跟 promise 类似的对象,也有 then() 方法。这个词别用于表示在正在使用的特定 promise 实现外面的一个 promise。

这个功能能让我们构建 promise 链,能够更容易地在几个配置中聚合和安排异步操作。同样,如果我们没有指定一个 onFulfilled() 或者 onRejected() 句柄,那么 fulfillment 的值或者 rejection 的原因会被自动前传到链中的下一个promise。这能够让我们跨整个链中自动传递错误知道由一个 onRejected() 句柄捕获。使用 promise 链,顺序执行的任务突然就变成了一个很平常的操作:

asyncOperation(arg)
	.then(function(result1) {
		// returns another promise
		return asyncOperation(arg2);
	})
	.then(function(result2) {
		// return a value
		return 'done';
	})
	.then(undefined, function(err) {
		// any error in the chain is caught here
	});

promises 的另一个重要属性是 onFulfilled() 和 onRejected() 函数都被确保被异步调用。最棒的地方在于如果一个异常被从 onFulfilled() 或者 onRejected() 处理器中抛出,由 then() 方法返回的 promise 会使用这个异常作为 rejection reason 自动拒绝。这个比 CPS 有优势多了,因为这意味着使用 promise,异常将自动跨链传递。

Note: 有关 Promise/A+ 规范的详细描述,参见官方网站 http://promises-aplus.github.io/promises-spec/

Promises/A+ 实现

下面是一些最常见的 Promises/A+ 实现:

它们之间真正的区别是它们在规范之上提供的额外功能。规范实际只定义了 then() 方法的功能以及 promise 的解析过程,并没有指定其他功能。例如,一个 promise 是如何从一个基于回调的异步函数创建的。

在我们的示例中,我们尝试使用一套由 ES6 promises 实现的 API。因为它们将变成 JavaScript 内置的无需任何额外的库进行支持。幸运的是,上面这些库都平滑地适配支持 ES6 API。

注意:现在由 Node.js 所使用 V8 尚未原生支持 promise.

作为参考,现在由 ES6 promises 所提供的 API 列表:

  • Constructor(new Promise(function(resolve, reject) {})): 创建一个新的 promise,fulfill 或 reject 基于作为一个参数传给它的函数的行为。构造器的参数解释如下:
    • resolve(obj):使用一个 fulfillment 值来决定 promise,这个值将是 obj,如果 obj 是一个值的话。如果 obj 是一个 promise 或者 thenable 的话,它将是 obj 的 fulfilemnt 值。
    • reject(err):使用原因 err 拒绝 promise。通常是让 err 成为 Error 的一个实例。
  • Promise 对象的静态方法:
    • Promise.resolve(obj): 从一个 thenable 或者一个值来创建一个新的 promise
    • Promise.reject(err): 创建一个使用 err 作为原因进行拒绝的 promise
    • Promise.all(array): 创建一个 promise,使用一个 fulfillment 值数组进行 fulfill,当数组中所有项目都 fulfill 时,并且在任何一个项目 reject 时,使用第一个 rejection reason 进行 reject。数组中的每一个项目都可以是一个 promise, 一个普通的 thenable 或者一个值
  • 一个 Promise 实例的方法:
    • promise.then(onFulfilled, onRejected):这是 promise 最基本的方法。跟之前描述的规范相符。
    • promise.catch(onRejected): 跟 promise.then(undefined, onRejected) 句法不同而已

Notes: jQuery 和 Q 实现的 then 好像都是叫做 deferreds。

Promisifying 一个 Node.js 风格的函数

只有少量的库自身提供 promise,多数情况下,我们要把一个典型的基于回调的函数转换成能够返回一个 promise,这也被称做 promisification。

使用 Promise 对象的构造器。我们创建一个新的名为 promisify() 的函数并把它包含进 utilities.js 模块中:

var Promise = require('bluebird');

module.exports.promisify = function(callbackBasedApi) {
	return function promisified() {
		var args = [].slice.call(arguments);
		return new Promise(function(resolve, reject) {	//[1]
			args.push(function(err, result) {	//[2]
				if (err) {
					return reject(err);		//[3]
				}
				if (arguments.length <= 2) {	//[4]
					resolve(result);
				} else {
					resolve([].slice.call(arguments, 1));
				}
			});
			callbackBasedApi.apply(null, args);	//[5]
		});
	}
};

上面的函数返回了另一个叫做 promisified() 的函数,代表的是作为输入给出的 callbackBasedApi 的 promisified 版本。主要做了下列工作:

  1. promisified() 函数创建了一个新的 promise,使用 Promise 构造器,并且立即把它返回给调用者。
  2. 在传给 Promise 构造器的函数中,我们确保传给 callbackBasedApi 一个特别的回调。因为我们都知道回调总是最后一个,所以我们就简单地把它附加到提供给 promisified() 函数的参数列表中。
  3. 在这个特殊回调中,如果我们接收到一个错误,我们就立马拒绝 promise。
  4. 如果没有错误,我么使用一个值或者一个值的数组来决定 promise,取决于有多少结果传给了回调
  5. 最后,我们简单地调用 callbackBasedApi 使用我们已经构建的参数列表。

Notes:大多数 promise 的实现已经提供了一些帮助类来转换一个 Node.js 风格的 API 到返回一个 promise。比如,Q 有 Q.denodeify() 和 Q.nbind(),Bluebird 有 Promise.promisify(),以及 When.js 的 node.lift()。

顺序执行

我们直接把版本2,顺序下载一个页面中的链接,转换成使用 promise。

在 spider.js 模块中,第一步我们先加载我们的 promises 实现并且 promisify 我们打算用的回调函数:

var Promise = require('bluebird');
var utilities = require('./utilities');

var request = utilities.promisify(require('request'));
var mkdirp = utilities.promisify(require('mkdirp'));
var fs = require('fs');
var readFile = utilities.promisify(fs.readFile);
var writeFile = utilities.promisify(fs.writeFile);

现在我们可以开始转换 download() 函数了:

function download(url, filename) {
	console.log('Downloading ' + url);
	var body;
	return request(url)
		.then(function(results) {
			body = results[1];
			return mkdirp(path.dirname(filename));
		})
		.then(function() {
			return writeFile(filename, body);
		})
		.then(function() {
			console.log('Downloading and saved: ' + url);
			return body;
		});
}

这样的代码很优雅,调用者只有在操作(request, mkdirp, writeFile)全部完成以后才会收到一个 fulfill body 的一个 promise。现在开始修改 spider() 函数:

function spider(url, nesting) {
	var filename = utilities.urlToFilename(url);
	return readFile(filename, 'utf8)
		.then(
			function(body) {
				return spiderLinks(url, body, nesting);
			},
			function(err) {
				if (err.code !== 'ENOENT') {
					throw err;
				}
				
				return download(url, filename)
					.then(function(body) {
						return spiderLinks(url, body, nesting);
					});
				}
			);
		}

要注意的是我们还给 readFile 的 promise 传了一个 onRejected() 函数,来处理页面还没有被下载(文件找不到)的情况。现在我们同样来修改 spider() 函数的主要调用如下:

spider(process.argv[2], 1)
	.then(function() {
		console.log('Download complete');
	})
	.catch(function(err) {
		console.log(err);
	});

注意我们第一次使用 catch 来处理源自 spider 的错误情况。现在还只剩下 spiderLinks() 函数了。

使用 promise 相对 CPS 节省了大量错误传递的逻辑。

顺序迭代器

截至目前我们都在说 promise 是什么,使用 promise 实现一组顺序执行的任务有多优雅多简单,下面我们来看一下怎么用 promise 实现一个迭代。也就是我们要用 promise 来修改一下我们版本2的 spiderLinks() 函数:

function spiderLinks(currentUrl, body, nesting) {
	var promise = Promise.resolve();	//[1]
	if (nesting === 0) {
		return promise;
	}
	var links = utilities.getPageLinks(currentUrl, body);
	links.forEach(function(link) {	//[2]
		promise = promise.then(function() {
			return spider(link, nesting - 1);
		});
	});
	
	return promise;
}

要异步迭代一个 web 页面的全部链接,我们需要动态绑定一个 promises 链:

  1. 首先,我们定义了一个空的 promise, 决定到 undefined。该 promise 只是用来作为构建我们链的一个起点。
  2. 然后,在循环里面,我们使用一个新的获取自在链中前一个 promise 上调用 then() 得到的 promise 来更新 promise 变量。这就是我们实际的使用 promise 的异步迭代模式。

使用这种方式,循环的最后,该 promise 变量将包含循环中最后一个 then() 调用,所以它将在链中的所有 promise 都决定之后才决定。

顺序迭代器 – 模式

我们提炼一下顺序迭代一组 promise 的模式:

var tasks = [...]
var promise = Promise.resolve();
tasks.forEach(function(task) {
	promise = promise.then(function() {
		return task();
	});
});

promise.then(function() {
	// All tasks completed
});

替代使用 forEach() 循环的另一种方法是使用 reduce(),代码看起来更加紧凑:

var tasks = [...]
var promise = tasks.reduce(function(prev, task) {
	return prev.then(function() {
		return task();
	});
}, Promise.resolve());

promise.then(function() {
	// All tasks completed
});

Pattern(sequential iteration with promises):使用一个循环动态构建一个promise的链。

并行执行

另一个使用 promise 后变得轻松的是并行执行流程。我们要做的只是使用内置的 Promise.all() 来创建另一个 promise。我们更新一下版本3的 spiderLinks() 函数,使用 promise:

function spiderLinks(currentUrl, body, nesting) {
	if (nesting === 0) {
		return Promise.resolve();
	}
	var links = utilities.getPageLinks(currentUrl, body);
	var promises = links.map(function(link) {
		return spider(link, nesting - 1);
	});
	
	return Promise.all(promises);
}

在 elements.map() 上全部开始 spider() 任务,收集它们全部的 promises。我们使用 Promise.all() 方法,它在数组中的全部 promise fulfill 之后被 fulfill。换句话说,它在全部下载任务完成之后再被调用,正是我们所希望的那样。

受限的并行执行

ES6 Promise API 本身没有提供一种受限执行的控制流,但是我们可以把之前用纯 JavaScript 实现的思想借鉴过来。 // 本部分剩余代码(内容)待补充,from page 98(117)

Generators

也被称为 semi-coroutines,ES 6 规范引入的另一种能够简化异步控制流程代码的机制。generator 跟函数类似,但是可以被挂起,通过 yield 语句,然后在后面可以被恢复,尤其在实现迭代器时非常有用。

注意:在 node.js 里面,generator 从 0.11 开始有效,但是默认关闭,需要通过 –harmony 或者 –harmony-generators 标记来让 generators 工作。

基础

generator 函数的语法,在 function 关键词后面加一个*号:

function* makeGenerator() {
	// body
}

在 makeGenerator() 函数内部,我们可以使用关键词 yield 来暂停执行并返回给调用者传给它的值:

function* makeGenerator() {
	yield 'Hello World';
	console.log('Re-entered');
}

一个简单的示例

生成器作为迭代器

把值传回生成器

使用生成器异步控制流

使用 co: 基于生成器的控制流

顺序执行

并行执行

受限的并行执行

对比

已经提供了几种异步控制流程模式的对比了。

小结

Node.js 的模块系统和模式

Load modules

JavaScript 的 global namespace 和 Node.js CommonJS modules 标准

JavaScript: bad feature Node.js: No global namespace, using modules

modules and modules pattern

文件和模块一对一

传入模块标识字符串

var http = require(‘http’)

也可以指定其中的一种对象,而不是所有对象

var spawn = require(‘child_process’).spawn

Node 本身的模块,或是在 node_modules 下面的模块,可以直接使用 module 的 identifier

否则需要使用 / 指定路径

require(‘./mymodule’); 或者全路径
require(‘/path/to/mymodule’);

扩展名可以是 .js, .node, .json

模块加载过程

http://nodejs.org/api/modules.html

Core Modules are preloaded when a Node process starts

模块循环引用的问题

http://nodejs.org/api/modules.html

require

原来 require 返回的就是 module.exports 对象 NODE_PATH 环境变量

require is synchronous

As a consequence, any assignment to module.exports must be synchronous as well. For example, the following code is incorrect:

setTimeout(function() {
    module.exports = function() { ... };
}, 100);

The resolving algorithm

dependency hell

require.resolve 的完整解析过程

模块的导出:exports

导出一个 function constructor 导出N个functions and/or variables

导出 object/class 导出 instance

单次加载的问题

导出对象的封装

var Hello = require(‘./singleobject’).Hello

模块的导出:module.exports

module.exports vs exports

The variable exports is just a reference to the initial value of module.exports.

This means that we can only attach new properties to the object referenced by the exports variable, as shown in the follwoing code:

exports.hello = function () {
    console.log('Hello');
}

Reassigning the exports variable doesn’t have any effect, becasue it doesn’t change the contents of module.exports, it will only reassign the variable itself. The following code is therefore wrong:

exports = function () {
    console.log('Hello');
}

If we want to export something other than an object literal, as form example a function, an instance, or even a string, we have to reassign module.exports as follows:

module.exports = function() {
    console.log('Hello');
}

作为 class 导出

The module cache

模块复用:node_modules

package.json
git
npm init
npm adduser
npm publish

本地测试:npm install . -g

测试和发布自己的 Module

Summary

Node 抛弃了 JavaScript 默认的全局命名空间,反而使用了 CommonJS 的模块来代替。这让我们可以更好地组织代码,从而避免了安全性问题和bug。你可以使用 require() 来加载一个 core module,一个第三方的 module,或者你自己的一个来自文件或文件夹的 module。

你可以使用相对或者绝对文件路径来加载一个非核心 module。同样你也可以通过名称来加载 module,如果你把它放到了 node_modules 目录下,或者是使用 NPM 安装的。

你也可以导出自己的对象(函数,属性)等作为 module API,来创建自己的 module。

Module definition patterns

模块系统,除了作为加载依赖的机制之外,还是一个用来做定义API的工具。跟其他 API 设计的相关问题一样,主要因素是考虑私有功能和公开功能的平衡。目标是最大化实现信息隐藏以及API的可用性,同时还要平衡其他软件质量,像可扩展性以及代码复用。

Named exports

导出公开 API 的最基本的方法是采用 named exports,主要就是把我们希望公开的所有值 都赋给由 exports(或者module.exports)所引用的那个对象。在这种方式下,最终所导出的对象就变成了一个一套相关功能的容器或者命名空间。

// logger.js
exports.info = function (message) {
    console.log('info: ' + message);
};

exports.verbose = function (message) {
    console.log('verbose: ' + message);
};

导出的函数然后就在加载的模块中作为属性可以使用,如下:

// main.js
var logger = require('./logger');
logger.info('This is an informational message');
logger.verbose('This is a verbose message');

大多数 Node.js 的核心模块都采用这种模式。

Exporting a function

把整个 module.exports 变量重新赋给一个函数也是最常见的模块定义模式之一。只导出一个函数为模块提供了一个非常清晰的入口,理解和使用起来都非常容易,非常符合 ”’small surface area”’原则。这种定义模式在社区中也被称为 substack pattern(James Halliday 的昵称是substack)。

//logger.js
module.exports = function (message) {
    console.log('info: ' + message);
};

这种模式还可以扩展成把导出的函数作为其他公开API的命名空间。在提供给模块一个清晰的单一入口的同时,还能够让我们导出其他二级的或者更加高级用例的功能。下面代码就演示了如何使用导出的函数作为命名空间,来扩展我们之前定义的模块:

module.exports.verbose = function (message) {
    console.log('verbose: ' + message);
};

下面代码演示的是如何使用我们刚刚定义的模块:

// main.js
var logger = require('./logger');
logger('This is an informational message');
logger.verbose('This is a verbose message');

尽管只导出一个函数看起来可能会比较局限,但实际上这是一个把重点放在单一功能上非常好的方法,什么是对这个模块最重要的。其他次要的就作为导出功能的属性。

Pattern(substack): expose the main functionality of a module by exporting only one function. Use the exported function as namespace to expose any auxiliary functionality.

Exporting a constructor

一个模块导出了一个 constructor,是一个模块导出了函数的一个特例。区别在于使用这种新的模式,我们能够让用户使用这个 constructor 创建一个新的实例,但同时我们还给他们提供了扩展原型和打造新类的能力。

// logger.js
function Logger(name) {
    this.name = name;
};
Logger.prototype.log = function (message) {
    console.log('[' + this.name + ']' + message);
};
Logger.prototype.info = function (message) {
    this.log('info: ' + message);
};
Logger.prototype.verbose = function (message) {
    this.log('verbose: ' + message);
};
module.exports = Logger;

我们可以像下面这样使用上面的模块:

var Logger = require('./logger');
var dbLogger = new Logger('DB');
dbLogger.info('This is an information message');
var accessLogger = new Logger('ACCESS');
accessLogger.verbose('This is a verbose message');

跟 substack 模式相比,导出一个 constructor 尽管暴露了更多模块内部的内容,但是对了扩展功能来说非常强大。

不使用 new 调用的 guard,把模块作为 factory 的小把戏:

function Logger(name) {
    if (!(this instanceof Logger)) {
        return new Logger(name);
    }
    this.name = name;
};

该技术能够让我们把模块作为一个工厂:

var Logger = require('./logger');
var dbLogger = Logger('DB');
accesssLogger.verbose('This is a verbose message');

Exporting an instance

我们可以轻松利用 require() 的缓存机制定义一个有状态的实例,从 constructor 或者工厂创建的一个有状态的对象,可以跨不同的模块进行共享。

function Logger(name) {
    this.count = 0;
    this.name = name;
};
Logger.prototype.log = function(message) {
    this.count++;
    console.log('[' + this.name + ']' + messsage);
};
module.exports = new Logger('DEFAULT');

然后这个新定义的模块就可以像下面这样使用:

var logger = require('./logger');
logger.log('This is an informational message');

因为模块都是被缓存的,所以导入 logger 模块的每一个模块实际上取到的都是对象的同一个实例,从而实现了状态共享。很像 Singleton,但是并不保证在整个应用程序中这个实例的唯一性。因为在一个程序的依赖树中,模块可能被安装很多次。这就导致了同一个逻辑模块的多个实例。 (后面会讲更多有光状态实例的问题)

对这种模式的一个扩展是再把 construcotr 导出来,这样既可以扩展,也可以创建新对象。

module.exports.Logger = Logger;

然后我们就可以使用导出的 constructor 来创建该类的其他实例:

var customLogger = new logger.Logger('CUSTOM');
customLogger.log('This is an informational message');

Modifying other modules or the global scope

一个模块也可以什么也不导出。可以修改全局区域和其中的任何对象,包括缓存中的其他模块。 这通常被叫做 monkey patching,通常指的是在运行时修改已有模块来改变或者扩展它们的行为或者应用一些临时性的修复。

下面演示了如何给另一个模块增加一个新的功能:

// patcher.js, ./looger 是另一个模块
require('./logger').customMessage = function() {
    console.log('This is a new functionality');
};

使用新的 patcher 模块可以很容易地写出下面这样的代码:

// file main.js

require('./patcher');
var logger = require('./logger');
logger.customMessage();

编写 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(...) { ... });
};

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

小结

概览 – 设计模式

23 design patterns

设计模式列表 Facade, Adapter, Composite, Strategy, Bridge, Abstract Factory, Factory Method, Decorator, Observer, Template Method, Singleton, Command, State, Proxy, MVC

简单常用的几个模式:

  • Abstract Factory
  • Adapter
  • Composite
  • Decorator
  • Factory Methods
  • Observer
  • Strategy
  • Template Method

模式一览

  1. 创建型模式
    1. 抽象工厂 – Abstract Factory
    2. 生成器 – Builder
    3. 工厂方法 – Factory Method
    4. 原型 – Prototype
    5. 单例 – Singleton
  2. 结构型模式
    1. 适配器 – Adapter
    2. 桥接 – Bridge
    3. 组合 – Composite
    4. 装饰器 – Decorator
    5. 外观 – Facade
    6. 享元 – Flyweight
    7. 代理 – Proxy
  3. 行为型模式
    1. 职责链 – Chain of Responsibility
    2. 命令 – Command
    3. 解释器 – Interpreter
    4. 迭代器 – Iterator
    5. 中介者 – Mediator
    6. 备忘录 – Memento
    7. 观察者 – Observer
    8. 状态 – State
    9. 策略 – Strategy
    10. 模板方法 – Template Method
    11. 访问者 – Visitor

面向对象设计的基本原则

  1. 面向接口设计
  2. 组合优于继承
  3. 发现变化然后进行封装

学习设计模式的几个要素:模式名称、问题、解决方案、效果。

23 种设计模式一览:

  1. Abstract Factory: 提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。
  2. Adapter:将一个类的接口转换成客户希望的另外一个接口。Adapter 模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
  3. Bridge: 将抽象部分与它的实现部分分离,使他们都可以独立地变化。
  4. Builder:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。
  5. Chain of Responsibility: 为解除请求的发送者和接收者之间耦合,而使多个对象都有机会处理这个请求。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它。
  6. Command: 将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可取消的操作。
  7. Composite: 将对象组合成树形结构以表示“部分-整体”的层次结构。Composite 使得对象对单个对象和复合对象的使用具有一致性。
  8. Decorator: 动态地给一个对象添加一些额外的职责。就扩展功能而言,Decorator 模式比生成子类方式更为灵活。
  9. Facade:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
  10. Factory Method: 定义一个用于创建对象的接口,让子类决定将哪一个类实例化。Factory Method 使一个类的实例化延迟到其子类。
  11. Flyweight: 运用共享技术有效地支持大量细粒度的对象。
  12. Interpreter: 给定一个语言,定义它的文法的一种表示,并定义一个解释器,该解释器使用该表示来解释语言中的句子。
  13. Iterator:提供一种方法顺序访问一个聚合对象中各个元素,而又不需暴露该对象的内部表示。
  14. Mediator:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变他们之间的交互。
  15. Menmento:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可以将该对象恢复到保存的状态。
  16. Observer:定义对象间的一种一对多的依赖关系,以便当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并自动刷新。
  17. Prototype:用原型实例指定创建对象的种类,并且通过拷贝这个原型来创建新的对象。
  18. Proxy:为其他对象提供一个代理以控制对这个对象的访问。
  19. Singleton:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
  20. State:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它所属的类。
  21. Strategy:定义一系列的算法,把它们一个个封装起来,并且使他们可以相互替换。本模式使得算法的变化可独立于使用它的客户。
  22. Template Method: 定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。Template Method 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
  23. Vsisitor: 表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

postcss-less2scss:将 Less 转换成 Scss 的 PostCSS 插件

发布一个 PostCSS 插件:postcss-less2scss,可以将 Less 文件转换为 Scss。
* GitHub:https://www.npmjs.com/package/postcss-less2scss
* NPM:https://www.npmjs.com/package/postcss-less2scss

转换变量

转换变量的定义和使用

  • 不属于任何一个 Rule 的变量

Less:

@link-color: #428bca;

Scss:

$link-color: #428bca;
  • 在某个 Rule 中定义的变量

Less:

#main {
  @width: 5em;
  width: @width;
}

Scss:

#main {
  $width: 5em;
  width: $width;
}
  • 在 Declaration 的 value 中使用的变量

Less:

@text-color: @gray-dark;
@link-color-hover:  darken(@link-color, 10%);

Scss:

$text-color: $gray-dark;
$link-color-hover:  darken($link-color, 10%);
  • 在某个 Rule 中的一个 Declaration 的 value 中使用的变量

Less:

a:hover {
  color: @link-color-hover;
}

Scss:

a:hover {
  color: $link-color-hover;
}
  • 转换 At-Rules 中的变量

Less:

@screen-sm:                  768px;
@screen-sm-min:              @screen-sm;

.form-inline {

  // Kick in the inline
  @media (min-width: @screen-sm-min) {
    // Inline-block all the things for "inline"
    .form-group {
      display: inline-block;
      margin-bottom: 0;
      vertical-align: middle;
    }
  }
}

Scss:

$screen-sm:                  768px;
$screen-sm-min:              $screen-sm;

.form-inline {

  // Kick in the inline
  @media (min-width: $screen-sm-min) {
    // Inline-block all the things for "inline"
    .form-group {
      display: inline-block;
      margin-bottom: 0;
      vertical-align: middle;
    }
  }
}

Variable Interpolation

  • 转换选择器中的 variable interpolation

Less:

// Variables
@my-selector: banner;

// Usage
.@{my-selector} {
  font-weight: bold;
  line-height: 40px;
  margin: 0 auto;
}

Scss:

// Variables
$my-selector: banner;

// Usage
.#{$my-selector} {
  font-weight: bold;
  line-height: 40px;
  margin: 0 auto;
}

转换 Mixins

  • 转换 Mixins 的定义(可以支持默认参数)

Less:

.alert-variant(@background; @border; @text-color) {
  background-color: @background;
  border-color: @border;
  color: @text-color;

  hr {
    border-top-color: darken(@border, 5%);
  }
  .alert-link {
    color: darken(@text-color, 10%);
  }
}

Scss:

@mixin alert-variant($background, $border, $text-color) {
  background-color: $background;
  border-color: $border;
  color: $text-color;

  hr {
    border-top-color: darken($border, 5%);
  }
  .alert-link {
    color: darken($text-color, 10%);
  }
}
  • 转换 Mixins 的使用

Less:

.a {
    .center-block;
}

Scss:

.a {
    @include center-block;
}
  • 支持 Mixins 的具有默认值的参数

Less:

@state-success-text:             #3c763d;
@state-success-bg:               #dff0d8;
@state-success-border:           darken(spin(@state-success-bg, -10), 5%);

@state-info-text:                #31708f;
@state-info-bg:                  #d9edf7;
@state-info-border:              darken(spin(@state-info-bg, -10), 7%);

@state-warning-text:             #8a6d3b;
@state-warning-bg:               #fcf8e3;
@state-warning-border:           darken(spin(@state-warning-bg, -10), 5%);

@state-danger-text:              #a94442;
@state-danger-bg:                #f2dede;
@state-danger-border:            darken(spin(@state-danger-bg, -10), 5%);

.box-shadow(@shadow) {
  -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1
          box-shadow: @shadow;
}

.form-control-validation(@text-color: #555; @border-color: #ccc; @background-color: #f5f5f5) {
  // Color the label and help text
  .help-block,
  .control-label,
  .radio,
  .checkbox,
  .radio-inline,
  .checkbox-inline,
  &.radio label,
  &.checkbox label,
  &.radio-inline label,
  &.checkbox-inline label  {
    color: @text-color;
  }
  // Set the border and box shadow on specific inputs to match
  .form-control {
    border-color: @border-color;
    .box-shadow(inset 0 1px 1px rgba(0,0,0,.075)); // Redeclare so transitions work
    &:focus {
      border-color: darken(@border-color, 10%);
      @shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten(@border-color, 20%);
      .box-shadow(@shadow);
    }
  }
  // Set validation states also for addons
  .input-group-addon {
    color: @text-color;
    border-color: @border-color;
    background-color: @background-color;
  }
  // Optional feedback icon
  .form-control-feedback {
    color: @text-color;
  }
}

// Feedback states
.has-success {
  .form-control-validation(@state-success-text; @state-success-text; @state-success-bg);
}
.has-warning {
  .form-control-validation(@state-warning-text; @state-warning-text; @state-warning-bg);
}
.has-error {
  .form-control-validation(@state-danger-text; @state-danger-text; @state-danger-bg);
}

Scss:

$state-success-text:             #3c763d;
$state-success-bg:               #dff0d8;
$state-success-border:           darken(adjust_hue($state-success-bg, -10), 5%);

$state-info-text:                #31708f;
$state-info-bg:                  #d9edf7;
$state-info-border:              darken(adjust_hue($state-info-bg, -10), 7%);

$state-warning-text:             #8a6d3b;
$state-warning-bg:               #fcf8e3;
$state-warning-border:           darken(adjust_hue($state-warning-bg, -10), 5%);

$state-danger-text:              #a94442;
$state-danger-bg:                #f2dede;
$state-danger-border:            darken(adjust_hue($state-danger-bg, -10), 5%);

@mixin box-shadow($shadow) {
  -webkit-box-shadow: $shadow; // iOS <4.3 & Android <4.1
          box-shadow: $shadow;
}

@mixin form-control-validation($text-color: #555, $border-color: #ccc, $background-color: #f5f5f5) {
  // Color the label and help text
  .help-block,
  .control-label,
  .radio,
  .checkbox,
  .radio-inline,
  .checkbox-inline,
  &.radio label,
  &.checkbox label,
  &.radio-inline label,
  &.checkbox-inline label  {
    color: $text-color;
  }
  // Set the border and box shadow on specific inputs to match
  .form-control {
    border-color: $border-color;
    @include box-shadow(inset 0 1px 1px rgba(0,0,0,.075)); // Redeclare so transitions work
    &:focus {
      border-color: darken($border-color, 10%);
      $shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten($border-color, 20%);
      @include box-shadow($shadow);
    }
  }
  // Set validation states also for addons
  .input-group-addon {
    color: $text-color;
    border-color: $border-color;
    background-color: $background-color;
  }
  // Optional feedback icon
  .form-control-feedback {
    color: $text-color;
  }
}

// Feedback states
.has-success {
  @include form-control-validation($state-success-text, $state-success-text, $state-success-bg);
}
.has-warning {
  @include form-control-validation($state-warning-text, $state-warning-text, $state-warning-bg);
}
.has-error {
  @include form-control-validation($state-danger-text, $state-danger-text, $state-danger-bg);
}

转换函数

字符串函数

  • 转换 CSS 转义函数,也就是:~”xxx”

Less:

@input-border-focus:             #66afe9;

.box-shadow(@shadow) {
  -webkit-box-shadow: @shadow; // iOS &lt;4.3 & Android &lt;4.1
  box-shadow: @shadow;
}

.form-control-focus(@color: @input-border-focus) {
  @color-rgba: rgba(red(@color), green(@color), blue(@color), .6);
  &:focus {
    border-color: @color;
    outline: 0;
    .box-shadow(~"inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @{color-rgba}");
  }
}

.form-control {
  .form-control-focus();
}

Scss:

$input-border-focus:             #66afe9;

@mixin box-shadow($shadow) {
  -webkit-box-shadow: $shadow; // iOS &lt;4.3 & Android &lt;4.1
  box-shadow: $shadow;
}

@mixin form-control-focus($color: $input-border-focus) {
  $color-rgba: rgba(red($color), green($color), blue($color), .6);
  &:focus {
    border-color: $color;
    outline: 0;
    @include box-shadow(#{inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px $color-rgba});
  }
}

.form-control {
  @include form-control-focus();
}

颜色函数

  • 将 Less 的 spin() 函数转换为 Scss 的 adjust_hue() 函数

Less:

@state-success-border:           darken(spin(@state-success-bg, -10), 5%);

Scss:

$state-success-border:           darken(adjust_hue($state-success-bg, -10), 5%);

转换 @import At-Rules

Less:

@import "foo";
@import "foo.less";
@import "foo.php";
@import "foo.css";

Scss:

@import "foo";
@import "foo";
@import "foo";
@import "foo.css";

如果使用 postcss-less2scss 插件

const postcss = require('postcss')
const syntax = require('postcss-less')
const converter = require('postcss-less2scss')

const lessText = `
// Variables
@link-color:        #428bca; // sea blue
@link-color-hover:  darken(@link-color, 10%);

// Usage
a,
.link {
  color: @link-color;
}
a:hover {
  color: @link-color-hover;
}
.widget {
  color: #fff;
  background: @link-color;
}
`
const scssText = postcss([converter])
  .process(lessText, { syntax })
  .css
console.log('lessText = ', lessText, ', \nscssText = ', scssText)

和 gulp 集成

/**
 * Use postcss-less2scss to convert bootstrap v3.3.7 styles from less to scss
 */
gulp.task('less2scss', () => {
  return gulp.src('assets/less/bootstrap3/**/*.less')
    .pipe(postcss([less2scss], {
      syntax: less
    }))
    .pipe(rename(path => {
      if (path.basename !== 'bootstrap') {
        path.basename = '_' + path.basename
      }
      path.extname = '.scss'
    }))
    .pipe(gulp.dest('build/scss/bootstrap3/'))
})

postcss-remove-global: 从代码中移除 :global 的 PostCSS 插件

postcss-remove-global

发布一个 PostCSS 插件,用来移除样式中的 :global 标识符。
* github 链接:https://github.com/princetoad/postcss-remove-global
* npm 链接:https://www.npmjs.com/package/postcss-remove-global

目前支持三种场景,第一种是 :global 作为一个单独的选择器,第二种是 :global 作为选择器的一部分,第三种是 :global 作为 @keyframe 属性的一部分。分别对应下面的三个示例。

示例

  1. Remove :global as a single selector
:global {
a { }
}
a { }
  1. Remove :global as part of a selecotr
.root :global .text { margin: 0 6px; }
.root .text { margin: 0 6px; }
  1. Remove :global as part of params of @keyframe
@keyframes :global(zoomIn) { }
@keyframes zoomIn { }

如何使用

使用 postcss API

const postcss = require('postcss')
const removeGlobal = require('postcss-remove-global')

const css = postcss()
.use(removeGlobal())
.process(':global { a {color: gray(85); background-color: gray(10%, .25)}}')
.css
console.log('css = ', css)
//= 'a {color: gray(85); background-color: gray(10%, .25)}'

const css2 = postcss([removeGlobal])
.process('.root :global .text { margin: 0 6px; }')
.css
console.log('css2 = ', css2)
//= '.root .text { margin: 0 6px; }'

const css3 = postcss([removeGlobal])
.process('@keyframes :global(zoomIn) { }')
.css
console.log('css3 = ', css3)
//= '@keyframes zoomIn { }'

参见:https://github.com/princetoad/try-postcss/blob/master/src/Plugin/plugin-remove-global.js

结合 gulp

gulp.task('global', () => {
return gulp.src('assets/*.css')
.pipe(postcss([removeGlobal]))
.pipe(gulp.dest('build/'))
})

参见:https://github.com/princetoad/try-postcss/blob/master/gulpfile.js

generator-criket: 一个 Node.js 命令行项目脚手架

一看 generator-criket 这个名字,有经验的人就知道这是基于 yeoman 的.
至于为什么命名为 criket (蛐蛐)? 则是因为我正在看王世襄先生的<京华忆往>,里面有讲到百灵( Lark) 和蛐蛐( criket). Lark 已经被人用了, 有一个 Lark.js 以及对应的 generator-lark, 我就只能用蛐蛐了.

蛐蛐的用法很简单, 装好 yeoman(npm install -g yo) 之后,直接运行 yo criket,然后按照提示一步一步输入相关信息即可,然后一个 Node.js 命令行项目就创建完成了.
我的 augustine,frege 等命令行工具都是基于这个脚手架的.

Frege – 由 package.json 逆向生成 npm install/yarn add 安装命令的工具

更新 v0.3: 使用 -l -u 或者 --latest --update 选项可以直接运行生成的最新版本的依赖包安装脚本, 将 package.json 以及 node_moduels下面的依赖包都更新至最新版本. Note: 生产项目慎用


更新 v0.2: 支持生成 yarn 安装脚本, 使用 -y 或者 --yarnInstall 参数


frege 是什么?

frege 可以从一个现有的 package.json 逆向生成安装所需的 npm install 脚本. 特点是可以选择仅生成生产环境或者开发环境包安装脚本, 并且能够正确将 version range 转成 npm install 所需的语法.

安装

建议把 frege 安装到全局, 然后就可以在命令下直接使用了.

npm install frege -g

使用

参数说明

frege [options]

基本配置:
-f, --file String 要解析的 package.json 文件, 默认会解析当前目录下名为 package.json 的文件 - default: package.json
-l, --latest 不管 package.json 中 npm 包的具体版本号, 安装该包的最新版本 - default: false
-p, --productionOnly 仅生成 dependencies 项目下 npm 包的安装脚本. - default: false
-d, --devOnly 仅生成 devDependencies 项目下的 npm 包的安装脚本, 即开发使用的. - default: false
-h, --help Show help
-v, --version Output the version number

示例

frege

frege

不带任何参数直接运行 frege 命令将生成当前目录下 package.json 文件中 dependenciesdevDependencis(如果有的话) 全部 npm 包的安装脚本, 例如:

npm i -S debug@">=2.6.8 <3.0.0" optionator@">=0.8.2 <0.9.0" semver@">=5.3.0 <6.0.0" npm i -D ava@">=0.19.1 <0.20.0" chai@">=4.0.1 <5.0.0" eslint@">=3.19.0 <4.0.0" tap-nyan@">=1.1.0 <2.0.0"

frege -p

frege --productionOnly

的缩写, 仅生成 dependencis 下面 npm 包的安装脚本.

npm i -S debug@">=2.6.8 <3.0.0" optionator@">=0.8.2 <0.9.0" semver@">=5.3.0 <6.0.0"

frege -d

frege --devOnly

的缩写, 仅生成 devDependencis 项下面 npm 包的安装脚本.

npm i -D ava@">=0.19.1 <0.20.0" chai@">=4.0.1 <5.0.0" eslint@">=3.19.0 <4.0.0" tap-nyan@">=1.1.0 <2.0.0"

frege -l

frege --latest

的简写, 安装 npm 包的最新版本, 而不是原有 package.json 中指定的版本范围.

npm i -S debug optionator semver
npm i -D ava chai eslint tap-nyan

frege -f ../augustine/package.json

指定要解析的 package.json 文件的完整路径.

有问题欢迎反馈!

github 地址: https://github.com/princetoad/frege
npm 地址: https://www.npmjs.com/package/frege

范圣刚 <tom@tfan.org>

Augustine – 一个简单的静态文件 HTTP Server

发布了一个简单的基于 node.js 的 HTTP Server, 可以用来 serve 静态资源, 作为纯前端项目的 Web 服务器.

特点: 简单, 简小, 可以通过 npm 安装.

功能:

  1. 支持 HTTP Status Code – 404 Not Found
  2. 如果路径是目录的话, 支持默认到 index.html 文件
  3. 支持 debug 信息(DEBUG=augustine)

使用 npm 安装

npm install augustine --save-dev

如何使用

在作为 Web 目录的文件夹下新建一个例如名为 index.js 文件, 加入下面两行代码:

const augustine = require('augustine');
augustine.start(8080);

然后运行

node index.js

即可通过 8080 端口访问该目录下的内容了.

欢迎参与

github 地址: https://github.com/princetoad/augustine
npm 地址: https://www.npmjs.com/package/augustine