標籤:

前端中的 Pipeline

計算機領域的 Pipeline 通常認為起源於 Unix。最初 Douglas Mcllroy 發現很多時候人們會將 shell 命令的輸出傳遞給另外一個 shell 命令,因此就提出了 Pipeline 這一概念。後來同在貝爾實驗室的大牛 Ken Thompson 在 1973 年將其實現,並使用 | 作為 pipe 的語法符號:

$ ls -l | grep key | lessn

如此優雅而又實用的 Pipeline 很快在各種操作系統中傳播開來。

簡單來說,Pipeline 一般具有如下特點:

  1. 各個子過程高內聚,專註於解決特定問題,Simple & Sharp
  2. 所有子過程具有一致的介面,例如從標準輸入讀取數據,正常結果輸出到標準輸出,異常結果輸出到標準錯誤
  3. 能夠通過一定形式將子過程組合起來解決複雜問題,例如 pipe

事實上,Pipeline 作為化整為零、去繁就簡的重要手段,在前端中也有諸多應用。

Middleware Pipeline

NodeJS 框架 Express 在 1.0 版本中引入的 Middleware Pipeline 可以說為 Express 的流行居功至偉。透過下面簡單幾行代碼,你就能感受到它散發的優雅氣息:

express()n .use(bodyParser.json())n .use(cookieParser())n .use(session(sessionOptions))n .use(api, apiRoutes)n .use(errorHandler);n

或許對於很多後來人來說,並不覺得這有什麼精巧獨到之處。但在 NodeJS 剛剛開始流行的那個蠻荒年代,大多數人寫的還是流水賬一樣的過程式代碼,好一些的會去整理一些工具函數以供抽象和復用:

