发现某官方网站的一个服务端 Bug 🐞?

TL;DR:并非 bug 而是服务端压缩导致 Content-Length 和实际大小不一致。

原由是在写 🚫 AbortController 的 5 个注意点 这篇文章的时候,查阅资料发现一篇很好的文章 从 Fetch 到 Streams ------ 以流的角度处理网络请求 里面有一段代码可以用 fetch 展示下载进度:

js 复制代码
let total = null;
let loaded = 0;
const logProgress = (reader) => {
    return reader.read().then(({ value, done }) => {
        if (done) {
            console.log('Download completed');
            return;
        }
        loaded += value.length;
        if (total === null) {
            console.log(`Downloaded ${loaded}`);
        } else {
            console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
        }
        return logProgress(reader);
    });
};
fetch('/foo').then((res) => {
    total = res.headers.get('content-length');
    return res.body.getReader();
}).then(logProgress);

最近正好也在使用 UX 和 DX 都非常舒服的 Antd X 组件库,我就顺手用 ant-design-x.antgroup.com/components/... 这个网页的 index.html 运行下上述代码。

js 复制代码
Downloaded 3609 of 66421 (5.43%)
Downloaded 69145 of 66421 (104.10%)
Downloaded 174735 of 66421 (263.07%)
Downloaded 240271 of 66421 (361.74%)
Downloaded 336472 of 66421 (506.57%)
Downloaded 402008 of 66421 (605.24%)
Downloaded 442947 of 66421 (666.88%)
Downloaded 508483 of 66421 (765.55%)
Downloaded 520704 of 66421 (783.94%)
Download completed

为啥最终进度停止在"783.94%"而非"100%"?🤷‍♂️

第一反应是我们代码的问题,首先得弄明白代码里面 value 是什么?是字符串吗? 通过增加 console.log(value.slice(0, 10)) 发现是一个 Uint8Array 数组。

难道我们应该转成字符串再累加长度。尝试用 TextEncoder 转成字符串:

js 复制代码
let total = null;
let loaded = 0;
const logProgress = (reader) => {
  return reader.read().then(({ value, done }) => {
    if (done) {
      console.log('Download completed');
      return;
    }
    
    // 新增代码 ✅
    const decoder = new TextDecoder('utf-8');
    const text = decoder.decode(value, { stream: true });
    console.log(text.slice(0, 30));
    
    loaded += text.length;
    
    if (total === null) {
      console.log(`Downloaded ${loaded}`);
    } else {
      console.log(
        `Downloaded ${loaded} of ${total} (${((loaded / total) * 100).toFixed(
          2
        )}%)`
      );
    }
    return logProgress(reader);
  });
};
fetch(location.href)
  .then((res) => {
    total = res.headers.get('content-length');
    return res.body.getReader();
  })
  .then(logProgress)
  .then((result) => console.log('result:', result));

新增代码:

js 复制代码
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(value, { stream: true });
console.log(text.slice(0, 30));

通过打印确实发现变成了可读的字符串。但还是不对呀🙄 782.15%

有没有可能 AntD X index.html 本身返回的 Content-Length 就是错误的,这就是标题说的 Bug,但现在也只是猜测,我们继续往下看。

Content-Length 如何计算?

接下来我们将进入本文的"第一层"。首先了解下 Content-Length 是如何计算的。

通过 AI 搜索后找到了这么一篇文章 Be cautious about Content-Length HTTP headers一篇将近十年的文章 。里面引用了 HTTP/1.1 RFC 2616Content-Length 的精确描述:

The Content-Length entity-header field indicates the size of the entity-body, in decimal number of OCTETs, sent to the recipient

关键字:number of OCTETs

OCTETs 表示八位字节

再看看 MDN

The HTTP Content-Length header indicates the size, in bytes, of the message body sent to the recipient.
Content-Length: <length> // The length in octets.

关键字同样:in bytesoctets

确认了就是字节数(8 bit = 1 Byte)。问问大模型 is content-length equal to string length 结论一样。

Be cautious about Content-Length HTTP headers 文章中还提到一个有趣的事实,Node.js 官方文档计算Content-Length有误:

The official docs of Node.js for the HTTP module, shows usage of string length, but that is not really a safe thing to do. We should always use Buffer.byteLength or the corresponding methods in other languages to count the number of bytes.

毕竟是 9 年前,现在看官方文档已经纠正过来还特意嘱咐。

Content-Length value should be in bytes, not characters. Use Buffer.byteLength() to determine the length of the body in bytes.

Content-Length 的值应该以字节为单位,而不是字符个数。请使用 Buffer.byteLength() 来确定 body 的字节大小。

