qinghuan
← Java Web / 全部笔记

单元 33 / Java Web

P0 · 必会主线L1 · 基础

HTTP 请求与响应:先读懂接口的语言

读懂方法、地址、请求头和状态码,再用删除和编辑的例子理解幂等与条件请求。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能读懂请求和响应,并按 HTTP 规则设计成功、失败和重试时的接口行为。

学习理由、准备要求与知识点

为什么学它

HTTP 规定浏览器、代理和后端怎样通信;状态码和幂等性(重复同一请求是否具有与请求一次相同的预期效果)会影响接口正确性。

先读这些内容

知识点清单

  • 方法与请求目标
  • 路径和查询参数
  • 请求头与消息内容
  • Content-Type 与 Accept
  • 常见状态码
  • 安全方法语义
  • 幂等效果与响应区别
  • 重复提交业务机制

运行前先确认HTTP 语义依据 RFC 9110;源码定位 OpenJDK 17u jdk-17.0.16+8 的 HttpRequestBuilderImpl。Java 17 算法模拟单进程资源状态和版本条件更新,不是 HTTP 服务器,也不实现完整 If-Match 语法或跨进程事务。

01先弄懂它是什么

读一个 HTTP 接口时,可以先找四部分:方法说明要做什么,URI 指向哪个资源,请求头说明内容类型、身份或缓存条件,正文带具体数据。响应也有状态码、响应头和可选正文。不能把所有成功失败信息都只塞进 JSON 的 code 字段,否则网关、缓存和通用客户端就无法按 HTTP 状态正确判断。

两个常见词要分开:“安全方法”按协议含义不要求改变业务状态,例如读取仍可产生访问日志;“幂等”表示同样请求重复多次,预期的最终效果与一次相同。DELETE 第一次删成功,第二次返回不存在,也可能仍然幂等。判断重点是服务端产生的效果,不是每次状态码或响应文字都必须一样。

以商户说明文档为例,客户端读取文本 A 和版本 v1,另一个客户端把它改成 B。第一个客户端若直接提交 C,可能覆盖别人已完成的编辑。HTTP 条件请求允许客户端说明“我是依据哪个版本修改的”。服务端必须保证只有版本仍匹配时,这次修改才会真正写入;如果先查版本,再不附条件地写入,中间仍可能插入别人的修改。

文中这几个词,先认一认

资源与表示
资源是要操作的对象;表示是当时传给客户端的内容,例如某商品对应的一份 JSON。
安全方法
按 HTTP 约定不要求改变业务状态的方法,允许访问日志等附带活动。
幂等
同样请求执行多次,预期服务端效果与执行一次相同;响应本身不必完全相同。
条件请求
只有资源仍满足指定条件时才执行,例如版本没变才允许覆盖编辑,或验证缓存是否可继续用。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

接口收到请求后,要先弄清“交给谁处理、要做什么、谁在调用、允不允许、正文格式和数据对不对”。这些分别对应路由、方法、身份认证与权限、媒体类型及数据校验。响应状态也应说实话:200 表示成功,201 通常表示创建了资源,204 表示成功但没有内容;400 表示请求有错误,401 表示缺少有效认证凭据,403 表示拒绝执行,404 表示未找到目标等情况。

401 还要通过 WWW-Authenticate 响应头说明适用的认证方式,这叫认证挑战;不能把这些失败一律写成 200,再只在文字里解释。

两个人编辑同一份资料时,怎样避免后提交的人盖掉别人刚保存的内容?服务器读取成功时,可以把内容连同 ETag 标记一起返回;客户端保存时用 If-Match 带回标记,服务端只在条件符合时修改,否则报告前置条件失败。

ETag 是供比较的标记,客户端不用猜它的内部含义,也不能假定它就是数据库里递增的数字。If-Match 要做强比较:两边都得是强标记,且标记值相同;带 W/ 的弱标记即使文字相同,也不能这样匹配。下方算法只用整数版本模拟相等检查,不实现完整 HTTP 条件规则。

请求超时只说明调用方未在预算内得到预期结果,不说明服务端没有执行。对于安全读取可按策略重试,写操作则需要结合幂等语义、条件或稳定业务键。连接超时、读取超时和业务总截止时间不同,重试还会增加实际负载;协议语义为决策提供基础,却不会自动实现应用的可靠性。

正在加载流程图…

