消息複雜計算的抽象和簡化

本文將介紹客戶端消息數據計算的問題與解決方案。

消息客戶端計算的複雜性

在客戶端的設計中,一般的分層會至少包含下層的數據服務層和上層的 UI 層,下層的數據模型主要由所在領域決定,相對獨立、穩定,而 UI 則更多變,且會對多種數據進行組合。由於 UI 的相對多變性與模型的相對穩定性,在數據層和 UI 之間,就需要對數據進行若干的處理才能交給 UI 展示。比較簡單的情況比如將原始數據的時間戳轉換爲 PD 要求的字符串,如果涉及到對不同數據進行關聯、分頁加載、變更計算,這部分數據處理邏輯就會比較複雜。

消息作爲富客戶端,這部分邏輯非常複雜,加上狀態的存在,可以說是消息客戶端中最複雜的邏輯之一,這種複雜主要體現在這些維度:

  1. 本地部分數據:客戶端只存部分消息數據,獲取數據時本地數據不全需要再異步請求服務端,還需要支持上層指定請求策略,這使得接口無法採用 request-response 的形式,必須使用流式接口,數據回調和結果回調的分離,以及多次數據回調,增加了處理邏輯的複雜度;

  2. 支持變更同步:除了主動拉取,會話消息數據需要支持變更的推送,且對於所有的變更,需要保證數據(包括緩存和 UI 展示)的一致性;

  3. 多個數據來源:由於歷史的原因,消息的同一種數據(比如會話)存在多個來源,因此需要請求多次,將多次數據回調合並,處理錯誤,還要儘量保證加載速度。經過衆多同學的努力,手淘和千牛中下掉了 OpenIM、DT 兩個數據源,今天用戶在手淘和千牛中看的會話和消息,依然有 BC、CC、IMBA 三個來源;

  4. 多種數據聚合:UI 展示需要把會話、消息、Profile(頭像暱稱)、羣、羣成員信息以及其他業務數據進行聚合,把相關的多個不同數據按照不同的規則聚合在一起;

  5. 支持分頁請求:總的數據量比較大,需要通過分頁機制加載,除了標準的分頁加載之外,還要支持定位到某條消息從中間開始加載,這就出現了雙向分頁加載,以及 進入和退出 中間加載時的狀態轉換和異常處理;

  6. 多條數據合併:由於業務的需要,消息之間存在更新和替換關係(比如同一條訂單的物流狀態更新),拉取的新數據要修改已存的消息狀態數據,而非僅僅添加在頭部或尾部,新的消息會導致已有消息的更新以及在數據結構中的位置變化;

  7. 數據結構複雜:消息存在列表、樹兩種 UI 形態,對應的狀態也有兩種形態,對於這些數據結構的變更計算邏輯比較複雜,對於樹來說,還需要支持虛擬節點計算和結構動態變化。

這塊邏輯在消息客戶端涉及會話、消息、profile、羣、羣成員、關係等所有核心服務數據模型,總計大約 25000 行代碼,佔消息總代碼的 8% 左右,是核心的數據處理。由於這些邏輯很容易耦合在一起,形成一些高維的邏輯,表現爲大量的條件分支和遞歸嵌套,這種高維的邏輯很難寫,也很難維護,並且佔據了不少包大小,因此有必要對這些邏輯抽象和簡化。

目標

  1. 在不同的模型、不同的接口和相似的邏輯之上再建立一層抽象,統一客戶端的數據處理;

  2. 將高維的數據處理邏輯簡化爲一個更加清晰的處理模型,代碼量下降 60%;

  3. 實現數據處理的雙端一致。

消息數據處理過程分析

通常會將客戶端劃分爲數據服務層、邏輯層、UI 層三層,這部分數據獲取和計算會被歸到邏輯層。這裏的問題在於,數據服務層對應於領域定義,UI 層對應於渲染、動畫和交互事件處理,這樣邏輯層很容易變成一個縫合怪,數據請求、數據轉換、上下文維護、異步處理、遞歸邏輯、狀態管理、變更同步,所有不屬於另外兩層的部分都會被扔到邏輯層,導致邏輯層的臃腫。

