釐清 Express.js 和 Koa.js 的設計差異

Kevin Cheng
Cow Say
Published in
10 min readJun 24, 2021
Koa──次世代的 Node.js 網頁框架
Koa.js ──次世代 Node.js 網頁框架(圖片截自官網

Koa.js 官網就可以清楚感受到這個號稱「次世代 Node.js 網頁框架」,它所傳遞的設計概念──優雅和極簡。

設計差異

Koa.js 與 Express.js 師出同門,背後擁有一樣的開發團隊。借鑒於 Express.js 的設計經驗,重新推出一套 HTTP 服務框架。在這套新框架中,團隊重新定位了核心概念,以優雅和極簡的新風格重新出發。

「優雅」

優雅說的是對 JavaScript 生態中回調地獄(Callback Hell)的反省。在 Koa.js 第一版的 API 配合 co ── 一套基於 Generator 方法的控制流程模組 ──使用 Generator 方法和 Promise 物件作為 API 控制流程的核心主軸,在第二版則更進一步使用 async/await 取代了 Generator 方法。

「優雅」這個概念似乎有點抽象,我們為什麼需要在乎程式碼優不優雅?

優雅背後表示的是錯誤處理以及規避風險的能力,所謂的風險來源例如程式碼的可讀性和維護成本。

在 Koa.js 第二版中,我們得益於 async/await,因此可以直覺地利用 JavaScript 原生的 try/catch 語法去進行錯誤處理,而不同於生態中習以為常的 CPS(Continuous-Passing Style)和 Node-Style Callback 方式傳遞錯誤。

我們不得不承認生態始終還是在語言之外,這份無奈就好像先天血統之於後天努力一樣,相對於 CPS 和 Node-Style Callback,try/catch 就是原生的特性,因此出錯的機會相對更低。

「極簡」

另一個概念是極簡,在 Koa.js 的實現上來說,便是它的核心沒有任何Middleware,甚至沒有 Router 、沒有任何預設立場!── Koa.js 從不假設你的 HTTP 服務是基於路徑去區分服務的,而在 Express.js 中依照路徑做路由是它的設計核心,若沒有指明路徑,所有 Express Middleware 都預設是根目錄之下(/)。

讀到這邊你可能會想問,什麼樣的 HTTP 服務不須按路徑路由?

說起來會用路徑路由大概也是因為方便又快速的緣故,因為路徑就寫在 HTTP 訊息的第二個欄位,我想這是 HTTP 一開始設計的初衷。

把問題反過來想,會需要路由是因為我們提供了數個端點(Endpoint)──例如 Restful API ──因此需要有一個方式來區分端點;也就是說,如果從頭到尾只有一個端點,這不就不需要路由了嗎?

舉個實際一點的例子,我最近想嘗試以 Koa.js 搭配 Line Bot SDK 來重構我的 Line Bot(以後也會持續在 Medium 上更新我在這個專案上的進展)。Line Bot 這個服務就只需要一個端點,也就是我提供給 Line 的 Webhook URL。

基於 Webhook 需要具體指明一個端點的特性,機器人服務的邏輯單位就不再是端點,必須等到請求體(Body)全部讀完,依照請求體裡面的欄位進行任務分發。

在這個例子之下,如果用 Express.js 去實現,我整個伺服器就只會註冊一個路徑,如此一來便產生了這個想法──那是不是可以連路徑都別讀了,我直接等待請求體。

這就是我選擇 Koa.js 的初衷了。

Middleware 差異

但我碰上一個麻煩── Line Bot SDK 只實現了 Express.js Middleware,而 Express.js Middleware 不相容於 Koa.js Middleware。

這便是我好奇兩者 Middleware 差異的動機,我需要將 Line Bot SDK 移植到 Koa.js 來。

執行順序

兩者 Middleware 的執行順序是不一樣的。

這更牽涉到 next 方法,雖然兩者 Middleware 的方法參數裡面都有這個回調,但它們的語意不一樣。對於兩者都有 next 方法,我認為更好的理解是它們只是撞名而已,並不存在任何互相指涉的關聯性。

我曾讀過這樣的說法:

Express.js 的 Middleware 是直線型的執行順序;Koa.js 則是洋蔥型

Express.js 可以想作一個 FIFO Queue,先註冊的 Middleware 先執行;Koa.js 則是一個 Stack,按註冊順序正序進去,再倒序出來(順帶一提,Python ASGI Middleware 也採用了這樣的設計)。

從執行順序上我們可以看到,在接到請求時,兩者一樣都按註冊順序呼叫 Middleware,但 Express.js 正序呼叫完之後便結束了,而 Koa.js 則會反向再進行一次,這個用意何在呢?

洋蔥型的執行順序就好比程式碼 Scope 的概念,Middleware 之間出現了階層關係,越外層的 Middleware 所處理的任務越通用,越內層的 Middleware 處理的任務越專精。

舉個例子,錯誤紀錄的 Middleware 就會放在最外層,因為要 try/catch 所有錯誤;而決定響應訊息的 Middleware,通常正是應用服務的邏輯,就會放在最內層。

除了層級以外,另一個理解是為了實現 Middleware 之間民主式的合作模式,因為你發起的議題唯有控制權輪回到你才可能做出結論。反觀 Express.js 就是獨裁式,控制權每個 Middleare 只會傳遞一次,下好離手。

舉個簡單的例子,Kao.js 可以這麼寫:

const Kao = require('koa')
const numeral = require('numeral')
const app = new Kao()
app.use(async (ctx, next) => {
// 初始化 score
let score = 0
ctx.addScore = value => { score += value }
await next() // 渲染 score
ctx.body = 'Score: ' + numeral(score).format('0,0')
})
app.use(async (ctx, next) => {
ctx.addScore(await getScoreFromPlace1())
await next()
})
app.use(async (ctx, next) => {
ctx.addScore(await getScoreFromPlace2())
await next()
})

像這樣的好處大概就是模組化,第一個 Middleware 負責初始化 score、提供存取 score 的方法,並在其他 Middleware 之後對 score 進行結算和渲染;第二個 Middleware 負責 Place1 相關的分數計算,第三個負責 Place2 相關的分數計算。諸如此類的分工方式。

next() 和錯誤傳遞

在 Express.js 中, next() 用於呼叫下一個 Middleware 和傳遞錯誤兩種情境,差別在於 next() 的第一個參數有無 Error 物件。Express.js Middleware 在執行結束前一定會有以下三種動作任一:

  1. 透過 next(err) 傳遞錯誤
  2. next() 暗示 Middleware 執行結束進行下一個 Middleware

3. 使用 res 物件上的函數進行響應(如 res.send()res.sendFile() 或是 res.json() 等等)

在 Middleware 中如果有 next() 呼叫,一定是當前 Middleware 中的最後一個邏輯流程,因為 next() 與 return 在 Express.js Middleware 中象徵了控制權結束。

在 Koa.js 的 next() 則象徵了 Stack 後方其他 Middleware 的完整執行期間,因此我們得以透過 await next() 等待內層 Middleware 全數執行完畢並返回。如果有 next() 呼叫,它只是當前 Middleware 的中場,可能還會有後續的響應處理。在 Koa.js 中只有 return 象徵控制權結束。

至於錯誤只要直接透過原生語法 throw 往外層拋出即可。(這也是 Koa.js 強調的、錯誤處理的好處,任何外層 Middleware 都有機會接住、處理錯誤)

方法簽章

另一個 Express.js 實現上容易出錯的部分,便是因為它依賴方法簽章(Function Signature)來區別 Middleware 的種類,說得更精確一點,是依賴方法參數表的長度(Function.length)。

在 Express.js 中有兩種 Middleware:

  • 一般 Middleware(參數表記作 (req, res, next)
  • 錯誤處理 Middleware(參數表記作 (error, req, res, next)

但 JavaScript 的特性之一,便是方法宣告的參數數目可與呼叫時不一致,直譯器會在方法呼叫當下按參數宣告順序帶入參數,若宣告參數多於實際上呼叫時代入的參數,則剩餘的被填入 undefined;若實際上呼叫時多給了參數,則會被忽略。

JavaScript 的開發者(如我)很懶惰,能盡量少填參數就少填參數。

因此懶惰如我在第二種錯誤處理 Middleware 宣告時,因為我的 Middleware 一定會搞清楚狀況,因此用不著呼叫 next() 將控制權讓出,所以我就索性參數表不宣告最後一個 next 參數了,此時,我的方法簽章看起來就會像是這樣:(error, req, res)

在 Express.js 端,它只能依賴 Function.length 的值去判斷,滿足條件 fn.length === 4 者才會是錯誤處理 Middleware,但在懶惰如我的錯誤處理 Middleware 宣告中,fn.length === 3 導致 Express.js 誤判,把我的 Middleware 誤當了一般 Middleware,自然帶入的值意義就會不一樣,造成未知的錯誤。

Express.js 之所以依賴 Function.length,是因為它處理錯誤的流程與一般 Middleware 的處理階段不同,它使用了 next(error) 的方式將傳遞錯誤至另一個錯誤處理 Middleware 的流程執行,導致它必須知道哪些 Middleware 是一般的、哪些是錯誤處理的。

更明確一點的解決方式,我想會是另外設計一個有別於一般 Middleware 的註冊方式,一般 Middleware 使用 app.use() 註冊,錯誤處理 Middleware 換成用 app.catch() 也許就沒有問題了。

有鑑於此, Koa.js 不再依賴 Function.length,將方法簽章固定為 (ctx, next) 。另外,由於 Koa.js 採用洋蔥式並採用 async/await,使得錯誤處理可以很輕易地使用 try/catch 於 Middleware 中達成,因此 Koa.js 就沒有區分 Middleware 種類的必要了。

小結

Koa.js 的設計概念相當吸引我,極簡讓我輕易掌握框架核心,看完並理解核心程式碼其實不用多久時間,而且它以 async/await 所實現的優雅概念,讓程式碼追上潮流先鋒,讓潮流驅動開發(Hype-Driven Development)的小夥伴們內心都雀躍不已。

雖然「極簡」事實上是把開發者的負擔轉移到了「選擇第三方 Middleware 」上面,但這賦予了我們一個機會去檢視我們的 HTTP 服務需要什麼中間的功能,同時也是一個機會讓我們擺脫一些冗餘的處理過程(如 Line Bot 之於路徑路由),連帶提升我們程式的效率!

--

--

Kevin Cheng
Cow Say
Editor for

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