【安全篇】如何利用 service worker 在前端服务器崩掉后如何还能正常提供给用户使用

1,814 阅读7分钟

前言

前端的每一次上线,都是一次非常有风险的操作,就算我们有再多的经验再多的兜底方案,都无法避免特殊情况的产生,假设我们现在项目修改了一版需求,内容A -> 内部B,在部署项目后不可避免的会出现服务器挂掉的情况

在这种情况下我们不管是如何挽救,也避免不了在这个时间段内用户在使用网站时出现的一系列问题

比如淘宝双十一,京东 618 等,每分钟有多少的用户的成交量,这个时候哪怕是一分钟的服务器异常也是无法接受的,更别说用户在这个时候无法访问网站了

一旦发生这种问题,就问问哪个兄弟心不慌

在脑子里回想了一下自己目前所了解的所有有关前端方向的知识,当时没有想到特别好的处理方案,毕竟服务器挂了,这个时候我们无法访问任何的服务器资源,做任何的优化方案都是徒劳的

后来想到了浏览器缓存的方案,但是不论是 session storage 还是 local storage 都只能缓存部分数据,但是没有办法对静态资源进行缓存,indexDB 也不是最优解,通过缓存文件流进行下次加载,也不是一个合理的方案

后来看到了有关 service worker 方面的介绍,在网上看了一下有关 service worker 的应用介绍,但是大部分都是一个不太看好的态度,但是我想这个 api 在这时正好可以满足我的需求,所以我尝试了一下实现方案,最后取得了不错的成果

缓存方案的实现

image.png

注册 service worker

首先,我们要先确定当前用户使用的浏览器是否支持 service worker

if ('serviceWorker' in navigatior) {

}

在支持的前提下,我们对 service worker 进行注册:

navigator.serviceWorker.register('/serviceWorker.js', { scope: '/' })

确定缓存资源范围

通过 service worker 是可以缓存所有的资源,但是对资源的缓存还是需要做过滤的,因为不是所有的资源都需要做缓存的,比如后端服务器的缓存前端是暂时不需要考虑的,毕竟数据的实时性很重要

对于用户来说,他们对于数据的操作都是通过前端来实现的,如果后端也崩了,前端做了缓存会对大部分数据的处理出现问题,例如:限时限量抢购、限量优惠券等,前后端服务器都挂掉的时候对于用户来说,其实就是等于完全不可以使用的一个状态

所以这时我们是需要对浏览器访问到的所有资源做一层筛选,把有关服务端所有接口请求做一层过滤:

self.addEventListener('fetch', event => {
    if (!isAssetUrl(event.request.url) && event.request.destination !== 'document') return
})

在这里区分静态资源和接口的请求,并对访问首页的 document 做过滤,假设我们本地通过 index.html 起了一个服务,浏览器访问文件的 url 最后是 /,在接口过滤文件时候这个问题是不能过滤掉的,因为接口有可能后缀是 /

对访问资源的后缀参数进行过滤

