Node.js Webhook监听器接收POST请求时req.body始终为空的求助
Webhook接收请求体为空问题排查
现象描述
- 已针对该问题排查整整两天,始终未解决。需要捕获解析业务系统触发的简易格式Webhook请求
- 实现方案:搭建简易Node.js监听服务,通过Ngrok生成公网可访问的映射地址。服务可正常收到Webhook请求,但无论如何调整配置、设置各类Content-Type、配置
app.use(bodyParser.urlencoded())参数,最终拿到的请求体始终是空对象{} - 对照测试:将Webhook目标地址替换为公共Webhook接收服务时,可正常获取请求体全部内容;尝试过调整各类Content-Type组合、修改urlencode配置、排查gzip压缩影响、将服务部署到Azure VM等其他环境测试,均未解决问题
现有服务代码
'use strict' const express = require('express') const bodyParser = require('body-parser') // Create a new instance of express const app = express() // Tell express to use the body-parser middleware and to not parse extended bodies app.use(bodyParser.json()) app.use(bodyParser.urlencoded({ extended: false })) // Route that receives a POST request to /sms app.post('/', function (req, res) { console.log(req); const body = req.body console.log("body") console.log(body); res.set('Content-Type', 'text/plain') res.send(`You sent: ${body} to Express`) }) // Tell our app to listen on port 4545 app.listen(4545, function (err) { if (err) { throw err } console.log('Server started on port 4545') })
日志证据
从打印的req对象日志中可确认,请求携带339字节的有效内容,但解析得到的body为空对象:
complete: false, headers: { connection: 'Keep-Alive', 'accept-encoding': 'gzip', 'host': 'localhost:4646', 'max-forwards': '10', 'user-agent': 'Go-http-client/2.0', 'content-length': '339' }, rawHeaders: [ 'Connection', 'Keep-Alive', 'Accept-Encoding', 'gzip', 'Host', 'localhost:4646', 'Max-Forwards', '10', 'User-Agent', 'Go-http-client/2.0', 'X-Original-URL', '/', 'Content-Length', '339' ], trailers: {}, rawTrailers: [], aborted: false, upgrade: false, url: '/', method: 'POST', statusCode: null, statusMessage: null, client: Socket { connecting: false, _hadError: false, _parent: null, _host: null, _readableState: [ReadableState], readable: true, _events: [Object: null prototype], _eventsCount: 8, _maxListeners: undefined, _writableState: [WritableState], writable: true, allowHalfOpen: true, _sockname: null, _pendingData: null, _pendingEncoding: '', server: [Server], _server: [Server], timeout: 120000, parser: [HTTPParser], on: [Function: socketListenerWrap], addListener: [Function: socketListenerWrap], prependListener: [Function: socketListenerWrap], _paused: false, _httpMessage: [Circular], [Symbol(asyncId)]: 115, [Symbol(kHandle)]: [TCP], [Symbol(kSetNoDelay)]: false, [Symbol(lastWriteQueueSize)]: 0, [Symbol(timeout)]: Timeout { _idleTimeout: 120000, _idlePrev: [TimersList], _idleNext: [Timeout], _idleStart: 6602, _onTimeout: [Function: bound ], _timerArgs: undefined, _repeat: null, _destroyed: false, [Symbol(refed)]: false, [Symbol(asyncId)]: 116, [Symbol(triggerId)]: 115 }, [Symbol(kBuffer)]: null, [Symbol(kBufferCb)]: null, [Symbol(kBufferGen)]: null, [Symbol(kCapture)]: false, [Symbol(kBytesRead)]: 0, [Symbol(kBytesWritten)]: 0 }, _consuming: false, _dumped: false, next: [Function: next], baseUrl: '', originalUrl: '/', _parsedUrl: Url { protocol: null, slashes: null, auth: null, host: null, port: null, hostname: null, hash: null, search: null, query: null, pathname: '/', path: '/', href: '/', _raw: '/' }, params: {}, query: {}, body: {}, route: Route { path: '/', stack: [Array], methods: [Object] }, [Symbol(kCapture)]: false }, locals: [Object: null prototype] {}, [Symbol(kCapture)]: false, [Symbol(kNeedDrain)]: false, [Symbol(corked)]: 0, [Symbol(kOutHeaders)]: [Object: null prototype] { 'x-powered-by': [ 'X-Powered-By', 'Express' ] } }
- 关键观察点:
- 日志中的
complete: false不是异常原因:该状态是在路由处理逻辑的同步执行阶段打印req对象时的临时状态,此时请求流尚未完成消费,不代表请求传输不完整 - 对比请求头列表可发现,当前请求完全没有携带
Content-Type相关条目,仅存在content-length: 339标识证明传输层有有效内容
- 日志中的
根因定位与解决方法
核心根因
body-parser的两个内置中间件存在默认匹配规则:只有当请求的Content-Type与中间件预设类型一致时,才会消费请求流并解析内容填充req.body:bodyParser.json()默认仅匹配Content-Type: application/json的请求bodyParser.urlencoded()默认仅匹配Content-Type: application/x-www-form-urlencoded的请求
- 公共Webhook服务可正常接收的原因是,它们不依赖
Content-Type判断是否读取请求流,只要存在请求体就会先完整读取原始内容,再做格式解析。 - 该问题与Ngrok配置、gzip压缩、部署环境无关:日志中
content-length: 339已证明请求体在传输层完整到达服务端,不存在数据丢失。
解决方案
替换原有依赖body-parser默认匹配的配置,改为手动读取请求流,不依赖Content-Type判断即可完成解析:
'use strict' const express = require('express') const app = express() // 自定义请求体读取中间件,跳过Content-Type匹配限制 app.use((req, res, next) => { // 跳过无请求体的请求方法 if (['GET', 'HEAD'].includes(req.method)) return next() const chunks = [] req.on('data', chunk => chunks.push(chunk)) req.on('end', () => { if (!chunks.length) { req.body = {} return next() } const rawContent = Buffer.concat(chunks).toString() // 留存原始内容方便排查 req.rawBody = rawContent // 自动尝试解析常见请求格式 try { req.body = JSON.parse(rawContent) } catch (jsonErr) { try { // 按urlencoded格式解析 const params = new URLSearchParams(rawContent) req.body = Object.fromEntries(params) } catch (urlErr) { // 格式解析失败时直接存原始内容 req.body = { rawContent } } } next() }) req.on('error', next) }) app.post('/', function (req, res) { console.log("解析得到的请求体", req.body) res.set('Content-Type', 'text/plain') res.send(`You sent: ${JSON.stringify(req.body)} to Express`) }) app.listen(4545, function (err) { if (err) throw err console.log('Server started on port 4545') })
可选优化:如果后续排查确认业务端发送请求时可以补充
Content-Type头,也可以直接修改原中间件的匹配规则,比如给bodyParser.json()增加type: '*/*'配置,让中间件忽略Content-Type匹配直接尝试解析。
内容的提问来源于stack exchange,提问作者user5326446
相关产品推荐
相关产品推荐

