Forráskód Böngészése

教程点2/6/12补全:PUT/DELETE请求、文件下载、HTTP Client核心对象API - 示例代码、测试与doc.md完善

yangyi 1 hete
szülő
commit
209ba90e84

+ 247 - 17
doc.md

@@ -119,7 +119,7 @@ URI uri = URI.create(BASE_URL + "/api/users?account=alice01");
 
 ---
 
-## 三、POST 请求
+## 三、POST / PUT / DELETE 请求
 
 ### 3.1 文字说明
 
@@ -181,6 +181,86 @@ Result<UserVO> result = objectMapper.readValue(response.body(),
 
 > 测试使用 `System.nanoTime()` 生成带时间戳的唯一账号,避免与历史数据冲突。
 
+### 3.4 PUT 请求
+
+#### 3.4.1 文字说明
+
+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(...)` 方法名不同。
+
+#### 3.4.2 示例代码
+
+见 `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();
+```
+
+#### 3.4.3 测试代码
+
+见 `PutDeleteExampleTest.java`,测试点包括:
+
+- `updateUser_success` / `updateUserAndGetUser_success`:更新成功返回 200,
+  解析出的账号与请求一致;
+- `updateUser_notExists`:更新不存在的用户返回 404;
+- `buildPutRequest_hasBody`:构造的 PUT 请求 method 为 "PUT",且携带请求体。
+
+### 3.5 DELETE 请求
+
+#### 3.5.1 文字说明
+
+DELETE 用于**删除资源**,与 GET 一样**不携带请求体**,只需把 id 拼进路径。
+Builder 提供 `.DELETE()` 快捷方法(内部天然使用 `BodyPublishers.noBody()`)。
+
+| 要点 | 说明 |
+|------|------|
+| `.DELETE()` | DELETE 快捷方法,不传请求体 |
+| 路径参数 | id 拼在 URL 中,如 `/api/users/1` |
+| 状态码 200 | 删除成功(服务端返回 Result&lt;Void&gt;) |
+| 状态码 404 | 用户不存在 |
+
+#### 3.5.2 示例代码
+
+见 `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());
+```
+
+#### 3.5.3 测试代码
+
+见 `PutDeleteExampleTest.java`,测试点包括:
+
+- `deleteUser_success`:先创建再删除,返回 200;
+- `deleteUser_notExists`:删除不存在的用户返回 404;
+- `buildDeleteRequest_noBody`:构造的 DELETE 请求 method 为 "DELETE",
+  且无请求体(bodyPublisher 为空)。
+
 ---
 
 ## 四、请求头
@@ -332,10 +412,71 @@ HttpRequest request3 = HttpRequest.newBuilder()
 
 ---
 
-## 六、同步与异步
+## 六、文件下载
 
 ### 6.1 文字说明
 
+下载与上传相反,服务端以**二进制流**返回文件内容。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 去掉扩展名后的部分。
+
+### 6.2 示例代码
+
+见 `FileDownloadExample.java`,核心代码如下:
+
+```java
+// 方式一:DownloadToFile — 直接落盘到本地路径
+HttpResponse<Path> resp = httpClient.send(request,
+        HttpResponse.BodyHandlers.ofFile(target));
+
+// 方式二:DownloadAsBytes — 读入内存得到 byte[]
+HttpResponse<byte[]> resp2 = httpClient.send(request,
+        HttpResponse.BodyHandlers.ofByteArray());
+
+// 方式三:DownloadAsStream — 以输入流形式消费
+HttpResponse<InputStream> resp3 = httpClient.send(request,
+        HttpResponse.BodyHandlers.ofInputStream());
+```
+
+下载 URL 的构造同 GET:`BASE_URL + "/api/files/download/" + storedFileName`。
+
+### 6.3 测试代码
+
+见 `FileDownloadExampleTest.java`,测试点包括:
+
+- `downloadToFile_success`:下载内容写入指定本地文件,内容与服务端一致;
+- `downloadAsBytes_success`:下载得到 byte[],内容一致;
+- `downloadAsStream_success`:从 InputStream 读出的内容一致;
+- `readContentDisposition_present`:响应头含 `Content-Disposition` 且带
+  `attachment` 与原文件名;
+- `uploadThenDownload_roundTrip`:上传后再下载,字节完全一致(round-trip);
+- `download_notExists`:下载不存在的文件返回 404。
+
+> 每个测试都先调用上传接口生成临时文件、拿到 `storedFileName` 再下载,
+> 结束后清理临时文件。
+
+---
+
+## 七、同步与异步
+
+### 7.1 文字说明
+
 HttpClient 提供两种发送请求的方式:
 
 **1. 同步 `send()`** — 阻塞当前线程直到收到完整响应:
@@ -372,7 +513,7 @@ future.thenApply(resp -> ...);                    // 回调式处理,不阻塞
 | 异常 | IOException / InterruptedException | CompletionException |
 | 适用 | 少量串行请求 | 批量、并发、回调链 |
 
-### 6.2 示例代码
+### 7.2 示例代码
 
 见 `SyncAsyncExample.java`,核心代码如下:
 
@@ -394,7 +535,7 @@ httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 ```
 
