響應式程式設計入門:實現電梯排程模擬器

doodlewind發表於2017-08-27

據說每個程式設計師等電梯的時候都思考過電梯的排程演算法…所以怎麼動手實現一個呢?雖然這個場景貌似有些複雜,但卻非常適合使用響應式程式設計的正規化來處理。下面我們會在 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 + baseFloor
      return {
        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 false
      else 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 文件

相關文章