HTTP 语义、状态码与协商:让请求在代理链上仍保持原意
HTTP 请求把操作、目标、附加信息和内容放在不同位置。以下是一个 HTTP/1.1 请求的文本表示,空行分开 Header 和内容:
POST /orders?source=web HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json
Content-Length: 15
{"productId":7}POST 表示提交处理,/orders 是目标路径,source=web 是查询参数;Content-Type 解释发送的内容,Accept 表达希望接收的表示类型。JSON 的 UTF-8 内容为 15 字节。真实 HTTP/2、HTTP/3 使用帧编码,但这些业务语义继续适用。
方法、消息与表示
常用方法各自请求什么
| 方法 | 常用含义 | 安全 | 幂等 |
|---|---|---|---|
| GET | 获取目标资源的表示 | 是 | 是 |
| HEAD | 获取类似 GET 的响应元数据,不接收正文 | 是 | 是 |
| POST | 让目标资源处理提交的内容 | 否 | 通常否 |
| PUT | 创建或替换目标资源的状态 | 否 | 是 |
| PATCH | 按补丁格式执行局部修改 | 否 | 不作统一保证 |
| DELETE | 请求移除目标资源的关联 | 否 | 是 |
| OPTIONS | 查询通信选项;浏览器预检也使用它 | 是 | 是 |
| CONNECT | 建立到目标的隧道 | 否 | 否 |
| TRACE | 诊断沿途收到的请求,常被禁用 | 是 | 是 |
安全方法的语义是不请求改变业务状态,服务器仍可记访问日志。把删除动作挂在 GET URL 上,会让预取、爬虫或链接检查意外触发删除。幂等比较重复相同请求产生的预期状态效果,不要求每次状态码、响应正文或审计日志完全相同。方法总览、安全方法
例如重复 DELETE 第一次返回 204、第二次返回 404,资源最终仍处于被删除状态。PUT 将库存设置为 20 可以幂等;“库存增加 20”重复后数值继续变化,不能仅因为用了 PUT 就获得幂等实现。PATCH 的结果取决于补丁语义和条件:设置一个字段与对数组追加一项的重复效果不同。PATCH 规范
GET 内容没有通用定义的语义,缓存、代理和客户端支持也不一致,不适合把通用查询参数藏在 GET body 中。复杂检索可使用约定的 POST 查询接口,但它的缓存与重试行为需要另行设计。POST 也不限于“新增数据库行”,处理表单、执行动作或提交查询都可能使用它。POST 方法
查询参数与请求体是两处数据
查询串属于 URL,POST/PUT 同样可以携带查询参数。路径参数则由路由规则从路径中提取,例如 /orders/42 中的 42;HTTP 本身不知道它是订单号。
URL 百分号编码按字节表示字符,不应对完整 URL 不加区分地重复编码。表单 URL 编码还把加号用于空格,和普通路径中的加号含义不同。查询参数可能进入浏览器历史、代理日志与 Referer,密码或长期令牌不应放在其中。
| Content-Type | 内容形式 | 常见用途 |
|---|---|---|
| application/json | JSON 文本,网络交换通常使用 UTF-8 | API 对象、数组和结构化字段 |
| application/x-www-form-urlencoded | name=value,多字段以 & 分隔并编码 | 普通表单 |
| multipart/form-data | 多个 part,每段有独立元数据,以 boundary 分隔 | 文件和文本字段一起上传 |
| text/plain | 按约定字符集解释文本 | 简单文本输入 |
| application/octet-stream | 原始字节 | 文件流或私有二进制协议 |
| application/xml / application/*+xml | XML 文档 | XML 接口与行业协议 |
| application/problem+json | 标准化问题详情对象 | HTTP API 错误响应 |
Content-Type 声明输入应怎样解析,服务器仍需验证语法、大小与业务字段。上传时浏览器 FormData 或 curl -F 会生成匹配的 boundary,不要手动只设置 multipart/form-data 而漏掉参数。Content-Type、媒体类型
JSON 里的数字、布尔值和字符串有不同类型。金额精度、大整数、缺失与 null 的差异应在接口契约中明确;Content-Type 正确不能替代这些定义。服务端不支持媒体类型可返回 415,能解析但业务内容不符合要求时可按契约使用 400 或 422。
表示、压缩和消息长度
Accept、Accept-Language、Accept-Encoding 分别表达可接受的媒体类型、语言和内容编码。响应用 Content-Type、Content-Language、Content-Encoding 说明实际选择。q 值表达优先级,q=0 表示不可接受;服务器可能返回合适表示,也可能按接口策略返回 406。内容协商
资源 /manual
├─ 中文 HTML 表示
├─ 英文 HTML 表示
└─ JSON 表示
每种表示还可能经过 gzip / br 等内容编码Content-Encoding: gzip 描述表示经过的压缩;Content-Length 描述此次消息中编码后的字节长度。HTTP/1.1 的 Transfer-Encoding: chunked 是逐跳消息分帧方式,不是文件格式,也不是“压缩”。HTTP/2/3 用帧和流结束标记组织内容,不使用 HTTP/1.1 的 chunked 编码。HTTP/1.1 报文规则
HEAD、204、304 等响应有专门的无内容规则。HEAD 中合法的 Content-Length 可以描述相应 GET 表示的大小,客户端不能看到这个字段就继续等正文。对于有正文的消息,长度错误可能引起截断、等待或后续请求错位。
用 curl 操作一个完整实验服务
构建和首次读取
下载 HTTP 实验包,解压进入 http-semantics。完整 HttpLab.java 使用 JDK 自带 HttpServer,无 Maven 依赖;run.sh 严格编译为 Java 17 目标。测试已在 Java 17 和 25 运行,容器运行使用固定 Temurin 25 镜像。
Linux 宿主准备 Docker、curl 7.76+ 与 unzip,使用已有 Docker 权限的普通账号。以下仅把端口发布到宿主回环,应用和编译身份均为 10001:
docker run -d --name http-semantics-lab \
--user 10001:10001 --read-only --cap-drop ALL \
--tmpfs /tmp:rw,exec,size=256m \
-p 127.0.0.1:18085:18080 \
-v "$PWD:/src:ro" eclipse-temurin:25.0.4_7-jdk \
bash /src/run.sh serve
docker logs http-semantics-lab看到 LISTENING 18080 后请求:
BASE=http://127.0.0.1:18085
curl -q --noproxy '*' --fail-with-body \
--connect-timeout 2 --max-time 5 -i "$BASE/resource"状态为 200,正文为 initial,Header 包含 ETag: "v1" 和 Cache-Control: private, max-age=30。这里 -i 将响应头与正文一起显示。日期、Header 大小写和顺序可由实现决定,读者应核对字段语义,不逐字比较整块输出。
-q 放在第一选项位置禁止默认 curlrc,--noproxy '*' 排除代理环境;成功路径增加 --fail-with-body,使 4xx/5xx 返回非零退出码并保留正文。连接失败先看 docker ps -a 与容器日志,端口冲突只调整发布映射左侧并同步修改 BASE。curl 命令手册
JDK 自带 HttpServer 适合小型实验。固定线程池、内存版本和简化协议处理不构成生产 Web 框架;实际项目应使用经过配置的容器、认证、请求限制和持久化。HttpServer API
发送 JSON、表单、文件和原始字节
/inspect 返回收到的媒体类型与字节数。它只观察内容,不解析 JSON 语法、multipart 字段或业务对象。
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'Content-Type: application/json' \
--data-binary '{"productId":7}' "$BASE/inspect"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
--data-urlencode 'name=张三' --data-urlencode 'note=a+b' "$BASE/inspect"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-F 'name=demo' -F 'file=@README.md;type=text/plain' "$BASE/inspect"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'Content-Type: application/octet-stream' \
--data-binary @README.md "$BASE/inspect"JSON 请求输出 method=POST type=application/json bytes=15。表单输出类型为 application/x-www-form-urlencoded;文件请求为 multipart/form-data。multipart 的边界字符串可能变化,不把整段长度当作固定协议常量;README.md 必须是实验包中的小文件,否则会超过 4096 字节限制。
--data-binary 保留输入字节,@file 从文件读取;--data-urlencode 对表单值编码;-F 组装 multipart。带 --data 的 curl 请求默认选 POST,通常不用再写 -X POST。-X 只修改方法字符串,不负责把任意正文改成该方法适合的格式。
在 F12 Network 中可以同时查看 Query String Parameters、Form Data 或 Request Payload。它们的显示位置来自 URL 和 Content-Type,不能因为面板都叫“参数”就忽略传输格式差异。
条件读取和版本更新
重新启动的服务初始版本为 v1。若已经修改过,先从 GET 响应读取当前 ETag,再替换下面条件值。
curl -q --noproxy '*' --max-time 5 -i \
-H 'If-None-Match: "v1"' "$BASE/resource"
curl -q --noproxy '*' --fail-with-body --max-time 5 -i \
-X PUT -H 'Content-Type: text/plain' \
-H 'If-Match: "v1"' --data-binary 'new' "$BASE/resource"第一条应返回 304,正文为空;第二条应返回 204,ETag 更新为 "v2"。再执行 GET 得到 new。服务端的检查与写入在同一个同步块内,避免两个线程都检查 v1 后分别覆盖。
再次使用旧条件 v1 更新,保存传输结果和 HTTP 状态:
STATUS=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 -o /dev/null -w '%{http_code}' \
-X PUT -H 'Content-Type: text/plain' \
-H 'If-Match: "v1"' --data-binary 'another' "$BASE/resource")
TRANSPORT=$?
test "$TRANSPORT" -eq 0 && test "$STATUS" = 412这是预期 HTTP 负例,所以没有 --fail-with-body;脚本先确认 curl 传输成功,再确认状态确实是 412。修复时读取最新版本、决定如何合并修改,不能无条件去掉 If-Match 覆盖他人更新。
GET 的 If-None-Match 使用弱比较,If-Match 使用强比较。实验只实现这里需要的有限条件分支,完整的前置条件优先级及多种 Header 组合须按 HTTP 语义规范处理。
HEAD、范围请求和稳定负例
curl -q --noproxy '*' --fail-with-body --max-time 5 -I "$BASE/resource"
curl -q --noproxy '*' --fail-with-body --max-time 5 -i \
-H 'Range: bytes=0-3' "$BASE/file"HEAD 不传响应正文。/file 返回固定十字节 0123456789,范围请求得到 206、Content-Range: bytes 0-3/10 和正文 0123;请求 bytes=20-30 得到 416,Content-Range: bytes */10。实验仅支持单个显式起止范围,开放端、后缀和多段范围没有实现。生产下载还应处理验证器、If-Range 和资源变化。HTTP 范围请求
自动验证不依赖读者手动更新顺序,每轮启动全新内存状态:
docker run --rm --user 10001:10001 --read-only --network none \
--cap-drop ALL --tmpfs /tmp:rw,exec,size=256m \
-v "$PWD:/src:ro" eclipse-temurin:25.0.4_7-jdk bash /src/run.shget=200 cache=304 stale=412 put=204 headBody=0
json=200 unsupported=415 oversize=413 partial=206 unsatisfied=416 method=405自动测试通过真实 HTTP 请求检查状态、关键 Header、部分正文以及更新结果。实验结束时执行 docker stop -t 10 http-semantics-lab,再执行 docker rm http-semantics-lab;源码保留,临时 class 随容器删除。
缓存怎样选择和复用表示
新鲜度与重新验证
浏览器私有缓存服务于一个用户环境,共享缓存则可能为多个客户端复用响应。判断能否复用时,需要同时检查请求匹配、存储许可、新鲜度和重新验证要求。
| 指令 | 主要含义 |
|---|---|
| max-age=N | 按秒声明响应的新鲜度期限 |
| s-maxage=N | 为共享缓存指定新鲜度,按规范覆盖相应普通期限 |
| private | 允许私有缓存,不允许共享缓存存储 |
| public | 明确允许符合规则的共享缓存存储 |
| no-cache | 可以存储,但复用前需成功验证 |
| no-store | 不应存储本次请求/响应 |
| must-revalidate | 过期后未成功验证,不应直接复用 |
| immutable | 在新鲜期内表示不会改变,常配合内容哈希文件名 |
no-cache 不能用于表达“内容绝不落盘”;no-store 也不会自动删除过去已经存下的副本。用户专属数据与公共静态资源应采用不同策略,设置 private 只限制共享存储,并不等同于加密。Cache-Control
服务器可以提供 ETag 或 Last-Modified。缓存带条件请求回源,内容未变化时收到 304,随后更新元数据并继续使用已有正文;没有本地正文的客户端不能把空的 304 当作完整资源。HTTP 缓存规范
GET /resource → 200 + ETag + 正文
保存表示
↓
需要验证 → If-None-Match
├─ 未改变 → 304,继续使用缓存正文
└─ 已改变 → 200 + 新 ETag + 新正文实验的 curl 不自动成为浏览器式缓存:第二条命令显式发送条件,只验证服务器的 304 行为。要观察自动缓存,使用真实浏览器并注意是否打开了 Disable cache。
Vary 决定请求头是否参与匹配
同一 URL 如果根据 Accept-Language 返回中文和英文,缓存需要把选择依据纳入匹配,响应可以设置 Vary: Accept-Language。压缩表示常涉及 Vary: Accept-Encoding。Vary
Vary 不是独立的访问控制机制,也不应把任意用户输入都加进缓存键。变化维度过多会降低命中率;遗漏实际选择维度则可能把错误语言、编码甚至用户专属内容返回给别人。先明确表示怎样生成,再定义共享缓存许可和匹配字段。
部署内容哈希静态文件可以给较长新鲜度,HTML 入口则保持较短期限或验证。修改同名文件又给极长缓存期限时,浏览器可能持续使用旧内容。CDN 清缓存不会同步清除所有浏览器私有缓存;发布策略要考虑两层生命周期。浏览器缓存说明
状态码、浏览器观察与错误处理
状态码表达当前 HTTP 结果
| 组别 | 常用状态 | 判断重点 |
|---|---|---|
| 1xx | 100、101、103 | 中间信息;不是普通最终业务响应 |
| 2xx | 200、201、202、204、206 | 分别区分成功表示、创建、接受异步处理、无内容和部分内容 |
| 3xx | 301、302、303、304、307、308 | 重定向与缓存验证;304 没有重定向目标含义 |
| 4xx | 400、401、403、404、405、409、412、413、415、422、429 | 根据请求格式、身份、权限、冲突、条件、大小或限流处理 |
| 5xx | 500、502、503、504 | 区分应用异常、网关上游问题、不可用和网关超时 |
202 表示请求已接受处理,最终结果仍需任务查询或回调。201 可以通过 Location 指向新资源。401 通常涉及认证挑战,403 表示服务器拒绝当前请求;为了隐藏资源是否存在,服务端也可按安全策略返回 404。IANA 状态码注册表、状态码说明
303 通常引导客户端以 GET 读取另一个资源;307/308 保留方法和内容。301/302 对 POST 的历史处理与客户端策略有关,不能假定所有重定向都原样重放。curl -L 开启跟随,但对有副作用请求、跨主机凭证和上传内容,应先确认每一跳的目标和方法。HTTP 重定向
错误响应可采用 application/problem+json,使用 type、title、status、detail、instance 等字段,再补业务错误代码。不要把数据库密码、内部栈和完整连接串作为 detail 返回给客户端。Problem Details
F12 中逐项读取一次请求
打开 Chrome 或 Edge 的 F12,选择 Network,清空旧记录后访问 http://127.0.0.1:18085/resource。先保持缓存设置不变,观察:
| F12 位置 | 查看内容 | 解释时注意 |
|---|---|---|
| 请求列表 | Name、Method、Status 与实际协议 | favicon 是另外一条请求 |
| Headers | URL、方法、状态、ETag 和 Cache-Control | 分开请求头与响应头 |
| Payload | 查询串、表单或原始内容 | GET 没有内容时可能不显示此面板 |
| Response / Preview | 原始响应与浏览器展示 | Preview 不改变线上返回的字节 |
| Timing | 排队、连接和等待响应 | 复用连接时不会重新执行所有阶段 |
需要跟踪重定向时启用 Preserve log。比较首次网络加载可临时启用 Disable cache,它通常只在 DevTools 打开时生效;检查缓存命中时反而应关闭它。Service Worker 也可能参与响应,不能只看到 from memory cache 或状态 200 就推断本次必然请求过服务器。Network 面板参考
Copy as cURL 有助于重放浏览器请求,但复制内容可能含 Cookie、Authorization、用户数据和防跨站令牌。先在授权环境使用,再删除敏感字段后分享;删除身份字段后的结果也不能直接与登录态请求等同。
从异常位置选择修复动作
| 现象 | 首查项 | 下一步 |
|---|---|---|
| 415 | Content-Type 与实际编码 | 使用服务支持的格式;不要只换 Header 而不换内容 |
| 400 / 422 | 解析错误与字段约束 | 根据结构化错误修正输入,保留相同反例 |
| 405 | 路由支持的方法与 Allow | 改正确方法,检查代理是否改写 |
| 412 | 当前 ETag 与旧版本条件 | 重新读取并合并,不盲目覆盖 |
| 返回旧数据 | Cache-Control、Age、Vary、ETag、Service Worker | 分开比较浏览器、CDN 与源站 |
| 502 / 504 | 哪一层返回、上游连接与耗时 | 对照代理和应用日志,超时写入先确认最终结果 |
| 响应截断 | 长度、压缩、提前关闭或流式异常 | 修复输出链;客户端把不完整内容当失败 |
HTTP/1.1 的前后端若对 Content-Length、Transfer-Encoding 或非法字段采用不同解释,可能引起请求走私。入口应拒绝歧义消息,并使用受支持的代理/容器解析器;不要自己通过“优先取某个长度”修补。HTTP/2 到 HTTP/1.1 的转换同样需要一致的规范化。
重试还要考虑方法、副作用和错误发生阶段,详见 超时、重试与幂等。浏览器读取许可、会话与长连接另见 状态、流式通信与代理。
权威资料与规范地址
按协议、系统调用与 Java API 查阅完整定义。
