
前端代理 HTML5 时代的黑科技层出不穷,但最具创新的也许要数 Service Worker,它甚至可以颠覆传统的 B/S 网络架构。 顾名思义,Service 是服务程序,而 Worker 常用于多线程。因此 Service Worker(以下简称 SW)是一种独立于页面、可持续运行的浏览器后台程序。 SW 提供了一组 API,可让网站开发者拦截自己站点下 所有页面 产生的 所有请求,并且能自定义响应结果。(除了一些特殊请求无法拦截) 这,如同在本地开启一个反向代理服务! 有了这么逆天的功能,在前端做负载均衡就非常容易了,甚至还能实现过去不敢想象的效果 —— 实时无缝的切换。 实时切换 作为代理,当 SW 加载上游资源失败时,可选择不返回错误结果,而是尝试后备站点再次加载,直到返回正确结果,才响应给下游网页: 在网页看来,这只是一次普通的请求与响应 —— 也许用时更长一些,但结果仍是正常的。SW 中的重试细节,对于业务是完全透明的! 相比 DNS 最少也有数秒的缓存时间,这种通过程序控制的方案,能在极短的时间内切换源站点。这样即使某些节点出现故障,页面甚至都毫无感知! 校验加密 除了能改变 URL 之外,SW 当然还能操作返回的数据。 这意味着,我们可以增加一个校验机制,用以检测资源是否遭到篡改。于是那些插广告、加水印之类的问题,就能很好解决了! 此外,我们还可以对原始数据进行加密,再由 SW 解密。这对于私密性不高的节点,很是有意义。 例如用 Raw Git 作为免费空间,我们所有的文件都能在 GitHub 仓库里找到,任何人都可以轻易查看。但如果对文件进行加密,同时对 SW 中的解密算法进行混淆保护,就能增加查看难度了 —— 至少 GitHub 的搜索功能、以及普通的蜘蛛,是不会抓到明文内容了。 更进一步,我们甚至还可以对文件名进行 Hash 再存储。这样,暴露的只是一堆乱七八糟、没有目录层次的文件! 离线启动 前面我们提到,SW 能拦截页面里的请求。事实上 SW 开启之后,访问页面本身也会经过 SW。 这意味着:用户只要装上 SW,之后所有的请求都可代理到外部节点上,于是可大幅减少自己网站的流量消耗! 这样就算我们的网站挂了,但只要有一个节点可用,用户仍能正常访问! 精简启动 为了能在带宽吃紧的情况下迎接新用户,我们参照之前「迷你启动器」的方案,把安装 SW 所需的资源,精简到最小 —— 最终只需两个极小的文件:html 和 js 文件。(SW 的脚本必须在当前站点下) 用户首次访问时,无论访问哪个 URL,我们都返回这个 html 文件,用以安装 SW 服务;安装完成后,页面自动刷新,这时所有请求都走 SW 代理了! 关于 html 的内容,和之前探讨的一样,所有功能都由外部脚本实现: <script src=//free-host-n.net/boot.js></script> 而 SW 脚本的内容,同样也可以放置在外部: importScripts('//free-host-n.net/sw.js') 于是,我们的站点只需承载两个极小的文件,就能获得无尽的带宽! 改造成本 相比之前强缓存的方案,如今使用 SW 无需对前端做任何改造,页面里的资源仍保持原始路径即可。如同使用 VPN 一样,无需对应用程序对任何修改,开启后流量就能自动转发到代理上,用起来非常简单。 这样,任何一个网站都能轻松接入使用! 事实上 SW 可实现的效果远不止这些,我们继续深入挖掘吧。 下一篇
分块处理 上一篇曾提到,我们可对资源加密存储,然后在 SW 中进行解密。 理论上这当然可行,但事实上会出现一些问题:我们必须等整个资源下载完成后,才能开始解密操作。这对于用户体验,会产生很大的影响。 假如有个 1MB 的图片,通过 100 KB/s 的速度加载,那么要 10 秒后才能解密再展示;然而正常情况下,图片是边加载边显示的,并不会让用户等很久,然后一次性展示所有的。 为了解决这个问题,一个期待已久的新标准终于到来,那就是 Stream API。 有了流的支持,数据就可以渐进处理,而不必等待完整的。例如,我们使用 fetch 分块读取内容: // fetch 分块读取演示 async function load(url) { let res = await fetch(url); console.log('response:', res); let reader = res.body.getReader(); for (;;) { let r = await reader.read(); if (r.done) { break; } console.log('chunk:', r.value); } console.log('end'); } load('https://raw.githubusercontent.com/EtherDream/_/master/pic.jpg'); 演示:codepen.io/anon/pen/zPKrGX 同时,SW 也支持数据分块输出给下游: // SW 分块输出 let stream = new ReadableStream({ async pull(controller) { let chunk = read_from_upstream() controller.enqueue(chunk) if (is_done) { controller.close() } }, }); let res = new Response(stream, ...); ... 两者结合,我们就可以实现边下载、边解密、边输出的效果。于是对于加密的图片、视频等资源,也能循序渐进地展示了! 下载加速 除了解密、解压缩等场合,数据流还可用于传输优化。例如,用户下载大文件的场合。 由于免费空间单个节点的带宽是有限的,因此下载速度不会太快。这时就可以通过 SW 做加速了 —— 我们同时从多个节点获取相应的文件片段,然后依次输出到响应流里: 在用户看来,这只是浏览器默认的单线程下载,但事实上内部已通过 SW 加速,和传统的多线程下载软件并无本质区别! 当然,就算免费空间不支持 Range 请求也没关系,我们可事先把大文件分成多个小文件上传,然后分别加载即可。 动态加速 上一篇提到,通过 SW 可对故障节点「实时无缝」的切换。现在有了数据流,我们可将其发挥到极致,甚至能在传输的过程中进行调整。 例如,SW 默认选择 节点 1 加载资源,但发现速度没有预期的那么快,于是可增加 节点 2 参与加速: 这样,我们就能根据用户的实际网络情况,在端上动态调整,从而实现更智能的负载均衡! 插入脚本 有时候,我们希望给站点下所有页面的头部插入一个 JS 脚本。 这个功能,如果没有数据流支持的话,那么 SW 必须得下载整个 HTML 才能修改;而现在,我们只需改造最先返回的几个 chunk 即可! 不过需要注意的是,chunk 是二进制层面截断的,因此可能会把多字节字符截成两半,导致出现乱码。 为此,我们需要用「流模式」解码字符串。例如: // stream decode example // 你: 228, 189, 160 // 好: 229, 165, 189 let dec = new TextDecoder(); let chunk1 = new Uint8Array([228, 189, 160, 229, 165]); let chunk2 = new Uint8Array([189]); dec.decode(chunk1, {stream: true}); // "你" dec.decode(chunk2, {stream: true}); // "好" 如果 chunk 末尾的字符不完整,那么不足的部分则被暂存在内部,下次解码时会自动加在开头。 这样,我们就能用字符串方法,方便地操作二进制数据了: let dec = new TextDecoder(); let enc = new TextEncoder(); input.ondata = function(chunk) { // 二进制 -> 字符串 let str = dec.decode(chunk, {stream: true}); // 插入脚本元素 str = str.replace(/<head/i, '<script ...><head'); // 字符串 -> 二进制 chunk = enc.encode(str); ... }; 当然,这里的逻辑还有点瑕疵 —— 假如 <head 这个字符串正好跨越两个 chunk,那就无法匹配到了。 由于 JS 不支持流模式的正则匹配,因此这里可用个土办法:如果 str 匹配不到,则截掉末尾 5 个字符(即 <head 的长度),然后将截出来的尾巴暂存起来,留到下一次,拼到头部。。。 这样,虽然不怎么严格,但实现简单,并且也很高效! 此外,由于我们只需注入一次脚本,因此替换后即可跳过解码、匹配、编码这些步骤了。 小结 在数据流的配合下,SW 可实现非常丰富的玩法。不过目前只有 Chrome 浏览器支持 Stream API,因此兼容性也是个较大的问题。相信随着新标准的普及,今后使用前端加速的网站,一定会越来越多。 然而对于我们的「免费空间」来说,除了兼容性问题之外,还有 SW 的各种使用限制也是一个挑战。因此如何绕过 SW 的使用限制,也是需要我们思考的。 下一篇