17

简单策略让前端资源实现高可用

 4 years ago
source link: https://soulteary.com/2019/05/14/simple-policies-make-front-end-resources-highly-available.html?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前几天有朋友问我,曾经在前公司里使用过的前端资源高可用方案是怎么做的。资源高可用听起来应该是后端、运维同学的“分内之事”。但是前端资源的高可用并没有那么简单,在当前复杂的网络环境下,你是指望用户多刷新几次、还是期望用户把Wi-Fi切换为4G,撞大运解决问题?获客成本如此之高的今天,放弃用户是不明智的。

想到许久没有写前端相关的文章了,决定在这里简单聊聊。希望能帮助到创业阶段的公司和团队。

在聊技术细节之前,我们先聊聊“什么是前端资源高可用”。

资源高可用和前端有什么关系?

前端资源高可用这个需求,对于“大厂”的同学来说应该很陌生。

因为对于大公司来说,有大量冗余的云主机资源可以为业务团队提供,并且会配套一定规模的运维团队。当监控系统发现线上出现资源不可用的情况时,系统能够根据策略自动切换问题资源到备份资源,而有些不能自动切换的服务,则会有值守的运维同学,在第一时间手动进行切换,保障业务的高可用。

ENBRZfA.jpg!web

而小一些规模的创业公司就没那么幸运了,资源相对紧张,甚至没有完善的监控措施,更别提配一只相对完善的运维团队了。

或许会有人认为,将静态资源扔到 CDN 上后就一劳永逸了。然而现实世界中,网络环境十分复杂,相同主机在不同线路、不同地区、不同时间段的可用性和访问质量是不同的,所以使用 CDN 不是解决这个问题的银弹,但是同时使用多个 CDN 或许是当前阶段比较通用的方案。

比如默认不同地域的用户通过不同线路访问网站,如果其中一条线路出现问题,那么一部分用户就无法访问网站提供的服务。

7BbMZnY.jpg!web

这个时候,我们通常会使用切换请求资源服务器的方法来解决问题,比如下面这样。

ZnIFv26.jpg!web

当某条 CDN / 服务线路不正常的时候,我们可以通过切换域名来解决资源获取不到的问题,但是别忘记一件很重要的事情:

域名生效需要时间、多地域生效周期漫长,在这个切换域名的时间窗口内,你的服务质量将会持续受到影响。

并且这个方案的资源切换动作通常会在后端进行,而此时页面已经推送到用户侧,资源已经不可用,用户需要刷新后才有可能请求到新的资源地址,并且是在 DNS 能够生效的前提下,我们知道很多流行的应用客户端为了性能优化,都为资源(甚至包含页面)设置了很长的有效期,可以说这个方案并不是一个很有效的方案。

所以,假设你采取类似这种方案,你必须确保下面四个条件都生效,才能达到你的目的:

  • 你的监控系统发现了问题,并自动进行了资源切换。
  • 你的业务负责人,发现了问题,并手动进行资源切换。
  • 你成功切换了资源,并且 DNS 快速生效(网络层、客户端层)。
  • 你的用户在你切换资源、DNS 生效后,恰如其分的刷新了页面,而不是直接离开。

听起来是不是很魔幻。

那么有没有什么简单可靠的方案可以解决这个问题呢?

有,让资源在前端层面进行自动切换。

方案简介

通过在前端环境监听资源加载错误信息,并根据一定策略自动加载其他位置的资源,实现前端依赖的资源在前端(用户侧)进行自动切换,达到前端资源高可用的目的,减少因前端资源加载失败而导致的服务不可用和用户流失。

环境模拟

为了更直观的演示方案如何生效,我这里使用 Docker 做一个常见场景的模拟。

模拟多个网络

我们先创建一个 docker-compose.yml ,里面包含下面的内容。

version: '3'
 
services:
 
  web:
    image: ${NGX_IMAGE}
