前端资源静默刷新报错无解?不如用这个方案来避坑

639 阅读5分钟

一、问题背景

想必不少同学在开发过程中都经历过以下场景:

测试人员提出一个Bug,你在进行修复和Resolve后,测试人员Reopen并备注问题依然存在。你在本地确定代码无误后,去测试环境复现,发现原来是前端资源缓存的问题。

运维部署项目后,测试人员进行复查发现项目接口存在异常,后端与运维排查半天,最后发现原来是因为前端缓存还是上一个版本的。

浏览器缓存提供展现信息加速力的同时也会给所有使用者带来一些illusion。有时候即便经验非常丰富的前端技术人员,也可能会被hoisting所骗。这种本地缓存滞后的问题,虽然经常性遇到但很少真正被解决。

例如:在给客户部署产品后,使用过程中接口频频出现 4xx 状态码,在进行排查后端服务,排查代理等方向花费较长时间进行试错,最后发现是所使用的前端资源是浏览器缓存导致,在新版本中接口进行了变动,但由于是缓存资源,它请求的是新版本后端服务中废弃资源的 URI。

二、传统方案

对网络基础了解比较充分的同学肯定会马上想到:

重启 nginx 服务器强制更新缓存。

我们都知道浏览器的缓存分为两种:强缓存和协商缓存。并且根据优先级缓存被存储介质分为worker cache、memory cache、disk cache、push cache。通过HTTP heads 对它们施加限制,以控制缓存的使用。下面来逐一为大家说明为何这种方式不可取。

HTTP Headers

从上文的场景来说,我们需要的是及时有效的缓存清除,因此对于通过设置cache-conrol: max-age=1000;或HTTP 1.0时代的Expires: Wed, 22 Nov 2019 08:41:00 GMT 都无法满足我们对于实时性的需求。

是否可使用协商缓存呢?

协商缓存可能是大部分同学面对这种问题时首要想到的解决方法。它是通过询问服务端是否使用缓存,达到合法使用缓存的目的。可配合缓存服务器,降低服务器处理请求的负载,实现负载均衡。

即使这个解决方案看起来不错,但会带来一些其他问题:

若运维升级时,用户关闭了对应域名的服务,升级结束后,依然使用的是旧缓存,没有请求静态资源。

缓存服务会导致访问已经不存在的接口资源,返回4xx系列错误

由于数栈产品是大数据处理,localstorage的5MB容量无法满足需求,因此使用IndexDB进行数据持久化,那么在缓存资源更新时还需要额外清理持久化的旧数据

对于后端存在强依赖性,例如,前端bug fix后会重新部署,但是后端服务并没有改变。

WebSocket

那么若是使用websocket推送更新给用户呢?

此方案虽然在视觉效果上是完美的,但是需要在所有客户的服务上增加中间层有一定的风险,且时间成本较高。

轮询请求

那可否尝试使用轮询请求服务端以获取资源最新的版本信息?

轮询并不适合本场景使用,原因有三:

轮询请求不好指定时间,如果轮询间隔时间太长,比如 5 分钟,那么很可能在这 5 分钟里面用户已经使用缓存访问了错误资源。如果时间设置太短,请求会过于频繁并且这种频繁请求会在用户使用期间一直存在,这或多或少会影响用户体验。

轮询请求在非HTTP2使用Stream进行多路复用的场景,并行请求只有6个管道。 让一个只是某一时刻重要的缓存请求持续占据一个管道显然是不合适的。

轮询请求需要添加一个版本问询服务,这有服务挂掉的风险,但 nginx 代理服务器可以接手解决。

三、前端资源静默更新

在 nginx 代理服务层进行处理,能避免被链路后的环境干扰,并且可以命令 nginx 在满足某些条件的时候跳过后端服务的反向代理,直接返回制定的响应码亦或是响应报文。

由于整条链路还是比较长,我们先整理下思路。

思路

