Web框架的架構(gòu)模式探討
在寫干貨之前,我想先探(qiang)討(diao)兩個問題,模式的局限性?模式有什么用?
最近看到一篇文章對我啟發(fā)很大,許來西在知乎的回答《哲學(xué)和科學(xué)有什么關(guān)聯(lián)?》,全篇較長,這里摘錄我要引出的一點:
科學(xué)作為一種經(jīng)驗主義的認識論,有著經(jīng)驗主義的巨大缺陷:它永遠不能產(chǎn)生絕對正確的真理。這是歸納法的本質(zhì)決定的。而且值得注意的是,歸納不具有唯一性。
舉一個簡單的例子,我們假設(shè)一個世界,如下圖:
科學(xué)家很快有了兩種歸納方式:
- 世界上所有的青蛙都戴眼鏡
- 世界上所有戴眼鏡的都是青蛙
在沒有更多的信息的時候,我們應(yīng)該如何選擇正確的理論呢?答案是無法選擇。
舉個模式的例子,Scott Wlaschin 在《Functional Programming Design Patterns》(函數(shù)型編程模式)中對比了常用面向?qū)ο竽J健⒃瓌t,在函數(shù)型編程語言里面等價實現(xiàn):
OOP 和 FP,到底哪種編程范式更加先進呢?答案同樣是無法選擇。只能在不同的時候選用不同的假設(shè)和不同的理論來解釋問題,許來西的文章講到科學(xué)一定程度上通過放棄一貫性換取了實用性,放棄自洽性換取了它洽性??茖W(xué)追求實用和工具(實用主義和工具主義)。當(dāng)我看完許來西的文章,欣喜若狂,一直對編程技術(shù)理論的善變和不自洽感到恐懼和厭惡,其實只是經(jīng)驗主義科學(xué)發(fā)展的必然過程,善變代表更好的理論(更方便)在替換基礎(chǔ)理論,代表蓬勃發(fā)展。
所以我想引入***個觀點:
- 模式是一套立足于特定背景,基于共性總結(jié)出的方案,它絕不是真理。
了解這些有助于幫助從對模式的盲目崇拜到探究它的實用性和工具性,也就是我要引出的第二個問題:模式有什么用?
不好好寫代碼看哲學(xué)文章不是偶然,在文章落筆之前,我有思考過在 JavaScript 這門動態(tài),多范式,單線程,基于事件I/O的語言環(huán)境下,甚至在當(dāng)前時代,模式是否還有意義?顯然我不是唯一這樣想的,還有篇深度好文《20年前GoF提出的設(shè)計模式,對這個時代是否還有指導(dǎo)意義?》。這篇文章引經(jīng)據(jù)典,摘錄了GoF(又稱Gang of Four,即Erich Gamma, Richard Helm, Ralph Johnson & John Vlissides)在設(shè)計模式一書中觀點:
這本書的實際價值也許還值得商榷。畢竟它并沒有提出任何***的算法或者編程技術(shù)。它也沒能給出任何嚴格的系統(tǒng)設(shè)計方法或者新的設(shè)計開發(fā)理論——它只是對現(xiàn)有設(shè)計成果的一種審視。大家當(dāng)然可以將其視為一套不錯的教程,但它顯然無法為經(jīng)驗豐富的面向?qū)ο笤O(shè)計人員帶來多少幫助。
換言之,模式顯然毫無實際用處。
不僅如此,文章還列舉了一度模式濫用導(dǎo)致許多弊端,可謂警鐘長鳴。
但是……模式這一稱謂仍然不斷出現(xiàn),直到今天我們亦在大量使用。為什么?GoF實際早設(shè)計模式的書中做出了預(yù)言:
“設(shè)計模式為設(shè)計師們提供一種共通的詞匯儲備,幫助其溝通、編寫文檔并探索設(shè)計方案。設(shè)計模式允許我們立足于高級抽象層面進行探討,而非設(shè)計標注或者編程語言,這就大大降低了系統(tǒng)復(fù)雜性。設(shè)計模式提升了我們設(shè)計及與同事進行設(shè)計探討時的切入點層級。”(第389頁)
簡言之,模式方便了我們的溝通,提升了思考問題的抽象層級。
這個意義非常巨大,想象一下沒有 MVC 架構(gòu)模式,可能所有的 Web 框架必然的會實現(xiàn)一套幾乎解決同樣問題的方案,但是命名和文檔卻各不一樣,當(dāng)你去看一個新的框架文檔的api 接口,從頭到尾看完以后才恍然大悟,這不就是之前用的框架里面的 XXX 類似嗎,這樣的編程世界簡直地獄。慶幸的是,得益于計算機科學(xué)家(碼農(nóng))對問題和方案持續(xù)的抽象成模式,使得當(dāng)前高度復(fù)雜的計算機科學(xué)也能得到合理分層和適配,大大簡化了學(xué)習(xí)和溝通的成本。
為了感謝模式,是時候?qū)W習(xí)一波了,本文要介紹的主要有三種架構(gòu)模式:Middleware,MVC,DI。
Middleware 中間件模式
相信做過 Node.js 服務(wù)端開發(fā)的同學(xué)對這個模式一定不陌生,考慮如下 Web 應(yīng)用的場景:
在一個簡單的 HTTP 請求響應(yīng)周期里,有如下條件處理,
- 記錄開始時間
- 需要驗證用戶的身份 authentication。
- 解析cookie 并加載body
- 根據(jù)路由返回不同的業(yè)務(wù)處理結(jié)果
- 沒有***路由則返回404頁面
- 記錄日志
- 記錄總共花費時間
- 處理異常并顯示頁面(開發(fā)環(huán)境)
有些處理會根據(jù)是否成功決定是否繼續(xù)后面的粗粒,有些處理會生成額外的數(shù)據(jù),還有的要求攔截某些處理的開始和結(jié)束,***異常處理和記錄日志要求一定被執(zhí)行。
一般的解決方法是用嵌套條件判斷結(jié)合 try catch finally return 等控制語句,但是這樣的方案會導(dǎo)致代碼碎片化和復(fù)制粘貼的編碼風(fēng)格,因為控制流和邏輯耦合到了一起。理想的方案應(yīng)當(dāng)如下:
- 中心化控制流
- 解耦處理模塊(重用性)
- 聲明式、可配置的服務(wù)(配置和代碼無關(guān))
這些場景由來已久,很久以前J2EE總結(jié)了 Intercepting Filter 模式,有興趣大家可以看看這篇文(lun)章(wen),里面由淺入深提到三種方案,其中最初級的方案代碼如下:
- public class DebuggingFilter implements Processor {
- private Processor target;
- public DebuggingFilter(Processor myTarget) {
- target = myTarget;
- }
- public void execute(ServletRequest req,
- ServletResponse res) throws IOException,
- ServletException {
- // preprocess
- target.execute(req, res);
- // post-process
- }
- }
這個和 express 和 Koa 的中間件模式極其相似,但是因為靜態(tài)語言本身一些特征,導(dǎo)致***形成的企業(yè)級代碼極其繁瑣,并且有許多局限性。最主要的問題是處理模塊之間難以重用和共享數(shù)據(jù),因為 ServletRequest ServletResponse 無法動態(tài)添加屬性。以至于 JavaEE 把這個模式的適用性加了許多限制,包括和核心處理邏輯分開。
在動態(tài)語言的世界里面,我們可以很方便的往 req 和 res 里面添加數(shù)據(jù)(基于約定),因為沒有了很多 OOP 世界里面的”束縛“,Node.js 的實現(xiàn)通常更加優(yōu)雅和通用。
Express 中間件模式
express 實現(xiàn)如今廣泛接受的 Middleware 中間件模式。中間件的意思是在 請求 和 響應(yīng) 中間執(zhí)行的函數(shù)(為了區(qū)分另一個中間件),簽名如下:
- var express = require('express');
- var app = express();
這個模式包含了一套聲明式的路由規(guī)則,和 middleware 函數(shù)上的 next 簽名,它們共同構(gòu)成了整個中間件模式的控制流,如圖:
這個模式的核心構(gòu)成不是權(quán)限,解析等中間件邏輯,而是路由判斷,next和中斷響應(yīng)(驗證失敗、解析失敗),其作為中間件執(zhí)行控制,解耦了具體的處理邏輯,使得更容易寫出通用的細粒度的中間件。express 內(nèi)置的強大的聲明式路由,并且路由和 middleware 分離可以說是它最成功的設(shè)計之一。
然而在一些稍微復(fù)雜點的業(yè)務(wù)中,比如一個網(wǎng)站有管理端和用戶端,兩個端相當(dāng)于獨立的app。express 4.0 提供了一個非常強大的功能 Router。Router 拓展了鏈式?jīng)Q策變成樹形決策,可以讓 express 更好的支持大型項目。
- /*
- 文件 bird.js
- */
- var express = require('express')
- var router = express.Router()
- router.get('/', function (req, res) {
- res.send('Birds home page')
- })
- module.exports = router
- /*
- 文件 app.js
- */
- var birds = require('./birds')
- // ...
- app.use('/birds', birds)
Koa 異步中間件模型
Koa 的異步中間件模式-洋蔥模型,相比 Express,其中間件函數(shù)返回 Promise,支持 async/await,并且可以輕松實現(xiàn)前置和后置的處理。毫無疑問這個模式更加先進,一些在 express 里面不好實現(xiàn)的攔截處理邏輯,比如異常處理和統(tǒng)計時間,在 Koa 里用一個中間件就能搞定。然而遺憾的是 Koa 本身只提供了 Http 模塊和洋蔥模型的最小封裝。
未來我看好 Koa,其實 express 也意識到這點,他們計劃在 5.0 版本里添加 Promise 的支持,然而作為一個老牌和完整生態(tài)的框架,要克服的困難遠不是技術(shù)層面上看似的簡單,直到目前仍然沒有看到 5.x 宣布支持 Promise, 讓我們拭目以待。
MVC 模式
MVC 模式也需要介紹嗎,我們天天都在聊 MVC,不管前、后端框架,說一句 MVC,對一下眼神,基本確定對方懂你了。
事實是,前端框架已經(jīng)不適合用 MVC 討論,這個模式從1979年提出以來,作為萬精油模式,在各個框架和場景中被套用,背負了太多的歷史包袱,大家可以看 winter 的文章 談?wù)刄I架構(gòu)設(shè)計的演化。撥亂反正我覺得有希望,討論前端框架大家以后統(tǒng)稱 MV 模式就好了,就是模型和視圖分離。
我們今天要講的 MVC 模式是指在服務(wù)器上(后端) MVC 模式,它的定義經(jīng)受了時間和實踐的檢驗,在許多企業(yè)級 Web 框架的實現(xiàn)中高度一致。先列舉場景:
如果你的網(wǎng)站只有幾個簡單的頁面,所有邏輯都寫在 Controller 里面,是沒有問題的。隨后網(wǎng)站迅速的增長,你發(fā)現(xiàn),
- 許多頁面里面的視圖是一致的,但是背后的數(shù)據(jù)模型不一致。比如:網(wǎng)站上幾乎沒有一個視圖或者組件是***的,表格,下拉框等。
- 許多頁面里面的數(shù)據(jù)模型是一樣的,但是展現(xiàn)的視圖不一致。比如:同時支持PC和移動端,國際化本地化。
我們做一個數(shù)學(xué)模型模擬極端情況,大家很容易能看到問題
假設(shè)左邊是我們的系統(tǒng)最終的樣子,它剛好可以表示成 M(模型)和 V(視圖)的內(nèi)積,我們更傾向于右邊的表達,因為它更簡潔而且沒有重復(fù)。這里的內(nèi)積操作大家就可以理解成控制器,實際上不會如此巧合,但是分離模型和視圖幫助我們提高代碼復(fù)用,降低設(shè)計復(fù)雜度的好處是很顯然的,一個更通用的表達
模型視圖和控制器之間都是單向鏈接,所以整個系統(tǒng)的行為非常可控且容易測試,單獨把路由分開是想強調(diào) Router 和 Controller 是兩個概念,Router 只是一個觸發(fā)器(或者提供了一種映射關(guān)系),在寫測試的時候,我們也可以跳開 Router 單獨調(diào)用 Controller。
看到上面的兩種模式,是不是已經(jīng)開始想,那有沒有一個框架同時是 Koa + Router + MVC 呢,推薦大家一個非常好用的企業(yè)級 Web 框架 ThinkJS 3.0,***版的 ThinkJS 集成了大量***實踐和完善的文檔,不管是學(xué)習(xí)或者企業(yè)級開發(fā)都非常推薦。而且 ThinkJS 同樣實現(xiàn)了接下來要講的模式。
DI 依賴注入模式
還是先說場景,假如服務(wù)端需要實現(xiàn)session,前期考慮到成本和用戶量,單臺服務(wù)器存到文件就夠用了。后期如果用戶量大的時候,需要橫向擴展(Scale-out),就把 session 實現(xiàn)基于中心化的 Redis 服務(wù)。
我們系統(tǒng)設(shè)計目標是:
- 不需要修改業(yè)務(wù)邏輯代碼實現(xiàn)替換
- 不需要關(guān)注服務(wù)的創(chuàng)建和生命周期
解決這類系統(tǒng)擴展性問題有一個非常著名的設(shè)計原則 控制反轉(zhuǎn)(IoC Inversion of control),而 依賴注入(DI dependency injection) 就是其中的一個實現(xiàn)模式。
DI 的基本思路是這樣,首先我們的代碼不能依賴具體的服務(wù),需要總結(jié)歸納出一套抽象接口,業(yè)務(wù)實現(xiàn)依賴接口,而服務(wù)實現(xiàn)接口,***通過框架專門負責(zé)創(chuàng)建和提供接口的實例。
這里的 IoC 容器或者說 Ioc 框架,會在啟動的時候讀取配置文件,并在運行的時候根據(jù)需要創(chuàng)建實例提供給使用者,在靜態(tài)語言如 java,c# 需要用到反射等高級語法,而 JavaScript 本身是動態(tài)的,接口基于約定,并且使用的方式也更加靈活。比如 ThinkJS 3.0 里面的 extend 和 adapter 就可以理解成接口和實現(xiàn),如圖:
那之所以稱為 extend,是因為框架會直接把接口注入(mixin)到 controller 或者 think 對象中。這樣的好處是使用起來更方便,缺點是不同 extend 需要約定好不能重名。
***
本文介紹的三個架構(gòu)模式,你會發(fā)現(xiàn)幾乎在所有的Web框架實現(xiàn)都大同小異,這就是模式的好處。模式的意義類似于 IoC,我關(guān)注抽象和接口,抹平了具體語言特性下的細節(jié)問題,幫助我們更好的學(xué)習(xí),溝通和思考。