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

仙人之下我无敌,
仙人之上一换一。

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

multipart 报文自己切一遍,5 个字段会切成 7 段

2026-9-20 / 0 评论 / 8 阅读

multipart 报文自己切一遍,5 个字段会切成 7 段

上传接口出问题时,最快的定位方式是把浏览器实际发出去的字节打出来看,而不是猜框架哪里配错了。下面五步从起服务到切开报文,每一步都能单独跑,报文的原始字节与切分结果,还有两个客户端的差异,都有实测输出,脚本是 tools/verify-multipart-parse.mjs,Node v26.8.1。

第一步:起一个只记录原始报文的本地服务

服务端只做三件事:响应一个内嵌脚本的页面、把 POST 上来的 body 按字节收全、把分析结果以 JSON 返回。关键是不要用任何框架的 body parser,一旦用了框架,看到的就已经是解析后的字段,而这一步要看的是解析前的字节。监听的端口交给系统分配,避免和本机其他服务撞号。

第二步:让真实浏览器发一次

页面里的脚本拼一个 FormData,其中包含两个同名文本字段,一个空值字段,一个中文名文件,然后用 fetch 发给本地服务。驱动方式是直接连本机 Chrome 的调试端口,脚本自己读 DevToolsActivePort 拿到端口号,再开一个标签页打开本地页面,等页面里的 window.__done 变成 true。这一步的意义在于报文是浏览器生成的,不是自己拼的字符串,WebKitFormBoundary 这个前缀本身就说明来源。

第三步:按边界切出 parts

切分规则只有三条:用 -- 加 boundary 当分隔符,每段开头是若干头字段加一个空行,段尾去掉一个 CRLF。脚本用 latin1 视图做字符串切分,这样每个字节都能对应回一个字符,切完再把头行和值分别按 UTF-8 重新解码。这一步的输出里能看到 5 个 part,包括两个同名 dup 字段各自占一段,以及值为空的 empty 字段占了一段长度为 0 的段。

第四步:对照粗暴切法

常见的偷懒做法是把整个 body 按连续两个 CRLF 切开,假设第一个切点后面就是值。拿上面那份报文试一遍,段数是 7,而正确 part 数是 5。多出来的两段来自第一个字段的值本身:那个值里含一个空行,字节序列和「头与值之间的分隔」完全相同。这也是为什么规范要求 boundary 由发送方随机生成,且不得出现在内容里,解析端必须认边界,不能认空行。

检查点:五个可以自查的地方

第一,Content-Type 里的 boundary 必须原样取出来,浏览器这次给的是 38 字符,Node 的 fetch 给的是 32 字符,长度不是固定的,硬编码或者用正则抠错半截都会导致切分全废。

第二,文本字段没有 Content-Type,只有文件字段带一个,实测输出里 name=msg 的类型是空的,name=file 的类型是 text/csv。把「没有类型就是文件」当判据会出错,判据应该是有没有 filename 参数。

第三,中文文件名以原始 UTF-8 字节出现在 filename="..." 里,实测是 15 字节,没有任何百分号编码,也没有 RFC 5987 的 filename* 写法。如果按 latin1 或者 ASCII 去读这行头,拿到的会是 æ¥è¡¨ 2026.csv 这种乱码,必须再按 UTF-8 解一次才还原成 报表 2026.csv。浏览器与 Node 的 fetch 在这件事上给出的是同一份字节,这一点两边都对上了。

第四,字段值里的 CRLF 会原样保留。前两个字段的值实测是 16 字节,里面那个空行一个字节都没被吃掉,所以下游拿到的字符串要做 trim 或校验时,别假设发送方已经处理过换行。

第五,两边都带了精确的 Content-Length,没有用分块传输。服务端可以据此做长度上限校验,超限的请求在收全之前就能拒掉。

node v26.8.1
本机 http 服务: 127.0.0.1:63490

