本系列文章为《Node.js Design Patterns Second Edition》的原文翻译和读书笔记,在GitHub连载更新,同步翻译版连接。javascript
欢迎关注个人专栏,以后的博文将在专栏同步:前端
Node.js
生态圈的发展以package
的形式尽量多的复用模块,原则上每一个模块的容量尽可能小而精。java
原则:react
所以,一个Node.js
应用由多个包搭建而成,包管理器(npm
)的管理使得他们相互依赖而不起冲突。git
若是设计一个Node.js
的模块,尽量作到如下三点:程序员
以及,Don't Repeat Yourself(DRY)
复用性原则。github
每一个Node.js
模块都是一个函数(类也是以构造函数的形式呈现),咱们只须要调用相关API
便可,而不须要知道其它模块的实现。Node.js
模块是为了使用它们而建立,不只仅是在拓展性上,更要考虑到维护性和可用性。web
“简单就是终极的复杂” ————达尔文算法
遵循KISS(Keep It Simple, Stupid)原则
,即优秀的简洁的设计,可以更有效地传递信息。数据库
设计必须很简单,不管在实现仍是接口上,更重要的是实现比接口更简单,简单是重要的设计原则。
咱们作一个设计简单,功能完备,而不是完美的软件:
而对于Node.js
而言,由于其支持JavaScript
,简单和函数、闭包、对象等特性,可取代复杂的面向对象的类语法。如单例模式和装饰者模式,它们在面向对象的语言都须要很复杂的实现,而对于JavaScript
则较为简单。
ES5
以前,只有函数和全局做用域。
if (false) {
var x = "hello";
}
console.log(x); // undefined复制代码
如今用let
,建立词法做用域,则会报出一个错误Uncaught ReferenceError: x is not defined
if (false) {
let x = "hello";
}
console.log(x);复制代码
在循环语句中使用let
,也会报错Uncaught ReferenceError: i is not defined
:
for (let i = 0; i < 10; i++) {
// do something here
}
console.log(i);复制代码
使用let
和const
关键字,可让代码更安全,若是意外的访问另外一个做用域的变量,更容易发现错误。
使用const
关键字声明变量,变量不会被意外更改。
const x = 'This will never change';
x = '...';复制代码
这里会报出一个错误Uncaught TypeError: Assignment to constant variable.
可是对于对象属性的更改,const
显得毫无办法:
const x = {};
x.name = 'John';复制代码
上述代码并不会报错
可是若是直接更改对象,仍是会抛出一个错误。
const x = {};
x = null;复制代码
实际运用中,咱们使用const
引入模块,防止意外被更改:
const path = require('path');
let path = './some/path';复制代码
上述代码会报错,提醒咱们意外更改了模块。
若是须要建立不可变对象,只是简单的使用const
是不够的,须要使用Object.freeze()
或deep-freeze
我看了一下源码,其实不多,就是递归使用Object.freeze()
module.exports = function deepFreeze (o) {
Object.freeze(o);
Object.getOwnPropertyNames(o).forEach(function (prop) {
if (o.hasOwnProperty(prop)
&& o[prop] !== null
&& (typeof o[prop] === "object" || typeof o[prop] === "function")
&& !Object.isFrozen(o[prop])) {
deepFreeze(o[prop]);
}
});
return o;
};复制代码
箭头函数更易于理解,特别是在咱们定义回调的时候:
const numbers = [2, 6, 7, 8, 1];
const even = numbers.filter(function(x) {
return x % 2 === 0;
});复制代码
使用箭头函数语法,更简洁:
const numbers = [2, 6, 7, 8, 1];
const even = numbers.filter(x => x % 2 === 0);复制代码
若是不止一个return
语句则使用=> {}
const numbers = [2, 6, 7, 8, 1];
const even = numbers.filter((x) => {
if (x % 2 === 0) {
console.log(x + ' is even');
return true;
}
});复制代码
最重要是,箭头函数绑定了它的词法做用域,其this
与父级代码块的this
相同。
function DelayedGreeter(name) {
this.name = name;
}
DelayedGreeter.prototype.greet = function() {
setTimeout(function cb() {
console.log('Hello' + this.name);
}, 500);
}
const greeter = new DelayedGreeter('World');
greeter.greet(); // 'Hello'复制代码
要解决这个问题,使用箭头函数或bind
function DelayedGreeter(name) {
this.name = name;
}
DelayedGreeter.prototype.greet = function() {
setTimeout(function cb() {
console.log('Hello' + this.name);
}.bind(this), 500);
}
const greeter = new DelayedGreeter('World');
greeter.greet(); // 'HelloWorld'复制代码
或者箭头函数,与父级代码块做用域相同:
function DelayedGreeter(name) {
this.name = name;
}
DelayedGreeter.prototype.greet = function() {
setTimeout(() => console.log('Hello' + this.name), 500);
}
const greeter = new DelayedGreeter('World');
greeter.greet(); // 'HelloWorld'复制代码
class
是原型继承的语法糖,对于来自传统的面向对象语言的全部开发人员(如Java
和C#
)来讲更熟悉,新语法并无改变JavaScript
的运行特征,经过原型来完成更加方便和易读。
传统的经过构造器 + 原型
的写法:
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
语法来讲,最有意义的是extends
和super
关键字。
class PersonWithMiddlename extends Person {
constructor(name, middlename, surname, age) {
super(name, surname, age);
this.middlename = middlename;
}
getFullName() {
return this.name + '' + this.middlename + '' + this.surname;
}
}复制代码
这个例子是真正的面向对象的方式,咱们声明了一个但愿被继承的类,定义新的构造器,并可使用super
关键字调用父构造器,并重写getFullName
方法,使得其支持middlename
。
const x = 22;
const y = 17;
const obj = { x, y };复制代码
module.exports = {
square(x) {
return x * x;
},
cube(x) {
return x * x * x;
},
};复制代码
const namespace = '-webkit-';
const style = {
[namespace + 'box-sizing']: 'border-box',
[namespace + 'box-shadow']: '10px 10px 5px #888',
};复制代码
const person = {
name: 'George',
surname: 'Boole',
get fullname() {
return this.name + ' ' + this.surname;
},
set fullname(fullname) {
let parts = fullname.split(' ');
this.name = parts[0];
this.surname = parts[1];
}
};
console.log(person.fullname); // "George Boole"
console.log(person.fullname = 'Alan Turing'); // "Alan Turing"
console.log(person.name); // "Alan"复制代码
这里,第二个console.log
触发了set
方法。
reactor模式
是Node.js
异步编程的核心模块,其核心概念是:单线程
、非阻塞I/O
,经过下列例子能够看到reactor模式
在Node.js
平台的体现。
在计算机的基本操做中,输入输出确定是最慢的。访问内存的速度是纳秒级(10e-9 s
),同时访问磁盘上的数据或访问网络上的数据则更慢,是毫秒级(10e-3 s
)。内存的传输速度通常认为是GB/s
来计算,然而磁盘或网络的访问速度则比较慢,通常是MB/s
。虽然对于CPU
而言,I/O
操做的资源消耗并不算大,可是在发送I/O
请求和操做完成之间总会存在时间延迟。除此以外,咱们还必须考虑人为因素,一般状况下,应用程序的输入是人为产生的,例如:按钮的点击、即时聊天工具的信息发送。所以,输入输出的速度并不因网络和磁盘访问速率慢形成的,还有多方面的因素。
在一个阻塞I/O
模型的进程中,I/O
请求会阻塞以后代码块的运行。在I/O
请求操做完成以前,线程会有一段不定长的时间浪费。(它多是毫秒级的,但甚至有多是分钟级的,如用户按着一个按键不放的状况)。如下例子就是一个阻塞I/O
模型。
// 直到请求完成,数据可用,线程都是阻塞的
data = socket.read();
// 请求完成,数据可用
print(data);复制代码
咱们知道,阻塞I/O
的服务器模型并不能在一个线程中处理多个链接,每次I/O
都会阻塞其它链接的处理。出于这个缘由,对于每一个须要处理的并发链接,传统的web服务器的处理方式是新开一个新的进程或线程(或者从线程池中重用一个进程)。这样,当一个线程因 I/O
操做被阻塞时,它并不会影响另外一个线程的可用性,由于他们是在彼此独立的线程中处理的。
经过下面这张图:
经过上面的图片咱们能够看到每一个线程都有一段时间处于空闲等待状态,等待从关联链接接收新数据。若是全部种类的I/O
操做都会阻塞后续请求。例如,链接数据库和访问文件系统,如今咱们能很快知晓一个线程须要因等待I/O
操做的结果等待许多时间。不幸的是,一个线程所持有的CPU
资源并不廉价,它须要消耗内存、形成CPU
上下文切换,所以,长期占有CPU
而大部分时间并无使用的线程,在资源利用率上考虑,并非高效的选择。
除阻塞I/O
以外,大部分现代的操做系统支持另一种访问资源的机制,即非阻塞I/O
。在这种机制下,后续代码块不会等到I/O
请求数据的返回以后再执行。若是当前时刻全部数据都不可用,函数会先返回预先定义的常量值(如undefined
),代表当前时刻暂无数据可用。
例如,在Unix
操做系统中,fcntl()
函数操做一个已存在的文件描述符,改变其操做模式为非阻塞I/O
(经过O_NONBLOCK
状态字)。一旦资源是非阻塞模式,若是读取文件操做没有可读取的数据,或者若是写文件操做被阻塞,读操做或写操做返回-1
和EAGAIN
错误。
非阻塞I/O
最基本的模式是经过轮询获取数据,这也叫作忙-等模型。看下面这个例子,经过非阻塞I/O
和轮询机制获取I/O
的结果。
resources = [socketA, socketB, pipeA];
while(!resources.isEmpty()) {
for (i = 0; i < resources.length; i++) {
resource = resources[i];
// 进行读操做
let data = resource.read();
if (data === NO_DATA_AVAILABLE) {
// 此时尚未数据
continue;
}
if (data === RESOURCE_CLOSED) {
// 资源被释放,从队列中移除该连接
resources.remove(i);
} else {
consumeData(data);
}
}
}复制代码
咱们能够看到,经过这个简单的技术,已经能够在一个线程中处理不一样的资源了,但依然不是高效的。事实上,在前面的例子中,用于迭代资源的循环只会消耗宝贵的CPU
,而这些资源的浪费比起阻塞I/O
反而更不可接受,轮询算法一般浪费大量CPU
时间。
对于获取非阻塞的资源而言,忙-等模型
不是一个理想的技术。可是幸运的是,大多数现代的操做系统提供了一个原生的机制来处理并发,非阻塞资源(同步事件多路复用器)是一个有效的方法。这种机制被称做事件循环机制,这种事件收集和I/O队列
源于发布-订阅模式
。事件多路复用器收集资源的I/O
事件而且把这些事件放入队列中,直到事件被处理时都是阻塞状态。看下面这个伪代码:
socketA, pipeB;
wachedList.add(socketA, FOR_READ);
wachedList.add(pipeB, FOR_READ);
while(events = demultiplexer.watch(wachedList)) {
// 事件循环
foreach(event in events) {
// 这里并不会阻塞,而且总会有返回值(不论是不是确切的值)
data = event.resource.read();
if (data === RESOURCE_CLOSED) {
// 资源已经被释放,从观察者队列移除
demultiplexer.unwatch(event.resource);
} else {
// 成功拿到资源,放入缓冲池
consumeData(data);
}
}
}复制代码
事件多路复用的三个步骤:
read
。watch
函数,并返回这个可被处理的事件。event loop
。上图能够很好的帮助咱们理解在一个单线程的应用程序中使用同步的时间多路复用器和非阻塞I/O
实现并发。咱们可以看到,只使用一个线程并不会影响咱们处理多个I/O
任务的性能。同时,咱们看到任务是在单个线程中随着时间的推移而展开的,而不是分散在多个线程中。咱们看到,在单线程中传播的任务相对于多线程中传播的任务反而节约了线程的整体空闲时间,而且更利于程序员编写代码。在这本书中,你能够看到咱们能够用更简单的并发策略,由于不须要考虑多线程的互斥和同步问题。
在下一章中,咱们有更多机会讨论Node.js
的并发模型。
如今来讲reactor模式
,它经过一种特殊的算法设计的处理程序(在Node.js
中是使用一个回调函数表示),一旦事件产生并在事件循环中被处理,那么相关handler
将会被调用。
它的结构如图所示:
reactor模式
的步骤为:
I/O
操做。应用程序指定handler
,handler
在操做完成后被调用。提交请求到事件多路复用器是非阻塞的,其调用因此会立马返回,将执行权返回给应用程序。I/O
操做完成,事件多路复用器会将这些新事件添加到事件循环队列中。handler
被处理。handler
,是应用程序代码的一部分,handler
执行结束后执行权会交回事件循环。可是,在handler
执行时可能请求新的异步操做,从而新的操做被添加到事件多路复用器。咱们如今能够定义Node.js
的核心模式:
模式(反应器)阻塞处理I/O
到在一组观察的资源有新的事件可处理,而后以分派每一个事件对应handler
的方式反应。
每一个操做系统对于事件多路复用器有其自身的接口,Linux
是epoll
,Mac OSX
是kqueue
,Windows
的IOCP API
。除外,即便在相同的操做系统中,每一个I/O
操做对于不一样的资源表现不同。例如,在Unix
下,普通文件系统不支持非阻塞操做,因此,为了模拟非阻塞行为,须要使用在事件循环外用一个独立的线程。全部这些平台内和跨平台的不一致性须要在事件多路复用器的上层作抽象。这就是为何Node.js
为了兼容全部主流平台而
编写C语言库libuv
,目的就是为了使得Node.js
兼容全部主流平台和规范化不一样类型资源的非阻塞行为。libuv
今天做为Node.js
的I/O
引擎的底层。