Node.js 哪个版本出现的错误?翻了个遍发现是 v5.x。v6.x 已经纠正并且特意嘱咐

Note that Content-Length is given in bytes not characters . The above example works because the string 'hello world' contains only single byte characters. If the body contains higher coded characters then Buffer.byteLength() should be used to determine the number of bytes in a given encoding. And Node.js does not check whether Content-Length and the length of the body which has been transmitted are equal or not.

请注意,Content-Length 是以字节为单位,而不是字符 。上面的例子能够正常工作是因为字符串 'hello world' 只包含单字节字符。如果正文包含高位编码字符,那么应该使用 Buffer.byteLength() 来确定在给定编码下正文的字节数。此外,Node.js 并不会检查 Content-Length 和已传输正文的长度是否相等。

还有句话很有意思:

Node.js does not check whether Content-Length and the length of the body which has been transmitted are equal or not.

其实最新版的 Node.js v23.9.0 已经会做二者不一致的检查了:

`response.strictContentLength` 在 v18.10.0, v16.18.0 增加。

验证下

既然文字有问题,我们拿图片试试。

logmedia.ir/wp-content/...

大家可以打开这张图片控制台执行下代码:

js 复制代码
let total = null;
let loaded = 0;
const logProgress = (reader) => {
    return reader.read().then(({ value, done }) => {
        if (done) {
            console.log('Download completed');
            return;
        }
        loaded += value.length;
        if (total === null) {
            console.log(`Downloaded ${loaded}`);
        } else {
            console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
        }
        return logProgress(reader);
    });
};
fetch('https://logmedia.ir/wp-content/uploads/2023/10/Abort-Logpedia.webp').then((res) => {
    total = res.headers.get('content-length');
    return res.body.getReader();
}).then(logProgress).then(result => console.log('result:', result));

进度正常 ✅。 那说明我们的代码是正确的,那应该就是 index.html 返回了错误的 Content-Length,误用字符串 length 计算,本文似乎可以完结了 🎉。

第一次反转

别着急,我们再反面验证下。我们拿到 ant-design-x.antgroup.com/components/... 的 HTML 源码然后分别用 string.prototype.lengthBuffer.length 计算二者长度和实际该网页返回的 Content-Length 做对比,看看能否有什么神奇的事情(现在开始我深度怀疑 Antd X 采用字符串长度,继续验证下)。

注意以下代码 Node.js 中运行:

js 复制代码
// html 太长省略了大家可自行获取网站源码(获取过程没有想象得简单)
const html = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">...'

const byteLength = Buffer.byteLength(html);

console.log('Buffer.byteLength:', byteLength);
console.log('string.length:    ', html.length);

运行结果:

js 复制代码
❯ node content-length.js

Buffer.byteLength: 520704
string.length:     519514

对比下

然而不是提到的两种计算办法之一 -_-|| ,无效怀疑!难道 Content-Length 还有其他计算方式?或者说会被其他因素干扰?

Content-Length 究竟如何计算或还会受哪些因素影响?继续探寻第二层

本以为周五一天能写完,现在感觉要烂尾了,好在过了周末发现点有趣的东西本文才得以继续。

欢迎来到第二层,我们继续踏雪寻梅。我们试试其他一些流行的中英文网站。

实验 1:baidu.com 浏览器打开无 `Content-Length`

实验 2:cn.bing.com 浏览器打开无 `Content-Length`

再次发现惊天大坑:baidu.comContent-Length bing.com 和 github 也没有,这是为啥?正常吗?

简短答案:正常。至于为什么先卖个关子等会再回答。

💡 小提示:截图内红字标注在 Content-Encoding 处。

我们继续用 curl 试试(因为相对于浏览器环境 curl 是一个更纯净的客户端)。

js 复制代码
❯ curl -I https://cn.bing.com
HTTP/1.1 200 OK
Cache-Control: private
Content-Length: 13267 # 🔥
Content-Type: text/html; charset=utf-8
...
js 复制代码
❯ curl -I https://www.baidu.com/
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform
Connection: keep-alive
Content-Length: 277 # 🔥
Content-Type: text/html
Date: Mon, 10 Mar 2025 01:08:02 GMT
Etag: "575e1f60-115"
Last-Modified: Mon, 13 Jun 2016 02:50:08 GMT
Pragma: no-cache
Server: bfe/1.0.8.18

二者居然都返回了。再试试权威的英文网站 MDN(最后反过头来还是 MDN 拯救了这篇文章,本来以为会烂尾)。

浏览器请求 developer.mozilla.org/en-US/ 没有 Content-Length 意外发现一个新头部 X-Goog-Stored-Content-Length

