基于HTTP缓存轻松实现客户端应用的离线支持及网络优化

常规的客户端应用开发实践中,为了支持离线特性,往往需要引入本地数据存储并增加相应的『离线状态』逻辑分支。本地存储的大量使用对数据结构的前后向兼容设计提出了很高的要求,一旦考虑不足,往往不得不引入复杂的版本间数据升降级处理,进一步加剧开发和维护成本。而且针对『离线』与『在线』状态这两条并行的处理分支,对业务逻辑的清晰性和可维护性有一定的破坏,常常容易在后续开发中造成处理遗漏,给测试和维护带来更多的痛苦。

在此前的一个客户端开发项目中,我们另辟蹊径的借助HTTP协议层的缓存机制(Cache-Control),实现了一个简洁高效的离线支撑框架。一般HTTP缓存运用在客户端开发中大多是应对图片等静态资源的缓存,而我们更进一步将API也纳入缓存管理的模式。相比上述传统思路,它具有以下独到的优势:

  • 基本消除了离线相关的业务数据存储需求,免除了考虑数据结构前后向兼容性及版本间数据升降级处理的痛苦。
  • 大幅度减少了离线特性对现有业务实现的侵入性,只要API接口设计得当,现有特性实现只需要作微小的调整即可直接支持离线。
  • 在网络状况不佳的情况下,提供无缝的用户体验。(优先显示缓存内容,异步刷新)
  • 同时也能优化在线状态下的网络传输,减少不必要的重复网络请求。

基本的实现思路是,在API client层透明的管理所有API请求,对于占绝大部分比例的GET类(不影响业务状态的)API,根据当前网络状态智能的协调真正的网络请求与缓存的响应,实现对业务处理层基本透明的离线状态应对。这样,业务处理层的代码只需按照在线状态下的场景实现相应的业务逻辑,即可同时支持离线的场景。

下面以几个典型的业务场景为例说明这个框架的工作方式:

1. 用户信息的离线展现

假定业务后端提供了获取用户信息的API:『/api/v1/profile』,客户端启动后,会通过这个API获取用户的基本信息(如用户名、头像、积分等),并展现在主界面中。当启动主界面时,客户端发起API调用,API client首先判断本地是否已缓存了此前该API的响应,如果已有缓存则直接返回缓存的响应。这个缓存响应对业务处理层的代码而言,跟一个正常的服务端响应基本没有差别,因此只需当作在线状态处理即可。

在返回缓存响应的同时,API client还会根据当前的网络可用性及数据时效性,决定是否发起一次异步的重新请求。在这个例子中,我们可以为Profile API配置一个默认的时效期,比如『10分钟』。如果缓存的响应数据尚在时效期内,则不再发出这一额外的异步请求。这个策略有助于减少在线状态下客户端的重复请求频率,降低流量浪费。基于这一策略,我们在业务实现中完全抛弃了Profile数据的本地存储,简化了实现流程,在每次界面展现时均发起API调用,获取实际数据,让HTTP缓存同时充当业务数据存储的角色。这样就完全不必担心本地存储的数据结构前后向兼容和升降级问题了,而服务端的API接口URL协议中含有的API版本部分(如『/api/v2/profile』)确保了不同版本API响应的隔离。

时效的引入必然涉及到数据的一致性风险,因此除了根据业务场景合理为不同API设置各自的时效期外,客户端的业务逻辑中还需要在进行了显性影响此数据的操作后,使用『强制网络请求』的方式忽略缓存发起API调用。典型的场景如用户修改了头像或昵称后的个人信息刷新。

另一个应对数据时效性延滞的策略是采用服务端主动push数据变化的方式:为API设置一个较长的默认时效期(比如1小时),当数据发生变化时,服务端主动push一次响应。为了兼容API client使用的HTTP缓存机制,可以采用前端开发中比较成熟的『长连接挂起响应body』的push实现方式。

以上是最为常见且相对简单的离线需求场景,下面再以一个稍微复杂的例子说明这个框架的高级用法。

2. 可翻页清单的离线浏览

这是一个相对比较复杂的场景,『可翻页』意味着相关的清单数据具有关联延续性,比如搜索结果页、消息收件箱。支持此类数据的无缝离线体验,对API的设计会有一定的要求,才能确保URL在不同起点或页长下的一致性,使缓存能正确发挥作用。(其实这也是HTTP协议中URL结构的一个最初约束——『resource path』,只不过发展到今天,很多Web应用的URL规划早已忽视了这些基本原则)

搜索结果页和消息收件箱分别代表了两种不同的翻页需求场景,前者是起点固定,向后延续;而后者是终点固定,起点浮动。(假定清单的相对顺序在短期内不变)

(1) 搜索结果页

先说搜索结果页。确保URL一致性的最简单办法是固定页长(由服务端控制),URL中传递页码,例如:『/search/iphone+5/page/2』。这样,就可以保持离线搜索时的URL一致性。

