RxJava 復雜場景(一):高級緩存

OrlandoLsm 8年前發布 | 21K 次閱讀 RxJava 數據庫 Java開發

用 RxJava 處理網絡數據和本地緩存這個話題大家肯定聽過好多遍了,但今天這里還有點新花樣:高級緩存。什么叫高級緩存?我得向大家坦白,題目中的“高級”其實只是為了吸引大家點進來,內容有一定綜合性,希望大家喜歡。

1,先理解問題

首先我們當然需要理解清楚問題:

本地緩存了大量的用戶信息,放在一張數據庫的表中,當我們拿著一個 id 列表批量獲取用戶信息時,我們需要首先從數據庫中查詢這些用戶,這里就很可能有一部分數據命中了緩存,有一部分數據沒有命中,對于后者,我們需要請求一個批量獲取用戶信息的 API,然后把網絡數據保存到數據庫中,最后我們把兩批數據合并起來返回給業務層。

問題描述應該還算清晰,但這個需求確實算是比較復雜了,相比于典型的“先展示本地數據,再展示網絡數據”或者“本地緩存命中,就不請求網絡”,區別不言而喻。

2,理清處理過程

理解問題之后, 一定不要急著開始寫代碼 ,因為很可能寫到一半我們就會發現,這么寫好像不行,于是推倒重來,而且很可能會重復好幾遍。

道理都懂,但真正做起來還是挺難的,到現在我都還忍不住,而且依然會出現過早開始編碼的情況,好的習慣仍需繼續努力培養 :)

問題描述就比較清晰,我們只需要稍加整理即可,一圖勝千言:

我們可以很清晰地看到,左右兩邊分為兩個數據流,我們最后需要合并它們,但在中間它們又有一些交互,尤其是 cache users ,開出一個新分支,經過一系列處理之后,又和自己合并。

3,動手實現

這里主要展示核心代碼,用到了 Java8 lambda 表達式,不熟悉的朋友要先看看 Java8 lambda 相關的內容。

代碼看起來非常優雅,如行云流水一般,對不對?其實寫出這段代碼也還是花了一番心思。下面稍微講解一下:

  1. 我們用 defer 操作符來把一個同步的函數調用包裝為一個 Observable 。

  2. mUserDbAccessor 封裝了數據庫訪問的代碼,ORM/DAO 什么的,總覺得不夠酷 :) 這里使用 SELECT IN 來進行選擇,值得一提的是,SQL 語句的構造,有多少個參數,就需要多少個參數占位符( ? ),否則就選不出來。

  3. EMPTY 是一個空數組常量,表示沒有緩存缺失。

  4. 如何找出缺失的 id 列表?這里我用了一個比較簡單的算法,先把命中列表排序,再遍歷原 ids 列表,從命中列表中進行二分查找,沒找到就說明缺失了,復雜度 O(n * log n)。

  5. 我們利用 map 操作符,把 Pair 中的 User 列表取出來。

  6. 我們再利用 flatMap 操作符,把 Pair 中的缺失列表取出并轉化為發出 User 列表的 Observable 。

  7. 這一步很重要,在緩存沒有缺失的時候,可能有的朋友會直接返回 Observable.empty() ,理由也很簡單,沒有網絡數據嘛,當然就是 empty 。但這里我們需要考慮第 10 步中的 zip 操作符,如果這里我們返回 empty ,那 zip 的將不會有輸出!

  8. 我們調用 API 批量獲取缺失的數據。

  9. 我們把網絡數據放入到數據庫中。

  10. 我們用 zip 操作符,把緩存分支和網絡分支合并起來, zip 的機制就是所有來源都有了數據,才把它們合并起來(另外還有一個操作符 combineLatest ,它是每當有一個來源有了數據,就收集所有來源的最新數據進行合并)。

  11. 最后我們進行合并操作,這里有一個小技巧,我們已知了最終數組的大小,就可以提前預分配了,盡管這里不會是性能瓶頸,但是幾乎零編碼成本的提升,何樂而不為?

正如函數名中的 Unordered 所言,這里我們并不會保證結果與請求順序的一致性,如果需要保證,那也很簡單,最后再加一個 map 操作即可。

寫了這么大一段優雅的代碼,如果是你,會不會迫不及待想測試一下效果了呢?肯定是。但怎么測試呢?是接著編寫業務層、UI 層的代碼,(手工)集成測試,還是先寫一個單元測試呢?

其實只要想到這個問題,答案應該就很明確了,既然無論手工還是測例,總歸是要測試的,那我們何不稍微多花一點工夫,編寫單元測試呢?此外,還要再編寫一大堆業務/UI 代碼的話,我們等得未免也太久。而且, 永遠不要相信你的眼睛,一切用代碼說話 ,手工黑盒測試通過了,根本不能保證內部邏輯符合預期,尤其是上面這么復雜的邏輯。最后, 沒有單元測試覆蓋的重構,驗證成本呈指數增長

4,賴不掉的測試

既然賴不掉,那我們就寫個同樣漂亮的測試。

鑒于即便我已經寫過好多測試了,但是配置項目的測試依賴依然遇到了問題,所以這里我還是把依賴貼出來:

junit 依賴就不用說了,新建安卓項目默認就添加的這個依賴,mockito 是一個進行 mock 的框架,除了能 mock,還能 verify,很好很強大。另外還有一點值得一提的是,不要同時依賴 mockito 和 dexmaker,它們不能很好地一起工作。只添加這兩個依賴,測例就可以編寫和運行了。

接下來看代碼之前,不熟悉 junit 和 mockito 的朋友一定要先看看文檔,不然會云里霧里。

測試代碼千萬不能寫得丑,不然我們只會更討厭寫測試代碼,不過我自認為上面的測試代碼也還是非常漂亮的。

