1. 从一个被反复问到的面试题说起
每次带新人或者做技术分享,只要聊到 HTTP 请求,总有人会问:“GET 和 POST 到底有什么区别?”大多数人的回答停留在“GET 参数在 URL 里,POST 参数在 body 里”“GET 不安全,POST 安全”这种层面。这些说法不能说错,但都太表面了。真正让很多人在实际开发中栽跟头的,是另一个更隐蔽的问题:Content-Type 到底该在什么时候设置,GET 请求能不能带 Content-Type,POST 请求不设置 Content-Type 会怎样。
我自己就踩过这个坑。早些年做一个内部数据同步服务,前端用 GET 请求往一个接口传筛选条件,后端同事在接口上加了@RequestBody注解,结果前端怎么调都报 415 错误。排查了半天才发现,问题根本不在参数格式,而在于 GET 请求压根没有 body,后端却期望从 body 里读数据。这件事让我意识到,很多人对 Content-Type 的理解是碎片化的,知道有这么个东西,但说不清楚它和请求方法之间的关系。
这篇内容就是想把这件事彻底讲透。我会从 Content-Type 的本质讲起,拆解它在 GET 和 POST 中的不同表现,补充实际开发中最容易踩的坑,以及如何根据场景选择正确的 Content-Type。不管你是刚接触 HTTP 的新手,还是写了几年接口但没系统梳理过这块的老手,应该都能从中找到一些之前忽略的细节。
2. Content-Type 到底在描述什么
2.1 它不是“请求格式”,而是“实体格式”的声明
很多人把 Content-Type 理解成“我这次请求用的什么格式”,这个说法不够准确。Content-Type 的全称是 Media Type,它描述的是请求体或响应体里承载的实体数据的媒体类型。注意关键词:实体数据。也就是说,Content-Type 是给 body 用的,没有 body 的请求,理论上不需要 Content-Type。
这就引出了第一个关键认知:Content-Type 是描述 body 的,不是描述整个请求的。请求行里的方法、URL、请求头里的其他字段,都不受 Content-Type 管辖。它只回答一个问题——“我接下来发的这段 body,你该用什么解析器来读”。
打个比方,Content-Type 就像是快递包裹外面贴的标签,写着“易碎品”“液体”“电子产品”。快递员看到这个标签,才知道该怎么搬运。如果包裹是空的,贴什么标签都没意义。GET 请求大多数情况下就是那个“空包裹”,所以给它贴 Content-Type 标签,本身就是一件有点奇怪的事。
2.2 常见的 Content-Type 取值和它们的适用场景
实际开发中高频出现的 Content-Type 其实就那么几种,我把它们整理成一张表,方便对照:
| Content-Type 取值 | 数据形态 | 典型场景 | 解析方式 |
|---|---|---|---|
| application/x-www-form-urlencoded | key1=value1&key2=value2 | 传统表单提交、简单 POST | 按 & 和 = 拆分,URL 解码 |
| multipart/form-data | 分段边界分隔的二进制流 | 文件上传、含文件的表单 | 按 boundary 分段解析 |
| application/json | JSON 字符串 | 现代前后端分离接口 | JSON 解析器 |
| text/plain | 纯文本 | 简单文本传输、调试 | 按纯文本读取 |
| application/xml | XML 字符串 | 老式企业接口、SOAP | XML 解析器 |
| application/octet-stream | 二进制流 | 文件下载、未知类型 | 按字节流处理 |
这张表里,前三种占了日常开发的九成以上。application/x-www-form-urlencoded是 HTML 表单的默认编码方式,浏览器提交表单时如果不指定enctype,用的就是它。multipart/form-data是表单里有文件时必须切换的格式,因为 urlencoded 没法承载二进制数据。application/json则是现在 RESTful 接口的主流选择,结构清晰、嵌套方便。
2.3 服务端是如何根据 Content-Type 选择解析器的
理解服务端的处理逻辑,能帮你快速定位大部分 415、400 错误。以常见的 Java Web 框架为例,当一个请求进来时,框架会先看请求方法,再看 Content-Type,然后决定用哪个HttpMessageConverter来读取 body。
如果 Content-Type 是application/json,框架会找 JSON 转换器,把 body 反序列化成对象。如果 Content-Type 是application/x-www-form-urlencoded,框架会走表单解析逻辑,把参数塞进request.getParameterMap()。如果 Content-Type 缺失或者不被支持,框架就不知道该用哪个转换器,直接抛 415 Unsupported Media Type。
这里有个容易被忽略的点:框架判断用哪个转换器,依据的是 Content-Type,而不是你代码里写的注解。你写了@RequestBody,框架知道你要从 body 读数据,但具体怎么读,还是看 Content-Type。如果 Content-Type 是表单类型,而你的参数是个复杂对象,框架照样解析不出来,因为表单解析器只会给你扁平的键值对。
3. GET 请求里的 Content-Type:一个尴尬的存在
3.1 GET 到底能不能带 body
从 HTTP 规范的角度说,GET 请求是可以带 body 的,规范里没有明确禁止。但规范同时说了一句很关键的话:GET 请求的 body 没有语义。什么意思?就是说服务器收到 GET 请求的 body,可以选择忽略它,而且这么做完全符合规范。
这就导致了一个现实:不同服务器、不同框架、不同中间件对 GET body 的处理方式五花八门。有的直接丢弃,有的读取但不解析,有的甚至直接报错。你在本地测试通过的代码,换一个网关可能就挂了。所以工程上的共识是:不要依赖 GET 请求的 body。
既然 body 都不可靠,那给 GET 请求设置 Content-Type 就更没有意义了。Content-Type 是描述 body 的,body 都不被认可,描述它干什么。我见过一些前端代码在 GET 请求里手动设置Content-Type: application/json,然后困惑为什么后端收不到参数。原因很简单,参数要么在 URL 里,要么根本没被解析。
3.2 为什么有些工具会自动给 GET 加 Content-Type
你可能注意到,用某些接口测试工具发 GET 请求时,抓包会发现请求头里带了Content-Type。这不是工具在“多此一举”,而是它的默认行为——很多 HTTP 客户端库在初始化请求头时,会统一设置一个默认的 Content-Type,不管什么方法都带上。
这个默认值通常是application/x-www-form-urlencoded或者application/json。它存在,但不代表服务端会用它。服务端处理 GET 请求时,正常逻辑是直接从 URL 的 query string 里取参数,根本不会去看 Content-Type。所以这个头字段就是个“陪跑”的,带了也无妨,但别指望它起作用。
提示:如果你在排查 GET 请求参数收不到的问题,先确认参数是不是放在了 URL 里,而不是纠结 Content-Type 设置得对不对。方向错了,怎么调都是白费功夫。
3.3 GET 请求参数的正确传递方式
GET 请求传参只有一条正道:URL 的 query string。格式是?key1=value1&key2=value2。这里有几个实操细节值得注意。
第一,参数值必须做 URL 编码。空格要变成%20或+,中文要转成 UTF-8 百分号编码,特殊字符如&、=、#都要转义。不编码的话,参数值里的&会被当成参数分隔符,直接把一个参数切成两个。
第二,query string 的长度有限制。这个限制不是 HTTP 规范定的,而是浏览器和服务器各自设的。浏览器一般限制在 2000 到 8000 字符之间,服务器端如 Nginx 默认限制请求行大小是 8K。所以 GET 不适合传大量数据,这是它的天然短板。
第三,数组和嵌套对象在 query string 里没有标准表示法。常见做法是ids=1&ids=2&ids=3或者ids[]=1&ids[]=2,但这取决于服务端框架怎么解析,没有统一标准。复杂结构还是老老实实用 POST 加 JSON body。
4. POST 请求里的 Content-Type:决定成败的关键
4.1 不设置 Content-Type 会发生什么
这是实际开发中高频出问题的地方。很多人用 POST 发请求,body 里塞了 JSON 字符串,但忘了设置 Content-Type,结果后端报 415 或者参数全为 null。
原因在于,HTTP 客户端库在你不指定 Content-Type 时,行为并不一致。有的会默认设成application/x-www-form-urlencoded,有的干脆不设。如果默认成了表单类型,而你发的是 JSON 字符串,服务端的表单解析器会尝试把{"name":"test"}当成key=value来解析,结果自然是一团糟——它会把整个 JSON 串当成一个没有=的键,值就是空。
如果完全不设 Content-Type,服务端可能直接拒绝,返回 415。也可能按application/octet-stream处理,把 body 当二进制流,你的@RequestBody对象照样映射不上。
所以结论很明确:POST 请求只要带 body,就必须显式设置正确的 Content-Type。这不是可选项,是必选项。
4.2 application/json 场景下的完整实操
现在前后端分离项目基本都用 JSON 交互,我把这条链路上每个环节的要点拆开讲。
前端发送时,用 fetch 的话要这样写:
fetch('/api/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '张三', age: 25 }) })注意JSON.stringify这一步不能省。body 必须是字符串,直接传对象的话,fetch 会把它转成[object Object],那就彻底错了。
后端接收时,以 Spring 为例:
@PostMapping("/api/user") public Result createUser(@RequestBody User user) { // 处理逻辑 }@RequestBody告诉框架从 body 读数据,框架看到 Content-Type 是application/json,就用 Jackson 把 JSON 反序列化成 User 对象。这里有个细节:如果前端传的 JSON 字段名和 User 类的属性名对不上,又没配别名,那个字段就是 null,不会报错。这种“静默失败”最难排查,建议在开发阶段打开框架的未知字段报错开关。
4.3 表单提交和文件上传的 Content-Type 差异
application/x-www-form-urlencoded和multipart/form-data都是表单相关的类型,但适用场景完全不同。
urlencoded 适合纯文本字段,它的编码方式是key1=value1&key2=value2,所有值都会被 URL 编码。它的优点是简单、体积小,缺点是没法传二进制。如果你要传文件,用这个类型,文件内容会被当成普通字符串处理,直接损坏。
multipart/form-data 则是为文件而生的。它把 body 分成多个段,每段用 boundary 分隔,每段有自己的头部描述字段名和文件名。浏览器在表单里遇到<input type="file">时,会自动把 enctype 切成 multipart/form-data。手动构造这个请求比较麻烦,因为 boundary 要自己生成,格式要严格遵守。实际开发中一般用 FormData 对象:
const formData = new FormData(); formData.append('file', fileInput.files[0]); formData.append('description', '这是一个文件'); fetch('/api/upload', { method: 'POST', body: formData // 注意:不要手动设置 Content-Type })这里有个关键点:用 FormData 时不要手动设置 Content-Type。因为 multipart 的 Content-Type 里必须带 boundary 参数,而 boundary 是浏览器自动生成的。你手动设成multipart/form-data而不带 boundary,服务端就没法分段解析,直接报错。让浏览器自己设,它会带上完整的multipart/form-data; boundary=----WebKitFormBoundaryXXX。
4.4 一个容易混淆的点:Content-Type 和 Accept 的区别
这两个头字段经常被搞混。Content-Type 描述的是我发出去的 body 是什么格式,Accept 描述的是我希望你返回什么格式。一个是“我给你的”,一个是“我想要你给我的”。
举个例子,你发一个 POST 请求,Content-Type 是application/json,表示你发的 body 是 JSON。Accept 是application/xml,表示你希望服务端返回 XML。服务端如果支持,就会返回 XML;不支持的话,可能返回 406 Not Acceptable,或者忽略 Accept 返回默认格式。
很多接口调试工具会把这两个字段放在一起,导致新手以为它们是一回事。记住这个区分,能帮你少走很多弯路。
5. 那些年我踩过的 Content-Type 坑
5.1 415 错误的三种典型成因
415 Unsupported Media Type 是 Content-Type 相关最常见的错误。我把它拆成三种情况,每种的处理方式不同。
第一种,Content-Type 缺失。客户端压根没设,服务端不知道用什么解析器,直接拒绝。解决方法是补上正确的 Content-Type。
第二种,Content-Type 和服务端期望的不匹配。比如服务端接口只接受 JSON,客户端发了表单类型。这种要么改客户端,要么服务端加一个支持表单的接口。
第三种,Content-Type 带了参数但格式不对。比如application/json; charset=utf-8是合法的,但application/json;charset缺了值就是非法的。有些服务端对格式校验很严,参数写错就报 415。
排查 415 的时候,第一步永远是抓包看请求头里的 Content-Type 到底是什么。别猜,看实际发出去的值。
5.2 字符集问题:charset 写错导致中文乱码
Content-Type 可以带 charset 参数,比如application/json; charset=utf-8。这个参数告诉服务端 body 里的字节该用什么字符集解码。如果客户端用 UTF-8 编码,但 Content-Type 里写的是charset=gbk,服务端就会用 GBK 去解 UTF-8 的字节,中文必然乱码。
更隐蔽的情况是,Content-Type 里不写 charset。这时候服务端会用默认字符集,而不同服务端的默认值可能不同。有的默认 UTF-8,有的默认 ISO-8859-1。ISO-8859-1 是单字节编码,解中文直接变成问号。
所以我的习惯是:只要 body 里可能有非 ASCII 字符,Content-Type 里就显式写上charset=utf-8。多写这几个字符,能省掉大量排查乱码的时间。
5.3 网关和代理对 Content-Type 的改写
这个坑比较隐蔽,只有在分布式环境里才会遇到。请求经过网关或代理时,有些网关会“好心”地改写 Content-Type。比如把application/json改成text/plain,或者把 charset 参数去掉。
我遇到过一次,本地直连服务正常,走网关就报 415。抓包发现网关把 Content-Type 从application/json改成了application/json;charset=UTF-8,多了个参数本来没问题,但那个网关的版本有个 bug,参数大小写敏感,服务端匹配的是小写utf-8,结果就匹配不上。
这类问题的排查思路是:在客户端和服务端各抓一次包,对比 Content-Type 是否一致。不一致的话,问题就出在中间的转发环节。
5.4 前端库的默认行为差异
不同的 HTTP 客户端库,对 Content-Type 的默认处理差别很大。axios 在发送对象时,会自动设置application/json并序列化 body。而原生的 fetch,如果你不手动设,它不会自动加 Content-Type,body 是什么就发什么。
jQuery 的$.ajax又是另一套逻辑,默认是application/x-www-form-urlencoded,传对象会自动序列化成表单格式。
这些差异导致同一个接口,用不同库调,结果可能完全不同。我的建议是:不管用哪个库,都显式设置 Content-Type。不要依赖默认值,默认值会变,显式设置才是可控的。
6. 如何根据场景选对 Content-Type
6.1 一张决策表帮你快速定位
面对一个具体需求,怎么确定该用哪个 Content-Type?我整理了一个决策流程:
| 场景特征 | 推荐 Content-Type | 理由 |
|---|---|---|
| 纯文本字段,结构扁平 | application/x-www-form-urlencoded | 简单、兼容性好 |
| 含文件上传 | multipart/form-data | 唯一能承载二进制的方式 |
| 结构化数据,有嵌套 | application/json | 表达能力强,解析方便 |
| 纯文本内容,无结构 | text/plain | 最直接,无需解析 |
| 二进制文件流 | application/octet-stream | 通用二进制类型 |
| 老式企业接口 | application/xml | 历史遗留,按对方要求 |
选型的核心原则是:看数据的形态,不看请求的方法。同样是 POST,传表单和传 JSON 用的 Content-Type 完全不同。同样是传 JSON,GET 和 POST 的处理方式也不一样。
6.2 GET 和 POST 在 Content-Type 上的本质区别
回到标题本身,GET 和 POST 关于 Content-Type 的核心区别,可以归结为一句话:GET 没有可靠的 body,所以 Content-Type 对 GET 没有实际意义;POST 的 body 是核心载体,Content-Type 决定了 body 能否被正确解析。
这个区别的根源在于 HTTP 的设计意图。GET 被设计成“获取资源”,是幂等的、可缓存的,参数放在 URL 里,方便缓存和分享。POST 被设计成“提交数据”,是非幂等的,数据放在 body 里,适合传大量、复杂、敏感的内容。
理解了这层设计意图,就不会再纠结“GET 能不能设 Content-Type”这种问题了。能设,但没意义。就像给一辆没有货箱的卡车贴“易碎品”标签,贴了也没东西可碎。
6.3 接口设计时的一个实用建议
如果你在设计接口,我的建议是:GET 接口只从 query string 取参数,POST 接口明确声明接受的 Content-Type。
具体做法是,GET 接口的参数用@RequestParam或类似注解从 URL 取,不要用@RequestBody。POST 接口如果只接受 JSON,就在注解里限定consumes = "application/json",这样客户端发错类型时,框架会直接返回 415,而不是静默失败。明确的约束比模糊的兼容更容易排查问题。
另外,对于同一个资源,GET 和 POST 的接口最好分开设计,不要试图用一个接口同时处理两种方法。混在一起会让 Content-Type 的处理逻辑变得复杂,后期维护成本很高。
7. 调试 Content-Type 问题的完整排查链路
7.1 第一步:抓包确认实际发送的请求头
排查任何 Content-Type 问题,第一步都是抓包。不要看代码里写了什么,要看实际发出去的字节是什么。用浏览器的开发者工具、抓包工具或者服务端的访问日志,把完整的请求头打出来。
重点看三个字段:请求方法、Content-Type、Content-Length。Content-Length 能告诉你 body 到底有没有发出去,发了多少字节。如果 Content-Length 是 0,说明 body 是空的,那 Content-Type 设得再对也没用。
7.2 第二步:对比服务端期望的类型
拿到实际的 Content-Type 后,去服务端确认接口期望的是什么类型。看接口注解、看框架配置、看文档。如果服务端用的是@RequestBody,那基本就是期望 JSON。如果用的是@RequestParam或@ModelAttribute,那期望的是表单类型。
这一步的关键是找到“期望值”和“实际值”的差异。差异找到了,问题就定位了一半。
7.3 第三步:检查中间环节是否改写了请求
如果客户端和服务端直连正常,走网关或代理就出问题,那就要检查中间环节。常见的改写包括:修改 Content-Type、去掉 charset、改变 body 编码。有些网关还会根据 Content-Type 做路由,类型不对直接转发到错误的 backend。
检查方法是逐段抓包,在客户端出口、网关入口、网关出口、服务端入口各抓一次,对比 Content-Type 的变化。哪一段变了,问题就在哪一段。
7.4 第四步:验证修复并回归测试
找到原因后,修改配置或代码,然后重新抓包验证。不要只看接口返回 200 就认为修好了,要确认服务端实际解析出来的参数值是对的。特别是中文和特殊字符,一定要验证。
回归测试时,把相关的接口都跑一遍。因为 Content-Type 的修改可能影响其他接口,特别是共用同一个客户端配置的情况。改一处,测一片,这是基本的安全意识。
8. 几个容易被忽略的边界情况
8.1 HEAD 和 DELETE 请求的 Content-Type
HEAD 请求和 GET 类似,通常没有 body,所以 Content-Type 同样没有意义。DELETE 请求理论上可以带 body,但实际中大多数框架和网关都不支持,所以 DELETE 的参数一般也放在 URL 里。
如果你的接口设计里,DELETE 需要传复杂条件,建议改用 POST 加一个/delete路径,或者用 query string 传简单标识。不要跟框架和网关较劲,顺着它们的脾气来,开发效率更高。
8.2 Content-Type 大小写敏感吗
按照规范,媒体类型是大小写不敏感的。Application/JSON和application/json应该被同等对待。但实际中,有些框架和网关的实现是大小写敏感的,特别是自己写的解析逻辑。
我的经验是:统一用小写。这是社区惯例,也是大多数工具生成的默认值。不要为了测试框架的兼容性去写大写,没有收益,只有风险。
8.3 多个 Content-Type 头会怎样
HTTP 规范允许某些头字段出现多次,但 Content-Type 不在其中。一个请求应该只有一个 Content-Type。如果出现多个,服务端的行为是不确定的,可能取第一个,可能取最后一个,可能直接报错。
这种情况一般不会人为造成,但某些中间件拼接请求时可能产生。如果遇到诡异的解析问题,检查一下请求头里是不是有重复的 Content-Type。
8.4 空 body 的 POST 请求要不要设 Content-Type
如果 POST 请求确实没有 body,比如只是触发一个动作,那 Content-Type 可以不设。但有些服务端框架对 POST 请求强制要求 Content-Type,不设就报 415。这种情况下,设一个Content-Length: 0并配上任意合法的 Content-Type 就能绕过。
不过更规范的做法是,这种“只触发动作、不传数据”的请求,用 POST 但明确在接口文档里说明不需要 body,服务端也不做 Content-Type 校验。这样对调用方最友好。
9. 我个人的几条实操心得
写了这么多,最后分享几条我在实际工作中总结出来的经验,都是踩坑换来的。
第一条,永远显式设置 Content-Type。不要依赖任何库的默认值,默认值会随着版本变,也会因为调用方式不同而不同。显式写出来,代码的可读性和可维护性都更好。
第二条,GET 请求不要碰 body 和 Content-Type。参数老老实实放 URL 里,需要传复杂数据就改用 POST。跟 HTTP 的设计意图对着干,最后吃亏的是自己。
第三条,排查问题时先抓包,再改代码。我见过太多人一遇到 415 就开始改代码,改了半天发现是网关把 Content-Type 改了。抓包五分钟,能省下两小时的瞎折腾。
第四条,接口文档里明确写清 Content-Type。这不是可选项,是接口契约的一部分。调用方看到文档就知道该发什么类型,能避免大量沟通成本。
第五条,中文场景下 charset 一定要写。application/json; charset=utf-8比application/json多几个字符,但能避免 90% 的乱码问题。这笔账怎么算都划算。
Content-Type 这个东西,说简单也简单,就是一个头字段。说复杂也复杂,它牵扯到请求方法、body 语义、服务端解析、中间件转发一整条链路。把这条链路理清楚,以后遇到相关问题,就能快速定位,而不是靠猜。