这时需要解决的另一个关键问题是缓存的连带失效。对于没有关联延续性的单一页面,可以直接通过失效期和覆盖缓存的方式控制失效,但引入关联延续性之后,就需要连带失效多个关联页面的缓存了。比如在重新搜索相同的关键字后,原先缓存的后续几页就必须连带失效,以避免出现类似『新的第一页+老的第二页』所导致的清单内容混乱。

解决这个问题的方式有很多,在经过广泛的研究后,我们选择了使用Vary+特殊header的策略。这个header在每次刷新第一页时由客户端重新生成一个随机的token,并在连续的翻页期间保持不变。这个token的作用相当于一个session标识,借助HTTP协议的Vary header确保不同session的页面自动失效。这个实现方式可以很好的兼容HTTP协议的标识实现,而且对服务端没有特别的开发需求。

(2)信息收件箱

信息收件箱相比搜索结果页的复杂性在于,每次浏览的起点可能不固定(假定我们以常见的时间倒序方式浏览),但已缓存的清单条目具有相对不变性(那些旧消息)。如果我们仍然采用URL中传递页码的策略,那么就可能出现刷新后因新条目增加而顺延现有条目所造成的『页面错位』,倘若简单粗暴的连带失效后续页面,就太浪费实际上可缓存的不变内容了。

在这样的场景下,设计一个可充分利用缓存的API URL具有相当高的挑战。在经过多次尝试和摸索之后,我们最终选择了『等间隔区间』读取的API URL策略,形如:『/api/messages?last_id=120』这里的『last_id』是以20为间隔的最近区间终点ID,服务端返回ID在120之上的最多20条消息(比如121~126)。如果用户向后翻页,则发起的API请求为『/api/messages?last_id=100』,此时服务端返回ID从101~120的20条消息。首次请求时,可以不携带『last_id』参数,而第一次翻页时取首页ID范围内为20整倍数的ID作为这次请求的『last_id』。例如首页获取的ID范围为『117~126』,则第一次翻页时请求『last_id=120』。为了优化最终用户的体验,实际显示在UI中的消息清单仍是以最新消息开始的每20条分页,比如此例中的『126~117』、『116~107』,UI逻辑层对偏移映射进行了的包装。

3. 具体实现层面

Android和iOS下均有直接可用的成熟框架支持HTTP Cache机制。iOS的NSURLCache从2.0开始就提供给开发者,而Android的HttpResponseCache要到4.0版本才能直接使用。不过开源社区已经有其back-port项目,可以运用在Android 2.x版本中。

需要特别一提的是,Android虽然从4.0版本开始提供了HttpResponseCache,但其中有一个对IO性能影响较大的问题,直到4.2版本才得以解决。因此,建议运行在4.2之前的版本上时,仍旧使用开源社区的back-port(已包含了解决上述性能问题的补丁)。

业务框架层只需少量的工作就可以将其集成到现有的API library中,考虑到不同的API library接口设计,可能需要引入适当的调整以支持『优先缓存、异步请求』的机制。以我们Android ApiClient的部分片段为例:

switch (cache_policy) {
	case NeverFromCache:
		connection.addRequestProperty("Cache-Control", "no-cache");
		break;
	case OnlyFromCache:
		connection.addRequestProperty("Cache-Control", "only-if-cached, max-stale=" + KMaxStale);
		break;
	case Default:	// Controlled by server response header
		break;
	}

这是直接指挥HTTP cache的部分,其中的三种策略(NeverFromCache、OnlyFromCache和Default)需要结合业务场景作出区分选择。

通常的原则,我们应当将API的缓存策略交由服务端根据业务需求确定,这时直接使用『Default』即可,减少客户端对业务变化的依赖。对于服务端而言,可以为不同的API指定不同的缓存策略,分别通过『Cache-Control』header指定:

不允许客户端缓存:『Cache-Control: no-cache』

在指定时效内缓存:『Cache-Control: max-age=3600』 (1小时内有效)

注:这里服务端指定的是『在线』时的缓存时效策略,影响的是客户端在主动失效缓存前可以不必请求新数据而直接使用缓存的时限。此处不必担心离线条件下超出时效的数据不可用,因为客户端可以通过前述的『max-stale』在『max-age』基础上延长时效性。(通常客户端可将『max-stale』设置的足够大以保证缓存的数据始终可用。

如果只是实现简单的离线支持,不考虑在线期间的缓存省流,那么服务端并不需要作任何调整,客户端的相应逻辑也很简单:

cache_policy = is_offline ? OnlyFromCache : Default

但如果App中包含有显式展示最新状态的界面(陈旧或缓存的信息可能影响用户判断)时,则需要使用『NeverFromCache』。

除了上面提到的特殊场景外,在上层的业务代码中一般大部分的业务需求均不必涉及到cache策略的选择,往往只需为离线状态增加一些全局性的体验优化即可(如无缓存时的友好提示)。

Written on December 18, 2012