Rxjs建模入門

_ivan發表於2019-04-21

本文介紹如何使用 Rx 的響應式程式設計思維來對業務邏輯進行建模, 你會了解到響應式程式設計的優勢和業務抽象能力, 學會將現有的業務流程以資料流的方式表達出來. 你的工具庫中不能少了 Rx 這件利器.

Rx 學習曲線陡峭是總所周知的, 我們接觸的大部分程式語言或框架都是物件導向的. 在面對 Rx 這響應式程式設計的方式, 會覺得無從入手, 筆者也是 Rx 的初學者, 拜讀過多次徐飛Rx 的相關文章, 基本上都是雲裡霧裡. 主要原因還是思維沒有轉換過來.

如果你不理解響應式程式設計的奧妙,是很難在'面向場景程式設計'時考慮到 Rx 的優勢. 筆者一般遵循'面向場景程式設計', 即在對應的場景考慮不同的技術或框架. 可能是痛點還沒有到難以忍受的地步,或許是現有應用還不夠複雜,我目前為止還沒接觸到必須要應用 Rx 的場景.

我覺得應該反過來,採取刻意學習的方式來學習 Rx, 以流的方式來思考,再將其放在現有的場景中看是否有更簡潔的解決方案或化學反應. 不得不說寫 Rx 是一個比較有趣的事情。 但也要認識到 Rx 不是萬金油,正如很多教程所說的 Rx 擅長複雜的非同步協調,並不是所有場景都適合,一些問題有更簡潔的解決方案


Rx 的建模過程

對於 Rx 的入門者, 可以使用下面的流程, 一步一步將業務邏輯轉換為 Rx 資料流來進行表達.

流程圖 -> 資料流抽象 -> 實現
複製程式碼

① 流程圖

首先從流程圖開始, 這個階段沒什麼特別的, 不管是響應式程式設計還是其他正規化, 編碼之前都需要縷清業務邏輯.

這個階段使用流程圖來描述技術無關的事務過程, 讓業務邏輯更加清晰, 也方便我們識別業務流程的主體和關鍵事件.

什麼是業務邏輯? wiki 上這樣定義:
Business logic or domain logic is that part of the program which encodes the real-world business rules that determine how data can be created, displayed, stored, and changed. It prescribes how business objects interact with one another, and enforces the routes and the methods by which business objects are accessed and updated.
Business Rules describe the operations, definitions and constraints that apply to an organization. The operations collectively form a process; every business uses these processes to form systems that get things done.



② 資料流抽象

Rx 的世界裡面一切皆流, 換句話說就是面向流程式設計. 和物件導向程式設計把現實世界的實體抽象為物件一樣. 響應式程式設計將業務中的變動實體(想不到更好的詞, 或者變數?)抽象為流

(1)首先需要識別什麼是變動實體? 變動實體一般是資料流的源頭, 它驅動著業務走向. 像河流一樣, 源頭可能不只一個. 我認為變動實體至少具備以下特徵之一:

  • 它是變動的. 例如滑鼠的位置, 商品的價格, 隨著時間的遷移狀態會進行變動
  • 它是業務的'輸入'. 變動實體是一個系統的輸入(外部事件)或者是另一個流(衍生)的輸入.
  • 它是業務的參與者(或者說業務的主體).
  • 它表示業務的狀態. 例如一個 todo 列表, 這是一個純狀態的流

(2)接著識別變動實體之間的關係. 主體之間的關係也就是流之間的關係, 這是 Rx 建模的核心. 只有理解了主體之間的關係, 才能將主體與業務流程串聯起來, 才能真正地使用資料流的方式將業務表達出來. 在重新理解響應式程式設計一文中對'響應式程式設計'的定義和筆者的理解非常契合:

響應式程式設計是一種通過非同步和資料流來構建事務關係的程式設計模型 . 事務關係是響應式程式設計的核心理念, “資料流”和“非同步”是實現這個核心理念的關鍵.

這種關係和麵向物件的類關係是不一樣的, 物件導向的關係一般是指依賴關係. 而資料流之間關係, 是業務之間的實際關係, 比如流程 b 依賴流程 a, 資料流是變動實體之間的溝通橋樑.

