《Node.js設計模式》歡迎來到Node.js平臺

counterxing發表於2019-02-16

本系列文章為《Node.js Design Patterns Second Edition》的原文翻譯和讀書筆記,在GitHub連載更新,同步翻譯版連結

歡迎關注我的專欄,之後的博文將在專欄同步:

Welcom to the Node.js Platform

Node.js 的發展

  • 技術本身的發展
  • 龐大的Node.js生態圈的發展
  • 官方組織的維護

Node.js的特點

小模組

package的形式儘可能多的複用模組,原則上每個模組的容量儘量小而精。

原則:

  • “Small is beautiful” —小而精
  • “Make each program do one thing well” —單一職責原則

因此,一個Node.js應用由多個包搭建而成,包管理器(npm)的管理使得他們相互依賴而不起衝突。

如果設計一個Node.js的模組,儘可能做到以下三點:

  • 易於理解和使用
  • 易於測試和維護
  • 考慮到對客戶端(瀏覽器)的支援更友好

以及,Don`t Repeat Yourself(DRY)複用性原則。

以介面形式提供

每個Node.js模組都是一個函式(類也是以建構函式的形式呈現),我們只需要呼叫相關API即可,而不需要知道其它模組的實現。Node.js模組是為了使用它們而建立,不僅僅是在擴充性上,更要考慮到維護性和可用性。

簡單且實用

“簡單就是終極的複雜” ————達爾文

遵循KISS(Keep It Simple, Stupid)原則,即優秀的簡潔的設計,能夠更有效地傳遞資訊。

設計必須很簡單,無論在實現還是介面上,更重要的是實現比介面更簡單,簡單是重要的設計原則。

我們做一個設計簡單,功能完備,而不是完美的軟體:

  • 實現起來需要更少的努力
  • 允許用更少的速度進行更快的運輸資源
  • 具有伸縮性,更易於維護和理解
  • 促進社群貢獻,允許軟體本身的成長和改進

而對於Node.js而言,因為其支援JavaScript,簡單和函式、閉包、物件等特性,可取代複雜的物件導向的類語法。如單例模式和裝飾者模式,它們在物件導向的語言都需要很複雜的實現,而對於JavaScript則較為簡單。

介紹Node.js 6 和 ES2015的新語法

let和const關鍵字

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);

使用letconst關鍵字,可以讓程式碼更安全,如果意外的訪問另一個作用域的變數,更容易發現錯誤。

使用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是原型繼承的語法糖,對於來自傳統的面嚮物件語言的所有開發人員(如JavaC#)來說更熟悉,新語法並沒有改變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語法來說,最有意義的是extendssuper關鍵字。

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;
  },
};

key的計算屬性

const namespace = `-webkit-`;
const style = {
  [namespace + `box-sizing`]: `border-box`,
  [namespace + `box-shadow`]: `10px 10px 5px #888`,
};

新的定義getter和setter方式

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方法。

模板字串

其它ES2015語法

reactor模式

reactor模式Node.js非同步程式設計的核心模組,其核心概念是:單執行緒非阻塞I/O,通過下列例子可以看到reactor模式Node.js平臺的體現。

I/O是緩慢的

在計算機的基本操作中,輸入輸出肯定是最慢的。訪問記憶體的速度是納秒級(10e-9 s),同時訪問磁碟上的資料或訪問網路上的資料則更慢,是毫秒級(10e-3 s)。記憶體的傳輸速度一般認為是GB/s來計算,然而磁碟或網路的訪問速度則比較慢,一般是MB/s。雖然對於CPU而言,I/O操作的資源消耗並不算大,但是在傳送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。在這種機制下,後續程式碼塊不會等到I/O請求資料的返回之後再執行。如果當前時刻所有資料都不可用,函式會先返回預先定義的常量值(如undefined),表明當前時刻暫無資料可用。

例如,在Unix作業系統中,fcntl()函式操作一個已存在的檔案描述符,改變其操作模式為非阻塞I/O(通過O_NONBLOCK狀態字)。一旦資源是非阻塞模式,如果讀取檔案操作沒有可讀取的資料,或者如果寫檔案操作被阻塞,讀操作或寫操作返回-1EAGAIN錯誤。

非阻塞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模式

現在來說reactor模式,它通過一種特殊的演算法設計的處理程式(在Node.js中是使用一個回撥函式表示),一旦事件產生並在事件迴圈中被處理,那麼相關handler將會被呼叫。

它的結構如圖所示:

reactor模式的步驟為:

  • 應用程式通過提交請求到時間多路複用器產生一個新的I/O操作。應用程式指定handlerhandler 在操作完成後被呼叫。提交請求到事件多路複用器是非阻塞的,其呼叫所以會立馬返回,將執行權返回給應用程式。
  • 當一組I/O操作完成,事件多路複用器會將這些新事件新增到事件迴圈佇列中。
  • 此時,事件迴圈會迭代事件迴圈佇列中的每個事件。
  • 對於每個事件,對應的handler被處理。
  • handler,是應用程式程式碼的一部分,handler執行結束後執行權會交回事件迴圈。但是,在handler 執行時可能請求新的非同步操作,從而新的操作被新增到事件多路複用器。
  • 當事件迴圈佇列的全部事件被處理完後,迴圈會在事件多路複用器再次阻塞直到有一個新的事件可處理觸發下一次迴圈。

我們現在可以定義Node.js的核心模式:

模式(反應器)阻塞處理I/O到在一組觀察的資源有新的事件可處理,然後以分派每個事件對應handler的方式反應。

OS的非阻塞I/O引擎

每個作業系統對於事件多路複用器有其自身的介面,LinuxepollMac OSXkqueueWindowsIOCP API。除外,即使在相同的作業系統中,每個I/O操作對於不同的資源表現不一樣。例如,在Unix下,普通檔案系統不支援非阻塞操作,所以,為了模擬非阻塞行為,需要使用在事件迴圈外用一個獨立的執行緒。所有這些平臺內和跨平臺的不一致性需要在事件多路複用器的上層做抽象。這就是為什麼Node.js為了相容所有主流平臺而
編寫C語言庫libuv,目的就是為了使得Node.js相容所有主流平臺和規範化不同型別資源的非阻塞行為。libuv今天作為Node.jsI/O引擎的底層。

相關文章