Node.js哲学
小核心(small core)
Node.js的内核设计基于一些小原则, 其中之一就是由小的函数集合组成, 其余部分为由模块组成的生态圈, 而核心则起到连接不同模块进行自由的组合.
小模块(small modules)
Node.js中, 尽量设计小的模块, 这是基于Unix的哲学:
1. 小即美.
2. 使每个模块只做一件事.
Node.js使用npm解决了"依赖地狱"的问题, 保证每个安装的包具有完整可分离的依赖包, 而不会产生依赖冲突.
具有复用性的模块应该具有以下特性(符合DRY原则: Don't Repeat Yourself):
1. 易于理解和使用.
2. 简单测试和管理.
3. 浏览器中可共享.
小接触层(small surface area)
Node.js中, 定义模块的通用原则是导出一小块主要功能, 其它的实现细节应该隐藏起来. 这样方便调用者分清模块的主次, 并且更易于进行模块的组合使用.
模块是用来使用的, 而非用来扩展, 这样它可易于复用, 实现简单, 操作便利, 以及增加它的可用性.
简单和实用(simplicity and pragmatism)
遵循KISS(keep it simple, stupid)原则.
介绍Node.js 6和ES2015
let: 所定义的变量具有块级作用域.
const: 定义一个常量.
"=>"函数: 简化函数的编写. 如果函数只有一行, 可忽略return.
const numbers = [2, 5, 6, 7, 9];const even = numbers.filter(function(x) { return x % 2 === 0;});
使用"=>"函数可简化为:
const numbers = [2, 5, 6, 7, 9];const even = numbers.filter(x => x % 2 === 0);
"=>"函数同时保留着它的词法作用域, 所以this指针会指向此函数的对象.
function DelayedGreeter(name) { this.name = name;}DelayedGreeter.prototype.greet = function() { // Hello undefined setTimeout(function cb() { console.log('Hello ' + this.name); }, 500); // Hello world! setTimeout(() => console.log('Hello ' + this.name), 1000);};const greeter = new DelayedGreeter('world!');greeter.greet();
而class的引入则简化了继承.
function Person(name, surname, age) { this.name = name; this.surname = surname; this.age = age;}Person.prototype.getFullName = function() { return this.name + ' ' + this.surname;};Person.older = function(person1, person2) { return (person1.age >= person2.age) ? person1 : person2;};
而使用class属性则简化如下:
class Person { constructor(name, surname, age) { this.name = name; this.surname = surname; this.age = age; } getFullName() { return this.name + ' ' + this.surname; } static older(person1, person2) { return (person1.age >= person2.age) ? person1 : person2; }}
而class甚至可以使用extend进行继承:
class PersonWithMiddlename extends Person { constructor(name, middlename, surname, age) { super(name, surname, age); this.middlename = middlename; } getFullName() { return this.name + ' ' + this.middlename + ' ' + this.surname; }}
对象字面量的赋值功能被增强:
const x = 22;const y = 17;const obj = {x, y};// {x: 22, y: 17}console.log(obj);
之前我们导出一个module, 需要如此编写代码:
module.exports = { square: function(x) { return x * x; }, cube: function(x) { return x * x * x; }};
而现在可简化为:
module.exports = { square(x) { return x * x; }, cube(x) { return x * x * x; }};
Map数据结构的出现, 简化了"字典"型数据结构的编写:
const m = new Map();m.set('a', 1);m.set('b', 2);//2console.log(m.size);// trueconsole.log(m.has('a'));// 1console.log(m.get('a'));m.delete('b');// undefinedconsole.log(m.get('b'));// a:1for (const entry of m) { console.log(entry[0] + ':' + entry[1]);}
Set简化了集合的操作:
const s = new Set([0, 1, 2, 3]);s.add(3);// 4console.log(s.size);s.delete(0);// falseconsole.log(s.has(0));// 1 2 3for (const entry of s) { console.log(entry);}
模板字符串
我们可以使用"``"来实现模板字符串, 模板字符串中使用${expression}来求解表达式.
reactor模式
I/O是缓慢的
I/O操作是缓慢的, 接触RAM的速度为纳秒级别, 接触磁盘或者网络的速度为微秒级别.
阻塞I/O
在传统的I/O操作中, 在当前线程中I/O将阻塞直到操作完成.
// 线程阻塞直到数据有效data = socket.read();// 数据有效print(data);
如果一个Web服务器是基于阻塞I/O的, 那它在单线程情况下不支持多个连接. 传统的解决方案就是新建一个连接, 则fork出一个子线程来处理.
但线程耗费的资源是昂贵的, 并且每个线程的空闲时间也是及其浪费的(例如数据库操作时候的阻塞). 所以在Web中使用多线程来处理多连接, 不是完美的解决方案.
非阻塞I/O
非阻塞模式下, 系统不需要等待数据读写完毕而是马上返回. 调用的函数会立即返回一个变量, 可表示暂时没有数据, 正在读写, 或者读写完毕.
一种非阻塞I/O模式是将执行一次循环, 等待循环中数据全部操作完毕, 而非顺序等待每个元素操作执行完毕:
resources = [socketA, socketB, pipeA];while (!resources.isEmpty()) { for (i = 0; i < resources.length; i++) { resource = resources[i]; var data = resource.read(); if (data === NO_DATA_AVAILABLE) { // 当前没有任何的有效数据 continue; } if (data === RESOURCE_CLOSED) { // 数据读取完毕, 将resource从列表中删除 resource.remove(i); } else { // 读取到一些数据, 处理它 consumeData(data); } }}
考虑一个实际的Node.js例子, 我们使用net模块创建一个服务器, 客户端连接到服务器后, 输入数据, 服务器原样返回. 客户端全部关闭后, 服务器也关闭.
服务器代码test.js:
const net = require('net');const server = net.createServer((c) => { console.log('client connected'); c.on('end', () => { console.log('client disconnected'); server.unref(); }); c.write('hello\r\n'); c.pipe(c);});server.on('error', (err) => { throw err;});server.listen(8000, () => { console.log('server bound');});
服务器运行:
leicj@leicj:~/test$ node test.jsserver boundclient connectedclient connectedclient disconnectedclient disconnectedleicj@leicj:~/test$
客户端1:
leicj@leicj:~/test$ telnet localhost 8000Trying 127.0.0.1...Connected to localhost.Escape character is '^]'.hellonihaonihao^]telnet> quitConnection closed.leicj@leicj:~/test$
客户端2:
leicj@leicj:~/test$ telnet localhost 8000Trying 127.0.0.1...Connected to localhost.Escape character is '^]'.hellowhatwhat^]telnet> quitConnection closed.leicj@leicj:~/test$
事件多路(event demultiplexing)
同步事件多路操作: 将所有的I/O操作排列好, 循环监测事件直到新的事件可操作时候进行阻塞, 直到操作完成后继续循环监测.
socketA, pipeB;watchedList.add(socketA, FOR_READ); // [1]watchedList.add(pipeB, FOR_READ);while (events = dumultplexer.watch(watchedList)) { // [2] // event loop foreach(event in events) { // [3] // read将永远不会阻塞并且总是返回数据 data = event.resource.read(); if (data === RESOURCE_CLOSED) demultiplexer.unwatch(event.resource); else consumeData(data); }}
1. resource添加到具体的数据结构中, 关联一个特定的操作, 如read
2. 监测resources直到具体的操作有效(例如可读)后进行阻塞操作, 直到操作完毕后继续监测.
3. 针对每个resource操作完毕后, 重新阻塞.
这种情况下, 可在单线程下执行多个I/O操作。
reactor模式
reactor模式是特殊版的event demultiplexing, 它主要观点在于: 每个I/O操作都有一个关联的handler, 在event loop中会被产生和调用.
1. Application通过提交给Event Demultiplexer生成一个新的I/O操作.
2. 当一系列的I/O操作完成以后, Event Demultiplexer将新的事件push到Event Queue中.
3. Event Loop会顺序循环Event Queue, 确定哪个Event将被触发.
4. 针对每个Event, 相应的handler会被调用.
5. 当handler执行完毕后, 会将控制权返回给Event Loop中(5a). 然而, 也可能handler中会产生新的异步操作, 则执行(5b).
6. 当所有的Event Queue都执行完毕后, loop会被阻塞, Event Demultiplexer会执行下一次的循环.
回调模式
持续传递类型(The continuation-passing style)
在JavaScript中, 将一个函数作为参数进行传递, 在另一个函数执行完毕后进行调用, 则称为回调函数.
同步性回调
考虑以下函数:
function add(a, b) { return a + b;}
假设将return当做一个函数(实际上return为一个操作符), 则可修改成:
function (a, b, cb) { cb(a + b);}
测试代码如下:
function add(a, b, cb) { cb(a + b);}console.log('before');add(1, 2, result => console.log('Result: ' + result));console.log('after');
输出:
leicj@leicj:~/test$ node test.jsbeforeResult: 3after
异步性回调
我们使用setTimeout来模拟异步性回调:
function addAsync(a, b, cb) { setTimeout(() => cb(a + b), 100);}console.log('before');addAsync(1, 2, result => console.log('Result: ' + result));console.log('after');
输出:
leicj@leicj:~/test$ node test.jsbeforeafterResult: 3
类似异步, 实为同步的回调
例如, map函数:
> [1, 5, 7].map((item) => item - 1);[ 0, 4, 6 ]
同步还是异步?
针对一个封装的函数, 最危险的一个行为是在某些条件下为同步, 而在某些条件下为异步:
var fs = require('fs');var cache = {};function inconsistentRead(filename, cb) { if (cache[filename]) { cb(cache[filename]); } else { fs.readFile(filename, 'utf8', (err, data) => { cache[filename] = data; cb(data); }); }}
这种情况下, 会造成一些意外的情况, 考虑如下的代码:
const fs = require('fs');let cache = {};function inconsistentRead(filename, cb) { if (cache[filename]) cb(cache[filename]); else { fs.readFile(filename, 'utf8', (err, data) => { cache[filename] = data; cb(data); }); }}function createFileReader(filename) { let listeners = []; inconsistentRead(filename, value => { listeners.forEach(listener => listener(value)); }); return { onDataReady: listener => listeners.push(listener) };}let reader1 = createFileReader('data.txt');reader1.onDataReady(data => { console.log('First call data: ' + data); let reader2 = createFileReader('data.txt'); reader2.onDataReady(data => { console.log('Second call data: ' + data); });});
如果data.txt的内容为: some data, 则实际输出为:
leicj@leicj:~/test$ node test.jsFirst call data: some data
我们认真查看createFileReader函数, 里面inconsistentRead函数只有可能其为异步调用情况下, 回调函数才会被调用. 而第一次读取data.txt时候为异步, 第二次读取data.txt时候为同步, 则inconsistentRead函数在第二次永远不会被调用.
一种改进的方法为: 将函数修改为同步API:
function inconsistentRead(filename, cb) { if (cache[filename]) cb(cache[filename]); else cache[filename] = fs.readFileSync(filename, 'utf8');}
或者修改为异步API:
function inconsistentRead(filename, cb) { if (cache[filename]) { process.nextTick(() => { cb(cache[filename]); }); } else { fs.readFile(filename, 'utf8', (err, data) => { cache[filename] = data; cb(data); }); }}
Node.js回调规则
回调函数作为最后一个参数
fs.readFile(filename, [options], callback)
回调函数中, Error作为第一个参数
fs.readFile('foo.txt', 'utf8', function(err, data) { if (err) handleError(err); else processData(data);});
传播错误
一个典型的错误处理方式如下:
var fs = require('fs');function readJSON(filename, cb) { fs.readFile(filename, 'utf8', (err, data) => { let parsed; if (err) return cb(err); try { parsed = JSON.parse(data); } catch (err) { return cb(err); } cb(null, parsed); });}
正常情况下, 我们不能使用try/catch来捕获异步的异常. 在Node.js中, 一种使用on('error')来捕获, 一种使用process.on('uncaughtException')来捕获.
模块系统和模式
The revealing module pattern
var module = (() => { const privateFoo = () => {...}; const privateVar = []; const export = { publicFoo: () => {...}, publicBar: () => {...} }; return export;})();
Node.js模块
以下代码用于模拟Node.js的模块调用:
function loadModule(filename, module, require) { var wrappedSrc = '(function(module, exports, require) {' + fs.readFileSync(filename, 'utf8') + '})(module, module.exports, require);'; eval(wrappedSrc);}var require = function(moduleName) { console.log('Require invoked for module:' + moduleName); var id = require.resolve(moduleName); // [1] if (require.cache[id]) { // [2] return require.cache[id].exports; } // module metadata var module = { // [3] exports: {}, id: id }; // Update the cache require.cache[id] = module; // [4] // load the module loadModule(id, module, require); // [5] // return exported variables return module.exports; // [6]};require.cache = {};require.resolve = function(moduleName) { /* resolve a full module id from the moduleName */}
1. 通过模块名称, 使用require.resolve来解析, 得到模块的完整路径.
2. 判断id是否已经存在于缓存之中(即是否已加载).
3. 如果未加载, 则定义一个module包含exports和id.
4. 将此变量缓存起来.
5. 读取文件id的内容, 并与之前的module关联起来.
6. 导出其相应的模块.
备注: 这仅仅只是一个例子, [5]的操作会将文件的加载和module关联起来。
定义一个模块
// load another dependencyvar dependency = require('./anotherModule');// a private functionfunction log() { console.log('Well done ' + dependency.username);}// the API to be exported for public usemodule.exports.run = function() { log();};
模块内的所有变量都是私有的, 除非调用module.exports将变量暴露出去. 而使用require进行模块的调用.
虽然模块内的变量都是私有的, 但Node.js支持特殊的变量global, 任何赋值给global的都是全局变量.
exports和module.exports的区别
exports和module.exports本质上并没有什么区别, 如下的exports代码:
exports.hello = function() { console.log('hello');}exports.world = function() { console.log('world');}
等价于:
module.exports = { hello: function() { console.log('hello'); }, world: function() { console.log('world'); }};
require是同步的
加载一个模块是同步操作, 以下代码是错误的:
setTimeout(function() { module.exports = function() {...};}, 100);
resolving算法
File modules: 如果模块名称以"/"开头, 则为绝对路径. 如果以"./"开头, 则为相对路径.
Core modules: 如果模块不以"/"和"./"开头, 则从Node.js的核心模块进行寻找.
Package modules: 如果核心模块没有找到, 则从当前目录的node_modules进行查找, 找不到则向父目录继续查找...
针对目录或者文件, 则算法优先匹配:
<moduleName>.js
<moduleName>/index.js
<moduleName>/package.json中指定的文件.
一个具体的例子, 考虑如下的目录结构:
1. 在/myApp/foo.js中require('depA'), 则加载/myApp/node_modules/depA/index.js
2. 在/myApp/node_modules/depB/bar中require('depA'), 则加载/myApp/node_modules/depB/node_modules/depA/index.js
模块缓存
每个模块在第一次require时候会被加载和计算, 之后多次的加载均从缓存中读取.
缓存的作用有两个:1是避免模块加载中的循环依赖. 2是保证同一个模块被多次加载, 则输出一样.
Module a.js:
exports.loaded = false;var b = require('./b');module.exports = { bWasLoaded: b.loaded, loaded: true};
Module b.js:
exports.loaded = false;var a = require('./a');module.exports = { aWasLoaded: a.loaded, loaded: true};
test.js:
var a = require('./a');var b = require('./b');console.log(a);console.log(b);
输出:
leicj@leicj:~/test$ node test.js{ bWasLoaded: true, loaded: true }{ aWasLoaded: false, loaded: true }
观察者模型
观察者模型: 定义一个观察对象, 用于监测一个对象集合.
观察者模型不同于回调模型的一点在于: 它可以观察多个对象, 而每个对象被触发后所执行的动作可能不同, 而回调函数只有一个。
EventEmitter
var EventEmitter = require('events').EventEmitter;class MyEmitter extends EventEmitter {}var myEmitter = new MyEmitter();myEmitter.on('ok', () => { console.log('ok');});myEmitter.on('error', (err) => { console.log(err.message);});myEmitter.emit('ok');myEmitter.emit('error', new Error('err.....'));
一个典型的EventEmitter的实现如上, 可视化如下:
EventEmitter所给予的基本操作如下:
on(event, listener): 给特定一个事件注册一个监听处理函数.
once(event, listener): 给特定一个事件注册一个监听处理函数, 当此事件触发后移除处理函数.
emit(event, [arg1], [...]): 触发一个事件, 并传递特定的参数.
removeListener(event, listener): 移除特定时间的监听处理函数.
对于错误来说, 一般都是注册一个error事件, 然后emit('error', new Error());
如果我们想继承EventEmitter, 则可以如下编写代码:
const EventEmitter = require('events').EventEmitter;const fs = require('fs');class FindPattern extends EventEmitter { constructor(regex) { super(); this.regex = regex; this.files = []; } addFile(file) { this.files.push(file); return this; } find() { this.files.forEach(file => { fs.readFile(file, 'utf8', (err, content) => { if (err) return this.emit('error', err); this.emit('fileread', file); let match = null; if (match = content.match(this.regex)) { match.forEach(elem => this.emit('found', file, elem)); } }); }); return this; }}const findPatternObject = new FindPattern(/hello \w+/);findPatternObject.addFile('fileA.txt') .addFile('fileB.txt') .find() .on('found', (file, match) => console.log(`Matched "${match} in file ${file}`)) .on('error', err => console.log(`Error emitted ${err.message}`));
而对于EventEmitter来说, 它是同步注册的, 即遵循: 先注册, 后触发原则. 所以下例代码并无输出:
const EventEmitter = require('events').EventEmitter;class MyEmitter extends EventEmitter {}const myEmitter = new MyEmitter();myEmitter.emit('ok');myEmitter.on('ok', () => { console.log('....');});
而使用EventEmitter还是callbacks的一个原则是: EventEmitter用于处理"当某件事发生时候要做什么", 而callbacks用于处理:"当某件事处理完毕时候一定要做什么".