NodeJS Design Patterns #3

API 設計同步或非同步?誰說你可以不做選擇!

論控制流程中的混亂邪惡──是誰解放了 Z̐̓̉̇͠a̓̿͝l̈́͋̄͡g̽̊͊̏̚͠o͛̋́͡

Kevin Cheng
Cow Say

--

儘管我們口口聲聲嚷著「小孩子才做選擇,我全都要」,長大之後才會發現該做的選擇不減反增。任性是小孩子才擁有的權利,而身為一個成熟的軟體工程師,就應該學會審慎評估每個選擇,並做出一個風險最小、效益最大的決定。

當我們在設計一個 API 方法的行為時,最好打從一開始就決定好它將是同步或是非同步的方法,沒有中間情況,也就是別讓一個方法在某些情況下是非同步而在某些情況下又是同步的。

問題的關鍵是程式風格的一致性,不一致的行為將招來誤解和誤用──甚至很可能連自己都被自己的程式碼騙了。這種行為上的錯由於不會造成系統崩潰,反而是最難防範的錯誤。

以 CPS 為例說明問題

有了這樣的認知之後,我們可以先來看看這段程式碼。各位可以在閱讀完程式碼之後,試著說明這段程式碼會有什麼執行結果、有無什麼問題。

const fs = require('fs')const cache = new Map()function cacheableReadFile (filename, callback) {
if (cache.has(filename)) {
callback(null, cache.get(filename))
return
}
fs.readFile(filename, (error, buffer) => {
if (error) {
callback(error)
return
}

// 對 buffer 做一些處理
const data = buffer.toString().trim()
cache.set(filename, data)
callback(null, data)
})
}
function createFileReader (filename) {
const readyHandlers = []
cacheableReadFile(filename, (error, data) => {
if (error) {
console.error(error)
return
}
readyHandlers.forEach(h => h(data))
})
return (fn) => { readyHandlers.push(fn) }
}
(() => {
console.log('start')
// 假設檔案 './example.txt' 存在
const filename = './example.txt'
const onReady1 = createFileReader(filename)
onReady1(() => {
console.log('ready 1')
const onReady2 = createFileReader(filename)
onReady2(() => {
console.log('ready 2')
})
})
console.log('end')
})()

在回答這段程式碼有什麼問題之前,先把幾個重點帶到:

Continuous-Passing Style(CPS)

是一種傳遞方法執行結果的方式,結果會透過呼叫參數中的回調方法傳遞,如範例中的 cacheableReadFile 方法。有別於傳統的命令式程式設計(Imperative programming)透過關鍵字 return 將結果返回上一層,傳統的方式稱為 Direct Style。

反過來說,並非所有參數裡面包含回調的方法都是 CPS。舉個例子, Array.prototype.map 雖然也是提供一個回調方法作為參數,但這個回調的目的並非傳遞計算結果(每個元素都經過相同演算法計算的新陣列),而是用於定義新元素的演算法。

CPS 在另一方面也為 JavaScript 帶來一個維護上的成本,便是回調地獄(Callback Hell)的出現,多層次巢狀的 CPS 導致程式碼結構過深難以維護,如今已有許多解套方式,最簡單的方式,例如不要使用匿名方法而是把回調在外部區塊宣告成一個變數,然後將變數在方法呼叫的參數中使用,另外還可以使用 async/await 解套。

NodeJS 回調慣例

在 NodeJS 中的 CPS 有個慣例,這個慣例有兩個規則組成,分別是:

  • 回調方法會放在最後一個參數
  • 回調方法本身的第一個參數可能會是一個 Error 物件或是 null,第二個以後的參數才是執行結果

其實這樣的慣例在 Runtime 並沒有什麼特殊考量,倒是提供開發者之間一個共識,凝聚了共識才可以增加開發效率,也方便後續作介面風格的轉換,例如若要轉成 async 方法,則可以透過 promisify 一類的工具幫忙,promisify 的運作原理就是依賴於這項回調慣例。

回到前面的範例程式碼,如果實際跑一下就會發現 ready 2 並沒有被印出來,也就是提供給 onReady2 的回調沒有被執行,為什麼呢?

原因是 cacheableReadFile 方法中的 callback 在第一次讀檔的時候是非同步執行的,但因為我們給它做了快取,在第二次讀檔的時候因為發現了快取而會同步呼叫 callback ,等到第二次 cacheableReadFile 返回時,callback 已經呼叫完畢,但當下 readyHandlers 內都還是空的!此後再用 onReady 註冊回調至 readyHandlers 也都不會被執行,就如同錯過的時光不再回頭。

