Node.js 設(shè)計(jì)模式筆記 —— Callbacks 和 Events

在同步式編程中,為了解決特定的問題,代碼被組織成一系列連貫的計(jì)算步驟乐横。其中每一個(gè)步驟都是阻塞的,即只有當(dāng)某個(gè)操作完成以后阳藻,才有可能繼續(xù)執(zhí)行下一個(gè)步驟晰奖。這種方式形成的代碼非常容易閱讀、理解和調(diào)試腥泥。

而在異步式編程中匾南,某些操作比如讀取文件或者處理一個(gè)網(wǎng)絡(luò)請(qǐng)求,是在“后臺(tái)”啟動(dòng)和執(zhí)行的蛔外。當(dāng)我們調(diào)用某個(gè)異步操作后蛆楞,即使其并沒有執(zhí)行完畢溯乒,該異步操作之后的代碼指令也會(huì)立刻繼續(xù)執(zhí)行。
在這種情況下豹爹,我們就需要一種“通知”機(jī)制裆悄。當(dāng)異步操作執(zhí)行完畢,我們會(huì)收到通知臂聋,獲取該操作的結(jié)果并繼續(xù)之前定義的執(zhí)行流程光稼。在 Node.js 中,最基礎(chǔ)的通知機(jī)制就是回調(diào)函數(shù)孩等。它本質(zhì)上就是一種由 runtime 調(diào)用的帶有異步操作結(jié)果的函數(shù)艾君。

Callback 模式

回調(diào)函數(shù)是一種能夠傳遞操作結(jié)果的函數(shù),正是異步編程所需要的肄方。JavaScript 對(duì)于回調(diào)函數(shù)來說是一種理想的語言冰垄,函數(shù)是第一等對(duì)象,可以輕松地賦值給變量权她、作為參數(shù)傳遞給另一個(gè)函數(shù)虹茶、作為函數(shù)的返回值,以及存儲(chǔ)到數(shù)據(jù)結(jié)構(gòu)中隅要。

The continuation-passing style

在 JavaScript 中蝴罪,回調(diào)函數(shù)會(huì)作為參數(shù)傳遞給另一個(gè)函數(shù),并且在操作完成時(shí)連同結(jié)果一起被調(diào)用步清。即執(zhí)行結(jié)果被傳遞給另一個(gè)函數(shù)(callback)洲炊,而不是直接返回給調(diào)用者。這種方式在函數(shù)式編程里稱作 continuation-passing style (CPS)尼啡。

下面是一個(gè)非常簡(jiǎn)單的同步函數(shù):

function add(a, b) {
  return a + b
}

和上述函數(shù)等效的 CPS 形式:

function addCps(a, b, callback) {
  callback(a + b)
}

console.log('before')
addCps(1, 2, result => console.log(`Result: $result`))
console.log('after')
// => before
// => Result: $result
// => after

addCps 就是一個(gè)同步的 CPS 函數(shù)。

Asynchronous CPS

addCps 函數(shù)的異步版本:

function additionAsync(a, b, callback) {
  setTimeout(() => callback(a + b), 100)
}

console.log('before')
additionAsync(1, 2, result => console.log(`Result: ${result}`))
console.log('after')
// => before
// => after
// => Result: 3

上面的代碼使用 setTimeout 來模擬回調(diào)函數(shù)的異步調(diào)用询微。由于 setTimeout 觸發(fā)的是異步操作崖瞭,它并不會(huì)等待回調(diào)函數(shù) callback 執(zhí)行,而是立即返回撑毛。將控制權(quán)交還給 additionAsync 進(jìn)而回到調(diào)用者身上书聚,執(zhí)行主程序中的第二個(gè) console.log。當(dāng)異步操作執(zhí)行完畢后藻雌,程序從之前控制權(quán)轉(zhuǎn)移時(shí)的位置起恢復(fù)執(zhí)行雌续,callback 中的 console.log 被執(zhí)行。

Control flow of an asynchronous function's invocation

