RxJava 复杂场景(一):高级缓存

OrlandoLsm 8年前
   <p>用 RxJava 处理网络数据和本地缓存这个话题大家肯定听过好多遍了,但今天这里还有点新花样:高级缓存。什么叫高级缓存?我得向大家坦白,题目中的“高级”其实只是为了吸引大家点进来,内容有一定综合性,希望大家喜欢。</p>    <h2>1,先理解问题</h2>    <p>首先我们当然需要理解清楚问题:</p>    <p>本地缓存了大量的用户信息,放在一张数据库的表中,当我们拿着一个 id 列表批量获取用户信息时,我们需要首先从数据库中查询这些用户,这里就很可能有一部分数据命中了缓存,有一部分数据没有命中,对于后者,我们需要请求一个批量获取用户信息的 API,然后把网络数据保存到数据库中,最后我们把两批数据合并起来返回给业务层。</p>    <p>问题描述应该还算清晰,但这个需求确实算是比较复杂了,相比于典型的“先展示本地数据,再展示网络数据”或者“本地缓存命中,就不请求网络”,区别不言而喻。</p>    <h2>2,理清处理过程</h2>    <p>理解问题之后, <strong>一定不要急着开始写代码</strong> ,因为很可能写到一半我们就会发现,这么写好像不行,于是推倒重来,而且很可能会重复好几遍。</p>    <p>道理都懂,但真正做起来还是挺难的,到现在我都还忍不住,而且依然会出现过早开始编码的情况,好的习惯仍需继续努力培养 :)</p>    <p>问题描述就比较清晰,我们只需要稍加整理即可,一图胜千言:</p>    <p><img src="https://simg.open-open.com/show/0065a86dceede5e7f8ab53d0c46c147d.png"></p>    <p>我们可以很清晰地看到,左右两边分为两个数据流,我们最后需要合并它们,但在中间它们又有一些交互,尤其是 cache users ,开出一个新分支,经过一系列处理之后,又和自己合并。</p>    <h2>3,动手实现</h2>    <p>这里主要展示核心代码,用到了 Java8 lambda 表达式,不熟悉的朋友要先看看 Java8 lambda 相关的内容。</p>    <p><img src="https://simg.open-open.com/show/ab8c2144040c44853999f7047ec90e8c.jpg"></p>    <p><img src="https://simg.open-open.com/show/14658f0cea6c8a373a4a9a6ddb706488.jpg"></p>    <p>代码看起来非常优雅,如行云流水一般,对不对?其实写出这段代码也还是花了一番心思。下面稍微讲解一下:</p>    <ol>     <li> <p>我们用 defer 操作符来把一个同步的函数调用包装为一个 Observable 。</p> </li>     <li> <p>mUserDbAccessor 封装了数据库访问的代码,ORM/DAO 什么的,总觉得不够酷 :) 这里使用 SELECT IN 来进行选择,值得一提的是,SQL 语句的构造,有多少个参数,就需要多少个参数占位符( ? ),否则就选不出来。</p> </li>     <li> <p>EMPTY 是一个空数组常量,表示没有缓存缺失。</p> </li>     <li> <p>如何找出缺失的 id 列表?这里我用了一个比较简单的算法,先把命中列表排序,再遍历原 ids 列表,从命中列表中进行二分查找,没找到就说明缺失了,复杂度 O(n * log n)。</p> </li>     <li> <p>我们利用 map 操作符,把 Pair 中的 User 列表取出来。</p> </li>     <li> <p>我们再利用 flatMap 操作符,把 Pair 中的缺失列表取出并转化为发出 User 列表的 Observable 。</p> </li>     <li> <p>这一步很重要,在缓存没有缺失的时候,可能有的朋友会直接返回 Observable.empty() ,理由也很简单,没有网络数据嘛,当然就是 empty 。但这里我们需要考虑第 10 步中的 zip 操作符,如果这里我们返回 empty ,那 zip 的将不会有输出!</p> </li>     <li> <p>我们调用 API 批量获取缺失的数据。</p> </li>     <li> <p>我们把网络数据放入到数据库中。</p> </li>     <li> <p>我们用 zip 操作符,把缓存分支和网络分支合并起来, zip 的机制就是所有来源都有了数据,才把它们合并起来(另外还有一个操作符 combineLatest ,它是每当有一个来源有了数据,就收集所有来源的最新数据进行合并)。</p> </li>     <li> <p>最后我们进行合并操作,这里有一个小技巧,我们已知了最终数组的大小,就可以提前预分配了,尽管这里不会是性能瓶颈,但是几乎零编码成本的提升,何乐而不为?</p> </li>    </ol>    <p>正如函数名中的 Unordered 所言,这里我们并不会保证结果与请求顺序的一致性,如果需要保证,那也很简单,最后再加一个 map 操作即可。</p>    <p>写了这么大一段优雅的代码,如果是你,会不会迫不及待想测试一下效果了呢?肯定是。但怎么测试呢?是接着编写业务层、UI 层的代码,(手工)集成测试,还是先写一个单元测试呢?</p>    <p>其实只要想到这个问题,答案应该就很明确了,既然无论手工还是测例,总归是要测试的,那我们何不稍微多花一点工夫,编写单元测试呢?此外,还要再编写一大堆业务/UI 代码的话,我们等得未免也太久。而且, <strong>永远不要相信你的眼睛,一切用代码说话</strong> ,手工黑盒测试通过了,根本不能保证内部逻辑符合预期,尤其是上面这么复杂的逻辑。最后, <strong>没有单元测试覆盖的重构,验证成本呈指数增长</strong> 。</p>    <h2>4,赖不掉的测试</h2>    <p>既然赖不掉,那我们就写个同样漂亮的测试。</p>    <p>鉴于即便我已经写过好多测试了,但是配置项目的测试依赖依然遇到了问题,所以这里我还是把依赖贴出来:</p>    <p><img src="https://simg.open-open.com/show/b20bb8a49b2bde11930f6778db4144fd.jpg"></p>    <p>junit 依赖就不用说了,新建安卓项目默认就添加的这个依赖,mockito 是一个进行 mock 的框架,除了能 mock,还能 verify,很好很强大。另外还有一点值得一提的是,不要同时依赖 mockito 和 dexmaker,它们不能很好地一起工作。只添加这两个依赖,测例就可以编写和运行了。</p>    <p>接下来看代码之前,不熟悉 junit 和 mockito 的朋友一定要先看看文档,不然会云里雾里。</p>    <p><img src="https://simg.open-open.com/show/35615f39a2e5a5f43d69f66e1465c7fc.jpg"></p>    <p><img src="https://simg.open-open.com/show/11b7511c1bafe15416fc28f72976644c.jpg"></p>    <p>测试代码千万不能写得丑,不然我们只会更讨厌写测试代码,不过我自认为上面的测试代码也还是非常漂亮的。</p>    <p>先讲一下测试逻辑:我们配置数据库缓存返回空,即全部缺失,再配置 API 返回一个 User。那我们就应该验证:我们最终拿到了 API 返回的那个 User、并且进行了一次数据库查询、进行了一次 API 调用、进行了一次数据库保存。</p>    <p>下面看一下具体的代码:</p>    <ol>     <li> <p>@Rule 这个注解是让测试运行之前能进行一些初始化,例如 2 中的初始化 mock,初始化工作由 mockito 完成。我们也可以用 @RunWith 注解,使用 mockito 的 runner,但这就让我们无法使用其他的 runner 了。这一点类似于 composition over inheritance,让用户可以更加灵活,很好。</p> </li>     <li> <p>我们利用 @Mock 注解,让 mockito 替我们初始化 mock,简化代码。</p> </li>     <li> <p>配置 mock 的行为时,一定要注意参数的匹配,例如我们这里将要使用 {1, 2, 3} 这个数组,那如果用 any() 就无法匹配,最好是传什么参数,就直接用参数进行匹配。</p> </li>     <li> <p>RxJava 业界良心,为我们提供了 TestSubscriber 便于测试,非常棒。</p> </li>     <li> <p>我们首先验证我们确实拿到了 API 返回的 User。</p> </li>     <li> <p>再验证我们只调用了一次 API,而且没有调用其他任何接口。</p> </li>     <li> <p>最后我们再验证只进行了一次数据库查询、一次数据库保存,没有其他任何调用。</p> </li>    </ol>    <p>怎么样,逻辑非常严密吧?想想如果我们等到 UI 写完之后手动测试,能测到哪一步?那时我们只能验证第 5 点,6 确实可以通过抓包验证,7 呢?给数据库访问加 log 然后看 log?NO NO NO!工程师不应该这么傻。我们这里只需要一个测例,就完全 cover 了所有的测试点,完美。</p>    <h2>5,很遗憾,测试失败</h2>    <p>没有想象中的一次通过,我们“正常地”失败了:</p>    <p><img src="https://simg.open-open.com/show/6976b9d02f62d1931d6e23203d66f803.jpg"></p>    <p>这时候 mockito 的强大就体现出来了,非常简洁直观地告诉我们哪里出了什么问题: getIn 只应该调用一次,结果调用了两次!</p>    <p>看!我们拿到了正确的 User,但却不是按照正确的方式,以后只是经过了 QA 之手的版本,你敢信心满满地拍胸脯保证没问题?</p>    <h2>6,问题出在哪儿?</h2>    <p>遇见问题不可怕,只要我们有清晰缜密的思路,去寻找问题发生的原因、分析原因找出解决办法,那就好办。</p>    <p>不过遗憾的是,这个问题我已经知道了原因,我只是一开始不太确定,所以才亟需编写一个测例来解答我的疑惑,所以这里我没法和大家分享我解决这个问题的思路了。</p>    <p>问题就出在第一部分代码中的 5,6 步:我们分别对 cacheResult 进行了 map 和 flatMap ,得到了两个流,但 defer 创建的是一个 cold observable,多次 subscribe(分成多个流最终就会导致多次 subscribe)就会多次执行 defer 内的代码,所以我们进行了两次数据库查询。</p>    <h2>7,怎么解决它?</h2>    <p>我的第一反应就是 make it hot,但用什么操作符却不确定,所以我打算 google 一下,而且我还依稀记得有个大牛分享过这种用法。</p>    <p>首先 query 当然是 “rxjava observable”,但可想而知结果会太泛,基本不可能搜到目标,于是加上两个词,“rxjava observable expensive cache”,描述了一下我们的场景,但搜索结果依然不理想,而且都比较早,以 14,15 年的为主。</p>    <p>于是我限定了 site “site:androidweekly.net data cache”,因为我还依稀记得是在 AndroidWeekly 上看到的,改了 query 是因为第一下没有结果。但依然没有找到目标。</p>    <p>于是我再次想了想,好像是 Dan Lew 写的文章,于是换个 site “site:danlew.net observable”,第一个结果就是苦苦追寻的: Multicasting in RxJava 。</p>    <p>http://blog.danlew.net/2016/06/13/multicasting-in-rxjava/</p>    <p>而且 google 也显示我在不久前访问过它。</p>    <p>当然,你可能会选择查看 RxJava 手册,再次过一遍所有的操作符,并最终找到目标,不过由于我的脑海里残存了一点 Dan Lew 文章的印象,所以这种方式在我看来更快。</p>    <p><img src="https://simg.open-open.com/show/97abc4497114e38a2e6b7892d2fd7871.jpg"></p>    <p>用 publish 来 make it hot,用 autoConnect 来省去我们手动调用 connect ,我们知道只会有两次 subscribe,所以我们就用 autoConnect(2) 。这里还有一个关键点,就是 publish 的时机,不过在我们这里不是问题,因为我们有了明确的“分水岭”。</p>    <h2>8,测试全面了吗?</h2>    <p>修改之后执行测试,终于通过。</p>    <p>但我们测试得全面吗?看似测得很深,但其实只考虑了一种情况,那就是缓存完全缺失,还有缓存完全命中、部分命中的情况根本没考虑到!</p>    <p>所以我们加上一个缓存全部命中的情况:</p>    <p><img src="https://simg.open-open.com/show/97f3ee5779b81b26ab4f25e6a7657277.jpg"></p>    <p>我们这边让缓存全部命中(1),验证拿到了正确的数据(2)、没有调用 API(3)、只进行了一次数据库查询(4)。</p>    <p>执行测试,再次“正常地”失败了:</p>    <p><img src="https://simg.open-open.com/show/b1207043c1f4d07b6c8fbb90d54861e3.jpg"></p>    <p>我们额外调用了一次 mUserDbAccessor.put ,因为我们的代码是这样写的:</p>    <p><img src="https://simg.open-open.com/show/e6685333d982c4ced0b7d04c365d60ba.jpg"></p>    <p>无论如何我们都 put 了一次,如果缓存完全命中,网络数据是空的,那我们当然不应该调用 put!</p>    <p>所以我们把代码改成这样:</p>    <p><img src="https://simg.open-open.com/show/5b6629c7f92ecd33f3670f22bcb0c7e5.jpg"></p>    <p>再次运行测例,顺利通过。</p>    <p>当然,还有缓存部分命中的情况,测试逻辑类似,这里就不赘述了。</p>    <p>此外,这里还没有讲怎么做异步,如此复杂的情况下怎么做异步。这块内容我最近发现自己还是没有理解透彻,所以还需要再好好总结一下:)</p>    <h2> </h2>    <p> </p>    <p>来自:http://mp.weixin.qq.com/s?__biz=MzAxMjM0OTA3Nw==&mid=2650203681&idx=1&sn=8ed60fe07101532394de3830f193951d&scene=1&srcid=0828oVN5q4jxc8xoFgxtCh85</p>    <p> </p>