很多人第一次接触 cURL,都是在某个接口调试的现场:前端说接口报 400,后端说请求没问题,最后发现是有人把 POST 参数拼成了 GET 的 query string。这种问题太常见了。cURL 看着就是个命令行工具,但 POST 请求里那点门道,足够让新手绕不少弯路,也让老手在某些边界情况上翻车。
这篇文章就专门聊 cURL 发 POST 请求这件事。我会从最基本的-d参数讲起,把表单、JSON、文件上传、Cookie、身份认证、超时重试这些场景全部过一遍,每个命令都给可复制的完整写法,顺带解释为什么这样写、不那样写。无论你是刚接触命令行接口调试的测试新人,还是需要频繁在服务器上排查接口问题的后端开发者,这篇文章都能直接拿来用。
1. 先搞清楚 cURL 的-d到底在做什么
1.1 从一次"诡异"的 400 错误说起
有次我在排查一个登录接口的问题,对方发来的请求长这样:
curl http://example.com/api/login?username=admin&password=123456服务端日志里清清楚楚地记录着:请求方法是 GET,路径是/api/login,query string 是username=admin&password=123456,没有 request body。接口本身只接受 POST,所以直接返回 400。
这就是没用对-d的典型表现。cURL 的默认请求方法是 GET,URL 后面跟的?key=value只是 URL 的一部分,不会自动帮你变成 POST body。想发 POST 请求,最核心的动作是告诉 cURL:我要带数据,数据放哪,用什么格式。
1.2 最简单的 POST 长什么样
curl -d "name=zhangsan&age=18" http://example.com/api/user这一条命令做了什么?拆开看:
-d指定要发送的数据,数据内容用引号包起来。- 只要带上了
-d,cURL 会自动把请求方法从 GET 切换成 POST。 - 默认情况下,
-d发送的数据格式是application/x-www-form-urlencoded,也就是表单编码格式,你会看到请求头里自动出现了Content-Type: application/x-www-form-urlencoded。 - body 里的
name=zhangsan&age=18会经过 URL 编码后传输。
很多人第一次看到这里会问:为什么-d会自动改变请求方法?其实这是 cURL 的设计逻辑:-d本身就意味着"我要提交数据",而 GET 请求在语义上是不带 body 的。想发 GET 且带 body?HTTP 协议没禁止,但绝大多数服务端框架默认不读,cURL 也提供了-X GET强行指定方法的方式,不过实际场景里很少用,这里不展开。
1.3-X POST和-d的区别,一句话讲透
社区里经常看到有人写:
curl -X POST http://example.com/api/user这行命令确实把方法设成了 POST,但一个 body 都没带。大多数接口会直接报 400 或者 404,因为服务端根本等不到参数。
记住这个结论:-X POST只改方法,不改请求内容;-d才是真正往 body 里塞东西的那个参数。正常写法是两者配合,或者干脆只写-d让 cURL 自动切方法:
curl -X POST -d "name=zhangsan" http://example.com/api/user两种写法最终产生的请求基本一样,我个人习惯只写-d,少打几个字符,也少一层"为什么我明明 POST 了却没数据"的困惑。
1.4 最常见的编码坑:中文字符和特殊符号
-d的数据如果包含中文或特殊字符,直接塞进去容易出问题。比如提交name=张三,cURL 虽然会尝试自动编码,但某些老版本或特定环境下会原样发送,导致服务端解析乱码。
保险的做法是自己先编码,或者用--data-urlencode:
curl --data-urlencode "name=张三" --data-urlencode "city=北京" http://example.com/api/user--data-urlencode会自动对值部分做 URL 编码,张三会被转成%E5%BC%A0%E4%B8%89这样的形式,服务端收到的就是规范数据。这个参数我在实际项目里用得非常多,尤其是提交用户输入的中文内容时,基本是标准配置。
还有一点要注意:多个-d参数可以叠加。cURL 会把它们用&连接成一个完整的 body:
curl -d "name=zhangsan" -d "age=18" http://example.com/api/user这等价于:
curl -d "name=zhangsan&age=18" http://example.com/api/user叠加写法的好处是,每个参数单独维护,脚本里动态拼接参数时不容易出错。比如你要在 Shell 脚本里循环给不同用户发请求,用多个-d比手动拼&要清晰得多。
2. 发 JSON 数据:-H和-d的配合是关键
2.1 接口只要 JSON,你却发了表单
现在的后端接口,尤其是 RESTful 风格,绝大多数收的都是 JSON。新手最容易踩的坑是:接口文档写着Content-Type: application/json,结果 cURL 默认发的是表单格式,服务端解析 body 失败。
服务端常见的表现有两种:一是直接返回 400 参数错误;二是明明打印日志能看到 body 字符串,但框架解析 JSON 时报错,因为 Content-Type 不对,框架走了表单解析逻辑,body 里却是 JSON 文本,直接解析失败。
正确姿势是手动指定Content-Type,然后把 JSON 字符串作为-d的值:
curl -X POST -H "Content-Type: application/json" -d '{"name":"zhangsan","age":18}' http://example.com/api/user注意这里有个细节:JSON 字符串用了单引号包裹。因为 JSON 内部有双引号,如果外层也用双引号,Shell 会把双引号里的内容做变量展开和转义,容易出问题。用单引号是最省心的方式。如果你的 JSON 里有单引号(比如某个字段值是it's),那就得改写成双引号外层 + 内部转义,或者用\u0027这种转义序列,属于少数情况,碰到再处理。
2.2 JSON 文件传参:终于不用在命令行里手拼 JSON 了
接口参数一多,命令行的引号管理就变得很痛苦,尤其是嵌套层数多、横跨数百行的 JSON。这时候把 JSON 存成文件,用@语法读取文件内容作为 body,是最舒服的方案:
curl -X POST -H "Content-Type: application/json" -d @payload.json http://example.com/api/user@payload.json的意思是:把payload.json文件里的原始内容作为请求 body 发送。注意,文件不会被做任何转义或格式化处理,文件里是什么,body 里就是什么,连换行和空格都会带上。虽然 HTTP 规范里允许 body 带换行,JSON 也允许,但有些服务端的日志系统看到换行会头疼,所以如果服务端报错,先把文件压成一行再试试。
还有一个进阶用法:从标准输入读取数据。比如你想在命令行里先用 jq 处理完数据再发给接口:
cat data.json | jq '.user' | curl -X POST -H "Content-Type: application/json" -d @- http://example.com/api/user@-表示从标准输入读取。这个技巧在自动化脚本里很有用,配合管道能把复杂的 JSON 处理流程串起来。
2.3 为什么有时候要写-H "Accept: application/json"
Content-Type告诉服务端"我发给你的数据是什么格式",Accept告诉服务端"我希望你返回什么格式"。有些接口会根据Accept决定返回 JSON 还是 XML,或者返回不同结构的 JSON。如果你发现接口返回的数据格式不对劲,先看一眼Accept头。
不过大多数场景下服务端会忽略Accept,尤其是只返回 JSON 的接口。我个人的习惯是:只有在对返回格式有明确要求时才显式设置Accept,平时不写也没关系,服务端用什么格式返回是它自己定的事。
2.4 一个真实案例:JSON 里的引号把命令搞炸了
有次同事写了一个命令,用双引号包裹 JSON:
curl -X POST -H "Content-Type: application/json" -d "{\"name\":\"zhangsan\"}" http://example.com/api/user结果 Shell 把$name之类的变量展开了,整个请求体变成了奇怪的东西。原因就在于双引号里\"虽然转义了 JSON 的引号,但 Shell 的变量展开依然生效,如果 JSON 字符串里有$符号,会被当作变量名解析掉。
解决办法很简单:外层用单引号。如果 JSON 里有单引号,那就把外层单引号里的单引号用特殊写法代替,比如:
curl -X POST -H "Content-Type: application/json" -d '{'"'"'key'"'"':"value"}' http://example.com/api/user这种写法太丑了,实际项目里我更推荐直接使用 JSON 文件或--data-binary @file。文件方式完全绕开 Shell 引号地狱,是最稳妥的。
3. 文件上传的正确姿势:-F才是 multipart 的正主
3.1 为什么上传文件不能用-d
-d发送的是普通文本,适合表单字段和 JSON。但文件上传接口通常要求multipart/form-data格式,因为请求体里既要带文本字段,又要带二进制文件内容,普通的 URL 编码格式做不到。
用-F参数,cURL 会自动生成 multipart 格式的请求体,并且自动设置Content-Type: multipart/form-data; boundary=...。boundary 是分隔符,cURL 每次请求会随机生成一个,把不同字段和文件内容分隔开。
curl -F "file=@/path/to/avatar.jpg" -F "user_id=123" http://example.com/api/upload这条命令做了什么:
file=@/path/to/avatar.jpg:把本地文件作为file字段上传。user_id=123:普通文本字段,和文件一起放在 multipart 请求体里。- cURL 自动处理 Content-Type 和 boundary,不用手动指定。
3.2 给上传的文件改名、改类型
有时候接口对文件名有要求,但本地文件名不规范。可以在@后面指定;filename=重命名:
curl -F "file=@/path/to/avatar.jpg;filename=profile.jpg" http://example.com/api/upload还可以指定文件的 MIME 类型:
curl -F "file=@/path/to/avatar.jpg;type=image/jpeg" http://example.com/api/upload如果没指定type,cURL 会根据文件扩展名猜测,猜不出来就用application/octet-stream。有些接口对 MIME 类型校验严格,比如只接受image/png,这时候手动指定就很有必要。
3.3 多文件上传与[]后缀
很多接口支持一次上传多张图片,字段名可能是files[]。cURL 的-F可以叠加多个相同字段名:
curl -F "files[]=@/path/to/1.jpg" -F "files[]=@/path/to/2.jpg" http://example.com/api/upload服务端收到的files会是一个数组,包含两个文件。注意[]在 Shell 里有特殊含义(字符组),但在引号包裹下不会触发通配符展开,所以最好给-F的值加上引号。我见过有人不写引号,文件数量一多,Shell 直接把@后面的文件名做了通配符展开,导致请求体错乱,排查半天。
3.4 上传时同时传递 JSON 格式的附加参数
有些设计的接口,文件走 multipart,附加的元数据却要求 JSON 字符串。比如头像上传接口,除了文件还要传meta={"usage":"avatar","size":"large"}这样的字段。
curl -F "file=@avatar.jpg" -F 'meta={"usage":"avatar","size":"large"}' http://example.com/api/upload服务端拿到meta字段后,再解析里面的 JSON 字符串。这种情况在-F中直接传 JSON 是可行的,因为 multipart 的字段值本身是字符串,服务端只需要在代码里做json_decode即可。
4. Cookie、Header 和身份认证:POST 请求绕不开的三个附加项
4.1 Cookie 的两种传法
接口调试时经常需要模拟登录状态。cURL 传 Cookie 有两种方式:
方式一,手动在请求头里加:
curl -X POST -H "Cookie: session_id=abc123; user_token=xyz" -d "username=test" http://example.com/api/login方式二,用-b参数:
curl -X POST -b "session_id=abc123; user_token=xyz" -d "username=test" http://example.com/api/login两种写法等价。-b的好处是和-c(写 Cookie)配合使用:先用-c把登录接口返回的 Cookie 保存到文件,之后的请求再用-b读取,就能保持登录状态。
# 第一次请求:登录并保存 Cookie curl -c cookies.txt -X POST -d "username=admin&password=admin123" http://example.com/api/login # 第二次请求:带上 Cookie 访问需要登录的接口 curl -b cookies.txt http://example.com/api/profile-c是"写 Cookie 到文件",-b是"从文件读 Cookie",两个参数一个管保存一个管使用。这个组合在自动化测试脚本里非常常见,比每次手动复制粘贴 Cookie 值省心得多。
4.2 自定义 Header 的常见场景
除了Content-Type和Cookie,实际接口还会要求自定义 Header,比如X-Request-Id、Authorization、X-Api-Key之类。
curl -X POST \ -H "Content-Type: application/json" \ -H "X-Request-Id: 6b8d2f3a" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx" \ -d '{"name":"zhangsan"}' \ http://example.com/api/user在 Shell 脚本里,一行写太长不美观,可以用\换行,这是很常见的写法。注意\后面不能有空格,否则 Shell 会认为命令结束,这才是真正地踩坑。
4.3 两种身份认证的写法:Basic 和 Bearer
Basic 认证是老牌 HTTP 认证方式,用户名密码经过base64编码放在Authorization头里。cURL 提供了专门参数-u:
curl -u "admin:admin123" -X POST -d "name=zhangsan" http://example.com/api/user-u会自动完成 base64 编码并生成Authorization: Basic YWRtaW46YWRtaW4xMjM=这样的请求头。注意 base64 不是加密,是编码,HTTP 传输过程里如果没走 HTTPS,等于裸奔。
Bearer Token 是目前 RESTful API 的主流做法,Token 直接放在Authorization: Bearer <token>里。手写是:
curl -X POST -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx" -d '{"name":"zhangsan"}' http://example.com/api/user也可以用-H "Authorization: Bearer $TOKEN"把 Token 存成环境变量,避免把密钥写在命令行历史里。
4.4 请求头调试利器:-i和-v
调试 POST 请求时,看不到请求头和服务端返回头,全靠猜。有两个参数能帮你:
-i:在输出里包含响应头。适合快速看状态码和响应头。-v:显示完整的请求头和响应头,包括 cURL 发出去的请求详细信息。
curl -v -X POST -H "Content-Type: application/json" -d '{"name":"zhangsan"}' http://example.com/api/user-v输出里能看到:
- 实际请求方法。
- 请求头列表(包括自动生成的
Content-Type、Content-Length等)。 - 服务端状态行。
- 响应头。
- 响应体。
如果请求失败了,第一步永远是-v看细节。看到> POST /api/user HTTP/1.1就说明请求方法没问题;看到Content-Type: application/json就说明格式没配错;再往下看响应头里的HTTP/1.1 400就能定位是大致哪一类错误。这个方法我每次排错都用,比瞎猜效率高太多。
5. 超时、重试与安静输出:脚本化 POST 的保命三件套
5.1--connect-timeout和-m到底管什么
--connect-timeout控制连接建立的超时时间,单位秒。它只管 TCP 连接是否建立成功,一旦连接建立,这个参数就失效了。
-m或--max-time控制整个请求的最大耗时,包括连接、发送、等待响应、下载的全程。如果接口处理很慢,-m才是真正兜底的那个。
curl -m 30 --connect-timeout 5 -X POST -H "Content-Type: application/json" -d '{"name":"zhangsan"}' http://example.com/api/user这条命令的意思是:5 秒内必须建立连接,整个请求最多跑 30 秒,超了就强制断开。接口偶尔慢的时候,30 秒还等不到响应,脚本不会卡死,直接进入下一步逻辑。
5.2 重试策略:--retry与幂等性的尴尬
--retry参数可以指定失败后的重试次数:
curl --retry 3 --retry-delay 2 -X POST -H "Content-Type: application/json" -d '{"name":"zhangsan"}' http://example.com/api/user意思是在网络故障、超时或服务端返回 5xx 时重试 3 次,每次间隔 2 秒。但注意,POST 请求的重试有风险:如果服务端已经处理了请求,只是响应超时,你重试就可能导致数据重复提交。比如支付接口,重试很可能造成重复扣款。
所以在给 POST 请求加重试之前,先确认接口是否幂等。幂等的意思是同一个请求执行多次和执行一次结果相同。像查询、状态更新这类接口通常是幂等的,可以放心重试;而创建订单、扣款、发送短信这类接口,重试需谨慎,更好的做法是生成一个唯一的X-Request-Id,让服务端基于这个 ID 做去重。
5.3-s、-S、-o配合使用,让脚本输出干净
脚本里跑 cURL,最怕的就是进度条和错误信息污染日志。-s能静默掉进度条,-S让错误信息仍然显示:
curl -sS -X POST -H "Content-Type: application/json" -d '{"name":"zhangsan"}' http://example.com/api/user-sS连起来写的意思是:安静模式,但保留错误输出。这样脚本里看不到进度条刷屏,出错时又能看到具体报错。
接口响应体如果很长,直接输出到终端会把日志刷爆。用-o把响应体保存到文件:
curl -sS -o response.json -X POST -H "Content-Type: application/json" -d '{"name":"zhangsan"}' http://example.com/api/user响应体存到response.json,终端只显示状态码相关的信息(用-w可以自定义输出)。比如你想在脚本里拿到 HTTP 状态码:
curl -sS -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -d '{"name":"zhangsan"}' http://example.com/api/user-o /dev/null丢弃响应体,-w "%{http_code}"只输出状态码。这一行命令在脚本里可以配合变量捕获状态码,做条件判断,非常实用。
5.4 用--fail-with-body保留错误信息,而不是硬吞
cURL 默认情况下,服务端返回 4xx/5xx 时,进程退出码不是 0,但响应体还是会正常输出。如果加-f,则遇到 4xx/5xx 时直接静默失败,输出被丢弃,退出码变成非 0,脚本好判断。
但问题来了:-f把响应体丢了,你根本看不到服务端返回的错误信息,比如参数校验失败的具体原因。cURL 7.76.0 版本之后提供了--fail-with-body:
curl --fail-with-body -X POST -H "Content-Type: application/json" -d '{"name":"zhangsan"}' http://example.com/api/user这个参数的意思是:HTTP 状态码 >= 400 时,退出码非 0,但响应体仍然输出。非常适合脚本调用接口后需要看错误详情的场景。如果你的 cURL 版本较老,可以用-w "%{errormsg}"或者直接不加-f,手动解析响应内容。
6. 进阶玩法:把 POST 请求变成可复用的脚本化方案
6.1 从一行 cURL 到 Shell 函数
命令行里调试完成之后,如果这个接口会反复调用,我建议把它封装成一个 Shell 函数。举个例子:
create_user() { local name=$1 local age=$2 curl -sS --fail-with-body \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $API_TOKEN" \ -d "{\"name\":\"$name\",\"age\":$age}" \ http://example.com/api/user }注意我在-d后面用了双引号并把内部 JSON 的引号做了转义,因为这样才能让$name、$age变量展开。如果硬要用单引号,变量就传不进去。这个问题我在第 2.4 节提过,两种情况各有取舍:单引号适合纯静态 JSON,双引号适合带变量。封装函数时确定要动态传参,就选双引号加转义。
调用方式就很简单了:
create_user "lisi" 256.2 用 jq 解析响应结果,形成闭环
如果接口响应是 JSON,配合 jq 可以实现"请求-响应-解析"的完整链路。比如创建用户成功后,要拿到用户的 ID 再做下一步操作:
resp=$(curl -sS --fail-with-body -H "Content-Type: application/json" -d '{"name":"zhangsan","age":18}' http://example.com/api/user) code=$(echo "$resp" | jq -r '.code') user_id=$(echo "$resp" | jq -r '.data.user_id') echo "code=$code, user_id=$user_id"这样一行 cURL 命令就被拆成了可编程的请求逻辑,后面可以接条件判断、循环请求,甚至把多个接口串成完整流程。我在写自动化测试脚本时基本就是这么做的,能省大量手工验证的时间。
6.3 处理接口返回中的分页 POST
有些列表接口用 POST 加 body 传筛选条件,响应里带分页信息。用循环拉取全部数据:
page=1 while true; do resp=$(curl -sS -H "Content-Type: application/json" \ -d "{\"page\":$page,\"per_page\":50}" \ http://example.com/api/items) count=$(echo "$resp" | jq '.data.items | length') if [ "$count" -eq 0 ]; then break fi echo "$resp" >> all_items.jsonl page=$((page + 1)) done这段代码的要点是:每次请求翻一页,直到响应里的数据条数为 0 才退出。jq负责统计当前页数据量,echo "$resp" >> all_items.jsonl把每页数据追加到文件,方便后续统一处理。当然,如果接口有页数上限或总数提示,可以再加退出条件,避免死循环。
6.4 常见 POST 请求错误速查表
最后整理一张表,把 POST 请求最常见的错误现象、原因和排查动作列出来:
| 错误现象 | 常见原因 | 排查动作 |
|---|---|---|
| 返回 400 参数错误 | Content-Type 不对,body 格式不匹配 | 检查是否加了-H "Content-Type: application/json",确认 body 是合法 JSON |
| 返回 401 Unauthorized | 缺少认证头或 Token 失效 | 检查Authorization头,确认 Token 未过期 |
| 返回 403 Forbidden | 权限不足,IP 白名单等 | 检查账号权限和来源 IP |
| 返回 405 Method Not Allowed | 请求方法不对,可能用了 GET | 看-v输出里的> POST行,确认方法 |
| 返回 413 Payload Too Large | body 超过服务器限制 | 压缩 JSON,去掉无用字段,或分片上传 |
| 返回 422 Unprocessable Entity | 字段类型或取值范围错误 | 用--fail-with-body保留响应体,查看具体错误 |
| 连接超时 | 服务端网络不可达或防火墙拦截 | 先 ping 域名,再用--connect-timeout短超时测试 |
| 响应超时 | 服务端处理慢,或接口有死循环 | 检查数据库慢查询、外部依赖,适当调大-m |
| 中文乱码 | 未做 URL 编码或服务端字符集不一致 | 使用--data-urlencode,服务端确保 UTF-8 |
这张表不能覆盖所有情况,但大部分 POST 请求问题都能从这几类方向快速定位。
6.5 最后再分享一个我常用的调试习惯
我习惯在写正式的 POST 命令之前,先用-i跑一遍,把响应头、状态码、响应体全部看一遍。确认接口行为和预期一致后,再替换成-sS -o response.json的脚本化写法。这样做的好处是:调试阶段信息量最大,脚本阶段输出最干净。不要一上来就写静默模式,遇到问题反而什么都看不到。
另一个小技巧是,把常用的接口服务地址和 Token 配成环境变量。比如:
export API_BASE="http://example.com/api" export API_TOKEN="eyJhbGciOiJIUzI1NiJ9.xxx"之后命令写成:
curl -sS -H "Content-Type: application/json" -H "Authorization: Bearer $API_TOKEN" -d '{"name":"zhangsan"}' $API_BASE/userToken 不直接出现在命令行历史里,脚本也更简洁。当然环境变量也有泄露风险,用完了记得unset,或者放在仅自己可读的配置文件中。
cURL 的 POST 请求,说到底就是三件事:方法选对、Content-Type 对上、body 格式写对。把这三件事吃透,再配合超时重试和故障排查手段,绝大部分接口调试场景都能稳稳拿下。希望这篇内容能帮你少走一些弯路。