總結(jié)一下就是胯杭,同步函數(shù)會(huì)阻塞其他操作步驟驯杜,直到其自身執(zhí)行完畢;異步函數(shù)會(huì)立即返回做个,它的執(zhí)行結(jié)果會(huì)在 event loop 的后續(xù)周期中傳遞給 handler(即回調(diào)函數(shù))鸽心。

同步 or 異步

指令的執(zhí)行順序取決于函數(shù)的自然屬性——同步還是異步滚局,這對(duì)于整個(gè)應(yīng)用流程的正確性和效率都有很大的影響。所以需要時(shí)刻注意避免制造矛盾和困惑顽频。

Unleashing Zalgo

一個(gè) API 最危險(xiǎn)的情形之一藤肢,就是有些時(shí)候表現(xiàn)為同步另一些情況下表現(xiàn)為異步。

import {readFile} from 'fs'

const cache = new Map()

function inconsistentRead(filename, cb) {
  if (cache.has(filename)) {
    // invoked synchronously
    cb(cache.get(filename))
  } else {
    // asynchronous function
    readFile(filename, 'utf8', (err, data) => {
      cache.set(filename, data)
      cb(data)
    })
  }
}

上述程序就是危險(xiǎn)的糯景。假如某個(gè)文件是第一次被讀取嘁圈,它會(huì)表現(xiàn)為異步操作,讀取文件設(shè)置緩存蟀淮;當(dāng)某個(gè)文件的內(nèi)容已經(jīng)存在于緩存中時(shí)最住,它會(huì)表現(xiàn)為同步操作。

參考下面的示例:

function createFileReader(filename) {
  const listeners = []
  inconsistentRead(filename, value => {
    listeners.forEach(listener => listener(value))
  })

  return {
    onDataReady: listener => listeners.push(listener)
  }
}

const reader1 = createFileReader('data.txt')
reader1.onDataReady(data => {
  console.log(`First call data: ${data}`)

  const reader2 = createFileReader('data.txt')
  reader2.onDataReady(data => {
    console.log(`Second call data: ${data}`)
  })
})

其中 createFileReader 函數(shù)會(huì)創(chuàng)建一個(gè)新的 { onDataReady: function() } 對(duì)象作為通知器灭贷,以幫助我們?yōu)槲募x取操作設(shè)置多個(gè) listener温学。若 inconsistentRead 是純異步操作,實(shí)際上 onDataReady 會(huì)先被調(diào)用甚疟,將傳入的 listener 添加到 listeners 列表中仗岖。之后 inconsistentRead 讀取文件內(nèi)容完畢,回調(diào)函數(shù) cb 執(zhí)行览妖,遍歷 listeners 列表并將讀取到的文件內(nèi)容傳給 listener轧拄。

實(shí)際的執(zhí)行結(jié)果為:

First call data: some data

第二次讀取同一個(gè)文件并沒有獲取到任何內(nèi)容。

原因在于讽膏,當(dāng) reader1 創(chuàng)建時(shí)檩电,inconsistentRead 函數(shù)表現(xiàn)為異步的,因?yàn)樵撐募堑谝淮伪蛔x取府树。因而 onDataReady 會(huì)在剛開始讀取文件時(shí)就將傳入的 listener 添加到 listeners 列表中俐末。文件讀取完畢后 listeners 中注冊(cè)的 listener 被調(diào)用。
reader2 創(chuàng)建時(shí)同一個(gè)文件的緩存內(nèi)容已經(jīng)存在奄侠,inconsistentRead 表現(xiàn)為同步的卓箫。它的回調(diào)函數(shù)會(huì)立即調(diào)用,遍歷 listeners 列表垄潮。然而我們是先創(chuàng)建的 reader2 再添加的 listener烹卒,這就導(dǎo)致遍歷 listeners 列表時(shí),向 listeners 添加 listener 的操作還沒有執(zhí)行弯洗,我們傳入的 listener 并沒有來得及注冊(cè)旅急。

在實(shí)際的應(yīng)用中,上述類型的 bug 會(huì)非常難以定位和復(fù)現(xiàn)牡整。npm 的創(chuàng)造者 Isaac Z. Schlueter 將類似的使用不可預(yù)測(cè)函數(shù)的行為藐吮,叫做 unleashing Zalgo

