响应式编程入门:实现电梯调度模拟器
據說每個程序員等電梯的時候都思考過電梯的調度算法…所以怎么動手實現一個呢?雖然這個場景貌似有些復雜,但卻非常適合使用響應式編程的范式來處理。下面我們會在 RxJS 和 Vue 的基礎上,一步步實現出一個最小可用的電梯調度模擬 Demo。
Demo
為了避免讀者【脫了褲子就給我看這個?】的吐槽,在此我們先展示 50 行代碼最終所能實現的效果:一臺 10 層樓的電梯,你可以在每層樓按 ↓ 召喚電梯把你送到一樓。在多個樓層根據不同時序召喚出電梯的時候,這個模擬器的升降狀態應當是和日常的體驗一致的。先別急著吐槽它為什么這么簡陋,把它實現成這樣的理由會在下文中慢慢介紹?
鏈接 demo
掘金的 iframe 標簽不能正常工作,Demo 不妨查閱 Blog Post
Get Started
在介紹實際的編碼細節前,我們不妨先考慮清楚最基礎的思路,即如何表達電梯的調度?或者換一種表述方式,這其實是個更為有趣的話題:如何使用代碼抽象出一臺電梯呢?
也許高中物理學得好的同學首先會這么想:電梯可以抽象成由一條繩子掛著的盒子,我們可以傳入它的重量 m、離地高度 h、當前速度 v、當前加速度 a,然后用一系列精妙的公式來描述它的運動軌跡……恭喜你,理科思維把你引入歧途了?請放心,最后的 50 行代碼里不涉及任何高中物理知識。
倒是有個關于電梯的老段子更符合我們的抽象:【一個老屌絲看到一個老太婆進了電梯間,一會出來的居然是個白富美,于是就想著要是帶了自己的老婆來該多好啊……】這里對電梯的抽象,只不過是一扇數字會跳動的門而已。我們不需要關心它的機械到底怎樣運作,對于它的狀態,只要知道電梯口液晶屏上的方向和樓層號就足夠了。嗯,這就是 Duck Typing 的工科思維!
這兩種思維有什么區別呢?讓我們來考慮最簡單的情形:在十樓按一個鍵,把電梯從一樓叫上來。這時,兩種抽象方法所描述的內容會有很大的不同:
- 法一:盒子開始以速度 v 向上運動,在十樓的高度 h 停下來。
- 法二:樓層數字從 1 開始,按固定時間間隔加一,到 10 停止。
嗯,看起來后者實現起來很簡單啊:只要每隔一秒 setTimeout 改一下樓層數,這個電梯就模擬出來啦?恭喜你,你跳進了異步事件流的大坑里,考慮這些需求:
- 你在二樓想下樓,發現電梯正從三樓下來。這時候電梯會捎上你?
- 你在十樓想下樓,發現電梯正在九樓往下走。這時候電梯并不會回頭來接你?
- 你在十樓想下樓,發現電梯正從二樓上來。你以為它會停在你這,結果其實是二十樓的混蛋叫的電梯?
- ……
好的,這時候 setTimeout 恐怕不夠用啦,至于什么 Redux Flux MobX……寫這種需求也要掉層皮。嗯,到此我們的前戲終于差不多了,是時候介紹本文的主角 Reactive Programming 響應式編程了?
在 Reactive 范式中,Stream 事件流的概念非常強大。我們都知道計算機處理的數據本質上都是離散的,即便是小姐姐的視頻,也要拆成一秒 24 幀。對于我們的電梯模擬器,它的輸入其實就是用戶在各個樓層上隨時間變化的一系列離散操作,輸出則是一個當前時間樓層和方向的狀態。這樣,我們就能夠使用 Stream 來表達模擬器的輸入了。
Stream 和樸素的事件監聽器有什么區別呢?Stream 是可以在時間維度上進行組合、篩選等變換的。如果覺得這個說法很抽象,不妨考慮這個例子:在十樓按一次電梯按鈕,樓層數字會從 1 逐個走到 10。這時,我們就把一個事件流中的一個事件,映射為了一個依次觸發十次事件的新流。再比如,我們只要把從一樓到十樓的事件流和從十樓到一樓的事件流簡單地連接起來,就實現了上樓接人再返回的電梯基本功能!
話都說到這份上了,也差不多是時候 Show Me the Code 了?下面讓我們來一步步使用 Reactive 實現 Demo 吧。
Step 1
首先簡要介紹一下這個 Demo 的技術背景:為簡單起見,我們選擇了 Vue 來充當簡單的視圖層,選擇了 RxJS 這個 Reactive 庫來實現核心的功能。受限于篇幅,我們不會覆蓋 Vue 的使用細節,只介紹 Reactive 相關的重要特性?另一方面,從 0 到 1 總是最難的,因此 Step 1 的內容也會是最多的?
上文中,我們已經提到了 Rx 中流的強大。那么,我們首先考慮這個最最基本的需求吧:在十樓按一下 ↓,電梯數字從 1 開始逐次遞增。這時候,我們就從點擊事件流中的一個事件,映射出了一個新流:
import { Observable } from 'rxjs'const stream = Observable// 將 DOM 的樓層點擊事件轉化為 Observable 事件流.fromEvent(emitter, 'click')// 輸入事件流,輸出間隔 1s 觸發新事件的新流.interval(1000)// 流的一系列異步輸出可以被訂閱 stream.subscribe(x => console.log(x))復制代碼執行上面的代碼,點擊按鈕時,就會每秒觸發一個從 0 開始自增的事件流了,每秒也都能在控制臺看到穩定的輸出。但這并不符合要求:怎樣讓樓層只增加十次呢?我們引入 take 方法:
const up = Observable.fromEvent(emitter, 'click').interval(1000)// 只會觸發十次!.take(10)復制代碼嗯,接下來,我們發現還有一點不太優雅:樓層數字雖然按要求遞增了,但卻是從 0 到 9,而非從 1 到 10(你家有 0 層嗎?)要按照特定規則映射出新流,我們直接使用熟悉的 map 方法就行:
const up = Observable.fromEvent(emitter, 'click').interval(1000).take(10)// +1 ?.map(x => x + 1)復制代碼現在我們能夠從一樓到十樓了,但是怎么下樓呢?我們先造一個從十樓到一樓的 Stream 吧?
const down = Observable.interval(1000).map(x => 10 - x).take(10)復制代碼電梯需要先 UP 上樓,再 DOWN 下樓。為此,我們直接 concat 兩個 Stream 就行:
function getStream () {// 聲明 Up 和 Down...return up.concat(down) }復制代碼目前我們已經使用了 interval / take / map / concat 這幾個 API 了,不過離真正完成 Step 1 這一步,還有一個非常關鍵的地方:在不同樓層多次按下電梯按鈕時,如何控制事件流?
從這幾個 API 的使用上,有些逼格比較高的同學也許會發現,我們的編碼算法,其實有些接近拉普拉斯的決定論:電梯的按鈕被按下后,它在未來一段時間內的一系列狀態變化在那一個時刻就已經被決定了。換句話說,給我一個足夠精確的當前狀態,我能計算出整個未來(被拖走)……這時候我們首先遇到的麻煩是:如果在輸出的一系列事件執行時間中,又出現了新的輸入事件,該如何定義后續的狀態呢?
這里,我們引入了 switchMap 方法來表達邏輯:假設在十樓按下按鈕,在未來的十秒會觸發十個事件。那么經過 switchMap 的封裝,一旦在十秒中的某個時刻又有新按鈕被按下,原先剩余的事件就被舍棄,從這時起改為觸發新按鈕事件衍生出的新事件。換一種說法,就是從一樓到十樓的電梯,如果走到一半有人按了五樓,就立刻從一樓重新出發,走到五樓返回。既然我們只關心狀態,不關心這么量子化的電梯到底怎么實現的,這個 Step 1 的模擬器執行結果倒也是穩定的。稍微封裝出一些參數,第一個 Demo 就完成啦:
鏈接 step 1
在上面的 Demo 中點擊任何一個按鈕,電梯就會從一樓開始去接你,然后返回。中途如果再次點擊新樓層,電梯就會立刻重新從一樓出發(量子化?)去新樓層接人。嗯離實用還有段距離,不過已經有個樣子啦。而目前我們的 Rx 邏輯大概長這樣,非常簡短:
import { Observable } from 'rxjs'export function getStream (emitter, type) {return Observable.fromEvent(emitter, type)// target 為 Vue 中觸發按鈕事件的樓層號.switchMap(({ target }) => {const up = Observable.interval(1000).map(x => x + 1).take(target)const down = Observable.interval(1000).map(x => target - x).take(target)return up.concat(down)}) }復制代碼Step 2
這一步中,我們需要解決電梯在新按鈕按下時,神奇地量子化出現在一樓的問題(誤)。我們不需要引入新的 API,只需要稍微修正一下邏輯:
第一步中,我們輸入流中的狀態只有 target 這個唯一的目標樓層,這就意味著電梯甚至不知道按鈕觸發時,自己當前正在幾樓。為此,我們在 Vue 中添加一個 curr 參數來標記這個狀態,這樣,電梯每當新事件觸發時,就會從當前樓層去往新目標樓層,而不是直接出現在一樓:
// 增加一個 curr 參數 .switchMap(({ target, curr }) => {const up = Observable.interval(1000)// 從當前樓層出發去往新樓層.map(x => x + curr).take(target + 1 - curr)const down = Observable.interval(1000).map(x => target - x).take(target)return up.concat(down)復制代碼增加這個狀態后,Step 2 的效果如下所示:
鏈接 step 2
這個 Demo 里,你可以先點擊五樓,等到電梯走到三樓時再點擊七樓。這時電梯不會直接出現在一樓,而是會從三樓老老實實地爬上七樓再下來。
不過這就帶來了新的狀態問題:先點擊五樓,等電梯走到三樓時點擊二樓。Boom!電梯出 bug 走不動了……
Step 3
上一步的 bug 出現原因,是你 take 了一個負數(本來從五樓到六樓需要 take 一次,但從五樓到四樓則是 take -1 次)。普通的數組下標越界倒還好,面向時間序列的 Observable 下標越界的話,那可就是真正的 -1s 了……我們來補一點邏輯修復它吧!
.switchMap(({ target, curr }) => {// 目標樓層高于當前樓層,我們先上樓再下樓if (target >= curr) {const up = Observable.interval(1000).map(x => x + curr).take(target + 1 - curr)const down = Observable.interval(1000).map(x => target - x).take(target)return up.concat(down)} else {// 目標樓層低于當前樓層,我們直接下樓return Observable.interval(1000).map(x => curr - x).take(curr)}復制代碼好了,bug 修復了:
step 3
上面的例子中,不管怎么按按鈕,電梯終于都不會量子化,也都不會被玩壞啦!但是新的風暴又出現了:來回點十樓和五樓,會發現為什么這個電梯來來去去卻總是到不了一樓呢……
Step 4
在上面的例子中,我們傳入 Stream 的狀態其實始終不足以支撐電梯調度算法的正常工作。比如,我們并沒有標志出一個樓層有沒有被按鈕點亮。在這一步中,我們在 Vue 的視圖層增加一個這樣的狀態:
// ...data () {return {floors: [{ up: false, down: false },{ up: false, down: false },{ up: false, down: false },{ up: false, down: false },{ up: false, down: false },{ up: false, down: false },{ up: false, down: false },{ up: false, down: false },{ up: false, down: false },{ up: false, down: false }],currFloor: 1}},復制代碼嗯不要在意我們沒有 ↑ 按鈕為什么有 up 狀態這些細節了。而 Rx 中我們添加一些簡單的處理,讓事件流傳出的狀態不僅僅包括當前樓層,也包括當前方向:
if (targetFloor >= baseFloor) {const up = Observable.interval(1000).map(count => {const newFloor = count + baseFloorreturn {floor: newFloor,// 傳出當前方向direction: newFloor === targetFloor ? 'stop' : 'up'}}).take(targetFloor + 1 - baseFloor)// ... }復制代碼總之現在模擬器看起來長這樣:
鏈接 step 4
點擊時會在 Rx 中彈出一個醒目的 alert 來告訴你:我這個事件流是知道這些狀態的!不過目前仍然沒解決到不了一樓的問題……
Final Step
在最后一步里,我們需要使用 Rx 處理之前到不了一樓的問題。我們知道,根據【決定論】的思想,Rx 其實在每個按鈕事件觸發時,就已經規劃好了未來的電梯運動了。那么,我們能不能做做減法,把影響狀態的事件過濾掉呢?這里我們可以使用 filter 來操作事件流:
簡化的模型中,我們不妨認為電梯只會執行【先 up 再 down】的操作。這時,對于電梯運動過程中觸發的新事件,可以這樣分類:
- 如果電梯正在下降,那么不管在哪個樓層觸發的新事件都不能再次讓電梯再次 up and down,保證電梯總能下降到一樓
- 如果電梯正在上升,但是新的下降事件所在樓層低于當前樓層,那么電梯在這一輪下降過程中就可以經過這個新樓層,從而不需要再次 up and down
- 如果電梯正在上升,而且新的下降時間所在樓層高于當前樓層,那么我們重新進行一次目標為新樓層的 up and down 即可。
三種情形中,我們會判斷出是否需要 up and down。既然每次 up and down 都是輸入 switchMap 的一個事件,那么我們就可以直接在 switchMap 前放置一個 filter 來過濾掉無關的按鈕事件:
return Observable.fromEvent(emitter, type).filter(({ floors, targetFloor, currFloor, currDirection }) => {// 參考上文邏輯判斷if (currDirection === 'down') return falseelse if (currDirection === 'up' && targetFloor <= currFloor) {return false} else return true})復制代碼在放置這個邏輯后,我們把 up and down 的目標樓層由事件所在樓層,改為從 floors 中找出的最高樓層(maxTargetFloor),就能夠保證電梯正常抵達目標樓層并正常返回了。不過這時還有最后的一點小問題:如果電梯下降中你按下了十樓,那么電梯到達一樓后不會再次來接你…解決方法很簡單,在電梯下降到達一層時,嘗試讓電梯再 up and down 一次即可。
在我們實現完了最后的這一點異步邏輯后,就是本文開始時的 Demo 了:
鏈接 final
到這時,Rx 中的代碼仍然僅有 40 余行。而 Vue 中的代碼也沒有涉及任何的異步邏輯,僅僅需要對 Observable 做簡單的訂閱并渲染數據即可。
Wrap Up
目前為止,我們的模擬器功能其實還只是真正電梯的一個子集,它還缺少這樣的功能:
- 一個讓用戶在電梯里選擇狀態的面板
- 每層的 ↑ 按鈕
不過在 Rx 的基本思路基礎上,模擬出這些特性并不會顯著地增加復雜度:在電梯里選擇狀態所觸發的事件,其實在優先級上完全等效于在電梯門外的樓層選擇(在向上運行的電梯內按一樓,電梯不會理你,就能夠證明這一點);而引入 ↑ 按鈕同樣只是引入了新的【決定論】狀態而已……雖然這么說有些不負責任,不過從我們已有的實現來看 Rx 事件流確實是具備優雅解決這些問題的能力的。
如果你還在糾結需不需要在已有項目中引入 Rx,也許本文的實踐能夠為你提供一些小參考:Rx 在處理異步事件流時非常強大,類似Redux / MobX 等狀態管理器所關注的與 Rx 其實并非同個層面的問題,一旦將它們與 Rx 結合,是能夠處理很高的業務復雜度的。
不過如果你的需求僅僅是【數據加載時顯示 Loading 狀態】,那么引入 Rx 多少就有些殺雞用牛刀了。
最后,這其實作者第一次嘗試 Rx 的項目。真正編寫的代碼并不多,不過要適應它并使用它真正解決問題,所需要的思考時間其實比敲鍵盤寫幾行代碼的時間要多得多……這也算是一種樂趣吧?本文中每一個 Step 都是從開發過程中的真實 commit 抽取出來的,希望本文對大家有所幫助?
Github 傳送門
Observable 文檔
總結
以上是生活随笔為你收集整理的响应式编程入门:实现电梯调度模拟器的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: EventProcessor与WorkP
- 下一篇: Ubuntu 16.04调节屏幕显示字体