条件检查与写入需要处在同一原子边界。拒绝旧版本防止覆盖,但不会自动解决文本合并冲突。
查看流程图源码(Mermaid)
sequenceDiagram
 participant A as 客户端A
 participant S as 资源服务
 participant B as 客户端B
 A->>S: 读取文档
 S-->>A: 内容 A 与版本 v1
 B->>S: 基于 v1 更新为 B
 S-->>B: 更新成功,版本 v2
 A->>S: 基于 v1 更新为 C
 S-->>A: 前置条件失败
 A->>S: 重新读取并决定如何合并

按顺序看,每一步做了什么

  1. 先记录方法、目标、请求媒体类型与响应状态。
  2. 区分资源当前表示与客户端持有的旧表示。
  3. 更新时提交条件,在原子边界内比较并写入。
  4. 超时或冲突后根据操作语义恢复,不盲目把所有请求重复发送。

03对照实现代码

Java HTTP 请求构建器验证结构,但不替代业务语义 (新标签页)↗

OpenJDK 17u,jdk-17.0.16+8 · jdk.internal.net.http.HttpRequestBuilderImpl#checkURI、method、build

已核验真实类与方法。下面为原创局部构建模型,展示 URI 协议/主机、正超时和构建状态检查;省略请求头完整校验、BodyPublisher、协议协商与网络发送,不是原样源码。

核心机制代码 · java
import java.net.URI;
import java.time.Duration;
class SourceModel {
  record Plan(URI target,Duration timeout) {}
  static Plan build(URI target,Duration timeout) {
    if(target==null || timeout==null) throw new IllegalArgumentException("缺少构建参数");
    String scheme=target.getScheme();
    if(scheme==null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")))
      throw new IllegalArgumentException("只支持 HTTP 协议");
    if(target.getHost()==null) throw new IllegalArgumentException("缺少可识别主机");
    if(timeout.isZero() || timeout.isNegative()) throw new IllegalArgumentException("超时必须为正");
    return new Plan(target,timeout); // 只得到请求计划,尚未发送网络请求
  }
}

checkURI 在构建时检查协议是否为 http 或 https,以及能否获得主机。它验证的是该客户端支持的 URI 结构,不证明目标可信、域名可解析或服务可达。限制访问哪些外部目标属于应用安全策略,还要考虑解析结果和网络出站边界。

method 验证方法名称等条件,build 则要求已有 URI 并生成不可变请求。可变构建过程与构建后的请求对象分开,有助于防止发送时意外观察到半配置状态;但一个请求能成功 build,只能说明通过这层结构检查,不说明服务端一定支持该方法。

请求头也要符合名称、值和客户端保留字段的限制,但构建器不知道订单属于谁,也不理解任意 JSON 字段的业务含义。客户端负责把请求组织并传出去,服务端仍负责资源规则与权限检查。能构建、能发送,都不等于业务请求有效。

timeout 要求正值体现了早失败设计:不合法配置在构建时暴露,比在网络等待后得到模糊故障更容易定位。真实请求时间还受连接、重定向和处理过程影响,应用应读客户端具体超时契约,不能仅凭一个 Duration 推导整个跨服务业务的最长完成时间。

代码先调用谁,再调用谁

