编者按:

在前端实现验证CDN服务是否可用,多渠道的JavaScript脚本加载,CDN全部宕机也能换站内的相对路径,妈妈再也不用担心我用的CDN崩啦!

我还在研究非JavaScript内容(例如图片等静态资源)如何实现高可用,有知道的小伙伴欢迎高速我哦!

本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,作者苏洋。

原文链接: https://soulteary.com/2019/05/14/simple-policies-make-front-end-resources-highly-available.html


前几天有朋友问我,曾经在前公司里使用过的前端资源高可用方案是怎么做的。资源高可用听起来应该是后端、运维同学的“分内之事”。但是前端资源的高可用并没有那么简单,在当前复杂的网络环境下,你是指望用户多刷新几次、还是期望用户把Wi-Fi切换为4G,撞大运解决问题?获客成本如此之高的今天,放弃用户是不明智的。
想到许久没有写前端相关的文章了,决定在这里简单聊聊。希望能帮助到创业阶段的公司和团队。
在聊技术细节之前,我们先聊聊“什么是前端资源高可用”。

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

前端资源高可用这个需求,对于“大厂”的同学来说应该很陌生。
因为对于大公司来说,有大量冗余的云主机资源可以为业务团队提供,并且会配套一定规模的运维团队。当监控系统发现线上出现资源不可用的情况时,系统能够根据策略自动切换问题资源到备份资源,而有些不能自动切换的服务,则会有值守的运维同学,在第一时间手动进行切换,保障业务的高可用。
网络服务高可用基本玩法
而小一些规模的创业公司就没那么幸运了,资源相对紧张,甚至没有完善的监控措施,更别提配一只相对完善的运维团队了。
或许会有人认为,将静态资源扔到 CDN 上后就一劳永逸了。然而现实世界中,网络环境十分复杂,相同主机在不同线路、不同地区、不同时间段的可用性和访问质量是不同的,所以使用 CDN 不是解决这个问题的银弹,但是同时使用多个 CDN 或许是当前阶段比较通用的方案。
比如默认不同地域的用户通过不同线路访问网站,如果其中一条线路出现问题,那么一部分用户就无法访问网站提供的服务。
常见用户访问情况
这个时候,我们通常会使用切换请求资源服务器的方法来解决问题,比如下面这样。
如何解决用户可用性
当某条 CDN / 服务线路不正常的时候,我们可以通过切换域名来解决资源获取不到的问题,但是别忘记一件很重要的事情:
域名生效需要时间、多地域生效周期漫长,在这个切换域名的时间窗口内,你的服务质量将会持续受到影响。
并且这个方案的资源切换动作通常会在后端进行,而此时页面已经推送到用户侧,资源已经不可用,用户需要刷新后才有可能请求到新的资源地址,并且是在 DNS 能够生效的前提下,我们知道很多流行的应用客户端为了性能优化,都为资源(甚至包含页面)设置了很长的有效期,可以说这个方案并不是一个很有效的方案。
所以,假设你采取类似这种方案,你必须确保下面四个条件都生效,才能达到你的目的:

  • 你的监控系统发现了问题,并自动进行了资源切换。

  • 你的业务负责人,发现了问题,并手动进行资源切换。

  • 你成功切换了资源,并且 DNS 快速生效(网络层、客户端层)。

  • 你的用户在你切换资源、DNS 生效后,恰如其分的刷新了页面,而不是直接离开。
    听起来是不是很魔幻。
    那么有没有什么简单可靠的方案可以解决这个问题呢?
    有,让资源在前端层面进行自动切换。

    方案简介

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

    环境模拟

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

    模拟多个网络

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    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 和应用网站根域名相同、另外一个采取完全不同的域名,比如下面这样。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 默认使用的镜像
    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 自动帮我们创建了几个目录。

    1
    2
    3
    4
    ./public
    ├── demo-cdn.lab.io
    ├── demo.cdn2.io
    └── demo.lab.io

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!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 创建一个脚本文件,随便写点什么,模拟被加载的资源。

    1
    2
    3
    4
    5
    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”服务是否可用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!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 资源失效的场景。
    如果你的浏览器没有奇怪的缓存行为,你将会得到一个空白的页面,以及一行报错信息:

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

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <!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>

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

    进阶版本

    上面场景,我们模拟了常规场景下前端自动切换资源的方式。
    接下来我们来做一些小小的优化,让脚本加载支持更多的资源地址,达到更高的可用性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    <!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 使用资源自动切换。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    <!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 来让资源在加载的过程中,将依赖资源先进行下载和初始化,举两个实际的例子:

    1
    2
    3
    4
    5
    6
    requirejs.config({
    map: {
    // 这是一个 hack 用法,具体含义参考官方 API 文档
    '*': { 'http://demo.cdn2.io/assets/app.js': 'lodash' },
    }
    });
    1
    2
    3
    4
    5
    6
    7
    8
    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