先講一下測試邏輯:我們配置數據庫緩存返回空,即全部缺失,再配置 API 返回一個 User。那我們就應該驗證:我們最終拿到了 API 返回的那個 User、并且進行了一次數據庫查詢、進行了一次 API 調用、進行了一次數據庫保存。

下面看一下具體的代碼:

  1. @Rule 這個注解是讓測試運行之前能進行一些初始化,例如 2 中的初始化 mock,初始化工作由 mockito 完成。我們也可以用 @RunWith 注解,使用 mockito 的 runner,但這就讓我們無法使用其他的 runner 了。這一點類似于 composition over inheritance,讓用戶可以更加靈活,很好。

  2. 我們利用 @Mock 注解,讓 mockito 替我們初始化 mock,簡化代碼。

  3. 配置 mock 的行為時,一定要注意參數的匹配,例如我們這里將要使用 {1, 2, 3} 這個數組,那如果用 any() 就無法匹配,最好是傳什么參數,就直接用參數進行匹配。

  4. RxJava 業界良心,為我們提供了 TestSubscriber 便于測試,非常棒。

  5. 我們首先驗證我們確實拿到了 API 返回的 User。

  6. 再驗證我們只調用了一次 API,而且沒有調用其他任何接口。

  7. 最后我們再驗證只進行了一次數據庫查詢、一次數據庫保存,沒有其他任何調用。

怎么樣,邏輯非常嚴密吧?想想如果我們等到 UI 寫完之后手動測試,能測到哪一步?那時我們只能驗證第 5 點,6 確實可以通過抓包驗證,7 呢?給數據庫訪問加 log 然后看 log?NO NO NO!工程師不應該這么傻。我們這里只需要一個測例,就完全 cover 了所有的測試點,完美。

5,很遺憾,測試失敗

沒有想象中的一次通過,我們“正常地”失敗了:

這時候 mockito 的強大就體現出來了,非常簡潔直觀地告訴我們哪里出了什么問題: getIn 只應該調用一次,結果調用了兩次!

看!我們拿到了正確的 User,但卻不是按照正確的方式,以后只是經過了 QA 之手的版本,你敢信心滿滿地拍胸脯保證沒問題?

6,問題出在哪兒?

遇見問題不可怕,只要我們有清晰縝密的思路,去尋找問題發生的原因、分析原因找出解決辦法,那就好辦。

不過遺憾的是,這個問題我已經知道了原因,我只是一開始不太確定,所以才亟需編寫一個測例來解答我的疑惑,所以這里我沒法和大家分享我解決這個問題的思路了。

問題就出在第一部分代碼中的 5,6 步:我們分別對 cacheResult 進行了 map 和 flatMap ,得到了兩個流,但 defer 創建的是一個 cold observable,多次 subscribe(分成多個流最終就會導致多次 subscribe)就會多次執行 defer 內的代碼,所以我們進行了兩次數據庫查詢。

7,怎么解決它?

我的第一反應就是 make it hot,但用什么操作符卻不確定,所以我打算 google 一下,而且我還依稀記得有個大牛分享過這種用法。

首先 query 當然是 “rxjava observable”,但可想而知結果會太泛,基本不可能搜到目標,于是加上兩個詞,“rxjava observable expensive cache”,描述了一下我們的場景,但搜索結果依然不理想,而且都比較早,以 14,15 年的為主。

于是我限定了 site “site:androidweekly.net data cache”,因為我還依稀記得是在 AndroidWeekly 上看到的,改了 query 是因為第一下沒有結果。但依然沒有找到目標。

于是我再次想了想,好像是 Dan Lew 寫的文章,于是換個 site “site:danlew.net observable”,第一個結果就是苦苦追尋的: Multicasting in RxJava 。

http://blog.danlew.net/2016/06/13/multicasting-in-rxjava/

而且 google 也顯示我在不久前訪問過它。

當然,你可能會選擇查看 RxJava 手冊,再次過一遍所有的操作符,并最終找到目標,不過由于我的腦海里殘存了一點 Dan Lew 文章的印象,所以這種方式在我看來更快。

用 publish 來 make it hot,用 autoConnect 來省去我們手動調用 connect ,我們知道只會有兩次 subscribe,所以我們就用 autoConnect(2) 。這里還有一個關鍵點,就是 publish 的時機,不過在我們這里不是問題,因為我們有了明確的“分水嶺”。

8,測試全面了嗎?

修改之后執行測試,終于通過。

但我們測試得全面嗎?看似測得很深,但其實只考慮了一種情況,那就是緩存完全缺失,還有緩存完全命中、部分命中的情況根本沒考慮到!

所以我們加上一個緩存全部命中的情況:

我們這邊讓緩存全部命中(1),驗證拿到了正確的數據(2)、沒有調用 API(3)、只進行了一次數據庫查詢(4)。

執行測試,再次“正常地”失敗了:

我們額外調用了一次 mUserDbAccessor.put ,因為我們的代碼是這樣寫的:

無論如何我們都 put 了一次,如果緩存完全命中,網絡數據是空的,那我們當然不應該調用 put!

所以我們把代碼改成這樣:

再次運行測例,順利通過。

當然,還有緩存部分命中的情況,測試邏輯類似,這里就不贅述了。

此外,這里還沒有講怎么做異步,如此復雜的情況下怎么做異步。這塊內容我最近發現自己還是沒有理解透徹,所以還需要再好好總結一下:)

 

 

來自:http://mp.weixin.qq.com/s?__biz=MzAxMjM0OTA3Nw==&mid=2650203681&idx=1&sn=8ed60fe07101532394de3830f193951d&scene=1&srcid=0828oVN5q4jxc8xoFgxtCh85

 

 本文由用戶 OrlandoLsm 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!