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);
//2
console.log(m.size);
// true
console.log(m.has('a'));
// 1
console.log(m.get('a'));
m.delete('b');
// undefined
console.log(m.get('b'));
// a:1
for (const entry of m) {
console.log(entry[0] + ':' + entry[1]);
}
Set简化了集合的操作:
const s = new Set([0, 1, 2, 3]);
s.add(3);
// 4
console.log(s.size);
s.delete(0);
// false
console.log(s.has(0));
// 1 2 3
for (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.js
server bound
client connected
client connected
client disconnected
client disconnected
leicj@leicj:~/test$
客户端1:
leicj@leicj:~/test$ telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
nihao
nihao
^]
telnet> quit
Connection closed.
leicj@leicj:~/test$
客户端2:
leicj@leicj:~/test$ telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
what
what
^]
telnet> quit
Connection 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.js
before
Result: 3
after
异步性回调
我们使用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.js
before
after
Result: 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.js
First 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 dependency
var dependency = require('./anotherModule');
// a private function
function log() {
console.log('Well done ' + dependency.username);
}
// the API to be exported for public use
module.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用于处理:"当某件事处理完毕时候一定要做什么".