智聯招聘的大前端Ada提供的Web服務器能夠同時運行在服務器端及本機開發環境,其內核是Web框架Koa。Koa以其對異步編程的良好支持而聲名在外,而一樣讓人稱道的還有它的中間件機制。本質上,Koa實際上是一箇中間件運行時,幾乎全部實際功能都是經過中間件的形式註冊和實現的。javascript
Ada從1.0.0版本開始引入了獨立的@zpfe/koa-middleware模塊,用於維護Web服務中所需的中間件。該模塊單獨導出了全部的中間件,Web服務能夠按需自行註冊(use
)。隨着功能不斷完善,該模塊中逐漸累積了十數箇中間件。@zpfe/koa-middleware模塊的使用方式大概以下所示:前端
const app = new Koa() app.use(middleware1) app.use(middleware2) // ... app.use(middlewareN)
中間件之間隱式約定了執行順序,但卻把執行順序的控制交給了兩個使用方(渲染服務和API服務),這就意味着使用方必須知道每一箇中間件的技術細節,此爲「壞味道」之一。java
下圖展現了使用方與中間件的耦合狀況:編程
Koa中間件體系是一個洋蔥形結構,每個中間件均可以看作洋蔥的一層皮。最早註冊的位於最外層,最後註冊的位於最內層。在執行時,會從最外層依次執行到最內層,再倒序依次執行回最外層。下圖展現了Koa中間件的執行方式:segmentfault
每一箇中間件都有兩次可被執行的機會,而在咱們的場景中,大多數中間件實際上只有一段邏輯。隨着中間件的數量膨脹,完整的執行軌跡變得過於複雜,增長了調試和理解的成本,此爲「壞味道」之二。服務器
基於以上緣由,咱們決定對@zpfe/koa-middleware模塊進行重構,進一步提升其易用性、內聚性和可維護性。app
首先逐個分析一下@zpfe/koa-middleware所導出的功能和使用狀況,會發現以下模式:框架
這意味着咱們能夠收回中間件的註冊權,並容許使用方經過參數來控制個別中間件的開啓關閉狀態、參數、甚至實現。還能夠將非中間件功能直接抽離爲新的模塊。koa
接下來觀察這些中間件的執行順序,會發現它們能夠歸屬於幾種不一樣的類型:異步
x-zp-request-id
和日誌功能);進一步分析每個分類所包含的中間件,會發現它們的執行方式在分類內部也是高度一致的。除了預處理器和處理器須要異步執行以外,其他幾種類型所包含的中間件全均可以按照同步的方式執行。
上文提到Koa中間件會有兩次被執行的機會,@zpfe/koa-middleware也確實包含一些這樣的中間件(好比日誌功能)。剛纔在歸類中間件時,這樣的中間件被拆成了兩部分,歸屬到了不一樣的分類中。好比,日誌功能會被拆分到初始化器(初始化日誌功能)和後處理器(記錄請求結束的信息)。對於這樣的功能,咱們能夠換一種思路,將它當作一個完整的功能集,但對外輸出了兩個不一樣類型的具體功能。如此,咱們就能夠在同一個文件中編寫日誌功能的全部代碼,並將其初始化功能和後處理功能定義爲不一樣的函數來導出。
通過分析,咱們已經對@zpfe/koa-middleware模塊的現狀有了清晰的認知。如今來總結一下,造成一些有用的指導原則:
這一步驟比較簡單,只須要將這些非中間件功能的文件提取到獨立的模塊中便可。須要注意的是:
抽離非中間件功能以後,@zpfe/koa-middleware模塊如今已是一個名副其實的中間件模塊了。
下圖展現了抽離非中間件功能以後的代碼結構:
接下來封裝一個註冊函數,並做爲對外的惟一導出項,藉此來簡化使用方的代碼,並對其隱藏中間件細節。
根據以前的分析,這個註冊函數須要經過參數來容許使用方對部分中間件進行配置。下面展現了新的註冊函數的主要邏輯:
function registerTo(koaApp, options) { koaApp.use(middleware1) koaApp.use(middleware2) if (options.config3) koaApp.use(middleware3) if (options.config4) koaApp.use(middleware4(options.config4)) // ... koaApp.use(middlewareN) } module.exports = { registerTo }
options
參數不只能夠用來控制特定中間件的啓用狀態,還能夠向中間件提供配置。使用方能夠這樣來使用新的註冊函數:
const middleware = require('@zpfe/koa-middleware') const app = new Koa() middleware.registerTo(app, { config3: true, config4: function () { /* ... */ } })
如今中間件的註冊順序已經封裝在@zpfe/koa-middleware模塊的內部了,使用方只須要了解註冊函數的使用方法便可,假設之後想要增長一箇中間件,也不會對使用方形成大的影響。
下圖展現了封裝註冊函數以後的代碼結構:
值得注意的是這一步驟中的改動只涉及到@zpfe/koa-middleware模塊的主文件和使用方,並無對任何中間件進行修改,遵循了漸進式重構的原則。 補充和更新單元測試後,就能夠進行到下一步了。
根據以前的分析,中間件分屬幾種類型,初始化器是其中的第一種。初始化器所包含的中間件應該由它本身來註冊和管理,下面展現了初始化器的主要邏輯:
function register(koaApp, options) { koaApp.use(middleware1) // ... koaApp.use(middlewareN) } module.exports = register
看起來就是@zpfe/koa-middleware模塊主文件的翻版,接下來修改@zpfe/koa-middleware模塊主文件,將逐個註冊初始化器中間件的代碼替換爲使用初始化器來統一註冊:
const initiators = require('./initiators') function registerTo(koaApp, options) { initiators(koaApp, { configN: options.configN }) if (options.config3) koaApp.use(middleware3) if (options.config4) koaApp.use(middleware4(options.config4)) // ... koaApp.use(middlewareN) }
如今開始,@zpfe/koa-middleware模塊的主文件只與初始化器進行交互,再也不與後者所包含的多箇中間件進行交互。也就是說,咱們已經對外隱藏了初始化器中間件的邏輯細節。接下來要進一步重構這些邏輯時,也就不會超出初始化器的範圍了。
初始化器所包含的中間件均以同步的方式執行,能夠將它們化簡爲函數,組織成一個函數隊列,按順序執行。下面展現了修改後的初始化器:
const task1 = require('./tasks1') const taskN = require('./tasksn') function register(koaApp, options) { const tasks = [] if (options.config1) tasks.push(task1) // ... if (options.configN) tasks.push(taskN) async function initiate (ctx, next) { tasks.forEach(task => task(ctx)) return next() } koaApp.use(initiate) }
全部初始化器類型的中間件都被化簡成了同步函數,並根據註冊時傳入的參數建立了一個任務列表,接着將自身註冊爲一個按順序執行任務列表的中間件。
補充和更新單元測試後,初始化器的重構工做就宣告完成了。在這一步驟中,咱們將多箇中間件合而爲一,並將其邏輯封裝在其內部,這會讓@zpfe/koa-middleware模塊的代碼更加結構化,也更容易維護。
下圖展現了重構初始化器以後的代碼結構:
回顧一下本步驟中的全部重構操做,咱們會發現並無涉及到使用方,這就是在第二步重構過程當中對外隱藏內部邏輯所帶來的好處。 一樣地,咱們也沒有對非初始化器的中間件進行任何改動,這些中間件不在本步驟的重構範圍以內,咱們會在後續的步驟中進行重構。
初始化器重構完成以後,就能夠按照一樣的思路去依次重構其他幾種中間件類型:阻斷器、預處理器、處理器和後處理器。
這些重構工做完成以後的代碼結構以下圖所示:
須要注意的依然是要控制重構範圍,完成一種類型的重構(包含單元測試)以後,再開始下一個類型。
如今重構工做已經接近尾聲。對使用方而言,@zpfe/koa-middleware模塊只公開了一個函數,極大地提升了易用性;對@zpfe/koa-middleware模塊自身而言,其內部結構更加合理、執行順序更容易預測、也更容易進行單元測試。
在宣告重構完成以前,咱們還須要對@zpfe/koa-middleware模塊進行一次總體檢查,尋找遺漏的「壞味道」,以及在漸進式重構過程中逐漸累積出來的「壞味道」。
如今的@zpfe/koa-middleware模塊包含五個中間件,每一箇中間件的註冊函數能經過參數來控制本身的內部功能。@zpfe/koa-middleware模塊的主文件負責將使用方傳入的參數整理成每一箇中間件所指望的參數格式,以下所示:
function registerTo(koaApp, options) { initiators(koaApp, { configN: options.configN }) blockers(koaApp, { configO: options.configO }) preProcessors(koaApp, { configP: options.configP }) processors(koaApp, { configQ: options.configQ }) postProcessors(koaApp, { configR: options.configR }) }
既然每一箇中間件都須要從註冊函數的options
參數中獲取本身所須要的數據,那麼徹底能夠將options
參數的結構按照中間件進行分類,分類以後的註冊函數看上去會更加簡明:
function registerTo(koaApp, options) { initiators(koaApp, options.initiators) blockers(koaApp, options.blockers) preProcessors(koaApp, options.preProcessors) processors(koaApp, options.processors) postProcessors(koaApp, options.postProcessors) }
在以前的分析中,咱們已經知道初始化器會產生一些數據,而且但願這些數據能由它們本身來清理,這就意味着在後處理器有對應的任務來清理數據。將同一個功能的初始化和清理邏輯拆分到兩個文件中,也是一種「壞味道」。
處理這種狀況的方法很簡單,首先找出全部具有這樣特徵的功能,爲它們建立獨立的代碼文件。而後將其初始化邏輯和清理邏輯移動到該文件中,並分別導出。 如此一來,每一個功能都會變得更加內聚。
重構完成以後的代碼結構以下圖所示:
回顧一下整個重構過程,會發現咱們作的第一件事情並非編碼,而是對現狀進行深刻的剖析。在這個過程當中,求同存異,一些模式會天然而然地呈現出來,它們都是重構的「素材」。
在真正進行編碼時,咱們採起了漸進式的策略,將整個過程分解成多個步驟。爭取作到每個步驟完成後,整個模塊都能達到發佈標準。這就意味着須要把每一步所涉及的改動都限定到一個可控的範圍內,而且每一個步驟都須要包含完整的測試。
以上,就是重構與重寫的區別。
注:本文最初於2018年8月8日發表於智聯大前端內部Wiki。