-### 6.3 测试代码
+### 7.3 测试代码
 
 见 `SyncAsyncExampleTest.java`,测试点包括:
 
@@ -405,9 +546,9 @@ CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 
 ---
 
-## 、响应处理器
+## 、响应处理器
 
-### 7.1 文字说明
+### 8.1 文字说明
 
 `BodyHandler` 决定「如何消费响应体」:拿到响应头(状态码等)后,
 它返回一个 `BodySubscriber`,后者把响应体字节流转换为目标类型 T。
@@ -436,7 +577,7 @@ CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 > `ofByteArray` 组合使用,实现「先读字节流、再按业务逻辑解析」的能力,
 > 是接入统一响应包装结构的标准方式。
 
-### 7.2 示例代码
+### 8.2 示例代码
 
 见 `ResponseHandlerExample.java`,核心代码如下:
 
@@ -461,7 +602,7 @@ BodyHandler<String> handler2 = responseInfo ->
         BodySubscribers.mapping(upstream, body -> "[TAG] " + body);
 ```
 
-### 7.3 测试代码
+### 8.3 测试代码
 
 见 `ResponseHandlerExampleTest.java`,测试点包括:
 
@@ -474,9 +615,9 @@ BodyHandler<String> handler2 = responseInfo ->
 
 ---
 
-## 、HTTP Client 配置项
+## 、HTTP Client 配置项
 
-### 8.1 文字说明
+### 9.1 文字说明
 
 `HttpClient.newBuilder()` 返回的 Builder 可在创建客户端时配置**通用行为**。
 所有配置在客户端创建后**不可修改**,因此应在创建时一次设置好。
@@ -495,7 +636,7 @@ BodyHandler<String> handler2 = responseInfo ->
 | `.sslContext / .sslParameters` | TLS 配置 | 仅 HTTPS 生效 |
 | `.priority(int)` | HTTP/2 流优先级 | 范围 1~256,仅 HTTP_2 生效 |
 
-### 8.2 示例代码
+### 9.2 示例代码
 
 见 `ClientConfigExample.java`,核心代码如下:
 
@@ -515,7 +656,7 @@ HttpClient client = HttpClient.newBuilder()
 配置一旦生效,可通过 getter 读取(如 `client.version()`、
 `client.connectTimeout()`、`client.followRedirects()` 等),方便校验。
 
-### 8.3 测试代码
+### 9.3 测试代码
 
 见 `ClientConfigExampleTest.java`,测试点包括:
 
@@ -527,9 +668,9 @@ HttpClient client = HttpClient.newBuilder()
 
 ---
 
-## 、HTTP Request 配置项
+## 、HTTP Request 配置项
 
-### 9.1 文字说明
+### 10.1 文字说明
 
 `HttpRequest.newBuilder()` 用于构造单个请求,其可配置项如下
 (与 HttpClient 级配置互相独立,请求级优先级更高):
@@ -552,7 +693,7 @@ HttpClient client = HttpClient.newBuilder()
 | 作用阶段 | 建立 TCP 连接 | 从发送到拿到完整响应体 |
 | 超时抛错 | `ConnectTimeoutException` | `HttpTimeoutException` |
 
-### 9.2 示例代码
+### 10.2 示例代码
 
 见 `RequestConfigExample.java`,核心代码如下:
 
@@ -583,7 +724,7 @@ HttpRequest.Builder original = HttpRequest.newBuilder().uri(uri).header("X-Origi
 HttpRequest copied = original.copy().header("X-Copy", "yes").GET().build();
 ```
 
-### 9.3 测试代码
+### 10.3 测试代码
 
 见 `RequestConfigExampleTest.java`,测试点包括:
 
@@ -594,4 +735,93 @@ HttpRequest copied = original.copy().header("X-Copy", "yes").GET().build();
 - `customMethod`:method("PATCH", noBody) 生效;
 - `copyBuilder_isIndependent`:修改副本不影响原 Builder;
 - `sendWithTimeout`:带请求级超时的请求正常返回 200;