如果直接对浏览器请求的 url 进行过滤,这样不够严谨,因为说不准访问的资源后面会有 hash(#) 或者 query(?) ,我们需要对相应的参数进行过滤,保证检测 url 的地址是纯 url 地址:

function pureAssetUrl(assetUrl) {
  const decodeAssetUrl = decodeURIComponent(assetUrl)
  const lastIndex = decodeAssetUrl.lastIndexOf('/'); // 链接最后一个 /
  let lastSymbol1 = Math.max(0, decodeAssetUrl.indexOf('#', lastIndex)); // 匹配链接中第一个 #
  let lastSymbol2 = Math.max(0, decodeAssetUrl.indexOf('?', lastIndex)); // 匹配链接中第一个 ?
  let forwardIndex = [lastSymbol1, lastSymbol2].filter(num => num > 0); // 获取最靠前的一个特殊字符类型
  if (forwardIndex.length) {
    forwardIndex.push(forwardIndex[0])
    forwardIndex = Math.min(...forwardIndex)
  } else {
    forwardIndex = decodeAssetUrl.length
  }
  return decodeAssetUrl.substring(0, forwardIndex)
}

通过上面代码把所有的 url 做一层过滤,为了保证过滤覆盖的面够全,所以通过测试用例我做了一层测试,把目前可能存在的资源 url 都测试了一遍,目前是没有任何问题的:

const { pureAssetUrl } = require('./serviceWorker')
const url = 'http://baidu.com/'
test('test asset url is document', () => {
  expect(
    pureAssetUrl(url)
  ).toMatchSnapshot()
})

test('test asset url is asset', () => {
  expect(
    pureAssetUrl(`${url}a.js`)
  ).toMatchSnapshot()
})

test('test asset url is asset and params', () => {
  expect(
    pureAssetUrl(`${url}a.js?name=zhangsan`)
  ).toMatchSnapshot()
})

test('test asset url is asset and hash', () => {
  expect(
    pureAssetUrl(`${url}a.js#name=zhangsan`)
  ).toMatchSnapshot()
})

test('test asset url is asset and hash and params', () => {
  expect(
    pureAssetUrl(`${url}a.js#name=zhangsan?age=3`)
  ).toMatchSnapshot()
  expect(
    pureAssetUrl(`${url}a.js?age=3#name=zhangsan`)
  ).toMatchSnapshot()
})

下面是对应的快照:

exports[`test asset url is asset 1`] = `"http://baidu.com/a.js"`;

exports[`test asset url is asset and hash 1`] = `"http://baidu.com/a.js"`;

exports[`test asset url is asset and hash and params 1`] = `"http://baidu.com/a.js"`;

exports[`test asset url is asset and hash and params 2`] = `"http://baidu.com/a.js"`;

exports[`test asset url is asset and params 1`] = `"http://baidu.com/a.js"`;

exports[`test asset url is document 1`] = `"http://baidu.com/"`;

确认后缀是否是正常的浏览器访问资源

在把所有的 url 过滤后,我们拿到的就是上面的纯资源 url,这时候我们要去确定当前的浏览器资源是否是我们需要缓存的静态资源,这里比较简单,通过正则过滤一下就好:

function isAssetRegx(text) {
  return /\.(js|png|jpg|jpeg|svg|css|json|ico)$/.test(text)
}

这里我没有完全覆盖所有的资源后缀,最主要为了测试实现方案,有对应的后缀只要这里加一下就可以了

缓存实现

在确认了要缓存的目标后,我们就可以针对当前的请求来缓存资源,通过 caches.match 来判断当前的资源是否已经缓存,缓存的话会有对应的 response 体,如果没有的话,直接返回 undefined

caches.match(event.request).then(response => {})

其实在这个时候,我们并不需要关心是否有缓存,因为可能我们的静态资源是有更新的,要是这时候我们发现不是 undefined 就直接 return 的话,会导致用户访问的资源不是最新的

caches.match.then 回调内,通过 fetch 重新请求静态资源,以获取到最新的 response

这里知识点就来了!!!

如果是正常情况下,fetch 的请求是会走到 .then 内,因为服务器和网络都没有问题,可以获取正常的返回内容,现在我们做好缓存就可以了,但是如果前端服务器或者网络出现了问题,那么只会跑到 .catch 了,因为服务器访问是出现问题的

我们利用 api 提供的便利,为我们的项目在 .catch 阶段返回之前在服务器正常阶段所缓存的最新内容,这样就可以实现我们在前端服务器出现问题的情况下,让用户正常访问了!

实现代码如下:

return fetch(event.request)
.then(resp => {
    let respClone = resp.clone()
    caches.open('v1').then(cache => {
      cache.put(event.request, respClone)
    })
    return resp
})
.catch(err => {
    return response
})

这样,我们就可以在我们前端服务器出现问题的时候提出一个兜底方案,保证最近在访问过我们网站的用户正常使用我们的网站

完整代码如下:

// src/index.js
import * as serviceWorker from './sw'
serviceWorker.start()

// src/sw.js
export function start() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/serviceWorker.js', { scope: '/' })
  }
}

// public/serviceWorker.js
self.addEventListener('fetch', event => {
  if (!isAssetUrl(event.request.url) && event.request.destination !== 'document') return
  event.respondWith(
    caches.match(event.request)
    .then(response => {
      return fetch(event.request)
      .then(resp => {
        let respClone = resp.clone()
        caches.open('v1').then(cache => {
          cache.put(event.request, respClone)
        })
        return resp
      })
      .catch(err => {
        return response
      })
    })
  )
})
function isAssetUrl(assetUrl) {
  const filterAssetUrl = pureAssetUrl(assetUrl)
  return isAssetRegx(filterAssetUrl)
}

function pureAssetUrl(assetUrl) {
  const decodeAssetUrl = decodeURIComponent(assetUrl)
  const lastIndex = decodeAssetUrl.lastIndexOf('/'); // 链接最后一个 /
  let lastSymbol1 = Math.max(0, decodeAssetUrl.indexOf('#', lastIndex)); // 匹配链接中第一个 #
  let lastSymbol2 = Math.max(0, decodeAssetUrl.indexOf('?', lastIndex)); // 匹配链接中第一个 ?
  let forwardIndex = [lastSymbol1, lastSymbol2].filter(num => num > 0); // 获取最靠前的一个特殊字符类型
  if (forwardIndex.length) {
    forwardIndex.push(forwardIndex[0])
    forwardIndex = Math.min(...forwardIndex)
  } else {
    forwardIndex = decodeAssetUrl.length
  }
  return decodeAssetUrl.substring(0, forwardIndex)
}

function isAssetRegx(text) {
  return /\.(js|png|jpg|jpeg|svg|css|json|ico)$/.test(text)
}

总结

看到这里,大家可能会有一些疑问,我自己也给总结一下


问: 一年前访问的网站,一年后在访问,碰巧服务器出现问题要跑缓存资源了,按照上述的方法的话,访问的是一年前的内容

答: 这样肯定是不合理的,但是这个问题是可以解决的,定时确认缓存内容,如果超出范围内的,就直接清除所有缓存,并中断访问,这个时候是没有办法去让用户访问的,第一,我们一年前的旧内容是不会在现在展示给用户的,除非这个页面从上线就不需要更新的


问: 如果从来没有访问我们网站的用户,打开时候碰巧遇见前端服务器出现问题,怎么办

答: 这个时候就是天王老子来了也没办法解决,我们是在访问过的情况下才可以实现,但不代表这是万能的


大概的方案就是这个样子,有可能不能满足大家所有的需求,比如第二个问题这样的,但是我们这样的方案是可以规避一大部分这样的问题,毕竟前端服务器挂一次,谁都得慌

最后求关注、求评论、求点赞 🙏 ,给山区的孩子一点爱 🙏

image.png