下圖左側是這個處理過程的工作內容和上下游,右側爲數據拉取和變更處理的數據流向和計算過程:

可以看到,將這部分數據處理僅僅定義爲邏輯是過於寬泛的,不利於針對性的優化,因此有必要進行深入的分析和研究。

在對會話、消息、profile、羣、羣成員、關係 6 大核心數據處理鏈路進行歸納、分解、分析和綜合之後,我們可以將數據處理過程簡化爲如下的過程:

  1. 請求每個通道的 會話\消息 數據,並將多次結果回調合併爲一次結果回調,處理多次數據回調,請求會話 \ 消息對應的 Profile、羣、羣成員、關係數據、業務數據;

  2. 建立會話\消息 和 Profile、羣、羣成員、關係數據、業務數據之間的 關聯關係 ,生成聚合數據,並處理聚合數據之間的依賴、優先級和緩存一致性;

  3. 將數據轉換爲數組 \ 樹形結構,支持請求來的數據與數據結構中已存的數據進行替換、更新合併計算,支持樹結構和虛擬節點的動態計算,支持 UI 局部更新;

  4. 響應各種數據的增刪改等變更事件,根據事件處理計算變更和結果,保證數據的一致性;

  5. 進入和退出中間加載時,處理各種數據緩存、關聯關係、加載信息的正確性;

  6. 支持特殊邏輯,如實時新數據不按照時間排序,而是直接添加在頭部或尾部;

  7. 中間每個邏輯的異常處理、超時機制、線程同步、上屏時間優化、日誌、監控等邏輯。

我們可以將這些邏輯分爲兩類:

▐作爲計算的邏輯

對應於上面的過程 2、3、4、5、6。

如果我們將這塊邏輯看做黑盒,關心它的輸入輸出和功能,可以得出這塊邏輯的核心工作是將各種各樣的輸入數據轉換爲特定的輸出數據,這完美的對應着計算的概念的結論,即:

基於計算的概念,可以將這段計算過程形式化地抽象爲一個函數 f,從而實現對狀態計算的抽象,上圖很直觀的體現了入參爲輸入和當前狀態,輸出爲新的狀態和結果:

f :: (Input, State1) -> (State2, Result)

來分析一下函數f的入出參和形式:

第一,這裏的 Input 可以能是拉取回來的數據,也可以是增刪改等數據變更,或者是消息已讀等明確的事件。這裏我們可以通過定義插入、更新、刪除三個來統一所有的事件,因爲所有的事件邏輯上都必定可以唯一的映射到這三個事件上(儘管實際上,由於部分服務不具備計算變更細節的能力,我們還支持了 RemoveAll 和 Reload 兩個事件)。

第二,結合高階函數,Input 實際上已經決定了這個函數的形式,即對於一個數據插入事件,其對應的 f 必然爲 \state -> insert someData into state 的形式,即 Input 已經包含在 f 的實現中了,因此可以將函數 f 進一步簡化爲:

f :: (State1) -> (State2, Result)

其中 f 的形式由輸出的事件決定,這樣就得到了一個非常簡化的函數抽象。

第三,上面的分析還能得出一個推論,即事件和函數是等價的(可以互相轉換)****,這使得我們可以通過處理事件來實現對函數的處理,從而可以通過數據的處理來優化計算的性能,可以看到,數據和過程邊界的打破賦予我們更強的能力。

第四,對於 State 參數,需要包含聚合後的數據,因此需要處理數據的關聯,一般的,我們可以將數據的關聯場景抽象爲 一個主數據對應多個附屬數據 的形式,通過定義一個 pair 函數來進行關聯關係的判斷:

pair :: (mainData, subData) -> Bool

這樣就可以通過注入 pair 函數來實現主數據和附屬數據的關聯,然後將有關聯關係的數據進行聚合。

第五,State還涉及數據 \ 樹形結構計算,這裏在不同的場景是不一樣的,可以抽象爲一個 DataStructure ,定義增刪改查接口,然後在不同的場景使用不同的 DataStructure

▐作爲結構化數據獲取的邏輯

對應於上面的過程 1、6。