一般以下面的方法來構建流之間的關係:

  • 分治: 將業務劃分為多個模組(流), 一個大的流總是由小的流組成, 小的流職責更單一, 更容易理解和測試
  • 變換: 將流對映為另外一個流. 一般用於狀態變更或業務衍生(高階流變換)
  • 合併: 像河流一樣, 資料流最終是需要匯聚在一起注入大海的. 拆分和合並的方式都是依賴於所要表達的業務邏輯

總的來說變動實體一般就是業務的'輸入', 我們首先把它們確定為流, 再根據關係衍生出其他流(輸出). 對於流本身來說, 本質上只有輸入和輸出的關係:

stream

例如 increment$和decrement$就是 action$的輸入, action$就是 count$的輸入, 以此類推. 響應式程式設計將複雜業務關係轉換成原始的輸出/輸出關係

(3)符合函數語言程式設計的約束. 一般來說, 我們說的響應式程式設計指的是函式式響應式程式設計(Functional reactive programming FRP), 所以需要符合函式式的一些約束:

  • 純函式(Pure): 函式只是輸入引數到輸出結果的對映, 不要產生副作用
    • 沒有共享狀態: 不依賴外部變數來維護流程的狀態.
    • 冪等性: 冪等性在複雜流程中很重要, 這使得整個流程可被重試
    • 沒有副作用: 可預測, 可測試.
  • 不可變性(Immuatability): 資料一旦產生, 就肯定它的值不會變化, 這有利於程式碼的理解. 易於併發
  • 宣告式(Declarative):
    • 函數語言程式設計和指令式程式設計相比有較高的抽象級別, 他可以讓你專注於定義與事件相互依存的業務邏輯, 而不是在實現細節上. 換句話說, 函數語言程式設計定義關係, 而指令式程式設計定義步驟
    • 集中的邏輯. Rx 自然而然在一處定義邏輯, 避免其他正規化邏輯分散在程式碼庫的各個地方. 另外 Rx 的 Observable 通過訂閱來建立資源, 通過取消訂閱來釋放資源, 一般開發幾乎不需要去關心資源的生命週期, 例如時間器.

這個階段將第一個階段的流程圖轉換為 Rx 彈珠圖(Marble Diagrams)表示, 彈珠圖可以描述流之間關係, 表現'時間'的流逝, 讓複雜的資料流更容易理解



③ 實現

這個階段就是把彈珠圖翻譯為實現程式碼, 根據需求在 rxjs 工具箱中查詢合適的操作符. 當縷清了業務邏輯, 使用資料流進行建模後, 程式碼實現就是一件很簡單的事情了.

可以配合 Rxjs 官方的操作符決策樹選擇合適的操作符




下面使用例子來體會 Rx 的程式設計思維:

Example 1: c := a + b

這是最簡單的例項, 我們期望當 a 和 b 變動時能夠響應到 c, 我們按照上述的步驟對這個需求進行建模:

  • 流程:

    c=a+b

  • 資料流抽象: 從上可以識別出兩個變動的實體 a 和 b, 所以 a 和 b 都可以視作流, 那麼 c 就是 a 和 b 衍生出來的流, 表示 a 和 b 的實時加法結果, 使用彈珠圖來描述三者的關係:

    a$: ----1------------2---------------
    b$: --2-------4------------6------8------
                  \ (a + b) /
    c$: ----3-----5------6-----8------10-----
    複製程式碼
  • 程式碼實現: 由彈珠圖可以看出, c$流的輸出值就是a$和 b$輸出值的實時計算結果, 也就是說c$接收來自 a$和b$ 的最新資料, 輸出他們的和. 另外由原本的兩個流合併為單個流, 在 rxjs 工具箱中可以找到combineLatest操作符符合該場景. 程式碼實現如下:

    const a$ = interval(1000);
    const b$ = interval(500);
    
    a$.pipe(combineLatest(b$))
      .pipe(map(([a, b]) => a + b))
      .subscribe(sum => console.log(sum));
    複製程式碼



Example 2: 元素拖拽的例子

元素拖拽也是 Rx 的經典例子的的例子. 假設我們需要先移動端和桌面端都支援元素拖拽移動.

流程圖

Rxjs建模入門

資料流抽象

這裡使用分治的方法, 將流程進行一步步拆解, 然後使用彈珠圖的形式進行描述.