4    expose:
      - 80
    networks:
      - traefik
    labels:
      - "traefik.enable=true"
      - "traefik.frontend.rule=Host:${MAIN_HOST}"
      - "traefik.frontend.entryPoints=${SUPPORT_PROTOCOL}"
    volumes:
      - ./public/${MAIN_HOST}:/usr/share/nginx/html
    extra_hosts:
      - "${MAIN_HOST}:127.0.0.1"
 
  cdn1:
    image: ${NGX_IMAGE}
    expose:
      - 80
    networks:
      - traefik
    labels:
      - "traefik.enable=true"
      - "traefik.frontend.rule=Host:${CDN_HOST1}"
      - "traefik.frontend.entryPoints=${SUPPORT_PROTOCOL}"
      - "traefik.frontend.headers.customResponseHeaders=Access-Control-Allow-Origin:*"
    volumes:
      - ./public/${CDN_HOST1}:/usr/share/nginx/html
    extra_hosts:
      - "${CDN_HOST1}:127.0.0.1"
 
  cdn2:
    image: ${NGX_IMAGE}
    expose:
      - 80
    networks:
      - traefik
    labels:
      - "traefik.enable=true"
      - "traefik.frontend.rule=Host:${CDN_HOST2}"
      - "traefik.frontend.entryPoints=${SUPPORT_PROTOCOL}"
      - "traefik.frontend.headers.customResponseHeaders=Access-Control-Allow-Origin:*"
    volumes:
      - ./public/${CDN_HOST2}:/usr/share/nginx/html
    extra_hosts:
      - "${CDN_HOST2}:127.0.0.1"
 
networks:
  traefik:
    external: true

可以看到,编排文件里面定义了一个应用网站,和两个 CDN 服务,为了更接近真实场景。其中一个 CDN 和应用网站根域名相同、另外一个采取完全不同的域名,比如下面这样。

# 默认使用的镜像
NGX_IMAGE=nginx:1.15.8-alpine
# 支持访问的协议
SUPPORT_PROTOCOL=https,http
 
# 主站点的域名
MAIN_HOST=demo.lab.io
# 模拟根域名相同的CDN
CDN_HOST1=demo-cdn.lab.io
# 模拟根域名不同的CDN
CDN_HOST2=demo.cdn2.io

将上面的内容保存为 .env ,并将上面内容中的域名绑定到本地之后,执行 docker-compose up ,就可以开始实战了。

模拟常规场景

执行 docker-compose up 之后,我们会看到 Docker 自动帮我们创建了几个目录。

./public
├── demo-cdn.lab.io
├── demo.cdn2.io
└── demo.lab.io

我们在 demo.lab.io 目录下创建 index.html 文件,作为应用入口。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script src="assets/app.js"></script>
</head>
<body>
 
</body>
</html>

然后在 ./demo.lab.io/public/assets/app.js 创建一个脚本文件,随便写点什么,模拟被加载的资源。

document.addEventListener('DOMContentLoaded', function () {
    var p = document.createElement('p');
    p.innerText = 'script excute success.';
    document.body.appendChild(p);
});

当我们访问 http://demo.lab.io/index.html 的时候,不出意外,将会看到 由脚本输出的 script excute success. 内容。

我们将 ./public/demo.lab.io/assets/app.js 复制到 ./public/demo-cdn.lab.io/assets/app.js./public/demo.cdn2.io/assets/app.js 中,模拟资源分发到 CDN 的场景。

最简单的技术实现

先将上面请求的资源地址修改为“CDN”的地址,验证一下“CDN”服务是否可用。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script src="//demo-cdn.lab.io/assets/app.js"></script>
</head>
<body>
 
</body>
</html>

然后通过删除 ./public/demo-cdn.lab.io/assets/app.js 这个脚本,模拟 CDN 资源失效的场景。

如果你的浏览器没有奇怪的缓存行为,你将会得到一个空白的页面,以及一行报错信息:

default.html:8 GET http://demo-cdn.lab.io/assets/app.js 404 (Not Found)

如果碰到域名解析错误的场景下,我们会获得另外一种错误信息:

GET http://demo-cdn.lab.io/assets/app.js net::ERR_NAME_NOT_RESOLVED

