低成本打造一个带宽无限的网站
免费空间的遐想
免费空间
自从学习网页制作那天起,就开始期待有朝一日能有个自己的网站。
尽管当时有不少免费空间,对于简单的个人网站也够用了,然而像我这样挑剔的, 试用后几乎都不怎么满意 , 要么会偷偷插些广告,这对于有洁癖的我是无法容忍的; 要么奇慢无比,而我那些「炫酷」的页面充斥了大量图片和特效, 也不懂得优化,所以每次传到空间后,效果总是惨不忍睹。
也许你会说,为什么非要用免费的,花钱买个好点的配置不就得了。 不过那时零花钱十分有限,每天几块钱除了早饭偶尔买些书之外,所剩无几。 用在网站空间上?压根就没有过这样的念头! 好在有大把的时光,于是每当闲暇时,便开始鼓捣一些极(diao)客(si)的方案, 尝试将免费空间变废为宝。
有次耐下心来仔细分析,发现一些空间并没有想象中那么慢 —— 如果网页只有几个字符的话,还是很快就能出现的。 只是我的网页里图片太多了,光背景就是一组高清大图。。。 加上各种限速,所以才会显得十分缓慢。
客观地说,这些空间不算太差,至少延时并不高,只是带宽稍小而已。
既然找到痛点,那就能对症下药了 。当然,前提还是不!能!花!钱!于是被迫开启脑洞,激发各种猥琐思路:)
改进
免费空间 —— 既然是免费的嘛,一个费用是 0,一百个也是 0,为何不多注册几个呢?
然后,从中选一家「延时最低」的专门放网页,其他的则用来放图片 —— 也许你也猜到了,只要对网页做些调整,把所有的图片都改成「绝对路径」, 从不同的站点分别加载。这样,就能享受好几倍的免费带宽了~
事实上有些插广告的免费空间,只会篡改网页或脚本文件,图片倒不会变化。 于是这些空间就能充分利用起来~
要是脸皮厚的话,甚至还可以打起论坛、相册、网盘、图床的主意, 寻找那些附件可外链、下载速度快的网站,进一步扩充免费资源的节点~
只要节点充足,带宽显然是管够的!
不过,要同时维护这么多资源,显然是很麻烦的。因此需要一套自动化工具,用于各个节点的数据同步;若要利用论坛附件,还得实现更多功能,例如自动上传、外链检测、文件名记录、列表管理、定期维护。。。
此外,前端网页也需进行改造。为了方便使用,还得开发一个 JS 脚本,对页面中的图片路径自动调整。这其中涉及不少细节,例如站点选择的算法、无效资源的切换、本地缓存的命中。。。
看起来很有趣吧,似乎是一个前端版的负载均衡:) 要是算法够好、节点够多的话,估计 CDN 都可以省了~
缺陷
当然想象总是美好的,但真要放在现实中,估计没一个网站会这么做 —— 谁会为了省一点带宽费用,把原本很简单的东西搞得这么复杂呢。
除了复杂之外,风险也会大幅增加。 某些节点要是往图片里加些水印、广告之类的倒还好, 要是加入些非法反动内容,那简直就得不偿失了!
况且这样滥用免费资源,感觉也不太好意思。 于是简单尝试了一段时间后,觉得意义不大又麻烦,便不再折腾。
直到多年后的一天,又回想起这个方案。。。
通过缓存防御网站
网站攻击
有次在讨论网站防护时,提到一个信息发布的站点 —— 它的结构很简单,只有几个页面而已,正常情况下打开是非常快的。 然而一到关键时刻,流量如同洪水般涌来。 网站无法访问,那些付费发布的信息就错过最佳展现时间了。
对于网站攻击,现成的解决方案有很多, 例如用上 WAF、CDN 等服务,多少能分担一些。 不过,通用的防御方案,自然就有通用的攻击方案。
例如通过 DNS 实现的负载均衡,攻击者使用现成的工具, 就能轻易遍历出对应的 IP。更糟的是, 有时域名会缓存很久,使得攻击都快结束了解析还没生效。
对于前端爱好者来说,这种传统的方案一点都不 Geek —— 理想的防护,显然应该从前后端同时入手。
提到前端,也许你会觉得奇怪,网站都被打垮了,还哪来的前端? 别急。我们先来思考个本质问题:为什么网站容易垮。
相比网页,传统的应用程序在网络不好的情况下,表现的更为强劲。
例如一些网络视频播放器,即使没连网也能启动,只是不能观看在线视频而已,
但仍可作为普通播放器使用;而网页版的视频站点,显然就没这么强大了,
如果没连网,连基本界面都看不到。
这个道理大家都懂。因为应用程序是事先下载到本地的, 所以后期运行时,界面、程序可以直接启动,只有信息才依赖网络; 而网页的界面、程序、信息,很多时候是混在一起的,每次都得实时传输。 所以极端情况下,网页表现得更为脆弱。
改进
因此,我们需要对网页做一些改造,将「界面、程序」和「信息」彻底分离。
- 前者通常较少变动,因此可对相应的资源设置
强缓存
。 强缓存是不走流量、直接从本地读取的,所以用户只要访问过一次, 之后界面就可以瞬间展现、程序可以立即运行 —— 无论网站是否繁忙! - 后者是动态加载的数据,存放位置并没有限制, 因此可放在多个后备站点上 —— 无论自己的站点,还是免费空间。
当基本界面展现后,程序通过 AJAX 从外部站点获取信息,然后填充到页面里。 如果获取失败,则尝试后备列表 —— 除非所有站点都垮了,否则只要有一个活着,信息仍能展示!
有了这样的机制,就能降低网站故障的影响了。 除了没有缓存的「新用户」无法打开网站,那些曾经访问过的回头客,仍可正常浏览
再改进
更进一步,只要不是服务崩溃、流量被封那种硬故障, 我们还可继续优化,使新用户也有机会访问。
在带宽吃紧的情况下,我们需要对「界面和程序」进行精简, 使其只需极小的传输流量,从而能在夹缝中求生。
那么,这究竟能精简到多小?事实上,只需一行 HTML 就够了:
<script src=//free-host-n/boot.js></script>
我们可让网站所有的界面和功能,都由一个外部脚本来创建。 这样,整个站点只需一个几十字节的页面,仅仅作为启动器而已!
尽管最终 99.99% 的流量都来自其他站点,但浏览器的地址栏,显示的仍是当前站点:)
现在,只要带宽还有一丝残喘的余地,新用户就有机会获取到这个迷你启动页, 进而从互联网上各个节点加载出完整内容!
由于整个站点只承载一个极小的文件,因此防御策略可以简单很多。 此外,外部脚本的路径可通过后端工具随时改变,用以避开速度缓慢的节点 —— 毕竟 HTTP 控制缓存的能力,比 DNS 丰富多了!
缺陷
当然,这个方案似乎过于激进 —— 不仅需要对业务做大量改造,而且对搜索引擎也不利,因此最终并没有实际应用。
此外,还有一个大问题也未能解决:用户刷新页面,会导致强缓存失效,从而产生网络请求。 如果此时网站挂了,那么用户刷新后,不是长时间等待,就是直接显示错误。
如此美妙的防御方案,最终却防不住 F5 按钮。。。 这个问题,直到 HTML5 时代的一项新科技才能解决 —— 应用缓存。
应用缓存
关于应用缓存,熟悉前端的小伙伴们都不陌生,它正是为提高 WebApp 的体验而设计。
应用缓存的用法很简单,通过一个列表清单,告诉浏览器预先缓存哪些资源:
<html manifest="list.appcache">
之后访问列表中的资源时,浏览器就直接从本地读取。 相比强缓存,应用缓存的离线程度更高 —— 不仅没连网也能访问,甚至还可以刷新!
缓存不耐刷的问题,总算是能解决了。 只是此时兴致已过,并没有去尝试改进。 对于浏览器缓存,当时觉得更好玩的还是攻击方面 —— 中间人和前端脚本相互结合,批量污染缓存。 有兴趣的可以看之前写的流量劫持相关文章。
也许正是因为易受中间人污染,以及 不够灵活 等原因, 应用缓存始终存在一些争议, 以至于后来被 Web 标准废弃了。 取而代之的,则是一个更逆天的标准。。。
前端代理服务
前端代理
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');
同时,SW 也支持数据分块输出给下游:
// SW 分块输出
let stream = new ReadableStream({
start(controller) {
...
input.ondata = function(chunk) {
controller.enqueue(chunk);
};
input.onend = function() {
controller.close();
};
...
}
});
let res = new Response(stream, ...);
...
两者结合,我们就可以实现边下载、边解密、边输出的效果。 于是对于加密的图片、视频等资源,也能循序渐进地展示了!
下载加速
除了解密、解压缩等场合,数据流还可用于传输优化。例如,用户下载大文件的场合。
由于免费空间单个节点的带宽是有限的,因此下载速度不会太快。 这时就可以通过 SW 做加速了 —— 我们同时从多个节点获取相应的文件片段,然后依次输出到响应流里:
在用户看来,这只是浏览器默认的单线程下载, 但事实上内部已通过 SW 加速, 和传统的多线程下载软件并无本质区别!
当然,就算免费空间不支持 Range 请求也没关系, 我们可事先把大文件分成多个小文件上传,然后分别加载即可。
动态加速
上一篇提到,通过 SW 可对故障节点「实时无缝」的切换。 现在有了数据流,我们可将其发挥到极致,甚至能在传输的过程中进行调整。
例如,SW 默认选择节点 1 加载资源,但发现速度没有预期的那么快,于是可增加节点 2 参与加速:
这样,我们就能根据用户的实际网络情况,在端上动态调整,从而实现更智能的负载均衡!
插入脚本
有时候,我们希望给站点下所有页面的头部插入一个 JS 脚本。
这个功能,如果没有数据流支持的话,那么 SW 必须得下载整个 HTML 才能修改; 而现在,我们只需改造最先返回的几个 chunk 即可!
不过需要注意的是,chunk 是二进制层面截断的,因此可能把多字节字符截成两半,导致出现乱码。
为此,我们需要用「流模式」解码字符串。例如:
// stream decode example
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 个字符,然后将尾巴暂存起来, 拼到下一次的头部。。。 这样虽然没有流那么严格,但实现简单,并且也很高效。
此外,由于我们只需替换一次,因此之后可跳过这步,无需解码、匹配、编码了。
小结
在数据流的配合下,SW 可实现非常丰富的玩法。 不过目前只有 Chrome 浏览器支持 Stream API,因此兼容性也是个较大的问题。 相信随着新标准的普及,今后使用前端加速的网站,一定会越来越多。
然而对于我们的「免费空间」来说,除了兼容性问题之外, 还有 SW 的各种使用限制也是一个挑战。 因此如何绕过 SW 的使用限制,也是需要我们思考的。
免费空间的挖掘
突破限制
由于 SW 非常强大,因此使用条件也是非常严格的,以免被恶意使用。
例如 SW 必须在 HTTPS 站点上使用。 这本是件好事,彻底杜绝了中间人的隐患,但现在却成了一道门槛 —— 毕竟支持 HTTPS 并且域名可控的免费空间,那是极少的。
同时,这还引发了另一个问题: 由于 HTTPS 站点是禁止读取 HTTP 数据的,因此我们的节点也必须是 HTTPS 站点!
此外 SW 也得遵守同源策略。 如果要读取第三方站点的数据,目标响应头里还得有 Access-Control-Allow-Origin: * 字段 —— 这对于免费空间来说,是个不小的要求。
免费空间,要同时满足上述两个条件,确实有些苛刻。 好在 SW 能和页面交互,因此在代理网页资源时,可以往其中插入一个辅助脚本, 这样就能把任务交给页面来实现。毕竟页面里有丰富的 DOM 功能,可玩出各种奇技淫巧。
页面代理
对于 不支持 ACAO、但支持 HTTPS 的站点,可通过页面代理实现 CORS。
我们通过 iframe 引入目标站点下的一个代理页面, 由它来读取数据,然后将结果 postMessage
给父页面:
若是追求更高性能的话,还可通过 MessageChannel 直接从 iframe 传到 SW 里,减少一次消息中转:
只要目标站点能部署 html 资源,就可以用这个方案,读取站点下任意类型的资源!
需注意的是,该方案依赖页面。假如用户关闭了所有页面, 然后通过地址栏访问资源 —— 这时 SW 虽能拦截请求, 但由于没有可交互的页面,因此无法使用该方案。
不过,有个简单的办法可以解决这个问题: 我们让 SW 先返回一个临时的过渡页面,用它来配合内容加载; 完成后页面自动刷新,这时 SW 就能给出真正的内容了!
JSONP
对于上述情况,还有种不依赖页面的办法 —— 我们将资源打包在脚本文件里,通过 JSONP 的方式直接在 SW 中加载。
当然这种方案缺陷十分明显:脚本是文本格式的,编码二进制资源会增加不少体积。
此外,Worker 中加载脚本的函数 importScripts
是同步阻塞的, 因此会对程序带来很大影响。 除非使用 Sub Worker(在 Worker 中嵌套 Worker),但目前很多浏览器包括 Chrome 都不支持,所以暂不考虑。
混合内容
对于 不支持 HTTPS 、但支持 ACAO 的站点,这时就需要利用 混合内容(Mixed Content
)机制了。
虽然浏览器原则上不允许 HTTPS 页面引用 HTTP 资源, 但对于风险较低的资源,例如图片、多媒体,仍然是允许的!
因此,我们可将原始数据作为像素,打包在图片里。 页面通过设有 crossOrigin 属性的 Image 加载图片, 然后绘制到 canvas 上,这样就能读取像素,从而得到原始数据了!
演示:https://www.etherdream.com/FunnyScript/jszip/decode.html
关于数据编码成图片的细节,可参考 《利用 canvas 实现数据压缩》。 不过和文中不同的是,如今我们通过本地工具编码图片,因此最终结果还可以用 PNGout 、zopflipng 等工具进行强力优化。
当然,数据打包成图片后,体积不可避免会有所增加。但反正带宽是免费的,有总比没有好:)
不过,踩混合内容的黄线,也是有一定代价的。例如 Chrome 浏览器,界面上的证书图标不再是绿色了,并且控制台里也会出现告警:
对于这个问题,倒是有个简单的缓解策略: 假如当前开着多个页面(Tab)的话,我们可以让 SW 选一个不可见的, 由它来加载资源 —— 这样即使界面有变化,用户也不会立即看见了:)
Flash 代理
对于既不支持 ACAO 又不支持 HTTPS 的破站点,只能用同样破旧的东西来配它 —— Flash。
尽管浏览器并不允许 HTTPS 页面加载 HTTP Flash, 但我们可以 先加载一个 HTTPS Flash 作为跳板,然后通过它来加载 HTTP 的资源 。
因为插件内部是不受浏览器管控的,所以就能利用 Flash 宽松的限制,绕过混合内容策略!
只要目标站点支持 xml 资源(用于存放 cross domain xml), 我们就能读取该站点下任意类型的资源!
退一步,即使目标站点不支持 xml 也没关系,能支持 swf 文件也可以。 我们用这个 swf 作为目标站点的代理,这样就解决「网络通信」的同源策略了。
同时,再通过 AS 脚本开放自身权限:
Security.allowDomain('*');
这样,就能解决「模块交互」的同源策略了。
这里用了两个 swf 做代理 —— 前者规避混合内容,后者规避同源策略,是不是很巧妙:)
有了这个办法,那些能上传 swf 的论坛,我们就能读取和它同站点的图片附件了!
不过比较尴尬的是,如今主流浏览器都已禁用 Flash,当初写的这些「奇技淫巧」也没什么卵用了。。。
总结
HTTPS | ACAO | 获取方式 | 信息载荷类型 | 依赖页面 | 主要缺陷 |
---|---|---|---|---|---|
√ | √ | fetch() | * | × | - |
√ | × | 页面代理 | *(html) | √ | 额外嵌入一个页面 |
√ | × | JSONP | js | × | Worker 中会阻塞 |
× | √ | 图片像素 | image | √ | 混合内容界面警告 |
× | × | Flash 代理 | * (xml 或 swf) | √ | 很多浏览器已禁用 |
这里我们只是从 HTTPS 和 ACAO 两个条件进行探讨。现实中,当然还有更复杂的情况。
例如,一些图床同时支持 HTTPS 和 ACAO,但只能上传图片格式。 对于这种情况,其实不依赖页面也是可以加载的 —— 我们可以直接在 SW 中 fetch 图片, 然后用 JS 版的图像解码库,还原出像素里的数据。
更进一步,我们还可以检测图床是否会修改上传的原始文件。 如果不修改的话,我们可以把数据藏在图片辅助信息里, 甚至直接附加在文件末尾,这样直接截取即可,连解码都不需要了!
总之,只要发挥想象,很多网站都可以利用起来, 在我们宽带紧张的情况下,充当免费的后备节点:)