
一段 JSON 响应,本地 curl 看着正常,客户端偶尔收到半截,JSON.parse 报 Unterminated string in JSON at position 40。这类错的现场通常不长:日志里状态码 200,Content-Length 有值,断掉的位置也不固定。
先看长度这件事的算术。同一份响应体,两种算法差 28 字节:
== 1. 同一个响应体,两种 Content-Length 的算法 ==
JSON 文本:{"code":0,"msg":"操作成功,订单号 1001002345,请查收","data":{"name":"张三"}}
body.length(JS 里最顺手的写法) -> 63
Buffer.byteLength(body, 'utf8')(要发的)-> 91
非 ASCII 字符 14 个,每多一个差 2 字节,差值 28
Content-Length 数的是什么
数的是字节,而且只数响应体那一段的字节,不包含头部。HTTP 里长度以八位组为单位,UTF-8 下一个汉字占三字节,一个 ASCII 字符占一字节,所以字符数和字节数在中文内容上必然对不上。
body.length 在 JavaScript 里返回的是 UTF-16 码元个数,跟「要发多少字节」不是一回事。上面那段 JSON 有 14 个非 ASCII 字符,每个多占两字节,差值就是 28。全 ASCII 的响应体上两种算法结果相同,这就是为什么这类 bug 常常只在中文、emoji 或者订单备注字段上出现,测试用的英文样例永远测不出来。
声明偏小和偏大,客户端各是什么反应
两种情况都跑了,用的就是上面那段响应体:
== 2. 声明成 str.length(63,比实际少 28 字节)==
服务端发的头:Content-Length = 63
线路上实际有 91 字节的实体
curl:退出 0(它按声明的长度收完了就收工)
curl 交出的正文 64 字节 / 40 字符:"{\"code\":0,\"msg\":\"操作成功,订单号 1001002345,请查�"
拿这段去 JSON.parse -> SyntaxError: Unterminated string in JSON at position 40 (line 1 column 41)
末尾 3 字符:"请查�"
声明成 63 的时候,curl 退出码是 0,看起来一切正常,交出来的正文却只有 64 字节 40 个字符,末尾停在半个汉字上,JSON.parse 报 Unterminated string。这类错最费时间的地方就在这里:客户端不报网络错,只是解析失败,很容易被当成上游数据脏。
== 3. 声明成 byteLength + 20(111,比实际多 20 字节)==
声明 111,实际 91 字节
curl:退出非零,curl: (28) Operation timed out after 2132 milliseconds with 91 out of 111 bytes received
curl 交出的正文 91 字节,最后是:"\":\"张三\"}}"
声明得比实体长 20 字节,客户端读完实体之后还在等剩下的字节,一直等到超时,报的是 91 out of 111 bytes received。两种错法一个偏小一个偏大,症状完全不同:偏小是收到半截,偏大是卡住不动。
半个汉字落到解码器里是什么
按字符数量去截,切点常常落在汉字的三个字节中间。多切进一个字节,解码出来的就是替换字符:
只发前 59 个字符 -> 85 字节
末尾 9 字节:61 6d 65 22 3a 22 e5 bc a0
再多切 1 个字节:65 22 3a 22 e5 bc a0 e4
这 1 个字节单独解码 -> "�"
整段解码后最后两个字符 -> "张�"
Buffer.from 遇到不完整的 UTF-8 序列不会抛错,它会把这一个字节换成 U+FFFD,也就是那个菱形问号。截图里看到的「请查�」就是这么来的。字符串没有被截断在标点或者空格上,而是断在一个字的中间,这本身就是「按字符数切了字节流」的证据。
为什么本地测的时候是好的
本地那次测试用的是自己写的客户端,它收到 91 字节就收全了。区别在于连接怎么收尾:
== 4. 换成正确算法(byteLength),同一条路径 ==
curl:退出 0,拿到 91 字节,和原串一致 -> true
裸 socket:收到 233 字节,其中实体 91 字节
正确算法下两边的数字都是 91,跟原串一致。但把这条当验收标准是不够的:客户端的实现决定了它会不会替你兜住这个错误,同一个响应,宽松的客户端能收全,严格的客户端会卡住。想确认长度对不对,得看响应头里的数字和实体字节数是否相等,不能只看客户端有没有报错。
顺带一个自己踩到的坑:如果本地测试是同步调用在同一个进程里跑的(比如用 execFileSync 去拉 curl),服务端根本收不到那个请求,事件循环被同步调用占着。这时候看到的是「超时,0 字节收到」,我一开始以为是服务端把连接挂死了,来回查了几轮才回到测试代码上。换异步调用之后,同一个服务端的响应立刻正常。
改法只有一条
声明长度的时候,让计算方式跟内容编码一致:
const body = JSON.stringify(payload);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Length', Buffer.byteLength(body, 'utf8'));
res.end(body);
更省事的写法是不手设 Content-Length:让框架自己算,它本来就知道要发多少字节。要么用 res.end(body) 由 Node 计算并自动带上这个头,要么在 Content-Length 之外换成 Transfer-Encoding: chunked。手动设这个头唯一合理的场合是把已经序列化好的 Buffer 直接发出去,那时长度就是 buf.length,不用换算。
Content-Type 里的 charset=utf-8 也不能省。长度声明和字符集声明说的是两件事,前者决定怎么切,后者决定怎么解,任意一个错了都会得到同样的半截结果。
这一类问题最容易在返回里带用户自定义文本的时候触发:字段长度由用户决定,测试数据里的短名字换成真名、真地址之后就跨过了那条线。上线前把接口的响应换成一段带中文和 emoji 的数据跑一遍,比看代码快。