本系列文章爲《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
引擎的底層。