var srv = http.createServer(function (req, res) {n req.parsedBody = bodyParser(req);n req.parsedCookie = cookieParser(req);n session(req, res, function (err) {n if (err) {n errorHandler(err);n return;n }nn // routesn });n});n

相比之下,我們可以明顯看出 Middleware 的幾個優勢:

  1. 代碼簡練、符合直覺。這是一個很重要的優勢,因為代碼的大部分生命周期內都是由程序員在維護,符合直覺的代碼更容易被理解,在維護和定位問題時能夠更有效率
  2. 合理的錯誤處理。任意 Middleware 出現問題,會越過後續所有普通 Middleware,直接由 Error Middleware 進行處理

事實上,還有一個更為重要的優勢:標準化,為解決高層次問題提供了良好基礎。這一點在迷思專欄的 再談 API 的撰寫 - 架構 這篇文章中得到了充分的詮釋:通過將 API 執行路徑上的各個環節抽象為中間件,然後再將中間件劃分為通用邏輯(Pre-processing / Post-processing 等)和開發者需要關注的邏輯(Processing)等類別,並提供精細化的控制,最終得到一個流程清晰、功能完善、標準統一的 API 開發方案。

Middleware Pipeline 還有一個值得提及的獨特之處:由於本質是是一種遞歸調用,因此整個調用過程更像是一個環環相扣的洋蔥:

有興趣了解其實現的同學,可以查看早期 Express 所使用的 connect 或者 Koa 的 compose。

Stream Pipeline

Stream 是 NodeJS 的一個核心功能,使得快速、高效處理數據成為了可能。例如讀寫大文件、處理高並發網路請求等。

建立在 Stream 之上的 Pipeline 非常自然而形象:數據像水流一樣依次經過不同的處理流程,並最終得到期望的結果。下面這張 Gulp Cheet Sheet 中的圖片能夠形象地說明這一比喻:

gulp.task(js, () => {n return gulp.src(./js/src/*.coffee)n .pipe(coffee())n .pipe(uglify())n .pipe(gulp.dest(./js/));n});n

憑藉對 Stream 惟妙惟肖地運用,Gulp 在與配置為主的 Grunt 的競爭中迅速取得了領先優勢。

另一個必須提及的例子是 substack 的 Browserify。作為 Stream Handbook 的作者,substack 對 Stream 的理解可謂深刻。於是在 Browserify 的實現中,我們可以看到下面這段核心邏輯:

var pipeline = splicer.obj([n record, [ this._recorder() ],n deps, [ this._mdeps ],n json, [ this._json() ],n unbom, [ this._unbom() ],n unshebang, [ this._unshebang() ],n syntax, [ this._syntax() ],n sort, [ depsSort(dopts) ],n dedupe, [ this._dedupe() ],n label, [ this._label(opts) ],n emit-deps, [ this._emitDeps() ],n debug, [ this._debug(opts) ],n pack, [ this._bpack ],n wrap, []n]);n

Browserify 的設計目標是將 CommonJS 模塊組織的 JS 代碼打包為可以在瀏覽器中運行的代碼。實現這一目標所需要做的工作非常複雜,因此 Browserify 將其拆解為職責單一的多個子過程,例如分析依賴、拓撲排序、模塊去重、打包合併等,並通過 Stream Pipeline 打通整個流程。這使得整個代碼的架構異常清晰,對將來的維護和優化提供了良好基礎。

點睛之筆在於,這個基於 labeled-stream-splicer 實現的 pipeline 還支持動態修改和擴展,而且不僅在內部實現中多處應用,還暴露為外部介面方便調用方進行定製。下面這個示例展示了將 deps 子過程輸出結果的 source 屬性改為大寫的邏輯:

pipeline.get(deps).push(through.obj(function (row, enc, next) {n row.source = row.source.toUpperCase();n this.push(row);n next();n}));n

Browserify 眾多的 Plugin 也大多利用了這一特性進行功能的增強。例如編譯 TypeScript 的插件 Tsify 就是在 record 這一子過程之後插入一個遍歷所有輸入文件並進行編譯的過程:

b.pipeline.get(record).push(gatherEntryPoints());n

毫不誇張的說,這是筆者從業以來所見到過最為優秀的設計,沒有之一。在為一個使用 SeaJS 的團隊設計組件化方案時,由於各種限制並不能直接應用 Browserify,因此就借(chao)鑒(xi)它的設計思路,完成了一個簡單的組件打包工具 Tiler,並受用至今。

Promise Pipeline

由 Promise 組成的 Pipeline 與 Middleware Pipeline 有一些相通之處,例如都支持非同步,錯誤處理也有異曲同工之妙。但毫無疑問 Promise 天生就在非同步處理上更加得心應手,而且在函數式編程中具有一席之地,有人專門證明了下,Promise 屬於 Monad(感興趣的可以看下蝴蝶書的作者 Douglas Crockford 這個專門介紹 Monad 的講座:Monads and Gonads)。有了理論上的保證,我們總是可以通過 Promise.resolve/Promise.reject 將非 promise 的值轉換為 promise,而 promise.then/promise.catch 也總是返回一個新的 promise 從而方便鏈式調用。

此外,Promise 還有一個 Killer Feature:一旦有一個 promise 出現異常,那麼會忽視後面所有的 then 直到第一個 catch。這樣的錯誤處理機制和先前介紹的 Middleware Pipeline 非常類似,但卻更為強大,例如 catch 後還可以在做必要的處理後再次返回一個正常的 promise,實現優雅降級等業務需求。

下面是筆者在實現 VPAID Player 時的核心邏輯:

client.prototype.playAd = function (vastXMLString) {n return constructResponseFromString(vastXMLString)n .then(this.loadAdUnit)n .then(this.handshake)n .then(this.initAd)n .then(this.bindEvents)n .then(this.startAd)n .then(this.finish)n .catch(this.handleError);n};n

通過將播放廣告的邏輯劃分為構建返回值對象、載入第三方 JS、初始化廣告等各個小而精的細分子過程,然後串聯成 Promise Pipeline,並在最後做統一的錯誤處理,使得整體邏輯十分流暢清晰,提高了代碼的可維護性。

Ramda Pipeline

最後,讓我們再看一個函數式編程領域中的 Pipeline:Ramda Pipeline。

假設我們需要解決這個問題:將如下對象轉換為 query 字元串

const obj = {n foo: bar,n baz: true,n qux: 3.1415,n};n

先來看下 Lodash 的解法:

const objToQueryStr = (obj) =>n _.join(_.map(_.toPairs(obj), (kvs) => _.join(kvs, =)), =);n

再來看下 Ramda 的解法:

const objToQueryStr = R.pipe(n R.toPairs,n R.map(R.join(=)),n R.join(&)n);n

可以看出,Ramda 在如下兩個方面更加出色:

  1. 藉助 currying 和數據後置,Ramda 並不需要顯式創建新函數,代碼更簡練
  2. 順序執行,容易理解(雖然很多函數式編程的童鞋們更喜歡 R.compose)

因此,在推崇函數式編程的團隊中,Ramda 基本已成為必需品。

結語

前端中的 Pipeline 遠不止本文介紹的這幾種,比較知名的還有 RxJS 等等。從表面上看,它們每個都有著不同的目標問題域和因此而設計的特性,不過從本質上來講,基本都遵循了 Unix Pipeline 的基本思路:化整為零 + 靈活組合。希望我們前端工程師們再接再厲,將這種精神發揚光大,更好地解決實際問題,不斷推動前端的發展。

彩蛋

macOS 中的 workflow 定製工具 Automator 應用的圖標是一個機器人,它的手上拿著的正是一根管子(Pipe):

怎麼樣,非常的可愛吧~

參考

  1. Pipeline (Unix)
  2. Understanding the Middleware Pattern in Express.js - DZone Web Dev
  3. Youre Missing the Point of Promises
  4. Pipelines & Ramda

推薦閱讀:

怎樣的腳踝才是好看的?
第一次戴隱形眼鏡完全帶不上去,從來沒對任何事情如此絕望過,有點想放棄,已經試玩了2盒日拋,該腫么辦!?
中國女性的美,具體表現在哪些方面?

TAG:前端开发 | |