=== 浏览器 Chrome/153.0.8010.37 ===
  Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBZwa5namteUgOVBo
  Content-Length 头: 591,服务端实收 591 字节
  Transfer-Encoding: (无)
  boundary 长度: 38,前 64 字节的 hex: 2d 2d 2d 2d 2d 2d 57 65 62 4b 69 74 46 6f 72 6d 42 6f 75 6e 64 61 72 79 42 5a 77 61 35 6e 61 6d
  body 开头 96 字符: ------WebKitFormBoundaryBZwa5namteUgOVBo\r\nContent-Disposition: form-data; name="msg"\r\n\r\n行一\r\n\r\n行二
  body 结尾 40 字符: --WebKitFormBoundaryBZwa5namteUgOVBo--\r\n
  按 --boundary 切出来的 part 数: 5
    name=msg filename=null type=- 值字节数=16 值="行一\r\n\r\n行二"
    name=dup filename=null type=- 值字节数=1 值="1"
    name=dup filename=null type=- 值字节数=1 值="2"
    name=empty filename=null type=- 值字节数=0 值=""
    name=file filename="报表 2026.csv" type=text/csv 值字节数=24 值="城市,温度\n深圳,28\n"
  按 \r\n\r\n 粗暴切的段数: 7(正确 part 数是 5)
  文件字段的原始头行(按 latin1 读出来就是这样): Content-Disposition: form-data; name="file"; filename="æ¥è¡¨ 2026.csv"\r\nContent-Type: text/csv
  filename 里是 15 字节原始 UTF-8,按 latin1 读会显示成乱码 "æ¥è¡¨ 2026.csv"
  按 UTF-8 重新解一次才拿到 "报表 2026.csv",没有任何百分号编码,也没有 filename*

=== Node fetch + FormData ===
  Content-Type: multipart/form-data; boundary=----formdata-undici-046127555449
  Content-Length 头: 555,服务端实收 555 字节
  Transfer-Encoding: (无)
  boundary 长度: 32,前 64 字节的 hex: 2d 2d 2d 2d 2d 2d 66 6f 72 6d 64 61 74 61 2d 75 6e 64 69 63 69 2d 30 34 36 31 32 37 35 35 35 34
  body 开头 96 字符: ------formdata-undici-046127555449\r\nContent-Disposition: form-data; name="msg"\r\n\r\n行一\r\n\r\n行二\r\n----
  body 结尾 40 字符: \r\n------formdata-undici-046127555449--\r\n
  按 --boundary 切出来的 part 数: 5
    name=msg filename=null type=- 值字节数=16 值="行一\r\n\r\n行二"
    name=dup filename=null type=- 值字节数=1 值="1"
    name=dup filename=null type=- 值字节数=1 值="2"
    name=empty filename=null type=- 值字节数=0 值=""
    name=file filename="报表 2026.csv" type=text/csv 值字节数=24 值="城市,温度\n深圳,28\n"
  按 \r\n\r\n 粗暴切的段数: 7(正确 part 数是 5)
  文件字段的原始头行(按 latin1 读出来就是这样): Content-Disposition: form-data; name="file"; filename="æ¥è¡¨ 2026.csv"\r\nContent-Type: text/csv
  filename 里是 15 字节原始 UTF-8,按 latin1 读会显示成乱码 "æ¥è¡¨ 2026.csv"
  按 UTF-8 重新解一次才拿到 "报表 2026.csv",没有任何百分号编码,也没有 filename*

正经上线该怎么做

这套手写解析只适合排查,别放进生产:规范里还有转义引号,跨行长文件名,多段嵌套这些分支,手写版很容易漏。上线用 busboyformidable 这类成熟实现,把上面那个本地服务的日志能力留在调试分支里就行。我的取舍是接口层一律用库,只在怀疑客户端发的报文本身有问题时把日志开关打开,抓一份原始字节回来切一遍。

顺带说一句,fetchFormData 时不要手写 Content-Type,boundary 是客户端生成的,手写会把这段信息覆盖掉,服务端就再也切不开报文了;这一条没在本机复验,是按接口约定推的结论。

    🤞 分享