-- `timeoutExpired_throwsExpected`:不可达地址 + 短超时抛出预期异常。
+- `timeoutExpired_throwsExpected`:不可达地址 + 短超时抛出预期异常。
+
+---
+
+## 十一、HTTP Client 核心对象及核心对象的 API
+
+### 11.1 文字说明
+
+学完以上所有示例后会发现,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&lt;T&gt;**
+- `.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` 等常规方法。
+
+### 11.2 示例代码
+
+见 `CoreApiExample.java`,核心代码如下:
+
+```java
+// 1. 创建客户端
+HttpClient client = HttpClient.newBuilder()
+        .connectTimeout(Duration.ofSeconds(5))
+        .build();
+System.out.println("version=" + client.version());         // HTTP_2
+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();          // -> HttpHeaders
+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>>
+```
+
+### 11.3 测试代码
+
+见 `CoreApiExampleTest.java`,测试点包括:
+
+- `showCoreApis_runsAgainstServer`:真实发请求验证所有核心对象 API 可访问;
+- `httpRequest_hasExpectedFields`:method/uri/headers 读取正确,
+  同名头用 `allValues` 返回全部值;
+- `httpHeaders_firstAndAllValues`:`firstValue` 与 `allValues` 行为验证。

+ 7 - 5
httpClient.md

@@ -12,10 +12,12 @@
    - 在HTTP Request配置请求头
 4. 请求体
 5. 文件上传
-6. 同步
-7. 异步
-8. 响应处理器
+6. 文件下载
+7. 同步
+8. 异步
+9. 响应处理器
    - JDK提供的响应处理器
    - 自定义响应处理器
-9. HTTP Client配置项
-10. HTTP Request配置项
+10. HTTP Client配置项
+11. HTTP Request配置项
+12. HTTP Client核心对象及核心对象的API

+ 103 - 0
src/main/java/space/anyi/httpClient/CoreApiExample.java

@@ -0,0 +1,103 @@
+package space.anyi.httpClient;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * HTTP Client 核心对象及核心对象的 API 一览
+ *
+ * <p>JDK HTTP Client 的核心对象只有四个,理解了它们就掌握了全部用法:
+ * </p>
+ * <table>
+ *     <tr><th>核心对象</th><th>职责</th><th>获取方式</th></tr>
+ *     <tr><td>{@link HttpClient}</td><td>发送请求、管理连接</td><td>HttpClient.newHttpClient() / newBuilder()</td></tr>
+ *     <tr><td>{@link HttpRequest}</td><td>描述一次请求(URI、方法、头、体)</td><td>HttpRequest.newBuilder().build()</td></tr>
+ *     <tr><td>{@link HttpResponse}&lt;T&gt;</td><td>一次请求的结果(状态码、头、体)</td><td>client.send(...) 返回值</td></tr>
+ *     <tr><td>{@link HttpHeaders}</td><td>请求/响应的头部集合</td><td>request.headers() / response.headers()</td></tr>
+ * </table>
+ *
+ * <p>本类不新增请求接口,而是通过一个普通的 GET 请求,把每个核心对象
+ * 的<b>常用 API</b> 集中展示出来,方便查阅。</p>
+ */
+public class CoreApiExample {
+
+    /** 服务基地址常量 */
+    private static final String BASE_URL = "http://localhost:8080";
+
+    /**
+     * 演示核心对象的常用 API:发一个 GET 请求,随后逐一调用各个 getter / 方法。
+     *
+     * @return 一段汇总了各核心对象 API 的说明文本
+     */
+    public String showCoreApis() throws IOException, InterruptedException {
+        StringBuilder sb = new StringBuilder();
+
+        // ========== 1. HttpClient:发送请求、管理配置 ==========
+        HttpClient client = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofSeconds(5))
+                .build();
+        // HttpClient 常用 API
+        sb.append("HttpClient.version=").append(client.version()).append('\n');
+        sb.append("HttpClient.connectTimeout=").append(client.connectTimeout()).append('\n');
+        sb.append("HttpClient.followRedirects=").append(client.followRedirects()).append('\n');
+
+        // ========== 2. HttpRequest.Builder -> HttpRequest ==========
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                .header("Accept", "application/json")
+                .timeout(Duration.ofSeconds(3))
+                .GET()
+                .build();
+
+        // ========== 3. HttpRequest 常用 API ==========
+        sb.append("HttpRequest.method=").append(request.method()).append('\n');
+        sb.append("HttpRequest.uri=").append(request.uri()).append('\n');
+        sb.append("HttpRequest.timeout=").append(request.timeout()).append('\n');
+        // request.headers() -> HttpHeaders
+        HttpHeaders reqHeaders = request.headers();
+        sb.append("HttpRequest.header(Accept)=")
+                .append(printOptional(reqHeaders.firstValue("Accept"))).append('\n');
+        sb.append("HttpRequest.bodyPublisher empty=")
+                .append(request.bodyPublisher().isEmpty()).append('\n');
+
+        // ========== 4. HttpResponse<T> 与 HttpHeaders ==========
+        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
+        sb.append("HttpResponse.statusCode=").append(response.statusCode()).append('\n');
+        sb.append("HttpResponse.uri=").append(response.uri()).append('\n');
+        sb.append("HttpResponse.version=").append(response.version()).append('\n');
+        sb.append("HttpResponse.body length=").append(response.body().length()).append('\n');
+
+        // 响应头常用 API
+        HttpHeaders respHeaders = response.headers();
+        sb.append("HttpHeaders.firstValue(Content-Type)=")
+                .append(printOptional(respHeaders.firstValue("Content-Type"))).append('\n');
+        sb.append("HttpHeaders.toString head=")
+                .append(truncate(respHeaders.map().toString())).append('\n');
+
+        return sb.toString();
+    }
+
+    /**
+     * 演示 HttpHeaders 的 allValues:同一名字可能对应多个值。
+     *
+     * @return 名为 Accept 的所有请求头值
+     */
+    public List<String> allValuesOf(HttpRequest request, String name) {
+        return request.headers().allValues(name);
+    }
+
+    private static String printOptional(Optional<String> opt) {
+        return opt.orElse("<none>");
+    }
+
+    private static String truncate(String s) {
+        return s.length() > 60 ? s.substring(0, 60) + "..." : s;
+    }
+}