使用同步 API

想修復(fù)前面的 inconsistentRead 函數(shù),一種可能的方案就是令其徹底變成同步的炎码。實(shí)際上 Node.js 針對(duì)基礎(chǔ)的 I/O 操作提供了一系列同步的 API盟迟。比如 fs.readFileSync

import {readFileSync} from 'fs'

const cache = new Map()

function consistentReadSync(filename) {
  if (cache.has(filename)) {
    return cache.get(filename)
  } else {
    const data = readFileSync(filename)
    cache.set(filename, data)
    return data
  }
}

但是潦闲,使用同步 API 而不是異步 API 也有一定的風(fēng)險(xiǎn):

  • 針對(duì)特定功能的同步 API 有可能不存在
  • 同步 API 會(huì)阻塞 event loop攒菠,暫停任何并發(fā)請(qǐng)求。從而破壞 Node.js 的并發(fā)模型并拖慢整個(gè)應(yīng)用

在很多情況下歉闰,使用同步 I/O 操作在 Node.js 里都是非常不推薦的辖众。但在一些場(chǎng)景下,同步 I/O 可能是最簡(jiǎn)單和高效的方案和敬。比如在應(yīng)用啟動(dòng)時(shí)使用同步阻塞 API 加載配置文件凹炸。

通過延遲執(zhí)行保證異步性

另一種修復(fù) inconsistentRead 函數(shù)的方案就是,將其變成純異步操作昼弟。訣竅就是將同步的回調(diào)函數(shù)延期到“未來”執(zhí)行啤它,而不是在同一個(gè) event loop 周期里立即被調(diào)用。
在 Node.js 中舱痘,可以通過 process.nextTick() 來實(shí)現(xiàn)变骡。它會(huì)接收一個(gè)回調(diào)函數(shù)作為參數(shù),將其推入到事件隊(duì)列頂部芭逝,位于所有 pending 的 I/O 事件之前塌碌,然后立即返回⊙ⅲ回調(diào)函數(shù)會(huì)在 event loop 再次收回控制權(quán)時(shí)立即被調(diào)用台妆。

import {readFile} from 'fs'

const cache = new Map()

function inconsistentRead(filename, callback) {
  if (cache.has(filename)) {
    // deferred callback invocation
    process.nextTick(() => callback(cache.get(filename)))
  } else {
    // asynchronous function
    readFile(filename, 'utf8', (err, data) => {
      cache.set(filename, data)
      callback(data)
    })
  }
}

Node.js 回調(diào)函數(shù)的最佳實(shí)踐

回調(diào)函數(shù)出現(xiàn)在最后

在所有核心的 Node.js 函數(shù)中,當(dāng)其接收一個(gè)回調(diào)函數(shù)作為輸入時(shí)胖翰,回調(diào)函數(shù)必須作為最后一個(gè)參數(shù)傳入接剩。

readFile(filename, [options], callback)
error 總是出現(xiàn)在前面

在 Node.js 中,任何 CPS 函數(shù)產(chǎn)生的錯(cuò)誤都必須作為回調(diào)函數(shù)的第一個(gè)參數(shù)傳遞萨咳,任何實(shí)際的執(zhí)行結(jié)果都從第二個(gè)參數(shù)開始搂漠。

readFile('foo.txt', 'utf8', (err, data) => {
  if (err) {
    handleError(err)
  } else {
    processData(data)
  }
})

最佳實(shí)踐還在于總是檢查 error 是否存在,以及 error 的定義必須是 Error 類型某弦。

傳遞 error

在同步的函數(shù)中,傳遞 error 可以通過常用的 throw 語句而克。而在異步的 CPS 函數(shù)中靶壮,則可以簡(jiǎn)單地將 error 傳遞給鏈條上的下一個(gè)回調(diào)函數(shù)。

import {readFile} from 'fs'

function readJSON(filename, callack) {
  readFile(filename, 'utf8', (err, data) => {
    let parsed
    if (err) {
      // propagate the error and exit the current function
      return callack(err)
    }

    try {
      // parse the file contents
      parsed = JSON.parse(data)
    } catch (err) {
      // catch parsing errors
      return callack(err)
    }
    // no errors, propagate just the data
    callack(null, parsed)
  })
}

