在 Cloudflare Workers 上搭建一个测速服务
Cloudflare CDN 的连通性时好时坏,非常不稳定。一个解决办法就是自己挑选 CDN 的 IP 而不使用 DNS 返回的 IP。XIU2/CloudflareSpeedTest 是一个优选 IP 的工具,这个工具的原理是从 Cloudflare 公开的 IP 段 中挑选一些 IP 测试延迟和下载速度,从而筛选出一些可用性高的 IP。从这些 IP 访问 Cloudflare 代理的站点效果就能得到很大的提升。
如果遇到下载测速不可用的情况最好还是自己搭建一个测速服务。可以选择的方式有:
- 为自己的网站开启 CDN,自己的网站上提供测试文件或 API;
- 通过 Cloudflare Workers 反向代理到第三方网站,第三方网站提供测试文件或 API;
- 在 Cloudflare Workers 上使用 Streams API 的方式动态生成测试数据。
下面就将介绍后两种通过 Workers 实现的方式,并在最后总结这几种方式。
反向代理
反向代理到第三方的方式只给出参考代码,具体操作步骤见 关于下载测速不可用/不稳定的情况说明及解决方法。
不过上面的讨论存在一些问题,第三方不支持默认缓存的扩展名其实通过自定义 pathname
或修改响应头也可以实现被 CDN 缓存。另外有缓存对于测速并不一定是一件好事,关键是看自己想要测什么。
export interface Env {}
export default {
async fetch(request: Request, _env: Env, _ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// 通过自定义 `pathname` 以满足 Cloudflare 默认缓存的规则
if (url.pathname === '/speedtest.zip') {
const { body } = await fetch(`https://cachefly.cachefly.net/200mb.test`);
return new Response(body, { headers: { 'Content-Type': 'application/zip' } });
}
// https://icyflamestudio.com/test-file-download/
url.hostname = 'files.icyflamestudio.com';
return fetch(url);
},
};
测试地址示例:$workerRoute/speedtest.zip
$workerRoute/100MB.bin
Streams API
反向代理的方式非常依赖于源站点的可靠性,如果源站点已经跑满了带宽或发生故障则会导致测速结果不准确。不依赖后端的方案就是生成动态测试数据。
官方其实已经给出通过生成字符串测速的 模板代码,不过由于受到 128 MB 内存的限制,无法生成超过这个体积的文件。该代码也是设置了约 95 MB 的上限,如果不需要超过 100 MB 的文件可以使用这种方式。想要突破这个限制,可能唯一的方案就是通过流不断产生数据来减少内存的占用。这个方案还有一个好处就是可以降低延迟(TTFB),因为不需要一次性准备好所有数据才发送。
Workers 免费方案给了 10 ms 的 CPU 时间,付费方案也才 50 ms,如果代码实现不好的话很容易被强制中断执行。我在使用过程种就遇到了看似没问题但实际上会耗尽 CPU 时间的代码。
遇到的性能问题
Uint8Array.from({ length })
创建 1 MB 的数组就会直接超时挂掉,而 new Uint8Array(length)
则没有问题。只有测试过后才知道前一种方式存在性能问题,这行代码在我本机 Deno 和浏览器执行都需要约 50 ms。
Deno 测试代码和结果如下:
Deno.bench('`Uint8Array.from`', () => {
Uint8Array.from({ length: 1 << 20 });
});
Deno.bench('`new Uint8Array`', () => {
new Uint8Array(1 << 20);
});
benchmark time (avg) iter/s (min … max) p75 p99 p995
----------------------------------------------------------------------- -----------------------------
`Uint8Array.from` 49.56 ms/iter 20.2 (47.79 ms … 51.8 ms) 49.94 ms 51.8 ms 51.8 ms
`new Uint8Array` 10.25 µs/iter 97,589.5 (3.5 µs … 1.39 ms) 7.3 µs 110.2 µs 123.7 µs
在 Chromium 和 Firefox 中测试也是类似的结果:
const then = performance.now();
Uint8Array.from({ length: 1 << 20 });
performance.now() - then; //=> 55.40000000037253
Bun 使用的 JavaScript 引擎是 JavaScriptCore,在 WSL 中的测试结果比 V8 好很多,约 12 ms。不过还是比不上使用构造函数的方式。
Cloudflare Workers 使用的是 V8,其实要做的事情就是针对 V8 引擎写出更高效的 JavaScript 代码。我在这方面没有经验,只能给出一些我测试出来效果比较好的写法。
通过 Uint8Array
实现
export default {
async fetch(request, _env, _ctx) {
const url = new URL(request.url);
let t = Math.min(512, parseInt(url.searchParams.get('s')) || 128); // `t` MB
const chunkSize = 1 << 20;
const s = t << 20;
const u8 = new Uint8Array(chunkSize);
for (let i = 0; i < s; i++) {
u8[i] = i;
}
const stream = new ReadableStream({
type: 'bytes',
pull(controller) {
controller.enqueue(new Uint8Array(u8));
if (--t > 0) return;
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'applicatioin/octet-stream' },
});
},
};
通过字符串和 TextEncoder
实现
export default {
async fetch(request, _env, _ctx) {
const url = new URL(request.url);
let t = Math.min(512, parseInt(url.searchParams.get('s')) || 128);
const chunkSize = 1 << 20;
const baseContent = url.searchParams.get('c') || '内 容\n';
const content = baseContent.repeat(chunkSize / 8);
const encoder = new TextEncoder();
const stream = new ReadableStream({
pull(controller) {
controller.enqueue(encoder.encode(content));
if (--t > 0) return;
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'applicatioin/octet-stream' },
});
},
};
结论
TODO