侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 141 篇文章
  • 累计收到 7 条评论

网关先验签再解析 JSON,解析器拿到的是空串

2026-9-28 / 0 评论 / 7 阅读

网关先验签再解析 JSON,解析器拿到的是空串

签名校验通过,紧接着的 JSON 解析报 Unexpected end of JSON input。两段代码单独看都没问题,连起来就有一个拿不到数据。请求体在 Node 里是一支只能从头读一次的流,读完就到头:谁先读谁拿全,第二个人拿到 0 字节。

以下六条按依赖顺序排,每条后面跟能直接抄的写法。

1 请求体是一支流,读到尾就结束了

不用任何框架也能看到这一点。req 本身是 Readable,data 事件推完就触发 end,之后再读什么也拿不到:

const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
  const raw = Buffer.concat(chunks);   // 这一次读干完了整支流
});

2 两个中间件串起来,第二个拿到空 Buffer

真实工程里的写法一般是两层:一层读原始字节做验签,一层解析 JSON 填 req.body。两层的读数摆在一起,问题就看得见了:

== 1. 写法一:验签和解析各读一次同一支流 ==
  验签读到 37 字节,签名校验:通过
  解析器随后读到 0 字节
  SyntaxError: Unexpected end of JSON input
  客户端拿到:{"rawLen":37,"signOk":true,"parseLen":0,"result":"SyntaxError: Unexpected end of JSON input"}
== 3. 写法三:两个中间件串起来,第二个拿到什么 ==
  第一个中间件读到 37 字节,第二个读到 0 字节
  第二个中间件拿到空 Buffer:true
  客户端拿到:{"first":37,"second":0,"secondIsEmpty":true}

两次都是同一个模式:第一个读到 37 字节,第二个读到 0。第二个中间件不报错,它只是拿到一个空 Buffer,交给 JSON.parse 之后变成一句语法错误。

3 验签与解析要共用同一份 Buffer

正确的结构只有一种:读一次,把结果挂到 req 上,后面所有人从那个字段取。

== 2. 写法二:只读一次,验签与解析共用同一份 Buffer ==
  读到 37 字节,签名校验:通过
  解析成功 orderId=1001002345 amount=199
  客户端拿到:{"rawLen":37,"signOk":true,"result":"解析成功 orderId=1001002345 amount=199"}

对应写法是先把原始字节收全,验签和解析都对着这一段做事:

async function rawBody(req) {
  if (req.rawBody) return req.rawBody;
  const chunks = [];
  for await (const c of req) chunks.push(c);
  req.rawBody = Buffer.concat(chunks);
  return req.rawBody;
}

async function verify(req) {
  const raw = await rawBody(req);
  const mac = crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
  if (mac !== req.headers['x-sign']) throw new Error('签名不符');
  return raw;
}

async function parseJson(req) {
  return JSON.parse((await rawBody(req)).toString('utf8'));   // 不再自己读流
}

流行框架里这一类中间件已经有了,express 的 body-parser 把解析结果放在 req.body,自己的验签中间件应该挂在它后面读 req.rawBody 或 req.body,不要再去 req.on('data') 上接一遍。

顺序反过来也一样出问题。先解析后验签时,解析那层已经把整段字节读走,验签再去读只能拿到空的;而且 JSON 解析器不保存原文,从解析后的对象重新序列化得到的字节串和原始报文不一定逐字相同,键的顺序和空白都会变,拿它算摘要必然对不上。两层之间真正要共享的不是「读的结果」,是那份原始 Buffer。中间件挂载顺序在配置文件里往往隔着几十行,改一行 import 就能把顺序换掉,这类问题通常不是写错,是被改乱的。

4 不读流不会报错,客户端照样拿到 200

这一条最容易被忽略:服务端完全不理请求体、直接写响应,客户端那边看不出任何异常。

== 4. 直接返回、不读流:客户端那边看不出来 ==
  37 字节的请求体:200 ok
  1 MiB 的请求体:200 ok(耗时 1 ms)

两种大小的请求体都拿到了 200,响应时间都在毫秒级。也就是说,一个从来没读过请求体的服务端,可以在压测和联调里表现得毫无问题,只有在业务真正需要那段数据的时候才暴露。这类 bug 不会在访问日志里留下痕迹,能依赖的只有解析失败的那条错误。

5 读没读完有确定的判断依据

不用猜当前状态,对象上有现成的字段:

== 5. 请求体读完之后的状态 ==
  读完一次后 req.readableEnded=true req.complete=true 已收字节=37
  再调一次 req.read() 返回 null:true

readableEnded 为真、req.read() 返回 null,就说明这支流已经空了。写中间件时可以用它做一次断言:如果走到解析那一步发现 readableEnded 已经为真而 rawBody 还是空的,说明有人在你之前把这支流读掉了,直接抛错比让 JSON.parse 去猜快得多。

6 整体读进内存要有大小闸门

把请求体一次 Buffer.concat 进内存,代价由请求体大小决定。1 MiB 的请求在这台机器上跑得很轻松,但整体读入这个做法本身需要一道上限:内容长度头先看一遍,超过阈值就走流式处理,边读边算摘要。这一条我没实测上限,量级上只是提醒别把「内存里装得下」当成默认前提。

验签这类场景真正需要的是摘要,不用留全量原文。createHmac().update(chunk) 可以边读边喂,读完拿 digest,内存里一份副本都不用留。上面第 3 条的写法是为了让解析也能复用同一份数据才收全的,两种取舍按请求体大小选。

    🤞 分享