觀察者模式

在 Node.js 中另外一種非常重要和基礎(chǔ)的模式就是觀察者(Ovserver)模式员萍。同 Reactor 模式腾降、回調(diào)函數(shù)一起,它們都是掌握 Node.js 異步編程的絕對(duì)要求碎绎。
觀察者模式定義了一類稱為 subject 的對(duì)象螃壤,它們可以在狀態(tài)改變時(shí)向一系列稱為觀察者的對(duì)象發(fā)送通知抗果。它是對(duì)回調(diào)函數(shù)的完美補(bǔ)充。主要區(qū)別在于 subject 能夠通知多個(gè)觀察者奸晴,而傳統(tǒng)的 CPS 回調(diào)函數(shù)通常只會(huì)將結(jié)果傳遞給一個(gè) listener冤馏。

EventEmitter

觀察者模式實(shí)際上已經(jīng)通過 EventEmitter 類內(nèi)置到 Node.js 的核心中了。EventEmitter 類允許我們注冊(cè)一個(gè)或者多個(gè)函數(shù)作為 listener寄啼,這些 listener 會(huì)在特定的事件觸發(fā)時(shí)自動(dòng)被調(diào)用逮光。

Listeners receiving events from an EventEmitter

EventEmitter 類的基礎(chǔ)方法如下:

  • on(event, listener):該方法允許我們?yōu)橹付ǖ氖录愋停ㄒ粋€(gè)字符串)注冊(cè)一個(gè)新的 listener(一個(gè)函數(shù))
  • once(event, listener):該方法允許我們注冊(cè)一個(gè)新的 listener,并且該 listener 會(huì)在事件觸發(fā)一次之后自動(dòng)被移除
  • emit(event, [arg1], [...]):該方法會(huì)產(chǎn)生一個(gè)新的事件墩划,并向指定向 listeners 傳遞的額外的參數(shù)
  • removeListener(event, listener):該方法用來移除某個(gè) listener

上述所有的方法都會(huì)返回一個(gè) EventEmitter 實(shí)例并允許被串聯(lián)起來涕刚。

創(chuàng)建和使用 EventEmitter
import {EventEmitter} from 'events'
import {readFile} from 'fs'

function findRegex(files, regex) {
  const emitter = new EventEmitter()
  for (const file of files) {
    readFile(file, 'utf8', (err, content) => {
      if (err) {
        return emitter.emit('error', err)
      }

      emitter.emit('fileread', file)
      const match = content.match(regex)
      if (match) {
        match.forEach(elem => emitter.emit('found', file, elem))
      }
    })
  }
  return emitter
}

findRegex(['fileA.txt', 'fileB.json'], /hello \w+/g)
  .on('fileread', file => console.log(`${file} was read`))
  .on('found', (file, match) => console.log(`Matched "${match}" in ${file}`))
  .on('error', err => console.error(`Error emitted ${err.message}`))
令任意對(duì)象變得“可監(jiān)測(cè)”

在 Node.js 的世界里,EventEmitter 很少像上面的例子那樣被直接使用乙帮。更為常見的情況是其他類繼承 EventEmitter 從而變成一個(gè)可監(jiān)測(cè)的對(duì)象杜漠。

import {EventEmitter} from 'events'
import {readFile} from 'fs'

class FindRegex extends EventEmitter {
  constructor(regex) {
    super()
    this.regex = regex
    this.files = []
  }

  addFile(file) {
    this.files.push(file)
    return this
  }

  find() {
    for (const file of this.files) {
      readFile(file, 'utf8', (err, content) => {
        if (err) {
          return this.emit('error', err)
        }

        this.emit('fileread', file)

        const match = content.match(this.regex)
        if (match) {
          match.forEach(elem => this.emit('found', file, elem))
        }
      })
    }
    return this
  }
}

