调接口这件事,说到底是两拨人在互相较劲:写服务端的人觉得文档写清楚了,调接口的人觉得文档等于没写。我见过太多人拿到一份接口说明,上面第一行就写着curl -X POST ... -d '{"a":1}',复制粘贴到终端回车,屏幕上跳出个 400 或者 415,然后就卡在那里了。明明参数一个不差,怎么就报错了?问题往往不在接口本身,而在你根本没搞懂这条 curl 命令的每一块在干什么。
如何使用 curl 命令发送 json 格式请求体的 post 请求,这个问题看起来像是新手入门级别的疑问,但我带过的人里,工作三五年还在这个坑里反复摔的并不少见。它牵扯到的其实是四件事:HTTP 报文长什么样、curl 的参数各自管什么、你所处的 shell 怎么处理引号、以及出问题之后怎么定位。这四件事任意一环没打通,你抄来的命令就是一段玄学咒语,改一个字就失灵。
这篇内容面向所有需要手工调接口的人——后端自测、前端联调、测试造数据、运维写健康检查脚本,都能用得上。我会从报文结构讲起,把-d、-H、-X这几个参数的差别掰开,重点砸在引号转义和编码这两个最容易翻车的地方,最后给一套能直接抄走的脚本模板和排查清单。全文基于我在 Linux、macOS、Windows 三种环境下反复踩坑的经验整理,示例接口全部用本地搭的测试服务,你可以照着复现。
1. 一条 curl 命令背后到底发生了什么
1.1 把 POST 请求拆成四个零件
很多人对 curl 的理解停留在"一个能发请求的工具",但真正让你少走弯路的心智模型是:curl 干的事情就是按照你给的参数,手工拼一段文本,然后通过 TCP 连接把这段文本发出去。这段文本就是 HTTP 报文,它由四部分构成——请求行、请求头、空行、请求体。
请求行长这样:POST /api/users HTTP/1.1。它告诉服务端三件事——我用什么方法、我要访问哪个路径、我用哪个版本的协议。请求头是一堆Key: Value,比如Content-Type: application/json、Content-Length: 27、User-Agent: curl/8.x。空行是一个纯粹的换行符,它是头和体之间的分隔符,少了它服务端会把你的请求体当成请求头的一部分去解析,直接报格式错误。请求体才是你那段 JSON 真正待的地方。
对应到 curl 参数上:-X POST决定请求行的第一个词,URL 决定第二个词,-H决定请求头里的每一行,-d决定请求体。而Content-Length这个头你不用管,curl 会自己算——你把请求体交给它,它数一下字节数自动补上。这里有个细节值得记住:它数的是字节不是字符,一段包含 10 个汉字的 JSON,长度会增加 20 而不是 10,因为 UTF-8 下每个汉字占 3 字节。这个区别在后面排查"服务端只收到半截 JSON"的时候会用到。
理解了这层,你再看任何一条复杂的 curl 命令,都能瞬间把它拆成这四块,不会觉得是一坨看不懂的字符串。这也是我教新人时的第一课:先不急着敲命令,先在纸上把你要发的报文写出来,然后再去找对应的参数。
1.2 为什么 curl 默认不认为你在发 JSON
这是新手最容易困惑的点:我明明-d里放的是合法的 JSON 字符串,为什么服务端说"不支持的媒体类型"?答案在于 curl 的历史包袱。curl 诞生年代很早,那时候最主流的表单提交格式是application/x-www-form-urlencoded,所以-d这个参数从设计之初就默认使用这个 Content-Type,并且它会顺手把你给的数据做一层处理——把换行符去掉、把多个-d用&拼起来,完全是按照表单的思路在办事。
也就是说,你写-d '{"name":"张三"}',curl 发出去的时候会老老实实地带上Content-Type: application/x-www-form-urlencoded。服务端一看这个头,转头去按表单格式解析,结果发现解析不出来,于是返回 415 或者 400。数据和格式都没错,错的是那个它自动帮你加的头。
所以规矩很简单:只要你的请求体是 JSON,Content-Type: application/json这个头必须手动写,没有例外。有些框架比较宽容,会去猜你的 body 长什么样,但依赖服务端的宽容是一种赌博,换一个服务实现就翻车。养成手动写头的习惯,你的命令换到任何环境都能跑。
顺带说一个常见误区:不少人喜欢写成Content-Type: application/json; charset=UTF-8。这么写不错,但通常没必要。JSON 标准里明确规定文本必须是 UTF-8 编码,所以服务端看到application/json就知道该按 UTF-8 处理。有些老服务会在你只写application/json的时候报编码问题,那种情况下补上 charset 是合理的,但这是特例,不是规范要求。
1.3 一个能救命的最小模型
我建议你在脑子里固化一个最小模型,遇到任何 curl 报错都先回到它:
POST /path HTTP/1.1 Host: xxx Content-Type: application/json Content-Length: N {"key":"value"}这五行就是全部真相。你后面学的所有参数,都是在这五行上做增减。-H是往第三行区域塞内容,-d是填第五行,-X是改第一行的方法名,-v是让 curl 把它实际拼出来的报文打印给你看。
这套模型的价值在于:当你遇到"服务端说我少传了某个字段"的时候,你会立刻想到去看-v输出的实际报文,确认字段是不是在传输过程中被 shell 吃掉了、被引号截断了、或者被 cmd 的百分号展开搞没了。绝大多数"我明明传了但服务端说没有"的问题,看一眼-v的输出就能定位,比反复改参数高效得多。
2. 参数选型:-d 的几个变体差在哪
2.1 -d、--data-raw、--data-binary、--data-urlencode 对照
-d有四个亲戚,名字很像,行为差别不小。选错一个,可能就是"本地测试好好的,换台机器就废了"。
| 参数 | 对数据的处理 | 适用场景 |
|---|---|---|
-d/--data | 去掉换行符,多个-d用&连接,@开头会读文件并去换行 | 短小的单行 JSON、表单 |
--data-raw | 与-d相同,但不把@当文件引用符 | 请求体里含@字符 |
--data-binary | 原样发送,一个字节都不改 | 多行 JSON、含换行的文本 |
--data-urlencode | 对数据做 URL 编码后发送 | 表单参数、必须编码的字段 |
这几个里面,我最常用的是-d和--data-binary。短的一行 JSON 用-d完全够用;一旦你的 JSON 是格式化过的、带缩进和换行的,就必须用--data-binary。原因在于-d会把换行符统统删掉,你的多行 JSON 会被压成一行——压成一行本身对服务端没有影响,JSON 解析器不在乎换行,但如果你是想把请求体签名或者做哈希校验,换行被删掉会导致签名对不上,这个坑我在对接过一家需要 body 摘要校验的服务时踩得结结实实。
--data-raw的使用频率低,但有一个场景必须用它:请求体里包含邮箱地址或者@开头的字符串。因为-d @file.json的语法是"从文件读取",你如果直接写-d '{"email":"a@b.com"}',curl 发现第一个字符是{不是@,不会误判;但如果你写--data '@something',它就会去找一个叫 something 的文件。为了彻底避免这类歧义,含@的请求体我一律用--data-raw。
--data-urlencode在发 JSON 的时候基本用不到,它服务的是表单场景。但有一个变体值得一提:--data-urlencode "file@body.json"会读取文件内容并做 URL 编码。这个用法在处理老式接口的时候会碰到,你在 JSON 场景里看到它的概率不高。
2.2 -H 写头的三种风格与各自的坑
-H的写法看似简单,实际藏着几处容易忽略的细节。最基本的格式是-H "Key: Value",冒号后面那个空格不是必须的,但强烈建议加上,因为有些服务端的解析器写得不严谨,遇到Content-Type:application/json这种没有空格的写法会解析失败。加上空格成本为零,能规避一类莫名其妙的问题。
第二个细节是-H可以重复出现,写多个头就写多个-H。很多人会尝试在同一个-H里用换行符塞多个头,比如-H "A: 1\nB: 2",这在大多数 shell 下都不成立,因为引号里的\n只是个反斜杠加字母 n 两个字面字符,不会变成真正的换行。curl 收到的是一个包含奇怪字符的头,服务端大概率直接拒绝。
第三个细节,也是最容易出事的一个:不要自己写Content-Length。有人看到抓包里有个Content-Length: 27,就想着也手动加上去,这样"更完整"。结果请求体一改,长度对不上,服务端要么截断请求体、要么直接返回 400。让 curl 自己算,它算得比你准。唯一需要你干预长度的情况是发送空 JSON 对象{},长度是 2,这个它也能算对。
关于-H我还想补一个实战心得:除了Content-Type,很多接口还要求Accept: application/json。这个头的意思是"我希望你返回 JSON"。虽然大多数服务端不强制,但有些网关或者内容协商做得比较严格的框架,会根据 Accept 头来决定返回格式,你不写它就给你吐一段 HTML 错误页,然后你的 JSON 解析器在那边报"unexpected token <"。加上这个头,能省掉一类排查。
2.3 -X POST 到底该不该写
关于这个参数,社区里有两派意见。一派说必须写,理由是明确表达意图;另一派说不用写,因为-d已经隐含了 POST 方法,写了反而容易出错。
我的建议是:写,但要知道它为什么可能出错。写的好处是命令自解释,别人看你的脚本一眼就知道这是 POST。风险在于,一旦你写了-X POST同时又用了-G(把数据拼到 URL 查询串上),curl 会真的用 POST 方法去发一个没有请求体的请求,某些服务端会因此返回 405。但这种组合本身就不常见。
真正需要注意的场景反过来:如果你只写了-X PUT而没写-d,curl 会发一个没有请求体的 PUT 请求,有些服务端要求 PUT 必须有 body,就会返回 411 Length Required。所以配套关系是:方法名和请求体要一起考虑,不能只改一个。
另外一个隐蔽的坑:-X是个"强制覆盖"参数,它会覆盖 curl 基于其他参数自动推断出来的方法。比如你写了-X POST -F "file=@a.txt",-F本意是发起 multipart 表单上传,通常配 POST,你这么写没毛病;但如果你写-X GET -F "file=@a.txt",就会发出一个带 multipart body 的 GET 请求,服务端可能直接崩掉。JSON 场景下,-X POST配合-d或--data-binary是最稳的组合,别乱跨。
3. 引号与转义:九成的报错出在这里
3.1 Linux 和 macOS:单引号是首选
在 bash、zsh 这类 shell 下,写 JSON 请求体的首选方案是用单引号把整段 JSON 包起来:
curl -X POST 'http://localhost:8080/api/users' \ -H 'Content-Type: application/json' \ -d '{"name":"张三","age":28}'为什么首选单引号?因为在单引号里,shell 不做任何解析——$、反引号、反斜杠、双引号,全部当作普通字符原样传递。而 JSON 里天然大量使用双引号,用单引号包裹能避免一次转义地狱。
代价是:单引号里不能出现单引号。这个限制看起来很致命,因为 JSON 字符串里确实可能出现英文单引号,比如{"name":"O'Neil"}。遇到这种情况,标准做法是把单引号断出来:
-d '{"name":"O'\''Neil"}'这段看起来像乱码的东西,逻辑是:'{"name":"O'是第一个单引号字符串,\'是一个被转义的裸单引号,'Neil"}'是第二个单引号字符串。三段拼起来,shell 最终传给 curl 的就是{"name":"O'Neil"}。这个技巧值得背下来,写脚本的时候会反复用到。
如果你不想记这个,还有个更实际的建议:含单引号的 JSON,别写内联,写到文件里去,用--data-binary @body.json。既避开了转义问题,也让命令变得干净。这个习惯我保持了几年,凡是请求体超过一行的,一律走文件。
3.2 Windows cmd:双引号地狱与百分号陷阱
切到 Windows 的 cmd,画风立刻变了。cmd 不认单引号,你写-d '{"a":1}',它会把单引号当成普通字符传下去,curl 收到的请求体就变成了'{"a":1}'两边带引号,JSON 解析直接失败。所以在 cmd 里必须用双引号包裹,而 JSON 内部的双引号要用反斜杠转义:
curl -X POST "http://localhost:8080/api/users" ^ -H "Content-Type: application/json" ^ -d "{\"name\":\"张三\",\"age\":28}"注意 cmd 的续行符是^不是\,这是另一个常见的手滑点。而且^后面不能有空格,有空格续行就失效了。
更大的雷在百分号。cmd 里%是变量引用符,%PATH%这种会被展开。如果你请求体里恰好有%,比如{"discount":"20%"},cmd 会尝试把它当变量处理。命令行里单独的%通常不会出问题,但在批处理文件.bat里,%需要写成%%。这个差异导致过一个经典现象:同一条命令在命令行窗口跑得好好的,写进 bat 脚本就报 JSON 语法错误,排查半天最后发现是百分号被吃了。
还有一个容易忽略的点:cmd 的代码页。默认代码页可能是 936(GBK),你从别处复制的中文粘进去,curl 按当前代码页的字节序列发出去,服务端按 UTF-8 解,中文就成了乱码。稳妥做法是用chcp 65001把代码页切到 UTF-8,或者干脆把 JSON 写成 UTF-8 编码的文件,用-d @body.json读。
3.3 PowerShell 里 curl 不是 curl
这一条单独拎出来说,因为它是 Windows 上最隐蔽的坑。在 PowerShell 里输入curl,实际执行的很可能是Invoke-WebRequest的别名,而不是真正的 curl 可执行文件。它的参数体系完全不同,你照着网上的教程写-d、-H,会收到一堆"找不到参数"的报错。
验证方法很简单,跑一下Get-Command curl,如果输出显示Alias和Invoke-WebRequest,就说明你踩上了。解决办法是显式写curl.exe:
curl.exe -X POST 'http://localhost:8080/api/users' ` -H 'Content-Type: application/json' ` -d '{\"name\":\"张三\"}'PowerShell 的续行符是反引号`,它的引号规则又和 cmd 不一样:单引号是字面量字符串,双引号会解析变量。理论上用单引号包 JSON 最省事,但 PowerShell 5.1 在调用外部 exe 时对参数的重新解析有一套很别扭的逻辑,会二次处理引号,导致原本写对的转义在传给 exe 的时候变样。这也是为什么你在网上搜 "PowerShell curl json",会看到各种五花八门的转义写法,每一种都号称能用,换台机器就不灵。
我的经验是:在 Windows 上,别跟引号较劲,直接把 JSON 写进文件,用-d @body.json。这条规则适用于 cmd、PowerShell 5.1、PowerShell 7 全部场景,一次写好,到处能跑。花在转义上的时间,足够你写完三个接口的测试脚本了。
3.4 变量注入:什么时候该用双引号
脚本化的时候你一定需要往 JSON 里填变量,比如把用户名、时间戳、订单号塞进去。这时候单引号就不行了,因为单引号会阻止变量展开。你得改用双引号包裹,并注意两点:外层用双引号之后,JSON 内部的双引号需要转义;变量要用${var}的形式包起来,避免和后面的字符粘连。
name="张三" uid=1001 curl -X POST 'http://localhost:8080/api/users' \ -H 'Content-Type: application/json' \ -d "{\"name\":\"${name}\",\"id\":${uid}}"注意id那个位置没有加引号,因为它是数字类型,加了引号就变成字符串"1001",服务端如果做了严格的类型校验就会报错。这种细节在拼 JSON 的时候特别容易忽略,尤其是从 shell 变量拿到的值本来就是字符串,你得自己判断它在目标 JSON 里该是什么类型。
如果变量值里可能包含双引号、反斜杠、换行这些特殊字符,纯手工拼接就会变得极其脆弱。这种情况我推荐用jq来构造 JSON,它是专门干这个的:
payload=$(jq -n --arg name "$name" --argjson id "$uid" '{name: $name, id: $id}') curl -X POST 'http://localhost:8080/api/users' \ -H 'Content-Type: application/json' \ -d "$payload"--arg会把值当字符串处理并自动转义,--argjson会把值当 JSON 解析(数字、布尔、数组都能用)。这段代码的可读性比手工拼接高一个量级,而且不会因为用户名里有个引号就崩掉。多花五分钟学jq -n,能省掉后面无数次的"为什么只有某个特定用户的数据提交失败"。
4. 从零跑通:完整实操流程
4.1 先准备一个能吃 JSON 的测试端点
调接口之前,手里得有个能回显请求的端点,否则你连自己发出去的东西对不对都不知道。我用 Python 标准库搭一个最小的,不需要装任何依赖,粘进文件保存成echo_server.py就能用:
from http.server import BaseHTTPRequestHandler, HTTPServer import json class Handler(BaseHTTPRequestHandler): def do_POST(self): length = int(self.headers.get('Content-Length', 0)) raw = self.rfile.read(length) info = { "method": self.command, "path": self.path, "content_type": self.headers.get('Content-Type'), "raw_body": raw.decode('utf-8', errors='replace'), } try: info["parsed"] = json.loads(raw) info["json_ok"] = True except Exception as e: info["json_ok"] = False info["error"] = str(e) body = json.dumps(info, ensure_ascii=False, indent=2).encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) HTTPServer(('127.0.0.1', 8080), Handler).serve_forever()跑起来python echo_server.py,它会把收到的 Content-Type、原始 body、解析结果原样吐回来。这个回显服务是我每次换电脑都会重新搭一遍的东西,因为排查问题时,"服务端到底收到了什么"和"我以为我发了什么"往往是两回事,中间差的那部分就是 bug 所在。
用一个回显端点做验证还有一个好处:它能明确告诉你 JSON 是不是合法的。上面代码里json_ok那个字段就是干这个的,语法错的时候会把解析器的错误信息带回来,比如 "Expecting property name enclosed in double quotes",直接指向问题所在,比对着服务端返回的一句"参数错误"瞎猜高效得多。
4.2 最简命令与逐项验证
服务跑起来之后,先发一条最简单的:
curl -X POST 'http://127.0.0.1:8080/api/users' \ -H 'Content-Type: application/json' \ -d '{"name":"张三","age":28}'预期返回里json_ok应该是true,parsed里能看到你的字段,content_type是application/json。如果这三项都对,说明整条链路通了。
接着做个对照实验,把-H那行删掉再发一次。你会发现json_ok依然是true——因为我的测试服务端比较宽容,不管你声明什么类型都尝试按 JSON 解析。但content_type会变成application/x-www-form-urlencoded。这正是真实环境里出问题的根源:宽容的服务端能容忍,严格的服务端会直接 415。这个对照实验建议你自己跑一遍,亲眼看到那个头的变化,比看十遍文档印象都深。
再做一个实验:故意把 JSON 写错,比如把某个双引号换成单引号,或者末尾多一个逗号。观察返回的error字段。这一步是在建立你对错误信息的敏感度——真实环境里服务端可能只返回一个 400 加一句模糊的提示,你得能从自己的经验里推断出大概是哪类语法错误。
4.3 请求体放文件:@file 与 @-
请求体一旦超过一行,或者包含大量特殊字符,就该搬到文件里。语法是-d @文件名,注意@和文件名之间不能有空格。
curl -X POST 'http://127.0.0.1:8080/api/users' \ -H 'Content-Type: application/json' \ --data-binary @body.json这里我用--data-binary而不是-d,理由前面说过:-d @file会删掉文件里的换行符,--data-binary原样发送。对普通接口两者没区别,但涉及内容校验、日志留痕、幂等签名的场景,保留原始格式更安全。既然都要写文件了,不如一步到位用更保真的那个。
@-这个写法是从标准输入读,实战中非常有用。它让 curl 能接在管道后面,比如:
jq -n --arg n "张三" '{name:$n, ts: (now|floor)}' \ | curl -X POST 'http://127.0.0.1:8080/api/users' \ -H 'Content-Type: application/json' \ --data-binary @-这段的意思是:用 jq 动态生成一个带当前时间戳的 JSON,通过管道喂给 curl。好处是 JSON 的构造和发送彻底解耦,构造部分出错了你能单独调试,不用每次都发一次真实请求。批量造数据的时候,这个模式可以塞进for循环,一行命令灌几百条。
用文件还有两个容易忽略的注意点。第一是编码:文件必须是 UTF-8,不能带 BOM(那个文件开头的不可见字符会让 JSON 解析器报"unexpected character")。Windows 记事本另存为 UTF-8 的时候有这个选项,一定要选不带 BOM 的那个。第二是路径:@后面的路径是相对于你执行 curl 命令时所在的目录,不是相对于脚本文件所在目录。脚本里用相对路径,换个目录执行就找不到文件,这种问题排查起来很浪费时间,建议脚本里一律用绝对路径或者先cd到脚本目录。
4.4 变量注入与脚本化模板
单个请求跑通之后,下一步是把它变成可复用的脚本。我常用的模板长这样:
#!/usr/bin/env bash set -euo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:8080}" TOKEN="${API_TOKEN:?缺少 API_TOKEN 环境变量}" payload=$(jq -n \ --arg name "$1" \ --argjson age "$2" \ '{name: $name, age: $age}') curl -sS -X POST "${BASE_URL}/api/users" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Authorization: Bearer ${TOKEN}" \ --data-binary "$payload" \ -w '\nHTTP_STATUS:%{http_code}\n'几个设计意图说明一下。set -euo pipefail让脚本遇到错误立刻停,避免带着错误状态继续往下跑。${VAR:?}的写法在变量缺失时直接报错退出,比让空 token 发出去、然后收到一个莫名的 401 好得多。-sS是-s和-S的组合,前者静音进度条,后者保留错误信息——单独用-s会把错误也吞掉,这是很多人脚本调试困难的原因。-w把 HTTP 状态码打到输出末尾,方便后续 grep。
关于-w,它支持的变量比大多数人以为的多。除了%{http_code},还有%{time_total}(总耗时秒数)、%{size_download}(响应体字节数)、%{content_type}。做简单的接口性能对比时,加个-w '%{time_total}\n'就能看出哪个接口慢,不用上压测工具。
4.5 查看响应:-i、-o 与 jq 串接
默认情况下 curl 只打印响应体,看不到响应头。加上-i会把响应行和响应头一起打出来,调试的时候很有用,因为你的 JSON 里没有的信息往往在头里,比如限流剩余次数、请求追踪 ID、缓存状态。
反过来,如果你只想看头不想看体,用-I发一个 HEAD 请求,或者-D -把头 dump 到标准输出。这个在排查 3xx 跳转的时候特别有用,-i能让你看到Location头,从而知道请求被重定向到哪里去了。之前遇到过一个接口,本地调一直 200 但数据不对,加了-i才发现服务端返回了 302 跳到一个登录页,curl 默认不跟随跳转,于是你拿到的是跳转响应的空 body。补上-L让它跟随,问题立刻暴露成 401。
-o是把响应体写到文件,-O是用 URL 里的文件名保存。这两个参数在处理大响应的时候有用,日常调 JSON 接口用得不多。倒是-s加管道接jq的组合值得一说:
curl -sS -X POST 'http://127.0.0.1:8080/api/users' \ -H 'Content-Type: application/json' \ -d '{"name":"张三","age":28}' | jq .服务端如果返回的是一坨压缩过的长 JSON,人眼几乎没法读。jq .会把它格式化成带缩进的结构,还能高亮。更进一步,jq '.data.id'直接取某个字段,写脚本的时候用得上,省掉一次 grep 加 sed 的痛苦。
5. 常见返回值与排查路径
5.1 状态码速查表
下面这张表是我这些年遇到过的、和 JSON POST 直接相关的返回码。看到某个码能立刻对上原因,排查效率会高一个档次。
| 状态码 | 典型含义 | 首先怀疑什么 |
|---|---|---|
| 400 | 请求格式错误 | JSON 语法错、内容被 shell 截断、字段类型不符 |
| 401 | 未认证 | 少了 Authorization 头、token 过期 |
| 403 | 无权限 | token 有效但角色不够、IP 白名单 |
| 404 | 路径不存在 | URL 拼错、多了一层或少了一层路径、被网关重写 |
| 405 | 方法不允许 | 用了 GET 调 POST 接口、-X写错 |
| 411 | 需要 Content-Length | 发了空 body、某些服务端对 PUT 强制要求 |
| 413 | 请求体过大 | 超过服务端 body 上限,需要分批发送 |
| 415 | 媒体类型不支持 | Content-Type 没写或写错,最常见 |
| 500 | 服务端内部错误 | 你的 JSON 结构和服务端模型不匹配,也可能是它自己崩了 |
| 502/504 | 网关问题 | 后端没起来或超时,和你发的请求无关 |
看表的时候注意一个思维顺序:4xx 是"你的问题",5xx 是"它的问题"。但 500 未必真的和你无关——传了一个服务端没做空值校验的字段,照样能让它崩。所以收到 500 之后,第一件事是把请求体简化到最小,一个字段一个字段加回去,用二分法定位是哪个字段触发的。
5.2 几条典型报错的拆解
"Unexpected token ' in JSON at position 1"。这个报错的位置信息非常精准,position 1 意味着它读到的第一个字符不是{也不是[。结合前面的引号知识,基本可以断定是 cmd 环境用了单引号包裹,导致单引号被当成内容发过去了。修法就是换双引号加转义,或者走文件。
"Unexpected end of JSON input"。意思是 JSON 被截断了,读到一半没了。最常见的原因是请求体里有未被转义的双引号,导致 shell 提前截断了字符串。比如-d "{"a":1}"这种写法,shell 在第二个双引号处就结束了参数,curl 实际收到的只有{。另一个可能是 Content-Length 和你实际发的字节数不匹配——但这种情况通常你自己不算 Content-Length,更可能是用-d处理了含特殊字符的内容导致字节数变了。用-v看实际发出的报文长度,一眼就清楚。
"415 Unsupported Media Type"。字面意思就是 Content-Type 不对。检查三件事:-H是不是漏了、冒号后是不是有空格、拼写是不是application/json(别写成application/Json或者application/jsonp)。还有一种情况是你同时传了两个-H都设置了 Content-Type,curl 会把两个都发出去,某些服务端取第一个,某些取最后一个,行为不一致。这种问题只能靠-v看出来。
"中文变成问号或者乱码"。这几乎一定是编码问题。三个检查点:终端代码页(Windows 下chcp)、文件编码(是否 UTF-8 无 BOM)、以及你声明的 Content-Type 里有没有冲突的 charset。最稳的做法是把 JSON 写进文件,确认文件是无 BOM 的 UTF-8,然后用--data-binary @file发。我在 Windows 上处理中文 JSON 一律走这条路径,从来没出过错。
"服务端说少了某个字段,但我明明传了"。这时候不要改代码,先去用-v看实际发出的请求体。十有八九是那个字段被 shell 吃掉了。常见凶手是!在交互式 bash 里的历史展开、$被当变量、%在 cmd 里被当变量。另一种可能是那个字段的值是空字符串,某些框架会把空字符串当成"未传"处理,这就得看服务端的实现了。
5.3 一套固定的排查顺序
遇到问题时按固定顺序走,别东一榔头西一棒子。我自己的顺序是:
- 加
-v,看 curl 实际发出的请求行、请求头、请求体。确认你发的和你想的一样。 - 用回显端点替代真实接口。如果回显端点收到的内容是对的,问题在服务端;如果不对,问题在命令行。
- 缩小请求体。把 JSON 简化到
{},看能不能通。能通就逐字段加回去,二分定位。 - 检查环境差异。本地能通、服务器不能通,重点看 shell 类型、代码页、curl 版本(
curl --version)。 - 看响应头不看响应体。很多框架把详细错误放在头里或者特定的错误字段里,光看 body 那句话会误判。
这五步里,第一步的价值最高。我见过太多人跳过-v直接开始猜,改了半天参数,最后发现是 shell 把某个字符吃了。养成"先看实际发出什么"的习惯,能省掉大量时间。
6. 工程化:把 curl 用进脚本与流水线
6.1 超时、重试与退出码
默认情况下 curl 没有超时限制,遇到一个不响应的服务端,它能挂在那里等到天荒地老。脚本里必须加上--connect-timeout和--max-time,前者管建立连接,后者管整个请求的耗时。
curl -sS \ --connect-timeout 3 \ --max-time 10 \ --retry 2 \ --retry-delay 1 \ --retry-connrefused \ -X POST 'http://127.0.0.1:8080/api/users' \ -H 'Content-Type: application/json' \ --data-binary @body.json参数选择的依据:连接超时设 3 秒,因为正常的内网连接建立应该在毫秒级,3 秒还没连上基本可以判定不通;总超时设 10 秒,取决于你的接口正常耗时,一般设成平均耗时的 3 到 5 倍。--retry只对网络层错误和 5xx 生效,默认不会对 4xx 重试,这个设计很合理——400 你重试一百次还是 400。
有一个坑必须提:curl 默认对 4xx 和 5xx 返回退出码 0。也就是说服务端返回 500,curl 认为"我成功完成了这次 HTTP 交互",脚本里的set -e不会触发,你会带着一个错误的响应继续往下走。想让 curl 在 HTTP 错误时返回非零,加--fail(或--fail-with-body,后者在失败时仍然输出响应体,更适合调试)。这个参数加上之后,配合脚本的错误处理才真正可靠。
if ! curl -sS --fail-with-body -o resp.json -w '%{http_code}' \ -X POST "$URL" \ -H 'Content-Type: application/json' \ --data-binary @body.json > status.txt; then echo "请求失败,状态码 $(cat status.txt)" >&2 cat resp.json >&2 exit 1 fi这段把状态码和响应体分别落到两个文件,失败时一起打出来,日志里能完整还原现场。写自动化脚本的时候,我习惯把这个模式固定下来,后面所有接口调用都套这个壳子,错误处理逻辑只写一次。
6.2 认证头与自定义头的组织
认证方式无非几种:Authorization: Bearer <token>、X-API-Key: <key>、Basic 认证、Cookie。前两种直接用-H加。Basic 认证可以手写-H "Authorization: Basic $(echo -n user:pass | base64)",也可以用-u user:pass让 curl 帮你编码,后者更简洁也更不容易错。
关于 token,有一条安全习惯值得强调:不要把 token 硬编码在命令行里。命令行参数在进程列表里是可见的,同一台机器上其他人ps aux就能看到。脚本里一律从环境变量读,加上前面说的${VAR:?}保护。
curl -sS --fail-with-body \ -X POST "$URL" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ${API_TOKEN:?未设置 API_TOKEN}" \ -H "X-Request-Id: $(uuidgen)" \ --data-binary @body.jsonX-Request-Id这类追踪头在很多分布式系统里是必备的,方便你把一次请求在多个服务的日志里串起来。加上它成本很低,排障时收益很大。
还有一个环境相关的注意点:如果服务端用了自签证书,本地测试时-k能跳过校验,但这条命令绝对不能进生产脚本。我在代码评审里见过-k被一路复制到线上健康检查脚本里,等于把 TLS 校验整个废掉,这类问题很难在测试阶段暴露。稳妥做法是本地测试用-k,同时留一条注释说明为什么用、什么时候删掉。
6.3 踩过的坑与几条个人经验
关于请求体大小,有一个容易被忽略的行为:当 body 超过一定阈值(通常是 1KB 左右),curl 会自动加上Expect: 100-continue头,先问服务端"我要发一大坨,你准备好接收了吗",等服务端回 100 再发。大多数服务端正常处理,但有些老网关或者反向代理不认这个流程,会导致请求卡住几十秒然后超时。遇到这种"大 body 就超时、小 body 就正常"的诡异现象,加-H 'Expect:'把这个头去掉,问题立刻消失。这个坑我踩过一次,排查了整整一个下午,最后是在一份很老的邮件列表存档里找到的答案。
关于-v和-s的冲突:-v的输出走标准错误,-s静音的是进度条,两者可以共存。但如果你把标准错误重定向到文件,-v的调试信息和-w的输出会混在一起,需要自己分辨。我的习惯是调试阶段不用-s,看清楚每个环节;确认无误后再加-sS上生产。
关于 curl 版本差异:--fail-with-body是 7.76 版本才有的,--retry-connrefused是 7.52 之后。在老系统上跑脚本,先curl --version确认一下,别写出一个在自己机器上好好的、到服务器上就报 "unknown option" 的脚本。这类问题在容器环境里尤其常见,基础镜像自带的 curl 版本可能非常老。
最后一条,是关于"要不要用 curl 调接口"这件事本身的判断。简单的自测和一次性调试,curl 是最快的,不用打开任何图形工具,复制粘贴就能跑,还能直接贴进文档和聊天窗口让别人复现。但如果你需要频繁调整参数、管理多套环境变量、保存请求历史,那还是上专门的接口工具更合适。curl 的定位是"随手可用的瑞士军刀",不是"全功能工作台",认清这一点,你就不会在它不擅长的地方硬刚。
我个人的习惯是:凡是写进文档、写进脚本、写进工单的接口调用示例,一律用 curl,因为它是最小公分母,谁都能跑;凡是需要反复调试的探索性工作,用图形工具,省下的是心力和时间。两者不冲突,各干各的活。
最后分享一个小技巧,如果你经常需要在不同环境之间切换,把 BASE_URL 和 token 都放进环境变量,命令本身只保留路径和请求体,这样同一段命令在测试环境和预发环境之间来回切,只改环境变量,不动命令。这个小改动看着不起眼,但它能让你少犯一类错误:改命令的时候漏改了某个地方,结果一半请求打到测试环境、一半打到预发环境,日志对不上,排查起来极其痛苦。