由上面的流程圖可以識別出來, down, move 以及 up 都是變動實體, 我們可以將他們視作'流'.

① down/move/up 都是抽象的事件, 在桌面端下是 mousedown/mousemove/mouseup, 移動端下對應的是 touchstart/touchmove/touchend. 我們不區分這些事件, 例如接收到 mousedown 或 touchstart 事件都認為是一個'down'事件. 所以事件監聽的資料流如:

# 1
mousedown$ : ---d----------d--------
touchstart$: -s---s-----------s-----
        \(merge)/
down$      : -s-d-s--------d--s-----
複製程式碼

move 和 up 事件同理

② 接下來要識別 up$, move$, down$ 三個資料流之間的關係, down 事件觸發後我們才會去監聽 move 和 up 事件, 也就是說由 down$可以衍生出 move$和 up$流. 在 up 事件觸發後整個流程就終止. up$流決定了整個流程的生命週期的結束

使用彈珠圖的描述三者的關係如下:

# 2
down$: -----d-------------------------
             \
up$  :        ----------u|
move$:        -m--m--m---|
複製程式碼

③ 一個拖拽結束後還可以重新再發起拖拽, 即我們會持續監聽 down 事件. 上面的流程還規定如果當前拖拽還未結束, 其他 down 事件應該被忽略, 在移動端下多點觸控是可能導致多個 down 事件觸發的.

# 3
down$: ---d---d--d---------d------    # 中間兩個事件因為拖拽未完成被忽略
           \                \
up$:        -----u|          ------u|
move$:      -m-mm-|          m-m-m--|
複製程式碼

實現:

有了彈珠圖後, 就是把翻譯問題了, 現在就開啟 rxjs 的工具箱, 找找有什麼合適的工具.

首先是抽象事件的處理. 由#1 可以看出, 這就是一個資料流合併, 這個適合使用merge

merge(fromEvent(el, 'touchstart'), fromEvent(el, 'mousedown'));
複製程式碼

down$流的切換可以使用exhaustMap操作符, 這個操作符可以將輸出值對映為Observable, 最後再使用exhaust操作符對Observable進行合併. 這可以滿足我們'當一個拖拽未結束時, 新發起的 down$輸出會被忽略, 直到拖拽完結'的需求