const findRegexInstance = new FindRegex(/hello \w+/g)
findRegexInstance
  .addFile('fileA.txt')
  .addFile('fileB.json')
  .find()
  .on('found', (file, match) => console.log(`Matched "${match}" in file ${file}`))
  .on('error', err => console.error(`Error emitted ${err.message}`))

EventEmitter vs Callback

以下的幾點(diǎn)可以作為選擇 EventEmitter 還是 Callback 的依據(jù):

  • 當(dāng)涉及到需要支持不同類型的事件時(shí),Callback 會(huì)有一定的限制察净。實(shí)際上 Callback 也可以區(qū)分多個(gè)事件驾茴,只需要將事件類型作為參數(shù)傳給回調(diào)函數(shù),或者接收多個(gè)回調(diào)函數(shù)塞绿。但在這樣的情況下沟涨,EventEmitter 可以提供更優(yōu)雅的接口和更精簡(jiǎn)的代碼
  • 當(dāng)同樣的事件可能多次發(fā)生或者根本不會(huì)發(fā)生時(shí),應(yīng)該使用 EventEmitter异吻。而無論操作是否成功裹赴,回調(diào)函數(shù)都只會(huì)被調(diào)用一次
  • 回調(diào)函數(shù)機(jī)制只支持通知一個(gè)特定的 listener,而 EventEmitter 允許我們?yōu)橥粋€(gè)事件注冊(cè)多個(gè) listener

參考資料

Node.js Design Patterns: Design and implement production-grade Node.js applications using proven patterns and techniques, 3rd Edition

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末诀浪,一起剝皮案震驚了整個(gè)濱河市棋返,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌雷猪,老刑警劉巖睛竣,帶你破解...
    沈念sama閱讀 221,198評(píng)論 6 514
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異求摇,居然都是意外死亡射沟,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,334評(píng)論 3 398
  • 文/潘曉璐 我一進(jìn)店門与境,熙熙樓的掌柜王于貴愁眉苦臉地迎上來验夯,“玉大人,你說我怎么就攤上這事摔刁』幼” “怎么了?”我有些...
    開封第一講書人閱讀 167,643評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長绑谣。 經(jīng)常有香客問我党窜,道長,這世上最難降的妖魔是什么借宵? 我笑而不...
    開封第一講書人閱讀 59,495評(píng)論 1 296
  • 正文 為了忘掉前任幌衣,我火速辦了婚禮,結(jié)果婚禮上暇务,老公的妹妹穿的比我還像新娘泼掠。我一直安慰自己,他們只是感情好垦细,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,502評(píng)論 6 397
  • 文/花漫 我一把揭開白布择镇。 她就那樣靜靜地躺著,像睡著了一般括改。 火紅的嫁衣襯著肌膚如雪腻豌。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,156評(píng)論 1 308
  • 那天嘱能,我揣著相機(jī)與錄音吝梅,去河邊找鬼。 笑死惹骂,一個(gè)胖子當(dāng)著我的面吹牛苏携,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播对粪,決...
    沈念sama閱讀 40,743評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼右冻,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了著拭?” 一聲冷哼從身側(cè)響起纱扭,我...
    開封第一講書人閱讀 39,659評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎儡遮,沒想到半個(gè)月后乳蛾,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,200評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡鄙币,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,282評(píng)論 3 340
  • 正文 我和宋清朗相戀三年肃叶,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片十嘿。...
    茶點(diǎn)故事閱讀 40,424評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡因惭,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出详幽,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 36,107評(píng)論 5 349
  • 正文 年R本政府宣布唇聘,位于F島的核電站版姑,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏迟郎。R本人自食惡果不足惜剥险,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,789評(píng)論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望宪肖。 院中可真熱鬧表制,春花似錦、人聲如沸控乾。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,264評(píng)論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽蜕衡。三九已至壤短,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間慨仿,已是汗流浹背久脯。 一陣腳步聲響...
    開封第一講書人閱讀 33,390評(píng)論 1 271
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留镰吆,地道東北人帘撰。 一個(gè)月前我還...
    沈念sama閱讀 48,798評(píng)論 3 376
  • 正文 我出身青樓,卻偏偏與公主長得像万皿,于是被迫代替她去往敵國和親摧找。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,435評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容