  1. HttpRequest.newBuilder 创建可配置构建器。
  2. uri、method、header、timeout 等步骤验证局部配置。
  3. build 检查必需状态并生成请求对象。
  4. HttpClient 发送请求,业务结果由响应与应用规则共同判断。

04动手写一个例子

带版本条件的资源更新状态机

维护内存中的编号到文本资源。创建返回 201,重复创建返回 409;读取存在返回 200,不存在返回 404;更新仅在预期版本相等时成功,否则返回 412;删除已存在资源返回 204,再删返回 404。版本从实例内全局递增序列取得,避免删除重建后旧版本重新匹配。

输入 1
创建 A,读取版本 1,再基于 1 更新 B
输出 1
201、200、200,版本变为 2
输入 2
资源版本 2,基于版本 1 更新 C
输出 2
412,内容仍为 B
输入 3
删除同一资源两次
输出 3
204、404,最终均不存在
输入 4
删除重建后使用旧版本更新
输出 4
412

资源记录同时保存值和版本。更新把检查与替换放进同一个 synchronized 方法,因此在本单进程模型里不会有线程在两步之间写入。若先在一个方法检查再在另一个方法写入,就失去该保证。

版本不能简单在每个资源重建时从一开始,否则旧客户端持有的版本一可能错误匹配新资源。这里使用实例全局递增版本,删除后重建得到更大的值;进程重启和多副本的版本分配不在模型保证内。

返回结果同时说明状态与当前值,测试不仅检查状态码,也检查失败后资源没有变化。main 验证创建、重复创建、成功更新、旧版本拒绝、重复删除、重建与非法编号,从状态不变量观察幂等,而不是要求两次删除状态码相同。

为什么这样写是对的每次成功创建或更新获得新的实例内版本;条件失败不改变值与版本;删除任意次数后的目标状态都是不存在。

时间 每次操作期望 O(1)额外空间 O(r)

哈希表保存 r 个资源并做固定次数查询或更新;文本复制和网络成本不包含在这个内存状态模型中,全局锁会限制实际并行吞吐。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 47 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
  record Item(String value,long version) {}
  record Reply(int status,Item item) {}
  static class Store {
    final Map<Long,Item> items=new HashMap<>();
    long revision=0;
    void valid(long id) { if(id<=0) throw new IllegalArgumentException(); }
    long next() { revision=Math.incrementExact(revision); return revision; }
    synchronized Reply create(long id,String text) {
      valid(id); Objects.requireNonNull(text);
      if(items.containsKey(id)) return new Reply(409,items.get(id));
      Item value=new Item(text,next()); // 重建也取得新版本,避免旧版本重用
      items.put(id,value); return new Reply(201,value);
    }
    synchronized Reply get(long id) {
      valid(id); Item item=items.get(id); return new Reply(item==null?404:200,item);
    }
    synchronized Reply update(long id,long expected,String text) {
      valid(id); Objects.requireNonNull(text);
      Item old=items.get(id);
      if(old==null) return new Reply(404,null);
      if(old.version()!=expected) return new Reply(412,old); // 失败不能改状态
      Item fresh=new Item(text,next());
      items.put(id,fresh); return new Reply(200,fresh); // 与比较在同一把锁内
    }
    synchronized Reply delete(long id) {
      valid(id); return new Reply(items.remove(id)==null?404:204,null);
    }
  }
  static void check(boolean ok) { if(!ok) throw new AssertionError(); }
  public static void main(String[] args) {
    Store s=new Store(); Reply created=s.create(1,"A"); check(created.status()==201);
    long v1=created.item().version(); check(s.get(1).status()==200);
    check(s.create(1,"other").status()==409);
    Reply updated=s.update(1,v1,"B"); check(updated.status()==200 && updated.item().version()>v1);
    check(s.update(1,v1,"C").status()==412 && s.get(1).item().value().equals("B"));
    check(s.delete(1).status()==204); check(s.delete(1).status()==404);
    check(s.get(1).status()==404);
    Reply recreated=s.create(1,"new"); check(recreated.item().version()>updated.item().version());
    check(s.update(1,v1,"stale").status()==412);
    check(s.update(99,1,"missing").status()==404);
    try { s.get(0); throw new AssertionError("应拒绝非法编号"); }
    catch(IllegalArgumentException expected) { /* 输入边界已覆盖 */ }
    System.out.println("PASS 12 resource assertions");
  }
}

这些特殊情况也要试一试

  • 空文本可以是合法表示,null 引用在本题中拒绝。
  • 删除重建不能复用旧验证器,否则出现资源代次混淆。
  • 真实 If-Match 的语法、强比较与其他前置条件顺序应按 RFC 实现,不能直接用整数接口替代。
进阶选读 · 生产拓展:数据库条件更新与多副本验证器

场景多个应用实例同时编辑资源,单 JVM 锁不能形成共同互斥。

做法用数据库版本条件更新并检查影响行数,在同一事务内管理相关事实;为删除重建保留资源代次或使用不会复用的验证器。跨资源合并与外部副作用另行设计事务或补偿。

复杂度主键条件更新通常依赖索引定位及锁竞争;不能仅用内存 O(1) 代表数据库与网络延迟。

好处与代价乐观条件减少长时间持锁,但高冲突需要重读与合并;悲观锁能提前协调,却增加等待与故障恢复成本。

05项目里怎么用

接口应把 HTTP 状态与响应结构共同设计。统一错误体可以包含稳定错误码、字段路径和请求标识,但不能覆盖掉真实状态。不要把数据库堆栈、密码或内部网络细节返回给调用者;日志与客户端消息服务于不同受众。

