近來前端社群有越來越多的人開始關注前端資料層的設計。DaoCloud 也遇到了這方面的問題。我們調研了很多種解決方案,最終採用 RxJs 來設計一套資料層。這一想法並非我們的首創,社群裡已有很多前輩、大牛分享過關於用 RxJs 設計資料層的構想和實踐。站在巨人的肩膀上,才能走得更遠。因此我們也打算把我們的經驗公佈給大家,也算是對社群的回饋吧。
作者簡介
瞬光
DaoCloud 前端工程師
一名中文系畢業的非典型程式設計師。
我們遇到了什麼困難
DaoCloud Enterprise(下文簡稱 DCE) 是 DaoCloud 的主要產品,它是一個應用雲平臺,也是一個非常複雜的單頁面應用。它的複雜性主要體現在資料和互動邏輯兩方面上。在資料方面,DCE 需要展示大量資料,資料之間依賴關係繁雜。在互動邏輯方面,DCE 中有著大量的互動操作,而且幾乎每一個操作幾乎都是牽一髮而動全身。但是互動邏輯的複雜最終還是會表現為資料的複雜。因為每一次互動,本質上都是在處理資料。一開始的時候,為了保證資料的正確性,DCE 裡寫了很多處理資料、檢測資料變化的程式碼,結果導致應用非常地卡頓,而且程式碼非常難以維護。
在整理了應用資料層的邏輯後,我們總結出了以下幾個難點。本文會用較大的篇幅來描述我們所遇到的場景,這是因為如此複雜的前端場景比較少見,只有充分理解我們所遇到的場景,才能充分理解我們使用這一套設計的原因,以及這一套設計的優勢所在。
>>>> 應用的難點
資料來源多
DCE 的獲取資料的來源很多,主要有以下幾種:
後端、 Docker 和 Kubernetes 的 API
API 是資料的主要來源,應用、服務、容器、儲存、租戶等等資訊都是通過 API 獲取的。
WebSocket
後端通過 WebSocket 來通知應用等資料的狀態的變化。
LocalStorage
儲存使用者資訊、租戶等資訊。
使用者操作
使用者操作最終也會反應為資料的變化,因此也是一個資料的來源。
資料來源多導致了兩個問題:
複用處理資料的邏輯比較困難
由於資料來源多,因此獲取資料的邏輯常常分佈在程式碼各處。比如說容器列表,展示它的時候我們需要一段程式碼來格式化容器列表。但是容器列表之後還會更新,由於更新的邏輯和獲取的邏輯不一樣,所以就很難再複用之前所使用的格式化程式碼。
獲取資料的介面形式不統一
如今我們呼叫 API 時,都會返回一個 Promise。但並不是所有的資料來源都能轉換成 Promise,比如 WebSocket 怎麼轉換成 Promise 呢?結果就是在獲取資料的時候,要先呼叫 API,然後再監聽 WebSocket 的事件。或許還要同時再去監聽使用者的點選事件等等。等於說有多個資料來源影響同一個資料,對每一個資料來源都要分別寫一套對應的邏輯,十分囉嗦。
聰明的讀者可能會想到:只要把處理資料的邏輯和獲取資料的邏輯解耦不就可以了嗎?很好,現在我們有兩個問題了。
資料複雜
DCE 資料的複雜主要體現在下面三個方面:
從後端獲取的資料不能直接展示,要經過一系列複雜邏輯的格式化。
其中部分格式化邏輯還包括髮送請求。
資料之間存在著複雜的依賴關係。所謂依賴關係是指,必須要有 B 資料才能格式化 A 資料。下圖是 DCE 資料依賴關係的大體示意圖。
以格式化應用列表為例,總共有這麼幾個步驟。讀者不需要完全搞清楚,領會大意即可:
獲取應用列表的資料
獲取服務列表的資料。這是因為應用是由服務組成的,應用的狀態取決於服務的狀態,因此要格式化應用的狀態,就必須獲取服務列表的資料。
獲取任務列表的資料。服務列表裡其實也不包含服務的狀態,服務的狀態取決於服務的任務的狀態,因此要格式化服務的狀態,就必須獲取任務列表的資料。
格式化任務列表。
根據服務的 id 從任務列表中找到服務所對應的任務,然後根據任務的狀態,得出服務的狀態。
格式化 服務列表。
根據應用的 id 從服務列表中找到應用所對應的服務,然後根據服務的狀態,得出應用的狀態。順便還要把每個應用的服務的資料塞到每個應用裡,因為之後還要用到。
格式化應用列表。
完成!
這其中摻雜了同步和非同步的邏輯,非常繁瑣,非常難以維護(肺腑之言)。況且,這還只是處理應用列表的邏輯,服務、容器、儲存、網路等等列表需要獲取呢,並且邏輯也不比應用列表簡單。所以說,要想解耦獲取和處理資料的邏輯並不容易。因為處理資料這件事本身,就包括了獲取資料的邏輯。
如此複雜的依賴關係,經常會傳送重複的請求。比如說我之前格式化應用列表的時候請求過服務列表了,下次要獲取服務列表的時候又得再請求一次服務列表。
聰明的讀者會想:我把資料快取起來保管到一個地方,每次要格式化資料的時候,不要重新去請求依賴的資料,而是從快取裡讀取資料,然後一股腦傳給格式化函式,這樣不就可以了嗎?很好!現在我們有三個問題了!
資料更新困難
快取是個很好的想法。但是在 DCE 裡很難做,DCE 是一個對資料的實時性和一致性要求非常高的應用。
DCE 中幾乎所有資料都是會被全域性使用到的。比如說應用列表的資料,不僅要在應用列表中顯示,側邊欄裡也會顯示應用的數量,還有很多下拉選單裡面也會出現它。所以如果一處資料更新了,另一處沒更新,那就非常尷尬了。
還有就是之前提到的應用和服務的依賴關係。由於應用是依賴服務的,理論上來說服務變了,應用也是要變的,這個時候也要更新應用的快取資料。但事實上,因為資料的依賴樹實在是太深了(比如上圖中的應用和主機),有些依賴關係不那麼明顯,結果就會忘記更新快取,資料就會不一致。
什麼時候要使用快取、快取儲存在哪裡、何時更新快取,這些是都是非常棘手的問題。
聰明讀者又會想:我用 redux 之類的庫,弄個全域性的狀態樹,各個元件使用全域性的狀態,這樣不就能保證資料的一致了嗎?這個想法很好的,但是會遇到上面兩個難點的阻礙。redux 在面對複雜的非同步邏輯時就無能為力了。
>>>> 結論
結果我們會發現這三個難點每個單獨看起來都有辦法可以解決,但是合在一起似乎就成了無解死迴圈。因此,在經過廣泛調研之後,我們選擇了 RxJs。
為什麼 RxJs 可以解決我們的困難
在說明我們如何用 RxJs 解決上面三個難題之前,首先要說明 RxJs 的特性。畢竟 RxJs 目前還是個比較新的技術,大部分人可能還沒有接觸過,所以有必要給大家普及一下 RxJs。
統一了資料來源
RxJs 最大的特點就是可以把所有的事件封裝成一個 Observable,翻譯過來就是可觀察物件。只要訂閱這個可觀察物件,就可以獲取到事件源所產生的所有事件。想象一下,所有的 DOM 事件、ajax 請求、WebSocket、陣列等等資料,統統可以封裝成同一種資料型別。這就意味著,對於有多個來源的資料,我們可以每個資料來源都包裝成 Observable,統一給檢視層去訂閱,這樣就抹平了資料來源的差異,解決了第一個難題。
強大的非同步同步處理能力
RxJs 還提供了功能非常強大且複雜的操作符( Operator) 用來處理、組合 Observable,因此 RxJs 擁有十分強大的非同步處理能力,幾乎可以滿足任何非同步邏輯的需求,同步邏輯更不在話下。它也抹平了同步和非同步之間的鴻溝,解決了第二個難題。
資料推送的機制把拉取的操作變成了推送的操作
RxJs 傳遞資料的方式和傳統的方式有很大不同,那就是改“拉取”為“推送”。原本一個元件如果需要請求資料,那它必須主動去傳送請求才能獲得資料,這稱為“拉取”。如果像 WebSocket 那樣被動地接受資料,這稱為“推送”。如果這個資料只要請求一次,那麼採用“拉取”的形式獲取資料就沒什麼問題。但是如果這個資料之後需要更新,那麼“拉取”就無能為力了,開發者不得不在程式碼裡再寫一段程式碼來處理更新。
但是 RxJs 則不同。RxJs 的精髓在於推送資料。元件不需要寫請求資料和更新資料的兩套邏輯,只要訂閱一次,就能得到現在和將來的資料。這一點改變了我們寫程式碼的思路。我們在拿資料的時候,不是拿到了資料就萬事大吉了,還需要考慮未來的資料何時獲取、如何獲取。如果不考慮這一點,就很難開發出具備實時性的應用。
如此一來,就能更好地解耦檢視層和資料層的邏輯。檢視層從此不用再操心任何有關獲取資料和更新資料的邏輯,只要從資料層訂閱一次就可以獲取到所有資料,從而可以只專注於檢視層本身的邏輯。
BehaviorSubject 可以快取資料。
BehaviorSubject 是一種特殊的 Observable。如果 BehaviorSubject 已經產生過一次資料,那麼當它再一次被訂閱的時候,就可以直接產生上次所快取的資料。比起使用一個全域性變數或屬性來快取資料,BehaviorSubject 的好處在於它本身也是 Observable,所以非同步邏輯對於它來說根本不是問題。這樣一來第三個難題也解決了。
這樣一來三個問題是不是都沒有了呢?不,這下其實我們有了四個問題。
我們是怎麼用 RxJs 解決困難的
相信讀者看到這裡肯定是一臉懵逼。這就是第四個問題。RxJs 學習曲線非常陡峭,能參考的資料也很少。我們在開發的時候,甚至都不確定怎麼做才是最佳實踐,可以說是摸著石頭過河。建議大家閱讀下文之前先看一下 RxJs 的文件,不然接下來肯定十臉懵逼。
RxJs 真是太 TM 難啦!Observable、Subject、Scheduler 都是什麼鬼啦!Operator 怎麼有這麼多啊!每個 Operator 後面只是加個 Map 怎麼變化這麼大啊!都是
map
,為什麼這個map
和_.map
還不一樣啦!文件還只有英文噠(現在有中文了)!我昨天還在寫 jQuery,怎麼一下子就要寫這麼難的東西啊啊啊!!!(劃掉)——來自實習生的吐槽
首先,給大家看一個整體的資料層的設計。熟悉單向資料流的讀者應該不會覺得太陌生。
從 API 獲取一些必須的資料
由事件分發器來分發事件
事件分發器觸發控制各個資料管道
檢視層拼接資料管道,獲得用來展示的資料
檢視層通過事件分發器來更新資料管道
形成閉環
可以看到,我們的資料層設計基本上是一個單向資料流,確切地說是“單向資料樹”。
樹的最上面是樹根。樹根會從各個 API 獲得資料。樹根的下面是樹幹。從樹幹分岔出一個個樹枝。每個樹枝的終點都是一個可以供檢視層訂閱的 BehaviorSubject,每個檢視層元件可以按自己的需求來訂閱各個資料。資料和資料之間也可以互相訂閱。這樣一來,當一個資料變化的時候,依賴它的資料也會跟著變化,最終將會反應到檢視層上。
>>>> 設計詳細說明
root(樹根)
root 是樹根。樹根有許多觸鬚,用來吸收養分。我們的 root 也差不多。一個應用總有一些資料是關鍵的資料,比如說認證資訊、許可證資訊、使用者資訊。要使用我們的應用,我們首先得知道你登入沒登入,付沒付過錢對不對?所以,這一部分資料是最底層資料,如果不先獲取這些資料,其他的資料便無法獲取。而這些資料一旦改變,整個應用其他的資料也會發生根本的變化。比方說,如果登入的使用者改變了,整個應用展示的資料肯定也會大變樣。
在具體的實現中,root 通過
zip
操作符彙總所有的 api 的資料。為了方便理解,本文中的程式碼都有所簡化,實際場景肯定遠比這個複雜。// 從各個 API 獲取資料 const license$ = Rx.Observable.fromPromise(getLicense()); const auth$ = Rx.Observable.fromPromise(getAuth()); const systemInfo$ = Rx.Observable.fromPromise(getSystemInfo()); // 通過 zip 拼接三個資料,當三個 API 全部返回時,root$ 將會發出這三個資料 const root$ = Rx.Observable.zip(license$, auth$, systemInfo$);複製程式碼
當所有必須的的資料都獲取到了,就可以進入到樹幹的部分了。
trunk(樹幹)
trunk 是我們的樹幹,所有的資料都首先流到 trunk ,trunk 會根據資料的種類,來決定這個資料需要流到哪一個樹枝中。簡而言之,trunk 是一個事件分發器。所有事件首先都彙總到 trunk 中。然後由 trunk 根據事件的型別,來決定哪些資料需要更新。有點類似於 redux 中根據 action 來觸發相應 reducer 的概念。
之所以要有這麼一個事件分發器,是因為 DCE 的資料都是牽一髮而動全身的,一個事件發生時,往往需要觸發多個資料的更新。此時有一個統一的地方來管理事件和資料之間的對應關係就會非常方便。一個統一的事件的入口,可以大大降低未來追蹤資料更新過程的難度。
在具體的實現中,trunk 是一個 Subject。因為 trunk 不但要訂閱 WebSocket,同時還要允許檢視層手動地釋出一些事件。當有事件發生時,無論是 WebSocket 事件還是檢視層釋出的事件,經過 trunk 的處理後,我們都可以一視同仁。
//一個產生 WebSocket 事件的 Observable const socket$ = Observable.webSocket('ws://localhost:8081'); // trunk 是一個 Subject const trunk$ = new Rx.Subject() // 在 root 產生資料之前,trunk 不會發布任何值。trunk 之後的所有邏輯也都不會執行。 .skipUntil(root$) // 把 WebSocket 推送過來的事件,合併到 trunk 中 .merge(socket$) .map(event => { // 在實際開發過程中,trunk 可能會接受來自各種事件源的事件 // 這些事件的資料格式可能會大不相同,所以一般在這裡還需要一些格式化事件的資料格式的邏輯。 });複製程式碼
branch(樹枝)
trunk 的資料最終會流到各個 branch。branch 究竟是什麼,下面就會提到。
在具體的實現中,我們在 trunk 的基礎上,用操作符對 trunk 所分發的事件進行過濾,從而建立出各個資料的 Observable,就像從樹幹中分出的樹枝一樣。
// trunk 格式化好的事件的資料格式是一個陣列,其中是需要更新的資料的名稱 // 這裡通過 filter 操作符來過濾事件,給每個資料建立一個 Observable。相當於於從 trunk 分岔出多條樹枝。 // 比如說 trunk 釋出了一個 ['app', 'services'] 的事件,那麼 apps$ 和 services$ 就能得到通知 const apps$ = trunk$.filter(events => events.includes('app')); const services$ = trunk$.filter(events => events.includes('service')); const containers$ = trunk$.filter(events => events.includes('container')); const nodes$ = trunk$.filter(events => events.includes('node'));複製程式碼
僅僅如此,我們的 branch 還沒有什麼實質性的內容,它僅僅能接受到資料更新的通知而已,後面還需要加上具體的獲取和處理資料的邏輯,下面就是一個容器列表的 branch 的例子。
// containers$ 就是從 trunk 分出來的一個 branch。 // 當 containers$ 收到來自 trunk 的通知的時候,containers$ 後面的邏輯就會開始執行 containers$ // 當收到通知後,首先呼叫 API 獲取容器列表 .switchMap(() => Rx.Observable.fromPromise(containerApi.list())) // 獲取到容器列表後,對每個容器分別進行格式化。 // 每個容器都是作為引數傳遞給格式化函式的。格式化函式中不包含任何非同步的邏輯。 .map(containers => containers.map(container, container => formatContainer(container)));複製程式碼
現在我們就有了一個能夠產生容器列表的資料的
containers$
。我們只要訂閱containers$
就可以獲得最新的容器列表資料,並且當 trunk 發出更新通知的時候,資料還能夠自動更新。這是巨大的進步。現在還有一個問題,那就是如何處理資料之間的依賴關係呢?比如說,格式化應用列表的時候假如需要格式化好的容器列表和服務列表應該怎麼做呢?這個步驟在以前一直都十分麻煩,寫出來的程式碼猶如義大利麵。因為這個步驟需要處理不少的非同步和同步邏輯,這其中的順序還不能出錯,否則可能就會因為關鍵資料還沒有拿到導致格式化時報錯。
實際上,我們可以把 branch 想象成一個“管道”,或者“流”。這兩個概念都不是新東西,大家應該比較熟悉。
We should have some ways of connecting programs like garden hose—screw in another segment when it becomes necessary to massage data in another way.
——Douglas McIlroy
如果資料是以管道的形式存在的,那麼當一個資料需要另一個資料的時候,只要把管道接起來不就可以了嗎?幸運的是,藉助 RxJs 的 Operator,我們可以非常輕鬆地拼接資料管道。下面就是一個應用列表拼接容器列表的例子。
// apps$ 也是從 trunk 分出來的一個 branch apps$ // 同樣也從 API 獲取資料 .switchMap(() => Rx.Observable.fromPromise(appApi.list())) // 這裡使用 combineLatest 操作符來把容器列表和服務列表的資料拼接到應用列表中 // 當容器或服務的資料更新時,combineLatest 之後的程式碼也會執行,應用的資料也能得到更新。 .combineLatest(containers$, services$) // 把這三個資料一起作為引數傳遞給格式化函式。 // 注意,格式化函式中還是沒有任何非同步邏輯,因為需要非同步獲取的資料已經在上面的 combineLatest 操作符中得到了。 .map(([apps, containers, services]) => apps.map(app => formatApp(app, containers, services)));複製程式碼
格式化函式
格式化函式就是上文中的
formatApp
和formatContainer
。它沒有什麼特別的,和 RxJs 沒什麼關係。唯一值得一提的是,以前我們的格式化函式中充斥著非同步邏輯,很難維護。所以在用 RxJs 設計資料層的時候我們刻意地保證了格式化函式中沒有任何非同步邏輯。即使有的格式化步驟需要非同步獲取資料,也是在 branch 中通過資料管道的拼接獲取,再以引數的形式統一傳遞給格式化函式。這麼做的目的就是為了將非同步和同步解耦,畢竟非同步的邏輯由 RxJs 處理更加合適,也更便於理解。
fruit
現在我們只差快取沒有做了。雖然我們現在只要訂閱
apps$
和containers$
就能獲取到相應的資料,但是前提是 trunk 必需要釋出事件才行。這是因為 trunk 是一個 Subject,假如 trunk 不釋出事件,那麼所有訂閱者都獲取不到資料。所以,我們必須要把 branch 吐出來的資料快取起來。 RxJs 中的 BehaviorSubject 就非常適合承擔這個任務。BehaviorSubject 可以快取每次產生的資料。當有新的訂閱者訂閱它時,它就會立刻提供最近一次所產生的資料,這就是我們要的快取功能。所以對於每個 branch,還需要用 BehaviorSubject 包裝一下。資料層最終對外暴露的介面實際上是 BehaviorSubject,檢視層所訂閱的也是 BehaviorSubject。在我們的設計中,BehaviorSubject 叫作 fruit,這些經過層層格式化的資料,就好像果實一樣。
具體的實現並不複雜,下面是一個容器列表的例子。
// 每個資料流對外暴露的一個藉口是 BehaviorSubject,我們在變數末尾用$$,表示這是一個BehaviorSubject const containers$$ = new Rx.BehaviorSubject(); // 用 BehaviorSubject 去訂閱 containers$ 這個 branch // 這樣 BehaviorSubject 就能快取最新的容器列表資料,同時當有新資料的時它也能產生新的資料 containers$.subscribe(containers$$);複製程式碼
檢視層
整個資料層到上面為止就完成了,但是在我們用檢視層對接資料層的時候,也走了一些彎路。一般情況下,我們只需要用 vue-rx 所提供的 subscriptions 來訂閱 fruit 就可以了。
<template> <app-list :data="apps"></app-list> </template> <script> import app$$ from '../branch/app.branch'; export default { name: 'app', subscriptions: { apps: app$$, }, }; </script>複製程式碼
但有些時候,有些頁面的資料很複雜,需要進一步處理資料。遇到這種情況,那就要考慮兩點。一是這個資料是否在別的頁面或元件中也要用,如果是的話,那麼就應該考慮把它做進資料層中。如果不是的話,那其實可以考慮在頁面中單獨再建立一個 Observable,然後用 vue-rx 去訂閱這個 Observable。
還有一個問題就是,假如檢視層需要更新資料怎麼辦?之前已經提到過,整個資料層的事件分發是由 trunk 來管理的。因此,檢視層如果想要更新資料,也必須取道 trunk。這樣一來,資料層和檢視層就形成了一個閉環。檢視層根本不用擔心資料怎麼處理,只要向資料層釋出一個事件就能全部搞定。
methods: { updateApp(app) { appApi.update(app) .then(() => { trunk$.next(['app']) }) }, },複製程式碼
下面是整個資料層設計的全貌,供大家參考。
總結
之後的開發過程證明,這一套資料層很大程度上解決了我們的問題。它最大的好處在於提高了程式碼的可維護性,從而使得開發效率大大提高,bug 也大大減少。
我們對 RxJs 的實踐也是剛剛開始,這一套設計肯定還有很多可改進的地方。如果大家對本文有什麼疑惑或建議,可以寫郵件給 bowen.tan@daocloud.io,還望大家不吝賜教。