You need to enable JavaScript to run this app. Node.js Webhook监听器接收POST请求时req.body始终为空的求助
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.11.09 04:24:00