先说结论:这个报错十有八九不是你中文编码的问题,而是你压根没把 Python requests 的 POST 请求体发送成服务端期望的 JSON 格式。只要看到Unrecognized token 'pageNo'这一句,整条排查路线其实已经被“剧透”了一大半。
最近我在对接一个查询类接口时正好踩了同一个坑——接口文档写得很明白,body 要 JSON,我图省事用data=传了个 dict,结果跑到带中文参数的场景就收到JSON parse error: Unrecognized token 'pageNo': was expecting ('true', 'false' or 'null')。一开始我也以为是的编码问题,毕竟报错里出现了中文,可后来把请求体抓出来一看,根子完全不在编码上,而是请求格式和 Content-Type 根本没配对。这篇文章就从头到尾还原一下整个排查过程,顺便把这类 JSON 解析报错的常见变体一起捋清楚。
1. 报错现场还原:从“有汉字才崩”到“必现”的复现实验
1.1 先写一个最小复现脚本
先说当时我写的代码,基本长这样:
import requests url = "https://example.com/api/order/query" payload = { "pageNo": 1, "pageSize": 20, "pageName": "张三", } resp = requests.post(url, data=payload) print(resp.status_code) print(resp.text)服务端返回的不是业务数据,而是一段异常响应:
JSON parse error: Unrecognized token 'pageNo': was expecting ('true', 'false' or 'null')我把pageName从 payload 里去掉,只保留数字和 pageNo,再发一次,结果正常返回了。这就非常迷惑:难道汉字真有这么大威力?加上汉字就触发解析错误?
后来我做了更细的对照实验,发现事情没那么简单。无中文时能通,很可能是因为服务端的某个边缘网关或过滤器做了宽容处理,一旦 body 里出现非 ASCII 字符,就踏入了一段完全不兼容的解析路径。这个判断不一定适用于所有后台,但足以让我意识到:直接看“有汉字/无汉字”这个维度,容易把问题归因到编码上。
1.2 三组对照实验结果
我把同一个接口试了四种发送方式,整理成一张表:
| 发送方式 | 实际请求体(body) | Content-Type | 结果 |
|---|---|---|---|
data=dict(含中文) | pageNo=1&pageSize=20&pageName=张三 | application/x-www-form-urlencoded | 解析失败,报Unrecognized token 'pageNo' |
data=dict(无中文) | pageNo=1&pageSize=20 | application/x-www-form-urlencoded | 同一路径下同样解析失败 |
json=dict | {"pageNo": 1, "pageSize": 20, "pageName": "张三"} | application/json | 正常 |
data=json.dumps(dict) | {"pageNo": 1, "pageSize": 20, "pageName": "张三"} | 未手动设置时为表单格式 | 取决于服务端是否检查 Content-Type,部分服务端仍会解析 JSON |
看到这里,核心疑点其实已经浮出水面了。data=dict会把参数拼成key=value&key2=value2这种表单格式,而服务端接口要的是 JSON 文本。它拿着 JSON 解析器去解析一段pageNo=1&pageSize=20...,第一眼看到裸单词pageNo,当然不认。
1.3 汉字为什么成了“背锅侠”
既然无中文也报错,标题里那句“有汉字时解析错误”到底是怎么回事?我的理解是:汉字不是原因,但它是把问题推到明面上的“催化剂”。
对一个格式正确、Content-Type 也正确的 JSON 请求体来说,中文字符本身没有任何问题,JSON 标准允许字符串内出现任意 Unicode 字符,只要服务端按 UTF-8 读取就行。问题在于,一旦 body 被拼成表单串,里面出现中文后,整个 body 会先做一次 URL 编码,变成一堆%E5%BC%A0%E4%B8%89这样的百分号序列。服务端的 JSON 解析器在解析到第一个百分号之前可能还在尝试识别 token,遇到%这种字符就直接抛出异常。于是“带中文”看起来成了触发条件,实际上根子是在“请求格式错了”。
这就像你去一家只接受“一菜一单”的食堂,递了张写满菜名的纸,对方本来就看不明白;纸上还涂了几笔圈圈点点,你以为是墨迹影响了他认字,其实是他连你那张纸的格式都不接受。
2. 追根溯源:JSON 解析器到底在哪个环节说“不认识 pageNo”
2.1 把报错翻译成人话
Unrecognized token 'pageNo'是很多 Java 服务端 JSON 解析框架(尤其是 Jackson)在解析失败时给出的典型提示。它想表达的意思是:在一段期望出现 JSON 合法 token 的位置,我看到了一个没加引号的裸标识符pageNo。
合法的 JSON 对象长这样:{"pageNo": 1},键名必须用双引号包起来,冒号后面跟着值,值可以是数字、字符串、布尔值、数组和对象。而服务端拿到的 body 是pageNo=1&pageSize=20,解析器第一个看到的字符是p,后面跟着一整段pageNo,这不是 JSON 语法中的任何合法 token,于是它把整个单词当作非法 token 报出来。
你注意看那句was expecting ('true', 'false' or 'null'),这其实是 Jackson 在报告“我期望的是某个合法值起始符,但收到的是别的”。有些版本还会报was expecting a value,本质都一样——解析器在错误的位置碰到了一个无法归类的词。
2.2 用本地 mock 服务把请求体打出来
排查这类问题最快的方法,不是在线上环境猜,而是起一个本地 mock 服务,把服务端视角的东西全部打印出来。我用 Python 标准库就能做一个最小实现:
from wsgiref.simple_server import make_server def app(environ, start_response): length = int(environ.get("CONTENT_LENGTH", 0) or 0) body = environ["wsgi.input"].read(length).decode("utf-8", errors="replace") print("Method:", environ.get("REQUEST_METHOD")) print("Content-Type:", environ.get("CONTENT_TYPE")) print("Body:", body) print("----") start_response("200 OK", [("Content-Type", "text/plain")]) return [b"ok"] if __name__ == "__main__": with make_server("127.0.0.1", 8080, app) as httpd: print("mock server on 8080") httpd.serve_forever()然后用 requests 分别发几种请求,mock 服务端打出来的内容一目了然。你会发现用data=payload时,收到的 body 是pageNo=1&pageSize=20&pageName=%E5%BC%A0%E4%B8%89,而 Content-Type 是application/x-www-form-urlencoded。这就解释了服务端为什么一脸懵。
2.3 URL 编码和 JSON 编码是两种“语言”
这里必须把两个概念分清楚。URL 编码(application/x-www-form-urlencoded)的规则是:参数名和参数值用=连接,参数之间用&连接,非 ASCII 字符按 UTF-8 编码后转成百分号加十六进制。而 JSON 编码的规则是:键值对放在{}里,键名用双引号包裹,值按类型书写,字符串类型也必须用双引号包裹。
这两套规则天然不兼容。requests 的data参数默认走前一套,json参数默认走后一套。服务端接口写@RequestBody或者等价注解时,明确要求接收 JSON 文本;你发一个表单串过去,服务端只能用 JSON 解析器硬啃,啃到第一个=就报错。
2.4 汉字到底有没有影响编码
不是所有场景都像上面这么简单。另有一种情况是:请求体确实是 JSON 结构,比如{"pageName": "张三"},但服务端读取时用了错误字符集,或者中间的网关把字节流转码转坏了,导致张字的 UTF-8 字节被错误切分,原本的字符串引号被截断或转义,这时候解析器可能会报Unterminated string或Unexpected character。
怎么区分是格式问题还是编码问题?最直接的办法是看原始字节。用 mock 服务打印出来的 body 是这样的,就是格式问题:
Body: pageNo=1&pageSize=20&pageName=张三如果打印出来长这样,那说明 JSON 结构完整,问题才可能出在编码链路:
{"pageNo":1,"pageSize":20,"pageName":"张三"}注意:单纯“Unrecognized token 'pageNo'”这个报错,几乎不可能由中文编码引起,因为 pageNo 这个裸词在中文出现之前就已经非法了。
3. 修复实操:把请求体改对,顺便把编码理顺
3.1 首选方案:用 requests 的 json 参数
requests 提供了一个专门发送 JSON 的入口,直接写json=payload就行:
import requests url = "https://example.com/api/order/query" payload = { "pageNo": 1, "pageSize": 20, "pageName": "张三", } resp = requests.post(url, json=payload) print(resp.status_code) print(resp.text)这行代码背后做了两件事:一是把 payload 序列化成 JSON 字符串,二是自动把 Content-Type 设置为application/json。这两个条件同时满足,服务端就能用正常的 JSON 解析路径去处理。
还有一个很多人忽略的细节:requests 序列化 dict 时,默认ensure_ascii=True,也就是说“张三”会被写成"\u5f20\u4e09"。这其实是好事,因为\u转义序列是纯 ASCII 字符,能彻底规避网络传输过程中的字符集混乱问题,服务端不管用什么编码读取,转义序列都能被正确还原成中文。我见过不少同事为了日志好看,特意把ensure_ascii=False打开,结果碰到非 UTF-8 场景反而引入新坑。
3.2 需要保留中文可读时:手动构造 body 并显式声明编码
有些老系统或者特殊中间件确实需要服务端日志里直接看到中文,这时候你可以手动构造 JSON 字符串,但必须把 Content-Type 和 charset 一起显式带上:
import json import requests url = "https://example.com/api/order/query" payload = { "pageNo": 1, "pageSize": 20, "pageName": "张三", } body = json.dumps(payload, ensure_ascii=False) resp = requests.post( url, data=body.encode("utf-8"), headers={"Content-Type": "application/json; charset=utf-8"}, )几个细节值得注意。
body.encode("utf-8")这一步把字符串转成字节数组,交给 requests 时不会再有隐式编码的二次猜测。如果你直接传data=body,requests 会自动按某种编码方式编码字符串,大多数时候是 utf-8,但一旦运行环境默认编码被改过,就可能出乱码。显式 encode 是最稳妥的。
另外,charset=utf-8写在 Content-Type 里,是明确告诉服务端按 UTF-8 解码。有些服务端框架不读这个 charset,默认按 UTF-8,那也没问题;遇到默认按 GBK 的老框架,这个声明就能救你一次。
3.3 如果服务端接口真的只认表单格式
反过来也有一种情况:接口文档写的是application/x-www-form-urlencoded,body 就是pageNo=1&pageSize=20&pageName=张三这种串。这时候你非要用json=,服务端可能返回 415 Unsupported Media Type,或者业务参数解析不出来。
判断标准很简单:看接口文档或 Swagger 里标注的 consumes 类型。如果写的是表单,就用data=payload;如果写的是 JSON,就用json=payload。这二者不能混。
用表格整理一下:
| 服务端接收类型 | 推荐发送方式 | Content-Type 自动值 |
|---|---|---|
| application/x-www-form-urlencoded | data=dict | application/x-www-form-urlencoded |
| application/json | json=dict | application/json |
| text/plain 或自定义格式 | data=str/data=bytes | 需手动设置 |
3.4 汉字编码相关的几个实操细节
围绕汉字,还有几个容易踩的边边角角,我一起说一下。
第一个坑:不要对 JSON 字符串再做一次 URL 编码。有人发现 body 里有中文后,会手动urllib.parse.quote一遍再塞进 JSON,结果中文变成%E5%BC%A0%E4%B8%89,放在字符串内部会被服务端原样当成字符串内容,业务数据直接错了。中文该以什么形态出现在 JSON 里,就保持什么形态,最多用ensure_ascii=False或True控制转义。
第二个坑:requests.Session 里遗留的自定义 Content-Type。如果你在 session 级别设置过headers={"Content-Type": "application/json"},后续某个接口想用data=dict发表单,requests 仍会沿用你手动设置的 headers,导致表单 body 配上了 JSON 头,服务端可能依然走 JSON 解析然后报错。所以每次请求要么显式传 headers,要么确认 session 上没挂全局头。
第三个坑:URL 里带中文参数。这是另一个问题,查询参数里的中文应该用params参数交给 requests 处理,同时设置好编码,不要自己去拼 URL。POST body 里的中文归 body 管,URL 里的中文归 params 管,两套逻辑不要混在一起。
4. 排查链路:从“带汉字就崩”到定位根因的四步法
4.1 第一步:打印你即将发出的请求头和请求体
requests 允许你先预览 PreparedRequest,不需要真的发出去就能看到 body 和 headers:
import requests req = requests.Request( "POST", "https://example.com/api/order/query", data={"pageNo": 1, "pageName": "张三"}, ) prepared = req.prepare() print("Headers:", prepared.headers) print("Body:", prepared.body)输出里能看到 Content-Type 和 body 的真实内容。如果你发现 body 是pageNo=1&pageName=张三,那就可以确定发送的是表单格式,问题出在格式不匹配。
这里有个容易被忽略的点:prepared.body在 requests 内部可能是 bytes,打印时中文会直接显示,也可能显示转义序列,不影响判断。你重点看它是不是key=value&key2=value2这种形态。
4.2 第二步:起本地 mock 服务复现
上一节已经给了 wsgiref 的 mock 代码,这一步的关键是让服务端视角变得可见。我习惯把分支条件也加上:根据 Content-Type 走不同的处理路径,然后打印不同的提示。
def app(environ, start_response): length = int(environ.get("CONTENT_LENGTH", 0) or 0) body = environ["wsgi.input"].read(length).decode("utf-8", errors="replace") content_type = environ.get("CONTENT_TYPE", "") if "application/json" in content_type: print("收到 JSON 格式请求") elif "application/x-www-form-urlencoded" in content_type: print("收到表单格式请求") else: print("收到未知格式请求") print("Body repr:", repr(body)) print("Content-Type:", content_type) print("----") start_response("200 OK", [("Content-Type", "text/plain")]) return [b"ok"]在本地跑起来之后,再用目标环境里失败的脚本打过去,观察打印结果。如果打印显示“表单格式请求”,而接口文档写的是 JSON,问题就非常明确了。
4.3 第三步:用二分法把变量拆开
当 mock 服务打印出来的信息还是不够清晰时,我习惯用二分法缩小范围。具体操作是这样:
- 固定 URL 和请求头不变,只改 body:分别用
data=和json=发一次,看响应差异。 - 固定 body 不变,只改 Content-Type:手动设置
headers={"Content-Type": "application/json"},对比手动设和不设时的响应差异。 - 固定格式,只增删中文:在
json=模式下分别带中文和不带中文,发现都能成功,说明格式修好后中文本身不是问题。 - 如果带不带中文结果不同,再去检查请求前的中文字符串有没有被别的地方转码过。
这套流程下来,基本能把“格式问题”“Header 问题”“编码问题”三个嫌疑分离清楚。
4.4 第四步:让后端同事把原始 body 打出来
如果你本地 mock 已经复现,但线上环境还是有差异,那就需要服务端配合。不要只看异常堆栈,要让后端在入口处把request.getBody()或等价原始内容打出来,看看线上真正收到的字节是什么。很多时候中间经过网关、负载均衡、ESB,body 可能被二次编码或格式转换,这只有线上日志能暴露。
我遇到过一次,本地 mock 一切正常,线上同样代码就报错,最后发现是统一的 API 网关把 JSON body 里所有中文做了 UTF-8 百分号转码,导致{"pageName":"张三"}变成了{"pageName":"%E5%BC%A0%E4%B8%89"},服务端解析出来的就是百分号串。这类问题如果不看网关配置,纯靠客户端代码排查很难发现。
5. 同类 JSON 解析报错的变体与发送前自查清单
5.1 高频报错速查表
这类报错在对接第三方接口时非常常见,我把几种高频变体整理成了表,方便大家对照:
| 报错关键字 | 含义 | 常见原因 |
|---|---|---|
Unrecognized token 'pageNo' | 解析器看到一个裸标识符 | body 是表单格式但被当 JSON 解析;键名漏了双引号 |
Unexpected character ('x') | 当前上下文中出现不该出现的字符 | JSON 尾部多了逗号;字符串用了单引号;注释未移除 |
Unterminated string | 字符串没有结束引号 | 中文被中间层截断;反斜杠转义处理不当 |
Illegal character (<CTRL-CHAR>) | 出现控制字符 | 字符集不匹配;body 里混入了不可见字符 |
Expected ':' but got 'x' | 键名后面不是冒号 | 键名与值之间少了冒号,或键名没闭合 |
每一条背后的排查方法都类似:先把实际 body 抓出来看结构,再对照 Content-Type 判断是否匹配,最后检查编码链路。
5.2 发送前自查清单
我在这个坑里爬出来后,给自己定了一个发送前自查流程,基本不踩雷了:
- 先看接口文档要求的 Content-Type 是什么。
- 如果是 JSON,优先用
json=参数,不要手动拼字符串。 - 如果必须手动拼 JSON,确认 headers 里 Content-Type 带上
charset=utf-8。 - 确认 body 里没有残留的单引号、冗余逗号、注释。
- 确认没有对 JSON 字符串再做 URL 编码。
- 如果用一个 requests.Session 复用连接,先确认 session 上没有全局占位 Content-Type。
- 如果同时带文件和 JSON 字段,文件走
files=,JSON 字段建议放进data=的字符串里并指定Content-Type: application/json,或者放到 form 字段里,但不能既想传 JSON 又想走 multipart 还指望服务端自动识别。
5.3 一个容易被忽略的坑:dict 里嵌 dict
补充一个很常见的坑。当 payload 本身不是扁平 dict,而是嵌套结构时,用data=发送会出现更奇怪的现象:
payload = { "pageNo": 1, "filter": { "name": "张三", }, }requests 处理data=payload时,会尝试把内层 dict 转成字符串,结果 body 变成:
pageNo=1&filter={'name': '张三'}那串{'name': '张三'}出现在表单值里,虽然 URL 编码后能传输,但服务端如果用 JSON 解析,遇到{可能反而能被识别为对象开始,然后遇到单引号就报Unexpected character。这种问题比扁平 dict 更隐蔽,因为它看起来确实有一部分 JSON 的影子。
如果你要发的是嵌套 JSON,老老实实用json=payload,它是唯一稳妥的路径。手动拼 JSON 字符串也可以,但字典里所有字符串都得自己加双引号,容易出错。
5.4 顺手封一个通用发送函数
排查完这个问题后,我把公司内部项目里的 POST 发送逻辑统一封装了一下,核心思路特别简单:默认发 JSON,遇到确需发表单的接口单独走一个分支。
import json import requests def post_json(url, payload, timeout=10): return requests.post( url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers={"Content-Type": "application/json; charset=utf-8"}, timeout=timeout, )这里我用data=手动构造,而不是json=payload,其实是想把编码选择权握在自己手里。对绝大多数场景来说,json=payload已经足够可靠,但如果团队里有人需要日志可读、或者要统一控制 UTF-8,手动构造反而更直白。封装好后,后续所有对接都走同一个入口,再遇到类似报错,只需排查一个函数而不是十几个脚本。
实际操作中我的体会是:凡是报错信息里有Unrecognized token、Unexpected character这类字眼,先别急着怀疑中文编码,先把请求体原样打出来看一眼,确认它到底是什么格式。这个动作 30 秒就能完成,却能把排查方向直接掰正。等格式和 Content-Type 都对齐之后,如果还有中文乱码问题,再回头查 charset、网关转码、中间件配置,那时候才是真正处理编码问题的时候。