這個問題的根源來自於,CPS 通常都是用來解決非同步的議題,如果是同步的動作直接 Direct Style 反而更加直觀。因此 createFileReader 的設計便是依賴了非同步這一點,才得以在 createFileReader 返回之後還可以繼續註冊回調至 readyHandlers

結論,若要解決問題,我們應該將 cacheableReadFile 以非同步的方式實現。

process.nextTick 修正

我們可以利用 process.nextTick 這個方法來實現,這也是這個方法設計出來的初衷之一,因為我們確信 callback 必須在事件迴圈當前的階段之後才被執行,因此使用 process.nextTickcallback 註冊至 nextTick 佇列上,實現非同步的行為。

將範例中的 cacheableReadFile 修改後如下:

function cacheableReadFile (filename, callback) {
if (cache.has(filename)) {
const data = cache.get(filename)
process.nextTick(() => callback(null, data))
return
}
fs.readFile(filename, (error, buffer) => {
if (error) {
callback(error)
return
}

// 對 buffer 做一些處理
const data = buffer.toString().trim()
cache.set(filename, data)
callback(null, data)
})
}

以 EventEmitter 為例說明問題

除了 CPS 以外,EventEmitter 也可能有這樣的問題發生!

可以參考以下程式碼:

const { EventEmitter } = require('events')
const fs = require
class FileReader extends EventEmitter {
constructor () {
super()
this.files = []
this.cache = new Map()
this.emit('init')
}
addFile (filename) {
this.files.push(filename)
this.emit('add', filename)
return this
}
read () {
this.files.forEach(filename => {
if (this.cache.has(filename)) {
const data = this.cache.get(filename)
this.emit('data', filename, data)
return
}
fs.readFile(filename, (error, buffer) => {
if (error) {
this.emit('error', error)
return
}
const data = buffer.toString().trim()
this.cache.set(filename, data)
this.emit('data', filename, data)
})
})
return this
}
}
(() => {
const reader = new FileReader()
.addFile(filename2)
.read()
.on('init', () => console.log('init'))
.on('add', f => console.log('add', f))
.on('data', f => console.log('data', f))
.on('error', error => console.error(error))
})()

如同範例中,initadd 事件處理器皆沒有被觸發,因為它們事件是同步發布的,然而在 EventEmitter 中,慣例會將 API 設計為 Chainable 的風格,因此我們不得不預期 readaddFile 函數會在任何監聽器註冊之前呼叫,而導致錯過了事件發布。

同樣的,我們也可以使用 process.nextTick 將同步的事件發布(this.emit 的部分)作為非同步呼叫。

被釋放的渾沌惡魔──Z͔͚̞̰̳̙̭̜̗̞̩͑̌̈́͑ã͙̭̯̬͈̠͇̰̩̑̑͛̅̃̿̊̊̈́͐l̗͖̣̦̖̠̤̠͈̠̞̂̽́̓̀̎̌̅̑̔g̪̤̭͕̝̝͉̟̟̭͊̿̓̓̈͋̎ͅͅǒ̮̲͓̫̪͍͈̗̠̰͑̇͐̽͌̒

zalgo是虛擬角色,代表著網絡世界的破懷王、惡魔,右手托著死亡星球,左手握著陰影之光的蠟燭,臉上還有七張嘴巴,只要是被它汙染的文字圖片就會出現詭異的文字。當zalgo接近時,周邊的人們會眼神呈現黑洞並嘴裡說著:「He comes!」

https://tw.piliapp.com/cool-text/zalgo-text/

Zalgo 是在 NodeJS 的社群裡面指的就是前述 cacheableReadFile 這種行為不穩定、無法預測而導致問題的方法。可參考 npm 的作者 Isaac Z. Schlueter 在 2013 年的文章「Designing APIs for Asynchrony」。

在程式設計中,Zalgo 肯定是一個混亂邪惡的角色,而混亂的根源,追根究柢是因為當初在設計程式行為的時候,沒有堅守風格一致的原則,導致設計出來的方法與慣例不符,開發者之間缺乏共識造成誤用,而誤把 Zalgo 釋放出來!

--

--

Kevin Cheng
Cow Say
Editor for

貓奴 / 後端工程師 / 人生最重要的四件事:溫柔、勇敢、自由、浪漫