Quellcode durchsuchen

JDK HTTP Client 使用教程 blog 文档输出

yangyi vor 1 Woche
Ursprung
Commit
5761b9cf62
1 geänderte Dateien mit 809 neuen und 0 gelöschten Zeilen
  1. 809 0
      blog.md

+ 809 - 0
blog.md

@@ -0,0 +1,809 @@
+# JDK HTTP Client 完全指南:从入门到实战
+
+## 前言
+
+Java 11 开始,JDK 终于内置了官方的 HTTP 客户端——`java.net.http.HttpClient`。在此之前,发送 HTTP 请求要么依赖 Apache HttpClient,要么使用 HttpURLConnection 这个"上古 API"。新 API 设计现代、支持 HTTP/2、提供同步和异步两种模式,是 Java 生态中处理 HTTP 通信的首选方案。
+
+本文基于 **Java 17**,围绕一个本地运行的 **User Management API**(`http://localhost:8080`)进行实战演示,每个知识点均包含:**文字说明、示例代码、JUnit 测试**。
+
+> 运行示例/测试前,请确保 API 服务已在本机 8080 端口启动。
+
+---
+
+## 一、快速入门:请求的构造与响应处理
+
+### 核心概念
+
+HTTP Client 的基本使用流程只有一句话:**构造请求,发送,处理响应**。核心 API 有三个:
+
+| 类 | 职责 |
+|----|------|
+| `java.net.http.HttpClient` | HTTP 客户端,负责发送请求、管理连接 |
+| `java.net.http.HttpRequest` | 请求对象,描述 URI、方法、请求头、请求体 |
+| `java.net.http.HttpResponse<T>` | 响应对象,携带状态码、响应头和响应体 |
+
+最小化流程分四步:
+
+1. **创建客户端**:`HttpClient.newHttpClient()` 用 JDK 默认配置创建实例;
+2. **构造请求**:`HttpRequest.newBuilder().uri(...).GET().build()` 链式构建;
+3. **发送请求**:`client.send(request, BodyHandlers.ofString())` 同步阻塞发送;
+4. **处理响应**:通过 `HttpResponse` 获取 `statusCode()`、`body()` 等。
+
+> 注意:`send()` 会抛出 `IOException`(IO 失败)和 `InterruptedException`(线程被中断),需要显式处理或向上抛出。
+
+### 示例代码
+
+见 `QuickStart.java`,核心代码如下:
+
+```java
+// 1. 创建 HttpClient:newHttpClient() 使用 JDK 默认的配置创建一个客户端
+HttpClient httpClient = HttpClient.newHttpClient();
+
+// 2. 构造请求:HttpRequest.newBuilder() 返回一个 Builder,链式配置请求
+HttpRequest request = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))  // 设置请求的目标地址
+        .GET()                                     // 指定请求方法为 GET
+        .build();                                  // 结束构建,返回不可变对象
+
+// 3. 发送请求:send() 同步阻塞,BodyHandlers.ofString() 将响应体转为字符串
+HttpResponse<String> response =
+        httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+
+// 4. 处理响应
+System.out.println("HTTP 状态码: " + response.statusCode());
+System.out.println("响应体: " + response.body());
+```
+
+### 测试验证
+
+见 `QuickStartTest.java`。测试会真实调用本地 API,运行后控制台打印响应结果。
+
+(测试目标:GET `http://localhost:8080/api/users`,正常情况下返回 `{"code":200,"message":"OK","data":[...]}`)
+
+---
+
+## 二、GET 请求
+
+### 核心概念
+
+GET 是最常用的 HTTP 方法,用于从服务器获取数据,且**不携带请求体**。用 JDK HTTP Client 发送 GET 请求时,有三种常见写法:
+
+1. **不带参数的 GET**:`.uri(url)` + `.GET()`,例如查询用户列表;
+2. **带路径参数的 GET**:把 id 等参数拼进 URL 路径,例如查询单个用户 `/api/users/1`;
+3. **带查询字符串的 GET**:`?key=value` 跟在 URI 后面,服务端按查询条件过滤。
+
+| 要点 | 说明 |
+|------|------|
+| `.GET()` | 显式声明请求方法;省略时默认也是 GET,但显式写出更清晰 |
+| 路径参数 | 直接拼在 URL 中,如 `/api/users/{id}` |
+| 查询参数 | 拼在 `?` 之后,多个用 `&` 连接 |
+| 无请求体 | GET 请求使用 `BodyPublishers.noBody()`(默认),无需设置请求体 |
+| 响应码 | 200 找到资源;404 资源不存在 |
+
+### 示例代码
+
+见 `GetExample.java`,核心代码如下:
+
+```java
+// 不带参数的 GET:查询所有用户
+HttpRequest request = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .GET()
+        .build();
+HttpResponse<String> response =
+        httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+
+// 带路径参数的 GET:根据 id 查询单个用户
+HttpRequest request2 = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users/" + id))
+        .GET()
+        .build();
+HttpResponse<String> response2 =
+        httpClient.send(request2, HttpResponse.BodyHandlers.ofString());
+
+// 带查询字符串的 GET(标准写法演示)
+URI uri = URI.create(BASE_URL + "/api/users?account=alice01");
+```
+
+### 测试验证
+
+见 `GetExampleTest.java`,测试点包括:
+
+- `getUsers`:GET `/api/users` 返回 200,且响应体含 `code` 包装字段;
+- `getUserById_notExists`:GET `/api/users/999999999` 返回 404;
+- `buildRequestWithQuery`:验证带 `?account=alice01` 查询参数的请求 URI 拼接正确,且 GET 请求体为空。
+
+---
+
+## 三、POST / PUT / DELETE 请求
+
+### 3.1 POST 请求
+
+POST 用于向服务器**提交数据**(创建资源),与 GET 最大的区别是**携带请求体**。提交 JSON 的标准流程:
+
+1. **序列化**:把 Java 对象转成 JSON 字符串(教程中使用 Jackson `ObjectMapper`);
+2. **设置请求头**:`Content-Type: application/json`,告诉服务器请求体格式;
+3. **构造请求体**:`BodyPublishers.ofString(json)` 把字符串包装为请求体;
+4. **声明方法**:`.POST(publisher)` 指定请求方法及请求体;
+5. **处理响应**:服务端返回统一包装 `Result<UserVO>`,用 `TypeReference` 反序列化拿到具体数据对象。
+
+| 要点 | 说明 |
+|------|------|
+| `.POST(BodyPublishers.ofString(json))` | POST 方法必须携带请求体发布器 |
+| `BodyPublishers.ofString` | 将字符串作为请求体;另有 ofInputStream/ofByteArray 等 |
+| 状态码 200 | 创建成功(该服务返回 200) |
+| 状态码 400 | 请求体格式不正确 / 缺少必填字段 |
+| 状态码 409 | 账号已存在,创建冲突 |
+
+> 注意:为便于演示,本项目引入了 Jackson(`jackson-databind`)。JDK HttpClient 本身不关心请求体是 JSON 还是其他格式,序列化的职责由调用方承担。
+
+#### 示例代码
+
+见 `PostExample.java`,核心代码如下:
+
+```java
+// 1. 使用 Jackson 把对象序列化为 JSON 字符串
+String json = objectMapper.writeValueAsString(user);
+
+// 2. 构造 POST 请求
+HttpRequest request = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .header("Content-Type", "application/json")   // 声明请求体是 JSON
+        .POST(BodyPublishers.ofString(json))          // 指定请求体
+        .build();
+
+// 3. 发送请求
+HttpResponse<String> response =
+        HttpClient.newHttpClient().send(request, BodyHandlers.ofString());
+
+// 4. 反序列化统一包装结构 Result<UserVO>
+Result<UserVO> result = objectMapper.readValue(response.body(),
+        new TypeReference<Result<UserVO>>() { });
+```
+
+配套模型类位于 `model/` 包下:`UserRequest`(请求体)、`UserVO`(用户响应)、`Result<T>`(统一包装)。
+
+#### 测试验证
+
+见 `PostExampleTest.java`,测试点包括:
+
+- `createUser_success`:POST 唯一账号返回 200,响应体含 code 与账号;
+- `createUserAndGetUser_success`:解析出创建后的用户对象,账号与请求一致;
+- `createUser_duplicateAccount`:相同账号重复创建返回 409。
+
+> 测试使用 `System.nanoTime()` 生成带时间戳的唯一账号,避免与历史数据冲突。
+
+### 3.2 PUT 请求
+
+PUT 用于**整体更新**已有资源,与 POST 的写法几乎一致:同样携带 JSON 请求体、同样用 `BodyPublishers.ofString`,唯一区别是把 `.POST(...)` 换成 `.PUT(...)`,并把目标地址改为带路径参数的 `/api/users/{id}`。
+
+| 要点 | 说明 |
+|------|------|
+| `.PUT(BodyPublishers.ofString(json))` | PUT 快捷方法,必须携带请求体发布器 |
+| 路径参数 | id 拼在 URL 中,如 `/api/users/1` |
+| 状态码 200 | 更新成功 |
+| 状态码 404 | 用户不存在 |
+| 状态码 409 | 账号与其他用户冲突 |
+
+> PUT 与 POST 在 HTTP 语义上的区别:POST 是「创建新资源」,PUT 是「整体替换已有资源」。发送代码唯一的差别就是 `.POST(...)` / `.PUT(...)` 方法名不同。
+
+#### 示例代码
+
+见 `PutDeleteExample.java`,核心代码如下:
+
+```java
+// PUT 请求:更新 id=1 的用户,请求体为更新后的完整用户信息
+String json = objectMapper.writeValueAsString(user);
+
+HttpRequest request = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users/" + id))
+        .header("Content-Type", "application/json")
+        .PUT(BodyPublishers.ofString(json))   // 与 POST 唯一的区别
+        .build();
+```
+
+#### 测试验证
+
+见 `PutDeleteExampleTest.java`,测试点包括:
+
+- `updateUser_success` / `updateUserAndGetUser_success`:更新成功返回 200,解析出的账号与请求一致;
+- `updateUser_notExists`:更新不存在的用户返回 404;
+- `buildPutRequest_hasBody`:构造的 PUT 请求 method 为 "PUT",且携带请求体。
+
+### 3.3 DELETE 请求
+
+DELETE 用于**删除资源**,与 GET 一样**不携带请求体**,只需把 id 拼进路径。Builder 提供 `.DELETE()` 快捷方法(内部天然使用 `BodyPublishers.noBody()`)。
+
+| 要点 | 说明 |
+|------|------|
+| `.DELETE()` | DELETE 快捷方法,不传请求体 |
+| 路径参数 | id 拼在 URL 中,如 `/api/users/1` |
+| 状态码 200 | 删除成功(服务端返回 Result\<Void\>) |
+| 状态码 404 | 用户不存在 |
+
+#### 示例代码
+
+见 `PutDeleteExample.java`,核心代码如下:
+
+```java
+HttpRequest request = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users/" + id))
+        .DELETE()                            // 不携带请求体
+        .build();
+
+HttpResponse<String> response =
+        HttpClient.newHttpClient().send(request, BodyHandlers.ofString());
+```
+
+#### 测试验证
+
+见 `PutDeleteExampleTest.java`,测试点包括:
+
+- `deleteUser_success`:先创建再删除,返回 200;
+- `deleteUser_notExists`:删除不存在的用户返回 404;
+- `buildDeleteRequest_noBody`:构造的 DELETE 请求 method 为 "DELETE",且无请求体(bodyPublisher 为空)。
+
+---
+
+## 四、请求头
+
+### 核心概念
+
+**先澄清一个误区**:JDK 的 `HttpClient.Builder` **没有**「设置默认请求头」的方法。(可通过 `javap --module java.net.http java.net.http.HttpClient$Builder` 查看,Builder 只提供 `cookieHandler / connectTimeout / executor / followRedirects / priority / proxy / authenticator / sslContext / version` 等配置。)
+
+因此请求头只能在 **HttpRequest 构造阶段**配置,JDK 提供了三个相关 API:
+
+| 方法 | 作用 |
+|------|------|
+| `.header(key, value)` | 追加一个请求头(同名头可存在多个) |
+| `.headers(k1, v1, k2, v2, ...)` | 一次追加多组请求头,参数个数必须为偶数 |
+| `.setHeader(key, value)` | 与 header 不同,会**覆盖**已存在的同名头 |
+
+**「Client 级默认头」的变通方案**:既然 HttpClient 不能配置默认头,业界通用做法是提供一个工厂方法,统一返回「已预置公共请求头」的 `HttpRequest.Builder`,让同一客户端发出的所有请求都携带公共头(如 `User-Agent`、`Accept`),达到集中管理的效果。
+
+**响应头读取**:`response.headers()` 返回 `HttpHeaders` 对象,常用方法:`firstValue(name)`(返回 `Optional<String>`)、`allValues(name)`(返回 `List<String>`)。
+
+### 示例代码
+
+见 `HeaderExample.java`,核心代码如下:
+
+```java
+// 单个请求头
+HttpRequest request = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .header("Accept", "application/json")
+        .GET()
+        .build();
+
+// 多个请求头:key/value 成对出现,个数为偶数
+HttpRequest request2 = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .headers(
+                "Content-Type", "application/json",
+                "Accept", "application/json",
+                "X-Request-Id", "tutorial-001"
+        )
+        .GET()
+        .build();
+
+// setHeader 覆盖同名头:最终 X-Version 只有一个值 2
+HttpRequest request3 = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .header("X-Version", "1")
+        .setHeader("X-Version", "2")
+        .GET()
+        .build();
+
+// 读取响应头
+String contentType = response.headers()
+        .firstValue("Content-Type")
+        .orElse("unknown");
+```
+
+工厂方法预置默认请求头的变通方案:
+
+```java
+public HttpRequest.Builder newBuilderWithDefaultHeaders() {
+    return HttpRequest.newBuilder()
+            .header("User-Agent", "JDK-HttpClient-Tutorial/1.0")
+            .header("Accept", "application/json");
+}
+```
+
+### 测试验证
+
+见 `HeaderExampleTest.java`,测试点包括:
+
+- `singleHeader`:`.header()` 设置单个头,可正常读出;
+- `multipleHeaders`:`.headers()` 一次设置三组头;
+- `setHeaderOverrides`:`.setHeader()` 覆盖后同名字只有一个值;
+- `defaultHeaderBuilder`:工厂方法预置的公共头可正常读出;
+- `sendRequestWithHeaders`:带请求头发送 GET 返回 200。
+
+---
+
+## 五、请求体
+
+### 核心概念
+
+请求体由 **BodyPublisher** 描述,由 `POST(BodyPublisher)`(或 PUT/PATCH)传入请求构造器中。JDK 内置了多种 BodyPublisher 实现:
+
+| 发布器 | 说明 | 典型场景 |
+|--------|------|----------|
+| `BodyPublishers.ofString(String)` | 字符串请求体 | JSON 字符串提交(最常用) |
+| `BodyPublishers.ofByteArray(byte[])` | 字节数组请求体 | 手工构建的二进制/复杂格式体 |
+| `BodyPublishers.ofInputStream(Supplier<InputStream>)` | 输入流请求体 | 大文件流式上传,避免整块载入内存 |
+| `BodyPublishers.ofFile(Path)` | 文件请求体 | 直接以文件为请求体 |
+| `BodyPublishers.noBody()` | 无请求体 | GET/DELETE 等无体请求 |
+
+**multipart/form-data 文件上传**:JDK HttpClient 没有内置 multipart 支持,按 RFC 2046 规范手工拼接请求体即可,格式如下:
+
+```text
+--boundary\r\n
+Content-Disposition: form-data; name="file"; filename="report.txt"\r\n
+Content-Type: text/plain\r\n
+\r\n
+(文件内容)\r\n
+--boundary--\r\n
+```
+
+注意:`Content-Type` 请求头中携带的 boundary 必须与请求体中使用的 boundary 完全一致,服务器才能正确切分字段。
+
+### 示例代码
+
+见 `BodyExample.java`,核心代码如下:
+
+```java
+// ofString:字符串 JSON 请求体
+HttpRequest request = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .header("Content-Type", "application/json")
+        .POST(BodyPublishers.ofString(json))   // <- 字符串请求体
+        .build();
+
+// ofInputStream:输入流请求体(懒加载)
+ByteArrayInputStream stream =
+        new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8));
+HttpRequest request2 = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .header("Content-Type", "application/json")
+        .POST(BodyPublishers.ofInputStream(() -> stream))
+        .build();
+
+// 手工构建 multipart/form-data 请求体(文件上传)
+String boundary = "----WebKitFormBoundary" + UUID.randomUUID();
+byte[] body = buildMultipartBody(fileName, fileBytes, boundary);
+
+HttpRequest request3 = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/files/upload"))
+        .header("Content-Type", "multipart/form-data; boundary=" + boundary)
+        .POST(BodyPublishers.ofByteArray(body))
+        .build();
+```
+
+### 测试验证
+
+见 `BodyExampleTest.java`,测试点包括:
+
+- `createUser_withJsonString`:ofString 提交 JSON 创建用户,返回 200;
+- `createUser_viaInputStream`:ofInputStream 提交 JSON 创建用户,返回 200;
+- `uploadFile`:上传文本文件,`FileVO` 返回的原始文件名与大小一致;
+- `uploadFile_empty`:上传空文件返回 400;
+- `noBodyRequest`:GET 请求对象无 bodyPublisher。
+
+---
+
+## 六、文件下载
+
+### 核心概念
+
+下载与上传相反,服务端以**二进制流**返回文件内容。JDK HTTP Client 本身对「如何消费响应体」并不关心,区别只在于传入的**响应处理器**。常用的三种:
+
+| 处理器 | 得到的内容 | 适用场景 |
+|--------|-----------|----------|
+| `BodyHandlers.ofFile(Path)` | 直接把响应体**写入本地文件** | 大文件下载,不占堆内存 |
+| `BodyHandlers.ofByteArray()` | `byte[]` | 文件较小,想整体读入内存处理 |
+| `BodyHandlers.ofInputStream()` | `InputStream` | 流式读取,边读边处理/转发 |
+
+下载流程分两步:
+
+1. **先上传拿文件名**:调用 `POST /api/files/upload` 得到 `FileVO`,其中的 `storedFileName` 是服务端磁盘上的文件名;
+2. **再按文件名下载**:GET `/api/files/download/{storedFileName}`,服务端返回文件内容二进制流。
+
+> 响应头里的 `Content-Disposition` 携带 `attachment` 标记与原文件名,可通过 `response.headers().firstValue("Content-Disposition")` 读取;注意本服务返回的文件名是 storedFileName 去掉扩展名后的部分。
+
+### 示例代码
+
+见 `FileDownloadExample.java`,核心代码如下:
+
+```java
+// 方式一:ofFile — 直接落盘到本地路径
+HttpResponse<Path> resp = httpClient.send(request,
+        HttpResponse.BodyHandlers.ofFile(target));
+
+// 方式二:ofByteArray — 读入内存得到 byte[]
+HttpResponse<byte[]> resp2 = httpClient.send(request,
+        HttpResponse.BodyHandlers.ofByteArray());
+
+// 方式三:ofInputStream — 以输入流形式消费
+HttpResponse<InputStream> resp3 = httpClient.send(request,
+        HttpResponse.BodyHandlers.ofInputStream());
+```
+
+下载 URL 的构造同 GET:`BASE_URL + "/api/files/download/" + storedFileName`。
+
+### 测试验证
+
+见 `FileDownloadExampleTest.java`,测试点包括:
+
+- `downloadToFile_success`:下载内容写入指定本地文件,内容与服务端一致;
+- `downloadAsBytes_success`:下载得到 byte[],内容一致;
+- `downloadAsStream_success`:从 InputStream 读出的内容一致;
+- `readContentDisposition_present`:响应头含 `Content-Disposition` 且带 `attachment` 与原文件名;
+- `uploadThenDownload_roundTrip`:上传后再下载,字节完全一致(round-trip);
+- `download_notExists`:下载不存在的文件返回 404。
+
+> 每个测试都先调用上传接口生成临时文件、拿到 `storedFileName` 再下载,结束后清理临时文件。
+
+---
+
+## 七、同步与异步
+
+### 核心概念
+
+HttpClient 提供两种发送请求的方式:
+
+**1. 同步 `send()`** — 阻塞当前线程直到收到完整响应:
+
+```java
+HttpResponse<String> response =
+        httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+```
+
+- 直接返回 `HttpResponse`;
+- 需处理 `IOException`(网络/IO 失败)与 `InterruptedException`(线程中断);
+- 适用于请求少的场景,简单直观。
+
+**2. 异步 `sendAsync()`** — 立即返回 `CompletableFuture<HttpResponse>`,请求在 HttpClient 的内部线程池中执行,不阻塞调用线程:
+
+```java
+CompletableFuture<HttpResponse<String>> future =
+        httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString());
+
+HttpResponse<String> response = future.join();   // 阻塞式获取,或:
+future.thenApply(resp -> ...);                    // 回调式处理,不阻塞
+```
+
+- 获取结果有两种方式:
+  - `join()`:阻塞直到完成(异常以 `CompletionException` 抛出);
+  - 回调链:`thenApply` / `whenComplete` / `thenCompose` 等 `CompletableFuture` API;
+- `CompletableFuture.allOf(...)` 可等待多个并发请求全部完成,非常适合批量并发。
+
+| 对比项 | send() | sendAsync() |
+|--------|--------|-------------|
+| 阻塞 | 阻塞当前线程 | 不阻塞 |
+| 返回 | HttpResponse | CompletableFuture\<HttpResponse> |
+| 异常 | IOException / InterruptedException | CompletionException |
+| 适用 | 少量串行请求 | 批量、并发、回调链 |
+
+### 示例代码
+
+见 `SyncAsyncExample.java`,核心代码如下:
+
+```java
+// 同步发送
+HttpResponse<String> response =
+        httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+
+// 异步发送 + join 阻塞取结果
+CompletableFuture<HttpResponse<String>> future =
+        httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString());
+HttpResponse<String> result = future.join();
+
+// 异步发送 + 回调链(不阻塞主线程)
+httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
+        .thenApply(resp -> "状态码=" + resp.statusCode()
+                + ", body=" + abbreviate(resp.body()));
+
+// 批量并发:并发发起 N 个请求,全部完成后汇总
+List<CompletableFuture<HttpResponse<String>>> futures =
+        IntStream.range(0, count)
+                .mapToObj(i -> httpClient.sendAsync(buildGetRequest(),
+                        HttpResponse.BodyHandlers.ofString()))
+                .collect(Collectors.toList());
+
+CompletableFuture<Void> all = CompletableFuture.allOf(
+        futures.toArray(new CompletableFuture[0]));
+all.join();
+
+return futures.stream()
+        .map(f -> f.join().statusCode())
+        .collect(Collectors.toList());
+```
+
+### 测试验证
+
+见 `SyncAsyncExampleTest.java`,测试点包括:
+
+- `sendSync`:同步发送返回 200;
+- `sendAsync`:异步 + join 返回 200;
+- `sendAsyncWithCallback`:回调链得到处理结果(以 `状态码=200,` 开头);
+- `sendAsyncInParallel`:并发 10 个请求全部成功(状态码均为 200)。
+
+---
+
+## 八、响应处理器
+
+### 核心概念
+
+`BodyHandler` 决定「如何消费响应体」:拿到响应头(状态码等)后,它返回一个 `BodySubscriber`,后者把响应体字节流转换为目标类型 T。发送请求时作为第二个参数传入:`client.send(request, bodyHandler)`。
+
+**JDK 内置响应处理器(BodyHandlers):**
+
+| 处理器 | 响应体类型 | 适用场景 |
+|--------|-----------|----------|
+| `BodyHandlers.ofString()` | String | 文本/JSON 响应(最常用) |
+| `BodyHandlers.ofByteArray()` | byte[] | 二进制内容 |
+| `BodyHandlers.ofFile(Path)` | Path | 大文件下载,直接落盘 |
+| `BodyHandlers.ofInputStream()` | InputStream | 流式读取 |
+| `BodyHandlers.discarding()` | Void | 只关心状态码,body() 为 null |
+| `BodyHandlers.ofLines()` | Stream\<String> | 逐行处理 |
+
+**自定义 BodyHandler:** 只需实现 `apply(ResponseInfo)` 方法返回一个 `BodySubscriber`:
+
+- **按状态码分流**:2xx 正常读取,非 2xx 用 `BodySubscribers.replacing(null)` 丢弃响应体,使 body() 返回 null;
+- **响应后处理**:用 `BodySubscribers.mapping(upstream, fn)` 把上游订阅器的结果再转换一次(如加前缀标记、组装业务对象)。
+
+> 提示:自定义 `BodyHandler<T>` 通常与 `BodySubscribers.ofString` / `ofByteArray` 组合使用,实现「先读字节流、再按业务逻辑解析」的能力,是接入统一响应包装结构的标准方式。
+
+### 示例代码
+
+见 `ResponseHandlerExample.java`,核心代码如下:
+
+```java
+// 内置:ofString
+HttpResponse<String> resp = client.send(req, BodyHandlers.ofString());
+
+// 内置:ofFile(下载直接落盘)
+HttpResponse<Path> resp2 = client.send(req, BodyHandlers.ofFile(target));
+
+// 自定义:按状态码分流(2xx 正常读取,非 2xx 丢弃响应体)
+BodyHandler<String> handler = responseInfo -> {
+    if (responseInfo.statusCode() >= 200 && responseInfo.statusCode() < 300) {
+        return BodySubscribers.ofString(StandardCharsets.UTF_8);
+    }
+    return BodySubscribers.replacing(null);
+};
+
+// 自定义:响应后处理(加前缀标记)
+BodySubscriber<String> upstream = BodySubscribers.ofString(StandardCharsets.UTF_8);
+BodyHandler<String> handler2 = responseInfo ->
+        BodySubscribers.mapping(upstream, body -> "[TAG] " + body);
+```
+
+### 测试验证
+
+见 `ResponseHandlerExampleTest.java`,测试点包括:
+
+- `getAsString` / `getAsByteArray` / `getAsFile` / `getAsInputStream`:验证各内置处理器行为;
+- `getDiscarded`:discarding 处理器 body() 为 null;
+- `customHandler_success`:自定义 mapping 处理器为响应体加前缀 `[MY-TAG]`;
+- `customHandler_404DiscardsBody`:自定义处理器在 404 时 body() 为 null;
+- `statusBasedHandler_successString`:按状态码分流的处理器 2xx 正常返回。
+
+---
+
+## 九、HTTP Client 配置项
+
+### 核心概念
+
+`HttpClient.newBuilder()` 返回的 Builder 可在创建客户端时配置**通用行为**。所有配置在客户端创建后**不可修改**,因此应在创建时一次设置好。
+
+| 配置项 | 作用 | 备注 |
+|--------|------|------|
+| `.version(HTTP_1_1 / HTTP_2)` | 协议版本 | |
+| `.connectTimeout(Duration)` | 连接建立超时 | 超时抛 ConnectTimeoutException |
+| `.executor(Executor)` | 异步请求线程池 | 默认内置线程池 |
+| `.followRedirects(NEVER/ALWAYS/NORMAL)` | 重定向策略 | NORMAL 只跟随 GET 等安全方法 |
+| `.proxy(ProxySelector)` | 代理选择器 | `ProxySelector.NO_PROXY` 直连 |
+| `.cookieHandler(CookieHandler)` | Cookie 管理器 | 通常搭配 `CookieManager` |
+| `.authenticator(Authenticator)` | 认证器 | Basic Auth 等 |
+| `.sslContext / .sslParameters` | TLS 配置 | 仅 HTTPS 生效 |
+| `.priority(int)` | HTTP/2 流优先级 | 范围 1~256,仅 HTTP_2 生效 |
+
+### 示例代码
+
+见 `ClientConfigExample.java`,核心代码如下:
+
+```java
+CookieManager cookieManager = new CookieManager();
+
+HttpClient client = HttpClient.newBuilder()
+        .version(HttpClient.Version.HTTP_1_1)          // 协议版本
+        .connectTimeout(Duration.ofSeconds(5))         // 连接超时
+        .executor(Executors.newFixedThreadPool(4))     // 异步线程池
+        .followRedirects(HttpClient.Redirect.NORMAL)   // 重定向策略
+        .proxy(ProxySelector.getDefault())             // 代理
+        .cookieHandler(cookieManager)                  // Cookie 管理
+        .build();
+```
+
+配置一旦生效,可通过 getter 读取(如 `client.version()`、`client.connectTimeout()`、`client.followRedirects()` 等),方便校验。
+
+### 测试验证
+
+见 `ClientConfigExampleTest.java`,测试点包括:
+
+- `buildConfiguredClient_hasExpectedValues`:校验 version / connectTimeout / followRedirects / executor / cookieHandler 配置均生效;
+- `buildHttp2Client_defaultsToHttp2`:HTTP_2 客户端版本正确;
+- `sendGet_withConfiguredClient`:配置后的客户端请求返回 200;
+- `connectTimeout_returns200OrThrows`:连接不可达主机时抛出预期异常。
+
+---
+
+## 十、HTTP Request 配置项
+
+### 核心概念
+
+`HttpRequest.newBuilder()` 用于构造单个请求,其可配置项如下(与 HttpClient 级配置互相独立,请求级优先级更高):
+
+| 配置项 | 作用 | 备注 |
+|--------|------|------|
+| `.uri(URI)` | 请求地址 | 必填 |
+| `.timeout(Duration)` | **整个请求**的超时 | 与 client 的 connectTimeout(仅连接阶段)不同 |
+| `.version(HTTP_1_1/HTTP_2)` | 请求级协议版本 | 覆盖 client 的 version |
+| `.expectContinue(true)` | 期望服务器先返回 100-continue | 标志位存储,发送时才生成 Expect 头 |
+| `.GET()/.POST(pub)/.PUT(pub)/.DELETE()` | 标准请求方法 | |
+| `.method(name, publisher)` | 自定义请求方法 | PATCH、HEAD 等 |
+| `.header / .headers / .setHeader` | 请求头 | |
+| `.copy()` | 复制 Builder | 写时复制,修改副本不影响原 Builder |
+
+**`.timeout` 与 `.connectTimeout` 的区别**
+
+| | connectTimeout(Client 级) | timeout(Request 级) |
+|---|---------------------------|-----------------------|
+| 作用阶段 | 建立 TCP 连接 | 从发送到拿到完整响应体 |
+| 超时抛错 | `ConnectTimeoutException` | `HttpTimeoutException` |
+
+### 示例代码
+
+见 `RequestConfigExample.java`,核心代码如下:
+
+```java
+// 请求级超时(整个请求 3 秒)
+HttpRequest request = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .timeout(Duration.ofSeconds(3))
+        .GET()
+        .build();
+
+// 期望继续:提交大请求体前先「询价」
+HttpRequest request2 = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .header("Content-Type", "application/json")
+        .expectContinue(true)
+        .POST(BodyPublishers.ofString(json))
+        .build();
+
+// 自定义请求方法
+HttpRequest request3 = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .method("PATCH", BodyPublishers.noBody())
+        .build();
+
+// 复制 Builder(写时复制,修改副本不影响原 Builder)
+HttpRequest.Builder original = HttpRequest.newBuilder()
+        .uri(uri).header("X-Original", "yes");
+HttpRequest copied = original.copy()
+        .header("X-Copy", "yes").GET().build();
+```
+
+### 测试验证
+
+见 `RequestConfigExampleTest.java`,测试点包括:
+
+- `timeout_configPresent`:请求级 timeout 可读,且为 3 秒;
+- `version_override`:请求级版本配置生效;
+- `expectContinue_flagSet`:expectContinue 标志位置为 true(Expect 头在发送阶段生成,headers() 中读取不到);
+- `customMethod`:method("PATCH", noBody) 生效;
+- `copyBuilder_isIndependent`:修改副本不影响原 Builder;
+- `sendWithTimeout`:带请求级超时的请求正常返回 200;
+- `timeoutExpired_throwsExpected`:不可达地址 + 短超时抛出预期异常。
+
+---
+
+## 十一、HTTP Client 核心对象及 API 速览
+
+学完以上所有示例后会发现,JDK HTTP Client 的核心对象其实只有四个,理解了它们就掌握了全部用法:
+
+| 核心对象 | 职责 | 获取方式 |
+|----------|------|----------|
+| `HttpClient` | 发送请求、管理连接与配置 | `HttpClient.newHttpClient()` / `HttpClient.newBuilder()` |
+| `HttpRequest` | 描述一次请求(URI、方法、头、体) | `HttpRequest.newBuilder().build()` |
+| `HttpResponse<T>` | 一次请求的结果(状态码、头、体) | `client.send(...)` 的返回值 |
+| `HttpHeaders` | 请求/响应的头部集合 | `request.headers()` / `response.headers()` |
+
+### HttpClient
+
+- `newHttpClient()` / `newBuilder()`:创建客户端;
+- `send(request, bodyHandler)` / `sendAsync(...)`:同步/异步发送;
+- `version()` / `connectTimeout()` / `followRedirects()` / `executor()` / `proxy()` / `cookieHandler()`:读取创建时配置的各项值。
+
+### HttpRequest 与 Builder
+
+- **Builder**:`.uri()`、`.header()/headers()/setHeader()`、`.timeout()`、`.version()`、`.expectContinue()`、`.GET()/.POST()/.PUT()/.DELETE()`、`.method()`、`.copy()`,最后 `.build()` 产出不可变请求;
+- **Request**:`.method()`、`.uri()`、`.timeout()`、`.headers()`(返回 `HttpHeaders`)、`.bodyPublisher()`(`Optional`)。
+
+### HttpResponse\<T\>
+
+- `.statusCode()`、`.uri()`、`.request()`、`.version()`;
+- `.headers()`(返回 `HttpHeaders`)、`.body()`(目标类型 T);
+- `.previousResponse()`:重定向场景下返回上一次响应。
+
+### HttpHeaders
+
+- `.firstValue(name)`:返回 `Optional<String>`,取第一个同名值;
+- `.allValues(name)`:返回 `List<String>`,取全部同名值;
+- `.map()`:返回底层 `Map<String, List<String>>`;
+- `equals/hashCode/toString` 等常规方法。
+
+### 示例代码
+
+见 `CoreApiExample.java`,核心代码如下:
+
+```java
+// 1. 创建客户端
+HttpClient client = HttpClient.newBuilder()
+        .connectTimeout(Duration.ofSeconds(5))
+        .build();
+System.out.println("version=" + client.version());
+System.out.println("connectTimeout=" + client.connectTimeout());
+
+// 2. 构造请求
+HttpRequest request = HttpRequest.newBuilder()
+        .uri(URI.create(BASE_URL + "/api/users"))
+        .header("Accept", "application/json")
+        .timeout(Duration.ofSeconds(3))
+        .GET()
+        .build();
+
+// 3. 查看请求对象
+System.out.println("method=" + request.method());
+HttpHeaders reqHeaders = request.headers();
+reqHeaders.firstValue("Accept");               // Optional<String>
+request.bodyPublisher().isEmpty();             // true(GET 无体)
+
+// 4. 发送并查看响应对象
+HttpResponse<String> response =
+        client.send(request, HttpResponse.BodyHandlers.ofString());
+System.out.println("statusCode=" + response.statusCode());
+System.out.println("responseUri=" + response.uri());
+
+// 响应头常用 API
+HttpHeaders respHeaders = response.headers();
+respHeaders.firstValue("Content-Type").orElse("unknown");
+respHeaders.map();                             // Map<String, List<String>>
+```
+
+### 测试验证
+
+见 `CoreApiExampleTest.java`,测试点包括:
+
+- `showCoreApis_runsAgainstServer`:真实发请求验证所有核心对象 API 可访问;
+- `httpRequest_hasExpectedFields`:method/uri/headers 读取正确,同名头用 `allValues` 返回全部值;
+- `httpHeaders_firstAndAllValues`:`firstValue` 与 `allValues` 行为验证。
+
+---
+
+## 总结
+
+JDK HTTP Client 的 API 设计简洁而统一,核心流程就是「构建请求 → 发送 → 处理响应」三步。掌握 `HttpClient`、`HttpRequest`、`HttpResponse`、`HttpHeaders` 四个核心对象,配合 `BodyHandlers` 和 `BodyPublishers` 的各种实现,就能覆盖绝大多数 HTTP 通信场景。
+
+对于日常开发,建议:
+
+- **简单请求**用同步 `send()`,直观易调试;
+- **批量/高并发**用异步 `sendAsync()` + `CompletableFuture`,充分利用线程池;
+- **统一响应处理**通过自定义 `BodyHandler` 实现,避免每个请求都重复反序列化逻辑;
+- **默认请求头**用工厂方法模式变通实现,保持代码整洁。
+
+完整示例代码见项目 `src/main/java/space/anyi/httpClient/` 目录,测试代码见 `src/test/java/space/anyi/httpClient/` 目录。所有测试均依赖本地 API 服务,请确保服务启动后再运行。