+ 140 - 0
src/main/java/space/anyi/httpClient/FileDownloadExample.java

@@ -0,0 +1,140 @@
+package space.anyi.httpClient;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import space.anyi.httpClient.model.FileVO;
+import space.anyi.httpClient.model.Result;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * 文件下载示例:GET /api/files/download/{storedFileName}
+ *
+ * <p>下载与上传相反,服务端以<b>二进制流</b>返回文件内容。JDK HTTP Client
+ * 提供了多种「响应处理器」来消费二进制响应体:</p>
+ * <ul>
+ *     <li>{@link BodyHandlers#ofFile(Path)}:直接把响应体写入本地文件(适合大文件)</li>
+ *     <li>{@link BodyHandlers#ofByteArray()}:把响应体读入内存,得到 byte[]</li>
+ *     <li>{@link BodyHandlers#ofInputStream()}:以输入流方式流式消费响应体</li>
+ * </ul>
+ *
+ * <p>下载前需要先用上传接口拿到服务端分配的 storedFileName(见
+ * {@link BodyExample#uploadFileAndGetFileVO})。下载响应头携带
+ * {@code Content-Disposition},内含原始文件名。</p>
+ */
+public class FileDownloadExample {
+
+    /** 服务基地址常量 */
+    private static final String BASE_URL = "http://localhost:8080";
+
+    /** Jackson 对象映射器 */
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /** 用于生成下载所需的 storedFileName(复用上传示例) */
+    private final BodyExample bodyExample = new BodyExample();
+
+    /**
+     * 先上传一个临时文件,返回其元信息(含 storedFileName)。
+     */
+    public FileVO uploadForDownload(Path file) throws IOException, InterruptedException {
+        return bodyExample.uploadFileAndGetFileVO(file);
+    }
+
+    /**
+     * 方式一:下载到本地文件(ofFile)。
+     *
+     * <p>适合大文件下载,内容直接落盘,不占用堆内存。
+     * 返回的 Path 即内容写入的目标文件。</p>
+     *
+     * @param storedFileName 服务端存储文件名
+     * @param target 本地保存路径
+     * @return 响应对象,body() 为目标文件路径
+     */
+    public HttpResponse<Path> downloadToFile(String storedFileName, Path target)
+            throws IOException, InterruptedException {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/files/download/" + storedFileName))
+                .GET()
+                .build();
+
+        return HttpClient.newHttpClient().send(request, BodyHandlers.ofFile(target));
+    }
+
+    /**
+     * 方式二:下载为字节数组(ofByteArray)。
+     *
+     * <p>文件较小时适用,直接得到 byte[] 便于在内存中处理。</p>
+     *
+     * @param storedFileName 服务端存储文件名
+     * @return 响应对象,body() 为文件内容字节数组
+     */
+    public HttpResponse<byte[]> downloadAsBytes(String storedFileName)
+            throws IOException, InterruptedException {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/files/download/" + storedFileName))
+                .GET()
+                .build();
+
+        return HttpClient.newHttpClient().send(request, BodyHandlers.ofByteArray());
+    }
+
+    /**
+     * 方式三:下载为输入流(ofInputStream)。
+     *
+     * <p>以流式方式读取,适合边读边处理/转发,避免整体载入内存。</p>
+     *
+     * @param storedFileName 服务端存储文件名
+     * @return 响应对象,body() 为内容输入流
+     */
+    public HttpResponse<InputStream> downloadAsStream(String storedFileName)
+            throws IOException, InterruptedException {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/files/download/" + storedFileName))
+                .GET()
+                .build();
+
+        return HttpClient.newHttpClient().send(request, BodyHandlers.ofInputStream());
+    }
+
+    /**
+     * 从下载响应头读取原始文件名:Content-Disposition 中的 filename。
+     */
+    public String readOriginalFileName(HttpResponse<?> response) {
+        return response.headers()
+                .firstValue("Content-Disposition")
+                .orElse("");
+    }
+
+    /**
+     * 便捷方法:上传一个文件后立即下载字节内容。
+     *
+     * @param source 待上传的本地文件
+     * @return 下载得到的字节内容
+     */
+    public byte[] uploadThenDownload(Path source) throws IOException, InterruptedException {
+        FileVO fileVO = uploadForDownload(source);
+        HttpResponse<byte[]> response = downloadAsBytes(fileVO.getStoredFileName());
+        if (response.statusCode() != 200) {
+            throw new IllegalStateException("下载失败: HTTP " + response.statusCode());
+        }
+        return response.body();
+    }
+
+    /**
+     * 解析上传响应,得到 FileVO(复用上传示例的逻辑以便本类独立演示下载)。
+     */
+    public FileVO parseFileVO(HttpResponse<String> response) throws IOException {
+        Result<FileVO> result = objectMapper.readValue(
+                response.body(), new TypeReference<Result<FileVO>>() {
+                });
+        return result.getData();
+    }
+}

+ 134 - 0
src/main/java/space/anyi/httpClient/PutDeleteExample.java

@@ -0,0 +1,134 @@
+package space.anyi.httpClient;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import space.anyi.httpClient.model.Result;
+import space.anyi.httpClient.model.UserRequest;
+import space.anyi.httpClient.model.UserVO;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpRequest.BodyPublishers;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+
+/**
+ * PUT 与 DELETE 请求示例:更新用户 与 删除用户。
+ *
+ * <p>除 GET / POST 之外,JDK HTTP Client 还提供 <b>PUT</b> 与 <b>DELETE</b>
+ * 的标准方法:</p>
+ * <ul>
+ *     <li><b>PUT</b>:整体更新资源。与 POST 一样携带 JSON 请求体,
+ *         目标地址带路径参数 {@code /api/users/{id}},请求体为更新后的完整资源。</li>
+ *     <li><b>DELETE</b>:删除资源。通常不带(或带空)请求体,
+ *         目标地址带路径参数 {@code /api/users/{id}}。</li>
+ * </ul>
+ *
+ * <p>三个标准方法的对比(都是 Builder 上的快捷方法):</p>
+ * <table>
+ *     <tr><th>方法</th><th>Builder API</th><th>请求体</th></tr>
+ *     <tr><td>POST</td><td>.POST(BodyPublisher)</td><td>携带</td></tr>
+ *     <tr><td>PUT</td><td>.PUT(BodyPublisher)</td><td>携带</td></tr>
+ *     <tr><td>DELETE</td><td>.DELETE()</td><td>默认为空</td></tr>
+ * </table>
+ */
+public class PutDeleteExample {
+
+    /** 服务基地址常量 */
+    private static final String BASE_URL = "http://localhost:8080";
+
+    /** Jackson 对象映射器,负责对象 <-> JSON 的互相转换 */
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 更新用户:PUT /api/users/{id},请求体为更新后的完整用户信息。
+     *
+     * @param id   要更新的用户 id
+     * @param user 更新后的用户信息(name、account、sex 必填)
+     * @return 原始响应对象;成功为 200,账号冲突为 409,用户不存在为 404
+     */
+    public HttpResponse<String> updateUser(long id, UserRequest user)
+            throws IOException, InterruptedException {
+        String json = objectMapper.writeValueAsString(user);
+
+        HttpRequest request = HttpRequest.newBuilder()
+                // 把 id 拼进路径,形成 /api/users/{id}
+                .uri(URI.create(BASE_URL + "/api/users/" + id))
+                // PUT 与 POST 一样需要声明 JSON 请求体格式
+                .header("Content-Type", "application/json")
+                // PUT 快捷方法,参数为 BodyPublisher(携带请求体)
+                .PUT(BodyPublishers.ofString(json))
+                .build();
+
+        return HttpClient.newHttpClient().send(request, BodyHandlers.ofString());
+    }
+
+    /**
+     * 删除用户:DELETE /api/users/{id},不带请求体。
+     *
+     * @param id 要删除的用户 id
+     * @return 原始响应对象;成功为 200,用户不存在为 404
+     */
+    public HttpResponse<String> deleteUser(long id) throws IOException, InterruptedException {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users/" + id))
+                // DELETE 快捷方法,内部使用 noBody(),不携带请求体
+                .DELETE()
+                .build();
+
+        return HttpClient.newHttpClient().send(request, BodyHandlers.ofString());
+    }
+
+    /**
+     * 更新用户并解析返回的用户对象。
+     *
+     * @param id   要更新的用户 id
+     * @param user 更新后的用户信息
+     * @return 更新成功后的用户对象(含服务器生成的 id)
+     */
+    public UserVO updateUserAndGetUser(long id, UserRequest user)
+            throws IOException, InterruptedException {
+        HttpResponse<String> response = updateUser(id, user);
+
+        Result<UserVO> result =
+                objectMapper.readValue(response.body(), new TypeReference<Result<UserVO>>() {
+                });
+
+        if (result.getCode() == 200) {
+            return result.getData();
+        }
+        throw new IllegalStateException("更新用户失败: " + result.getMessage());
+    }
+
+    /**
+     * 演示:构造一个 PUT 请求(不发送)。
+     *
+     * @param id   用户 id
+     * @param user 更新后的用户信息
+     * @return 构造好的 PUT 请求对象
+     */
+    public HttpRequest buildPutRequest(long id, UserRequest user) throws IOException {
+        String json = objectMapper.writeValueAsString(user);
+
+        return HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users/" + id))
+                .header("Content-Type", "application/json")
+                .PUT(BodyPublishers.ofString(json))
+                .build();
+    }
+
+    /**
+     * 演示:构造一个 DELETE 请求(不发送)。
+     *
+     * @param id 用户 id
+     * @return 构造好的 DELETE 请求对象
+     */
+    public HttpRequest buildDeleteRequest(long id) {
+        return HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users/" + id))
+                .DELETE()
+                .build();
+    }
+}

+ 70 - 0
src/test/java/space/anyi/httpClient/CoreApiExampleTest.java

@@ -0,0 +1,70 @@
+package space.anyi.httpClient;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpRequest;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * HTTP Client 核心对象 API 的测试类
+ */
+class CoreApiExampleTest {
+
+    private final CoreApiExample coreApiExample = new CoreApiExample();
+
+    @Test
+    void showCoreApis_runsAgainstServer() throws IOException, InterruptedException {
+        // 能正常发请求并汇总各核心对象 API
+        String summary = coreApiExample.showCoreApis();
+
+        // 核心对象的关键信息应都在输出中
+        assertTrue(summary.contains("HttpClient.version="));
+        assertTrue(summary.contains("HttpRequest.method=GET"));
+        assertNotNull(summary);
+    }
+
+    @Test
+    void httpRequest_hasExpectedFields() {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create("http://localhost:8080/api/users"))
+                .header("Accept", "application/json")
+                .header("X-Test", "a")
+                .header("X-Test", "b")   // 同名头第二次追加
+                .GET()
+                .build();
+
+        // method / uri / timeout
+        assertEquals("GET", request.method());
+        assertEquals(URI.create("http://localhost:8080/api/users"), request.uri());
+        assertTrue(request.timeout().isEmpty());
+
+        // headers()
+        assertEquals("application/json", request.headers().firstValue("Accept").orElse(null));
+        // 同名头的 allValues 应返回两个值
+        List<String> values = new CoreApiExample().allValuesOf(request, "X-Test");
+        assertEquals(List.of("a", "b"), values);
+    }
+
+    @Test
+    void httpHeaders_firstAndAllValues() {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create("http://localhost:8080/api/users"))
+                .header("Set-Cookie", "a=1")
+                .header("Set-Cookie", "b=2")
+                .GET()
+                .build();
+
+        HttpRequest.Builder builder = HttpRequest.newBuilder();
+        // 空请求头时 firstValue 为空
+        assertTrue(builder.uri(URI.create("http://localhost:8080"))
+                .headers("Only", "one")
+                .build().headers().firstValue("Missing").isEmpty());
+        // 同名多个值
+        assertEquals(List.of("a=1", "b=2"),
+                new CoreApiExample().allValuesOf(request, "Set-Cookie"));
+    }
+}

+ 117 - 0
src/test/java/space/anyi/httpClient/FileDownloadExampleTest.java

@@ -0,0 +1,117 @@
+package space.anyi.httpClient;
+
+import org.junit.jupiter.api.Test;
+import space.anyi.httpClient.model.FileVO;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 文件下载示例的测试类
+ *
+ * <p>测试依赖本地运行的 API 服务。每个测试都需要先上传一个文件,
+ * 拿到服务端返回的 storedFileName 后再下载。</p>
+ */
+class FileDownloadExampleTest {
+
+    private final FileDownloadExample downloadExample = new FileDownloadExample();
+
+    /** 在临时目录生成一个测试文件并上传,返回文件元信息 */
+    private FileVO uploadTempFile(String name, String content) throws IOException, InterruptedException {
+        Path tempDir = Files.createTempDirectory("dl");
+        Path file = tempDir.resolve(name);
+        Files.writeString(file, content, StandardCharsets.UTF_8);
+        FileVO vo = downloadExample.uploadForDownload(file);
+        Files.deleteIfExists(file);
+        Files.deleteIfExists(tempDir);
+        return vo;
+    }
+
+    @Test
+    void downloadToFile_success() throws IOException, InterruptedException {
+        String content = "download to file test\n";
+        FileVO vo = uploadTempFile("note.txt", content);
+
+        // 下载到临时目录
+        Path target = Files.createTempFile("dl_result", ".txt");
+        Files.deleteIfExists(target); // BodyHandlers.ofFile 要求目标文件不存在
+
+        HttpResponse<Path> response = downloadExample.downloadToFile(vo.getStoredFileName(), target);
+
+        assertEquals(200, response.statusCode());
+        // 落盘内容与服务端一致
+        assertEquals(content, Files.readString(target, StandardCharsets.UTF_8));
+
+        Files.deleteIfExists(target);
+    }
+
+    @Test
+    void downloadAsBytes_success() throws IOException, InterruptedException {
+        String content = "download as bytes test";
+        FileVO vo = uploadTempFile("bytes.bin", content);
+
+        HttpResponse<byte[]> response = downloadExample.downloadAsBytes(vo.getStoredFileName());
+
+        assertEquals(200, response.statusCode());
+        // 字节内容一致
+        assertEquals(content, new String(response.body(), StandardCharsets.UTF_8));
+    }
+
+    @Test
+    void downloadAsStream_success() throws IOException, InterruptedException {
+        String content = "download as stream test";
+        FileVO vo = uploadTempFile("stream.txt", content);
+
+        HttpResponse<InputStream> response = downloadExample.downloadAsStream(vo.getStoredFileName());
+
+        assertEquals(200, response.statusCode());
+        // 从输入流读取并比较内容
+        String downloaded = new String(response.body().readAllBytes(), StandardCharsets.UTF_8);
+        assertEquals(content, downloaded);
+    }
+
+    @Test
+    void readContentDisposition_present() throws IOException, InterruptedException {
+        // 服务端 originalFileName(storedFileName) 会把扩展名去掉,
+        // 因此 Content-Disposition 的 filename 为 storedFileName 去除扩展名后的部分
+        FileVO vo = uploadTempFile("report.txt", "hello");
+        String expectedBase = vo.getStoredFileName().substring(0, vo.getStoredFileName().lastIndexOf('.'));
+
+        HttpResponse<byte[]> response = downloadExample.downloadAsBytes(vo.getStoredFileName());
+
+        // 响应头应包含 Content-Disposition 且带 attachment 标记
+        String disposition = downloadExample.readOriginalFileName(response);
+        assertFalse(disposition.isEmpty());
+        assertTrue(disposition.toLowerCase().contains("attachment"));
+        assertTrue(disposition.contains(expectedBase));
+    }
+
+    @Test
+    void uploadThenDownload_roundTrip() throws IOException, InterruptedException {
+        // 上传与下载字节一致(round-trip)
+        String content = "round trip round trip";
+        Path tempDir = Files.createTempDirectory("rt");
+        Path source = tempDir.resolve("round.txt");
+        Files.writeString(source, content, StandardCharsets.UTF_8);
+
+        byte[] downloaded = downloadExample.uploadThenDownload(source);
+        assertEquals(content, new String(downloaded, StandardCharsets.UTF_8));
+
+        Files.deleteIfExists(source);
+        Files.deleteIfExists(tempDir);
+    }
+
+    @Test
+    void download_notExists() throws IOException, InterruptedException {
+        // 下载一个不存在的文件,应返回 404
+        HttpResponse<byte[]> response = downloadExample.downloadAsBytes("nonexistent-file.txt");
+
+        assertEquals(404, response.statusCode());
+    }
+}

+ 103 - 0
src/test/java/space/anyi/httpClient/PutDeleteExampleTest.java

@@ -0,0 +1,103 @@
+package space.anyi.httpClient;
+
+import org.junit.jupiter.api.Test;
+import space.anyi.httpClient.model.UserRequest;
+import space.anyi.httpClient.model.UserVO;
+
+import java.io.IOException;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * PUT 与 DELETE 请求示例的测试类
+ *
+ * <p>测试依赖本地运行的 User Management API 服务(http://localhost:8080)。</p>
+ */
+class PutDeleteExampleTest {
+
+    private final PutDeleteExample putDeleteExample = new PutDeleteExample();
+    private final PostExample postExample = new PostExample();
+
+    /** 创建一个唯一用户用于后续更新/删除 */
+    private UserVO createUser() throws IOException, InterruptedException {
+        UserRequest user = new UserRequest("待改用户", "pd_" + System.nanoTime(), "female");
+        return postExample.createUserAndGetUser(user);
+    }
+
+    @Test
+    void updateUser_success() throws IOException, InterruptedException {
+        // 先创建用户,再以新信息更新
+        UserVO created = createUser();
+
+        UserRequest update = new UserRequest("更新后用户",
+                "pd_" + System.nanoTime(), "male");
+
+        HttpResponse<String> response = putDeleteExample.updateUser(created.getId(), update);
+
+        assertEquals(200, response.statusCode());
+        // 响应体应包含更新后的账号
+        assertTrue(response.body().contains(update.getAccount()));
+    }
+
+    @Test
+    void updateUserAndGetUser_success() throws IOException, InterruptedException {
+        UserVO created = createUser();
+
+        UserRequest update = new UserRequest("更新后用户",
+                "pd_" + System.nanoTime(), "male");
+
+        UserVO updated = putDeleteExample.updateUserAndGetUser(created.getId(), update);
+
+        assertNotNull(updated.getId());
+        // 更新后的账号应与请求一致
+        assertEquals(update.getAccount(), updated.getAccount());
+    }
+
+    @Test
+    void updateUser_notExists() throws IOException, InterruptedException {
+        // 更新一个不存在的用户,应返回 404
+        UserRequest update = new UserRequest("无人", "nobody", "female");
+
+        HttpResponse<String> response = putDeleteExample.updateUser(999999999L, update);
+
+        assertEquals(404, response.statusCode());
+    }
+
+    @Test
+    void deleteUser_success() throws IOException, InterruptedException {
+        UserVO created = createUser();
+
+        HttpResponse<String> response = putDeleteExample.deleteUser(created.getId());
+
+        assertEquals(200, response.statusCode());
+    }
+
+    @Test
+    void deleteUser_notExists() throws IOException, InterruptedException {
+        // 删除不存在的用户,应返回 404
+        HttpResponse<String> response = putDeleteExample.deleteUser(999999999L);
+
+        assertEquals(404, response.statusCode());
+    }
+
+    @Test
+    void buildPutRequest_hasBody() throws IOException {
+        UserRequest user = new UserRequest("构造", "put_req", "female");
+        HttpRequest request = putDeleteExample.buildPutRequest(1L, user);
+
+        assertEquals("PUT", request.method());
+        // PUT 请求应携带请求体
+        assertTrue(request.bodyPublisher().isPresent());
+    }
+
+    @Test
+    void buildDeleteRequest_noBody() {
+        HttpRequest request = putDeleteExample.buildDeleteRequest(1L);
+
+        assertEquals("DELETE", request.method());
+        // DELETE 默认不带请求体
+        assertTrue(request.bodyPublisher().isEmpty());
+    }
+}