我们可以在部署时通过某种方式,把前端资源的标记位交给代理服务器,在代理服务器每次转发http response时把标记位塞入response head返回给客户端。客户端把标记位存储到本地存储,在此之后的 request head带上标记位信息。

代理服务器取出request head标记位与服务器环境的标记位进行对比,如果资源一致,可以表明用户使用的是最新的前端版本,反之返回412状态码,并在response中塞入最新的状态码。

前端的 Fetch / XHR 接收412响应后,做对应的清理工作并更新本地的标记位。确保客户端一直是最新的资源。

图片

具体措施

step1: Webpack Plugin

首先我们要生成交给服务器的标记位,一般情况下使用 UUID作为标记位即可,我司部署方案会使用 Kubernetes集群部署,在不同的容器环境中,会生成数个UUID。

出于环境的影响用户访问的并不是固定某个容器,因此容易出现代理服务器数次提醒客户端版本滞后的问题。所以最后决定在部署环节使用插件在webpack emit hooks生成一个以项目版本+部署时间戳为标示的key,置入最后产出的包中,以兼容k8s部署,所有最新的标志位以这份生成的bundle key为主。

图片

step2: Additional Shell

定义一份shell脚本,将其置入自动化CD脚本的末尾,此时前端资源已经存在于服务器上,只需在脚本中取出前端资源的bundle key并存入服务器即可,笔者选择存储在服务器的环境变量中。

图片

step3:Lua / Perl

通过以上环节,服务器准备工作已经完成,下一步需要给代理服务器赋能,取得环境变量中的标志位boundle key。

代理服务器想要拿到对应的bundle key并主动推送给客户端,只依靠自己是不可能的,但可以借助其他脚本语言完成。经过尝试与研究,可确定Lua和Perl支持从服务器环境变量中获取bundle key。

这里我们以nginx为例看一下,可通过扩展nginx的模块,借助lua-nginx-module将nginx配置文件中的lua 脚本交给系统环境的LuaJIT代替执行。成功读取环境变量后,让nginx动态加载环境变量中的bundle key。

step4:Nginx

nginx获取boundle key后需要判断标记位是否一致。

通过下图我们可知nginx的原生逻辑判断能力与lua的配合后,可以验证浏览器发送的request head信息,若和服务器的bundle key不一致,则返回412状态码。

由于我们使用自定义的response head ,按照规范需要以X开头,本例中的响应头为X-Application-Version。

图片

step5:Front end processing

最后添加客户端校验,判断响应返回的X-Application-Version信息,前端可以自定义处理方案,添加任意交互或提醒,以及清除本地资源等操作。

总体流程图

图片

四、可能存在的疑问

Q: 此方案和协商缓存类似,均在Http Heads中添加信息进行交互,和那么和传统的协商缓存有那些区别?

A: 其实区别还是非常大的,本方案只是交互模式比较类似而已。

传统的协商缓存如果要引入代理服务器验证是需要主服务器通过Cache-Control: public进行赋能的,并且服务器赋能会有时限和强制确定的限制(s-max-ageproxy-revalidate),而此方案完全是使用前端在服务器端的静态资源进行赋能的,和源服务器没有本质联系

传统的协商缓存无法跨越源服务器出现故障,缓存访问过期接口URL的等问题,而此方案因为不与源服务器进行交互,完全自给自足,是非常稳定的方案。

Q: 使用此方案部署前端项目后,就已经正常启动此方案了么?

A: 如果是首次使用本方案,必须预先确保当前浏览器能够获取到最新的前端静态资源,因为如果浏览器缓存着之前版本的前端资源,那么不会触发新版本中前端检测到标识符变化后的交互与清理操作。因此只需要保证首次部署本方案的时候,用户读取的不是缓存,那么之后的任何使用和部署便可无忧。

上文方案已经在袋鼠云内部经过测试推进并成功落地。成功解决了前端资源静默刷新方面的需求,由于笔者水平有限,此方案也只是一个抛砖引玉,肯定有值得优化的地方。若读者有更好的建议,欢迎对本文进行评论,帮助笔者完善方案过程。