自动重试前,分别检查方法含义、正文能否再发送,以及业务是否允许重复。POST 可以通过业务幂等键避免重复效果,但服务端必须真正保存并检查,光加请求头没用。客户端超时后可先查询已有业务状态,避免把结果未知当成确定失败,再次执行不可撤销动作。

拿着正确版本,也不代表就有权修改。保存订单前,仍要检查订单属于谁、当前状态允许怎样变化、金额是否符合规则。版本条件只能防住它所检查的那类并发覆盖,不能代替业务授权,也不能让多个服务的修改自动一起成功或失败。多人经常编辑冲突时,可以把冲突展示出来,让用户比较并合并内容;不要在背后无限重试,把别人的修改悄悄盖掉。

容易踩的坑

  • 把安全、幂等、可缓存当作同一个属性。
  • 要求幂等请求每次必须返回相同状态码。
  • 连接或读取超时后直接认定服务端没有执行。
  • 把客户端构建成功当作目标可信、业务有效或服务端已成功。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
无条件覆盖实现与调用最简单。并发编辑可能丢失他人更新。明确允许最后写入覆盖且有相应业务约定。
版本条件更新能显式发现基于旧状态的写入。冲突后需要重读、合并或用户决策。文档、库存状态等不允许静默覆盖的资源。
业务幂等键能识别同一业务操作的重试。需要持久记录、参数一致性和过期策略。创建订单等跨超时重试场景。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01DELETE 返回 204 后再返回 404,是否不幂等?

展开参考答案与推导

不能这样判断。应检查重复操作的预期服务端效果,删除后的资源都不存在即可符合相应幂等语义;响应不同并不自动改变最终效果。

02Content-Type 与方法是否互相决定?

展开参考答案与推导

不互相决定。方法表达操作语义,Content-Type 描述实际内容表示;POST 可以承载不同媒体类型,服务端需明确支持范围。

03为什么 If-Match 不能简单接受弱 ETag?

展开参考答案与推导

该条件使用强比较来约束表示匹配,弱验证器表达的等价程度不同。真实处理应遵守验证器语法和前置条件规则,不能只比较去掉前缀后的字符串。

换个情况,还会处理吗

01更新请求超时,用户点击重试,服务端已改过一次怎么办?

展开参考答案与推导

根据条件版本或业务幂等记录判断之前的结果,必要时查询当前资源。不能仅靠客户端超时断言第一次未执行,也不能无条件重复不可逆副作用。

02所有接口都返回 200,有哪些实际问题?

展开参考答案与推导

通用客户端、缓存、代理和监控难以按 HTTP 语义判断失败,认证挑战与重试策略也可能被破坏。应保留合适状态并提供统一错误表示。

03读取版本后再普通 UPDATE 为什么仍会丢更新?

展开参考答案与推导

另一个写入可以发生在检查与写入之间。应在同一原子边界执行版本条件更新并检查影响行数,不能把两次独立操作当作一次条件提交。

写代码试一试

01为资源状态机验证删除幂等效果。

输入 / 条件
create(1,A),delete(1),delete(1),get(1)
输出 / 预期
201、204、404、404

提示 同时断言最终资源不存在。

展开参考答案与推导

测试两次删除返回不同状态但都不产生额外资源;关注最终状态,不把状态码相等作为幂等断言。

02复现版本重用导致的错误匹配。

输入 / 条件
创建版本1,删除,重新创建又用版本1,旧客户端更新
输出 / 预期
错误设计会接受过时更新;正确设计应拒绝

提示 把资源代次与内容版本一起考虑。

展开参考答案与推导

使用新代次或不会复用的验证器,使重建资源与旧资源可区分;测试应明确覆盖删除再创建路径。

03给更新增加非空文本业务校验。

输入 / 条件
合法版本配合空字符串
输出 / 预期
按契约返回输入错误,版本与内容不变

提示 校验失败不得推进版本。

展开参考答案与推导

在生成新版本和写入之前执行约束检查,随后断言存储状态未改变;不要先更新再用异常伪装回滚。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能读懂请求和响应,并按 HTTP 规则设计成功、失败和重试时的接口行为。

本篇重点加练