這部分邏輯的作用是會話消息 Profile 等數據的獲取和變更事件監聽,由於 6 大服務的接口各不相同,之前的實現是一一對接。通過抽象之後,我們可以通過定義具備拉取接口和變更接口的 Inject 來實現這部分邏輯的抽象,這屬於標準操作,不再贅述。

這部分數據獲取的第二個特點是請求的平行分發和垂直組合,舉例來說,有多個通道決定了數據請求時需要平行的請求每個通道,每個通道的請求則根據不同的請求策略和每一步的回調數據決定下一次請求(這裏與標準的 Future/Promise 的區別在於,Future/Promise 前後步驟的任務是不同的,後面的邏輯需要前面的數據,這裏前後步驟的邏輯相同,可能上一步請求本地,下一次請求遠端,因此可以比 Future/Promise 更簡化)。

如果不進行抽象,這裏是一個至少三維的邏輯,即對於多個通道的多個步驟進行主數據的獲取,然後對獲取的主數據再獲取附屬數據,邏輯會寫的非常複雜。這裏的關鍵在於每個通道的請求,每個步驟的請求都是非常相似的,主要是多次請求的結構不同,並且數據請求的結構由參數和數據決定,因此可以把它稱爲結構化數據獲取,即這裏可以通過對請求結構的抽象進行簡化。

可以定義出結構化數據獲取任務平行和垂直組合的核心函數:

dispatch :: [param] -> [task]
compose :: strategy -> task

其中 dispatch 函數對應於 Rx 中的 flatMap,不過由於手淘 iOS 沒有集成 RxSwift 和 OpenCombine,官方的 Combine 框架要 iOS13 之上才能使用,因此只能自己實現一個輕量的。

這樣通過 dispatch 和 compose 將任務進行結構化組合實現任務獲取的抽象和簡化。

技術方案

▐核心技術方案

核心模塊:

  1. MergeDispatcher : 實現數據獲取的結構化,並將數據和變更統一爲變更,處理所有的異常

  2. Calculator : 實現主數據和附屬數據的關聯和聚合,計算的多線程同步,變更上報

  3. DataStructure : 進行主數據的結構計算

此外,Inject爲計算提供請求接口和變更事件,爲所有數據的注入點,上層通過 ModelService 獲取計算後有聚合數據構成的數據結構,以及變更事件。

▐調用關係與數據流向

ModelService會使用初始化DataStructureCalCulator主數據、附屬數據的Inject,並用來初始化MergeDispatcher

  1. 當 UI 需要數據時,調用ModelServiceload接口;

  2. ModelService直接調用MergeDispatcherload接口;

  3. MergeDispatcher 平行調用主數據Injectload接口,在每次回調主數據時,調用附屬數據Injectload接口請求附屬數據,根據場景執行對應的超時邏輯,將主數據和附屬數據給到Calculator進行計算,超時後的數據也繼續給到Calculator進行計算;

  4. Calculator 執行計算的多線程同步,更新主數據、附屬數據、關聯關係的緩存,生成聚合數據,並將主數據給到DataStructure計算結構,然後將返回的全量和變更進行上報;

  5. 數據結構接收數據後,對當前狀態(數據結構)執行增刪改操作,並返回對應的新狀態和變更數組。

技術效果

最終,我們實現了計算和數據獲取的分離,計算過程全部在Calculator,數據獲取主要在MergeDispatcher,兩部分獨立實現,不再耦合,將邏輯層次從原來的模型數量 * 接口數量 * 數據結構 降爲 事件數量 * 數據結構,處理模型非常清晰,且適用於任意模型。

針對計算過程,邏輯上抽象出一個高階計算函數 f :: (State1) -> (State2, Result),這個函數形式上非常簡單,卻緊緊抓住這種複雜狀態計算的本質,讓我們得以統一計算過程,整個計算過程的正確性有完備的理論基礎,後續新增模型不會增加計算邏輯

針對數據獲取,我們將數據類型化爲主數據和附屬數據,並針對由請求的結構進行抽象,實現了所有的數據獲取的統一和簡化

作者 | 四點

編輯 | 橙子君

出品 | 阿里巴巴新零售淘系技術

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Pzsowfo1ivfy3tjw87rBTw