但是当我们用 fetch 的时候却发现同时返回了 Content-LengthX-Goog-Stored-Content-Length 以及还有一个 X-Goog-Stored-Content-Encoding: identity。Amazing 啊,这又是个啥?

且二者不相等,到底应该取谁?我们问问 AI:prophetes.ai/c?source=qu...

  • X-Goog-Stored-Content-Length 是 Google 云存储的 header,表示存储的对象大小,不受压缩和编码的影响(可见 MDN 使用了 Google 云存储服务)
  • Content-Length 表示从发送到客户端的请求体大小。如果请求被压缩(比如 gzip)则其表示的是压缩后的大小

可见二者的目的不一样,大小自然也不一定相同。现在我们知道了 Content-Length 并非真实文件大小,而代码里面是客户端解压缩后 的长度自然结果 783.94% 远大于 100%(逆向思维,其实刚好反映了压缩率)。
AI 回答:content-length not equal to x-goog-stored-content-length The discrepancy between the Content-Length and X-Goog-Stored-Content-Length headers can occur due to several reasons, primarily related to how data is encoded or transferred:

  1. Encoding Differences:

    • The Content-Length header represents the size of the response body in bytes as it is sent to the client. If the response is compressed (e.g., gzip), the Content-Length will reflect the compressed size[6].
    • On the other hand, X-Goog-Stored-Content-Length indicates the size of the object as stored in Google Cloud Storage, which is independent of any compression or encoding applied during transfer[3].
  2. Chunked Transfer Encoding:

    • If chunked transfer encoding is used, the Content-Length header may not be present or may not match the actual size of the data being transferred. This is because chunked encoding sends data in chunks without specifying the total size upfront[9].
  3. Content Mismatch:

    • In some cases, there might be a mismatch between the actual data size and the reported Content-Length due to server-side issues or data corruption during transfer[10].
  4. Proxy or Middleware Modifications:

    • If a proxy or middleware modifies the response (e.g., adding headers or compressing the data), the Content-Length header might not accurately reflect the original size of the stored object[1].
Conclusion:

The difference between Content-Length and X-Goog-Stored-Content-Length is often due to encoding or transfer mechanisms. To ensure accurate data handling, it is essential to consider the context in which these headers are used and verify whether compression, chunked encoding, or proxy modifications are involved.

了解了我们回头看看上面的截图。

MDN 入口页,content-length 需要结合 content-encoding 一起看

ant-design-x.antgroup.com/components/... 经过 gzip 压缩 Content-Encoding: gzipContent-Length 66421 远小于 Buffer.byteLength: 520704string.length: 519514,真实长度虽然服务端没有给出,但一定是 520704 也就是 Buffer.byteLength

不返回 Content-Length 头部正常吗

再回答上面的关子。不返回 Content-Length 头部正常吗?

还真是正常 developer.mozilla.org/en-US/docs/... 说:

数据是按一系列块(chunks)发送的。内容可以以未知大小的流的形式发送,以作为一系列长度受限的缓冲区序列进行传输。这样,发送方可以保持连接处于打开状态,并告知接收方何时已收到完整消息。必须省略"Content-Length"头部 。在每个块的开头,一串十六进制数字表示块数据的大小(以八位字节为单位),随后是\r\n,然后是块本身,再跟一个\r\n。终止块是一个零长度的块。

同时 serverfault.com/questions/1... 提到:

Apache is chunking the reply so the content size is not known. For many people this is desirable (page loads faster). This comes at a cost of not being able to report the download progress.