重点加练状态码与重复请求的业务语义,要求客户端能据响应作出正确动作。

  • 常见状态码
  • 幂等效果与响应区别

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    阅读请求“POST /notes/7?draft=true,Content-Type: application/json,Accept: text/plain,内容 {"title":"A"}”。逐项定位方法、路径、查询、头和消息内容,并规定服务支持或不支持各媒体类型时应怎样响应。

    提交内容
    请求分解图和响应规则表。
    怎样算完成
    • 查询与消息内容不混淆
    • 请求媒体类型和响应协商分开

    练到的知识点方法与请求目标 · 路径和查询参数 · 请求头与消息内容 · Content-Type 与 Accept

  2. 作业 02设计评审

    为读取笔记、创建笔记、无权读取和参数格式错误四个操作设计 HTTP 响应,说明哪些操作具有安全方法语义,并讨论通过 GET 触发删除的接口评审依据。

    提交内容
    接口清单和状态码理由。
    怎样算完成
    • 依据协议语义而非仅页面表现
    • 区分安全语义与身份鉴权

    练到的知识点常见状态码 · 安全方法语义

综合应用

  1. 作业 03面试场景

    客户端在 t=0 提交创建请求,t=2 超时,t=3 重试;第一次提交在 t=4 才完成。定义业务希望达到的重复效果,设计需要记录和返回的信息。

    提交内容
    时间线、重复请求合同和验收场景。
    怎样算完成
    • 效果与响应内容分别描述
    • 覆盖首次请求仍在执行的情形

    练到的知识点幂等效果与响应区别 · 重复提交业务机制

  2. 作业 04编程实践

    在本地实现一个仅操作内存笔记的 HTTP 演示服务,支持读取单条和新增;自行规定 ID、非法 JSON、未知路径及不支持方法的合同,用客户端程序或命令脚本验证。

    提交内容
    可运行服务、请求脚本、自写边界检查及实际状态/响应记录。
    怎样算完成
    • 响应能被客户端实际读取
    • 错误合同与实现一致
    • 只绑定本地练习端点

    练到的知识点方法与请求目标 · 路径和查询参数 · 请求头与消息内容 · 常见状态码

  3. 作业 05原理问答

    为 GET、PUT、POST、DELETE 各构造一个业务操作,逐项解释重复执行、缓存观察和资源最终状态之间的关系;不根据方法名称直接宣称业务实现满足合同。

    提交内容
    四种操作的前后状态表与条件说明。
    怎样算完成
    • 区分协议约定与实现事实
    • 覆盖响应不同但效果判断独立的情况

    练到的知识点安全方法语义 · 幂等效果与响应区别

  4. 作业 06编程实践

    实现纯 Java HTTP 结果判定练习器:输入方法、状态码和客户端已知请求阶段,输出由你定义的“完成/需查询/明确失败”类别;业务仅限内存资源替换,先写合同。

    提交内容
    Java 程序、自写正常/超时未知/重复响应测试及运行证据。
    怎样算完成
    • 类别依据协议与业务事实共同定义
    • 不使用一个状态码概括全部方法

    练到的知识点常见状态码 · 幂等效果与响应区别

  5. 作业 07设计评审

    文件上传可能因内容过大、不支持格式、用户无权和服务暂不可用失败。为每类失败制定状态与可供客户端执行的下一步,不要求搭建上传服务。

    提交内容
    错误合同与客户端处理表。
    怎样算完成
    • 状态选择有场景依据
    • 重试和修正输入等动作有明确条件

    练到的知识点常见状态码

  6. 作业 08面试场景

    PUT 上传同一文档两次,服务器为每次接收写一条审计日志。定义评估幂等性时观察的资源状态、审计行为和响应分别是什么。

    提交内容
    可观察效果清单及重复请求验收。
    怎样算完成
    • 系统效果的观察范围明确
    • 结论不只比较两次响应字节

    练到的知识点幂等效果与响应区别

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成数据库事务后:同一幂等标识被两个租户或两种请求内容使用。设计幂等记录的业务边界、有效期和冲突处理政策,给出崩溃恢复验收场景。

    提交内容
    记录模型、状态图与并发失败用例。
    怎样算完成
    • 身份范围明确
    • 覆盖记录与业务提交之间的失败

    练到的知识点幂等效果与响应区别 · 重复提交业务机制

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    文档替换升级为“替换成功后自动发通知”。在相同请求重复到达、响应丢失的条件下,重新定义成功状态和可承诺的重复效果,并比较两个产品目标。

    提交内容
    事件时间线和两份接口合同。
    怎样算完成
    • 通知效果被纳入明确需求
    • 冲突或可行性有具体场景支持

    练到的知识点常见状态码 · 幂等效果与响应区别

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