在 Cloudflare Workers 上搭建一个测速服务

Cloudflare CDN 的连通性时好时坏,非常不稳定。一个解决办法就是自己挑选 CDN 的 IP 而不使用 DNS 返回的 IP。XIU2/CloudflareSpeedTest 是一个优选 IP 的工具,这个工具的原理是从 Cloudflare 公开的 IP 段 中挑选一些 IP 测试延迟和下载速度,从而筛选出一些可用性高的 IP。从这些 IP 访问 Cloudflare 代理的站点效果就能得到很大的提升。

如果遇到下载测速不可用的情况最好还是自己搭建一个测速服务。可以选择的方式有:

  1. 为自己的网站开启 CDN,自己的网站上提供测试文件或 API;
  2. 通过 Cloudflare Workers 反向代理到第三方网站,第三方网站提供测试文件或 API;
  3. 在 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

参考资料