down$
  .pipe(
    exhaustMap(evt => /* 轉換為新的Observable流 */)
複製程式碼

使用 exhaustMap 來將 down$輸出值轉換為move$ 流, 並在 up$ 輸出後結束, 可以使用takeUntil操作符:

down$
  .pipe(
    exhaustMap(evt => {
      evt.preventDefault();
      if (evt.type === 'mousedown') {
        // 滑鼠控制
        const { clientX, clientY } = evt as MouseEvent;
        return mouseMove$.pipe(
          map(evt => {
            return {
              deltaX: (evt as MouseEvent).clientX - clientX,
              deltaY: (evt as MouseEvent).clientY - clientY,
            };
          }),
          takeUntil(mouseUp$),
        );
      } else {
        // 觸控事件
        const { touches } = evt as TouchEvent;
        const touch = touches[0];
        const { clientX, clientY } = touch;

        const getTouch = (evt: TouchEvent) => {
          const touches = Array.from(evt.changedTouches);
          return touches.find(t => t.identifier === touch.identifier);
        };
        const touchFilter = filter((e: Event) => !!getTouch(e as TouchEvent));

        return touchMove$.pipe(
          touchFilter,
          map(evt => {
            const touch = getTouch(evt as TouchEvent)!;
            return {
              deltaX: touch.clientX - clientX,
              deltaY: touch.clientY - clientY,
            };
          }),
          takeUntil(touchUp$.pipe(touchFilter)),
        );
      }
    }),
  )
  .subscribe(delta => {
    el.style.transform = `translate(${delta.deltaX}px, ${delta.deltaY}px)`;
  });
複製程式碼



Example 3: Todos

如果使用 rxjs 來建立 Todos 應用, 首先是流程圖:

Rxjs建模入門

資料流抽象:

首先識別變動的實體, 變動的實體就是 todos 列表, 所以可以認為 todos 列表就是一個流. 它從 localStorage 中恢復 初始化狀態. 由新增, 刪除等事件觸發狀態改變, 這些事件也可以視作流

add$:      --a-----a------
modify$:   ----m----------
remove$    -------r-------
complete$: ------c----c---
             \(merge)/
update$    --a-m-cra--c--- # 各種事件合併為update$流
              \(reduce)/
todos$:    i-u-u-uuu--u---- # i 為初始化資料, update$的輸出將觸發重新計算狀態
複製程式碼

todos$流會響應到 view 上, 另一方面需要持久化到本地儲存. 也就是說這是一個多播流.

todos$: i-u-u-uuu--u---- #
          \(debounce)/
save$   i--u--u---u----- # 儲存流, 使用debounce來避免頻繁儲存
複製程式碼

並行渲染到頁面:

todos$: i-u-u-uuu--u---- #
       \(render)/
dom$:   i--u--u---u----- # dom渲染, 假設也是流(cycle.js就是如此)
複製程式碼

這個例項的資料流和 Redux 的模型非常像, add$, modify$, remove$和complete$就是 Action, todos 流會使用 類似 Reducer 的機制來處理這些 Action 生成新的 State

redux

程式碼實現:

首先 add$, modify$以及 remove$和complete$可以分別使用一個 Subject 物件來表示, 用於接收外部事件. 其實還可以簡化為一個流, 它們的區別只是引數

interface Action<T = any> {
  type: string;
  payload: T;
}

const INIT_ACTION = 'INIT'; // 初始化
const ADD_ACTION = 'ADD';
const REMOVE_ACTION = 'REMOVE';
const MODIFY_ACTION = 'MODIFY';
const COMPLETE_ACTION = 'COMPLETE';

const update$ = new Subject<Action>();

function add(value: string) {
  update$.next({
    type: ADD_ACTION,
    payload: value,
  });
}

function remove(id: string) {
  update$.next({
    type: REMOVE_ACTION,
    payload: id,
  });
}

function complete(id: string) {
  update$.next({
    type: COMPLETE_ACTION,
    payload: id,
  });
}

function modify(id: string, value: string) {
  update$.next({
    type: MODIFY_ACTION,
    payload: { id, value },
  });
}
複製程式碼

建立todos$流, 對update$ 的輸出進行 reduce:

/**
 * 初始化Store
 */
function initialStore(): Store {
  const value = window.localStorage.getItem(STORAGE_KEY);
  return value ? JSON.parse(value) : { list: [] };
}

const todos$ = update$.pipe(
  // 從INIT_ACTION 觸發scan初始化
  startWith({ type: INIT_ACTION } as Action),
  // reducer
  scan<Action, Store>((state, { type, payload }) => {
    return produce(state, draftState => {
      let idx: number;
      switch (type) {
        case ADD_ACTION:
          draftState.list.push({
            id: Date.now().toString(),
            value: payload,
          });
          break;
        case MODIFY_ACTION:
          idx = draftState.list.findIndex(i => i.id === payload.id);
          if (idx !== -1) {
            draftState.list[idx].value = payload.value;
          }
          break;
        case REMOVE_ACTION:
          idx = draftState.list.findIndex(i => i.id === payload);
          if (idx !== -1) {
            draftState.list.splice(idx, 1);
          }
          break;
        case COMPLETE_ACTION:
          idx = draftState.list.findIndex(i => i.id === payload);
          if (idx !== -1) {
            draftState.list[idx].completed = true;
          }
          break;
        default:
      }
    });
  }, initialStore()),
  // 支援多播
  shareReplay(),
);

// 持久化
todos$.pipe(debounceTime(1000)).subscribe(store => {
  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
});
複製程式碼

更多例子: 徐飛在"RxJS 入門指引和初步應用>"提到了一個"幸福人生"的例子, 挺有意思, 讀者可以嘗試對其進行建模




經過上述過程, 可以深刻體會到函式響應式程式設計優勢:

  • 資料流抽象了很多現實問題. 也就說資料流對業務邏輯的表達能力流程圖基本一致. 可以說彈珠圖是流程圖的直觀翻譯, 而 Rx 程式碼則是彈珠圖的直觀翻譯. 使用 Rx 以宣告式形式編寫程式碼, 可以讓程式碼更容易理解, 因為它們接近業務流程.
  • 把複雜的問題分解成簡單的問題的組合. Rx 程式設計本質上就是資料流的分治和合並

相關資料

相關文章