Apache 将返回值做了分块导致内容大小不可知(译者:chunked 则必然无法发送 Content-Length

Apache uses chunked encoding only if the compressed file size is larger than the DeflateBufferSize. Increasing this buffer size will therefore prevent the server using chunked encoding also for larger files, causing the Content-Length to be sent even for zipped data.

当大于 DeflateBufferSize 才会开启分块传输,如果将其放大,则会阻止文件被分块传输也就是说此时没有分块会返回 Content-Length

小结: 什么时候返回 Content-Length

Transfer-Encoding 影响,如下图:

客户端能禁止 chunked 吗?简单来说不能,因为没有类似 accept-transfer-encoding 的请求头,但是可以通过自定义请求头协商,比如服务端业务请求里面指定返回 content-length 这样 Apache 或 NGINX 就不会分块发送(Antd X 没有被 chunked,返回了 Content-Length;meta 搜的搜索结果页有 Transfer-Encoding,故没有 Content-Lengthbaidu.com 同样有 chunked 没有返回 Content-Length,但是 bing.com 没有 chunked 也没有返回 Content-Length

总结:Transfer-Encoding 有则必须省略 Content-Length,因为此时大小是未知的。没有则 Content-Length 可有可无取决于服务端实现。

如何强制不压缩,让其返回实际内容的 Content-Length

既然我们已经知道了何时会触发压缩,那我们在原来的代码 fetch 加一个头部 'Accept-Encoding': 'identity' 试试。

解释 Accept-Encoding identity:指示服务器不作任何修改或压缩(还是那句话协议和实现是两回事)

浏览器控制台运行:

ts 复制代码
// bun-accept-encoding-identity.ts
let total = null;
let loaded = 0;
const logProgress = (reader) => {
  return reader.read().then(({ value, done }) => {
    if (done) {
      console.log('Download completed');
      return;
    }
    
    loaded += value.length;
    if (total === null) {
      console.log(`Downloaded ${loaded}`);
    } else {
      console.log(
        `Downloaded ${loaded} of ${total} (${((loaded / total) * 100).toFixed(
          2
        )}%)`
      );
    }
    return logProgress(reader);
  });
};
fetch(`https://ant-design-x.antgroup.com/components/use-x-agent-cn`, { headers: { 'Accept-Encoding': 'identity' } })
  .then((res) => {
    console.log('Content-Length', res.headers.get('Content-Length'))
    console.log('Content-Encoding', res.headers.get('Content-Encoding'))
    total = res.headers.get('Content-Length');
    return res.body.getReader();
  })
  .then(logProgress)
  .then((result) => console.log('result:', result));

加了没用,仍然返回了压缩后的长度,因为浏览器会自动处理 Accept-Encoding 请求头,通常会根据浏览器的能力自动设置为支持的压缩方式(如 gzip, deflate, br)。因此,手动设置 Accept-Encoding 请求头通常会被忽略或覆盖。此时需要非浏览器环境的 HTTP 客户端(如 Node.js)或 curl,这种情况你才可以完全控制请求头的内容。

我们使用 bun 试试

ts 复制代码
❯ bun run accept-encoding-identity.ts
Content-Length 520704
Content-Encoding null
Downloaded 3039 of 520704 (0.58%)
Downloaded 15479 of 520704 (2.97%)
...
Downloaded 520704 of 520704 (100.00%)
Download completed

可以看到加了禁止压缩的头部后生效了,Content-Length 等于实际大小 100.00%

'Accept-Encoding': 'identity' 注释掉试试:

ts 复制代码
❯ bun run bun-accept-encoding-identity.ts
Content-Length null
Content-Encoding gzip

Downloaded 16384
Downloaded 32768
...
Downloaded 520704
Download completed
result: undefined

走到了默认压缩策略 gzip

增加浏览器默认压缩策略 'Accept-Encoding': 'gzip, deflate, br, zstd'

ts 复制代码
❯ bun run bun-accept-encoding-identity.ts
Content-Length 66421
Content-Encoding gzip
Downloaded 66421 of 66421 (100.00%)
Downloaded 132842 of 66421 (200.00%)
...
Downloaded 520704 of 66421 (783.94%)
Download completed
result: undefined

发送一个不支持的 encoding 呢?

如果客户端通过 Accept-Encoding 指定服务器不支持的压缩方式,那会还会返回压缩后的内容吗?答案:不会,服务器通常会选择不对响应体进行压缩,而是返回未压缩的内容。 大家可以试试指定 Accept-Encoding: br

Accept-Encoding Content-Length Content-Encoding Transfer-Encoding
identity 520704 gzip null
- 66421 gzip null
gzip 66421 gzip null
br 520704 null null

Bun 实验总结:可以通过 identity 禁止压缩,原始内容大小为 520704 压缩后为 66421,且仅支持 gzip 压缩。

curl 试试

js 复制代码
❯ curl -I https://ant-design-x.antgroup.com/components/use-x-agent-cn
HTTP/1.1 200 OK
Server: Tengine
Content-Type: text/html; charset=utf-8
Content-Length: 520704

curl 默认不压缩,返回原始大小 520704,让 curl 看看压缩后的大小,同样为 66421

ts 复制代码
❯ curl -sH 'Accept-Encoding: gzip' -I https://ant-design-x.antgroup.com/components/use-x-agent-cn  | grep -i content
Content-Type: text/html; charset=utf-8
Content-Length: 66421
Content-Encoding: gzip

还有一个有趣的事实,即使客户端指定压缩,但服务器仍然可以选择不压缩:

  • body 很小没有达到压缩的阈值
  • 要发送的数据已经经过压缩,再次压缩不会减少传输的数据量。这适用于预先压缩过的图像格式(如 JPEG)。比如本文用于实验的 webp 图片。
  • 服务器过载,无法分配计算资源来进行压缩。例如,微软建议如果服务器使用超过其计算能力的 80%,则不应进行压缩。

从服务端实现角度看看压缩后的大小是否为 66421 Bytes

gzip 压缩:

js 复制代码
// gzip.js
const zlib = require('zlib');
const fs = require('fs');

// 读取文件并压缩
const input = fs.createReadStream('input.html');
const output = fs.createWriteStream('input.html.gz');

// 使用 zlib 创建 GZIP 压缩流
input.pipe(zlib.createGzip()).pipe(output);

output.on('finish', () => {
  console.log('文件已成功压缩为 GZIP 格式');
});
js 复制代码
❯ node gzip.js
文件已成功压缩为 GZIP 格式

大小使用 ls 以及 Node.js 计算:

js 复制代码
❯ ls -l input.html input.html.gz
-rw-r--r-- 1 liuchuanzong 1049089 520704 Mar 11 08:46 input.html
-rw-r--r-- 1 liuchuanzong 1049089  66505 Mar 11 08:47 input.html.gz
js 复制代码
// 分别计算 'input.html.gz' 大小以及 Buffer.byteLength 大小
fs.stat('input.html.gz', (err, stats) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log('文件大小:', stats.size, '字节');
});

const buffer = fs.readFileSync('input.html.gz');
console.log('Buffer 长度:', buffer.length, '字节', Buffer.byteLength(buffer));
js 复制代码
Buffer 长度: 66505 字节 66505
文件大小: 66505 字节

二者都表明压缩后大小为 66505 Bytes 而非 66421 这是为何?难道是文件存储会比实际大一些?或者 gzip 有会有其他因素影响?确实 level 参数可以控制压缩大小。

zip level

接下来实验下不同 level 对应压缩后大小

js 复制代码
input.pipe(zlib.createGzip({ level })).pipe(output);

总体来说 level 越大压缩越狠,详见 expressjs/compression 文档。

原始大小:520704

Level Size 说明
-1 66505 默认值
0 520797 理论上不压缩但实际比原始大小更大了
1 86195
2 81009
3 77995
4 71967
5 68214
6 66505 指向 0
7 66166
8 65943
9 65943 最佳压缩

折线图由 AI 生成

结论:没有找到一个 level 让其等于 66421,故 Antd X 服务端应该没有使用 Node.js 的 zlib,暂且放弃。

有什么危害

如果是 GET 请求没啥大的 危害,甚至都可以不返回,其他危害参考 Be cautious about Content-Length HTTP headers

Key Takeaway

  • Content-Length 并非字符数而是字节数,且反应的是压缩后的大小。故需要结合 Content-Encoding 一起看。
  • 建议开启 response.strictContentLength 防止 Content-Length 计算错误。
  • Transfer-Encoding 开启 chunked 则不会返回 Content-Length
  • 一个页面可以多种方式获取如浏览器、fetch、curl、Node.js,作为不同的客户端,返回的内容不尽相同,curl 作为请求客户端相对来说更加纯净。
  • 某些请求头浏览器会覆盖掉手动设置的值比如 Accept-Encoding,你需要在非浏览器环境方可指定。
  • vary: Accept-Encoding:服务端可以根据其值动态返回压缩或未压缩内容。

更多阅读

HTTP内容编码和HTTP压缩的区别

相关推荐
南山不太冷2 小时前
Spring(3)—— 获取http头部信息
java·spring·http
冰淇淋@3 小时前
HTTP发送POST请求的两种方式
java·spring boot·http
他不爱吃香菜7 小时前
Nginx正向代理HTTPS配置指南(仅供参考)
网络·网络协议·tcp/ip·nginx·http·https·信息与通信
学习嵌入式的小羊~14 小时前
远程监控项目描述以及总体框架
网络协议·http
还是鼠鼠16 小时前
http 模块的概念及作用详细介绍
前端·javascript·vscode·http·node.js·web
程序员黄同学17 小时前
谈谈 HTTP 中的重定向,如何处理301和302重定向?
网络·网络协议·http
与光同尘 大道至简17 小时前
万字技术指南STM32F103C8T6 + ESP8266-01 连接 OneNet 平台 MQTT/HTTP
stm32·单片机·嵌入式硬件·物联网·http·docker·信息与通信
XiaoLeisj17 小时前
【计算机原理】深入解析 HTTP 中的 URL 格式、结构和 URL encode 转义与 URL decode 逆转义原理
网络·网络协议·tcp/ip·http·fiddler·信息与通信
ZZZ_Tong18 小时前
HTTP拾技杂谈
java·网络·网络协议·http