这个时候,我们可以在页面上做一些修改,让它能够在资源加载出错的时候,将资源切换到另外一个 CDN 资源上,比如这样:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script>
        function loadOthers(resource) {
            var script = document.createElement('script');
            script.src = resource.src.replace('demo-cdn.lab.io','demo.cdn2.io');
            document.head.appendChild(script);
        }
    </script>
    <script src="//demo-cdn.lab.io/assets/app.js" onerror="loadOthers(this)"></script>
</head>
<body>
 
</body>
</html>

再次打开地址,你会发现页面又正常了。

进阶版本

上面场景,我们模拟了常规场景下前端自动切换资源的方式。

接下来我们来做一些小小的优化,让脚本加载支持更多的资源地址,达到更高的可用性。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script>
        function loadResource(links, fnSuccess, fnError) {
            var script = document.createElement('script');
            script.onerror = function () {
                document.head.removeChild(script);
                fnError();
            };
            script.onload = fnSuccess
            script.src = links.shift();
            document.head.appendChild(script);
        }
 
        function autoSwitch(resourceList) {
            var resource = resourceList.shift();
            loadResource([resource], function (success) {
                console.log('loaded');
            }, function (err) {
                console.log('load error')
                autoSwitch(resourceList);
            });
        }
    </script>
</head>
<body>
    <script>
        var resourceList = [
            'http://demo-cdn.lab.io/assets/app.js',
            'http://demo.cdn2.io/assets/app.js',
            'assets/app.js',
        ];
 
        autoSwitch(resourceList);
    </script>
</body>
</html>

上面的实现中,我们将资源加载写的更加通用,并且添加了加载成功、失败的回调,以及额外做了一个自动切换资源的函数,并将页面脚本资源加载交给了脚本去处理。

这个方案已经能够解决多数场景下的问题了,但是如果你的资源之间存在依赖关系,又该怎么处理呢?

结合资源加载器使用

我们以 AMD 模块规范为例,聊聊如何结合 requirejs 使用资源自动切换。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
 
    <script src="assets/require-v2.3.6.min.js"></script>
 
    <script>
        function autoSwitch(resourceList) {
            var resource = resourceList.shift();
            requirejs([resource], function (success) {
                console.log('loaded');
            }, function (err) {
                console.log('load error')
                autoSwitch(resourceList);
            });
        }
    </script>
</head>
<body>
    <script>
        var resourceList = [
            'http://demo-cdn.lab.io/assets/app.js',
            'http://demo.cdn2.io/assets/app.js',
            'assets/app.js',
        ];
 
        autoSwitch(resourceList);
    </script>
</body>
</html>

requirejs 引入页面,然后使用 requirejs 方法替换 loadResource 方法后,你会发现似乎一切没有什么不同。

但是你其实可以通过配置 requirejs.config 来让资源在加载的过程中,将依赖资源先进行下载和初始化,举两个实际的例子:

requirejs.config({
    map: {
        // 这是一个 hack 用法,具体含义参考官方 API 文档
        '*': { 'http://demo.cdn2.io/assets/app.js': 'lodash' },
    }
});
requirejs.config({
    shim:{
        // 或者这样声明
        'http://demo.cdn2.io/assets/app.js':{
            deps:['vue']
        }
    }
});

当然,你也可以改造 autoSwitch 函数,自己动态维护依赖关联。

其他的坑

讲到这里,资源自动加载几乎讲完了,但是实际上还存在一些额外的坑。

比如结合当前最流行的构建工具 webpack 使用,图片资源是一次性写死的,需要支持动态化。

17年的时候,我曾经提交了一个解决方案,有兴趣的同学可以围观一下: https://github.com/soulteary/webpack-custom-plugin ,主要解决了  Not generating ouput with multiple entries 的问题。

最后

许多看似高大上的方案,本质其实都十分简单。与其追求高大上的概念,不如静下心来,踏实钻研细节,思考技术到底该如何有效的服务业务、产生价值。

—EOF


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK