Procházet zdrojové kódy

教程点7:响应处理器 - JDK内置BodyHandlers与自定义BodyHandler按状态码分流/后处理

yangyi před 1 týdnem
rodič
revize
ebae363f1c

+ 70 - 1
doc.md

@@ -401,4 +401,73 @@ CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 - `sendSync`:同步发送返回 200;
 - `sendAsync`:异步 + join 返回 200;
 - `sendAsyncWithCallback`:回调链得到处理结果;
-- `sendAsyncInParallel`:并发 10 个请求全部成功。
+- `sendAsyncInParallel`:并发 10 个请求全部成功。
+
+---
+
+## 七、响应处理器
+
+### 7.1 文字说明
+
+`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` 组合使用,实现「先读字节流、再按业务逻辑解析」的能力,
+> 是接入统一响应包装结构的标准方式。
+
+### 7.2 示例代码
+
+见 `ResponseHandlerExample.java`,核心代码如下:
+
+```java
+// 内置:ofString
+HttpResponse<String> resp = client.send(req, BodyHandlers.ofString());
+
+// 内置:ofFile(下载直接落盘)
+HttpResponse<Path> resp2 = client.send(req, BodyHandlers.ofFile(target));
+
+// 自定义:按状态码分流
+BodyHandler<String> handler = responseInfo -> {
+    if (responseInfo.statusCode() >= 200 && responseInfo.statusCode() < 300) {
+        return BodySubscribers.ofString(StandardCharsets.UTF_8);
+    }
+    return BodySubscribers.replacing(null);   // 非 2xx 丢弃响应体
+};
+
+// 自定义:响应后处理(加前缀标记)
+BodySubscriber<String> upstream = BodySubscribers.ofString(StandardCharsets.UTF_8);
+BodyHandler<String> handler2 = responseInfo ->
+        BodySubscribers.mapping(upstream, body -> "[TAG] " + body);
+```
+
+### 7.3 测试代码
+
+见 `ResponseHandlerExampleTest.java`,测试点包括:
+
+- `getAsString` / `getAsByteArray` / `getAsFile` / `getAsInputStream`:
+  验证各内置处理器行为;
+- `getDiscarded`:discarding 处理器 body() 为 null;
+- `customHandler_success`:自定义 mapping 处理器为响应体加前缀;
+- `customHandler_404DiscardsBody`:自定义处理器在 404 时 body() 为 null;
+- `statusBasedHandler_successString`:按状态码分流的处理器 2xx 正常返回。

+ 164 - 0
src/main/java/space/anyi/httpClient/ResponseHandlerExample.java

@@ -0,0 +1,164 @@
+package space.anyi.httpClient;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandler;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.net.http.HttpResponse.BodySubscriber;
+import java.net.http.HttpResponse.BodySubscribers;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * 响应处理器示例:BodyHandler 决定「如何消费响应体」
+ *
+ * <p>BodyHandler 在拿到响应头(状态码等)后被调用,返回一个 BodySubscriber,
+ * 后者负责把响应体字节流转换为目标类型 T。发送请求时通过第二个参数传入:
+ * {@code client.send(request, myBodyHandler)}。</p>
+ *
+ * <p>本章节演示两部分:</p>
+ * <ul>
+ *     <li><b>JDK 内置的 BodyHandlers</b>:ofString / ofByteArray / ofFile /
+ *         ofInputStream / discarding</li>
+ *     <li><b>自定义 BodyHandler</b>:按状态码分流响应体,或对响应做后处理</li>
+ * </ul>
+ */
+public class ResponseHandlerExample {
+
+    /** 服务基地址常量 */
+    private static final String BASE_URL = "http://localhost:8080";
+
+    private final HttpClient httpClient = HttpClient.newHttpClient();
+
+    /** 构造 GET /api/users 的请求 */
+    private HttpRequest buildListRequest() {
+        return HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                .GET()
+                .build();
+    }
+
+    /**
+     * 内置响应处理器一:ofString — 响应体转为字符串(最常用)。
+     */
+    public HttpResponse<String> getAsString() throws IOException, InterruptedException {
+        return httpClient.send(buildListRequest(), BodyHandlers.ofString());
+    }
+
+    /**
+     * 内置响应处理器二:ofByteArray — 响应体转为字节数组,
+     * 适合二进制内容(图片、文件等)。
+     */
+    public HttpResponse<byte[]> getAsByteArray() throws IOException, InterruptedException {
+        return httpClient.send(buildListRequest(), BodyHandlers.ofByteArray());
+    }
+
+    /**
+     * 内置响应处理器三:ofFile(Path) — 直接把响应体写入磁盘文件,
+     * 适合下载大文件,无需先把内容加载进内存。
+     *
+     * @param target 目标保存路径
+     * @return 响应对象,其 body 为文件路径
+     */
+    public HttpResponse<Path> getAsFile(Path target) throws IOException, InterruptedException {
+        return httpClient.send(buildListRequest(), BodyHandlers.ofFile(target));
+    }
+
+    /**
+     * 内置响应处理器四:ofInputStream — 以输入流接收响应体,
+     * 适合边下载边处理。
+     */
+    public HttpResponse<java.io.InputStream> getAsInputStream()
+            throws IOException, InterruptedException {
+        return httpClient.send(buildListRequest(), BodyHandlers.ofInputStream());
+    }
+
+    /**
+     * 内置响应处理器五:discarding — 丢弃响应体,body() 返回 null。
+     * 适合只关心状态码、不关心内容的场景(如健康检查)。
+     */
+    public HttpResponse<Void> getDiscarded() throws IOException, InterruptedException {
+        return httpClient.send(buildListRequest(), BodyHandlers.discarding());
+    }
+
+    /**
+     * 自定义响应处理器一:按状态码分流。
+     *
+     * <p>2xx 时正常读取响应体;非 2xx(如 404)时丢弃响应体,返回 null。
+     * 通过 {@link BodySubscribers#replacing(Object)} 把订阅器替换为固定值。</p>
+     */
+    public BodyHandler<String> statusBasedHandler() {
+        // apply 方法返回 BodySubscriber,参数 ResponseInfo 携带状态码与响应头
+        return responseInfo -> {
+            if (responseInfo.statusCode() >= 200 && responseInfo.statusCode() < 300) {
+                // 成功:正常读取响应体为字符串
+                return BodySubscribers.ofString(StandardCharsets.UTF_8);
+            }
+            // 失败:丢弃内容,最终 body() 返回 null
+            return BodySubscribers.replacing(null);
+        };
+    }
+
+    /**
+     * 自定义响应处理器二:对响应体做后处理。
+     *
+     * <p>用 {@link BodySubscribers#mapping(BodySubscriber, Function)} 把
+     * 上游订阅器转换后的结果再处理一次,此处给响应体加上一个前缀标记。</p>
+     */
+    public BodyHandler<String> mappingHandler(String tag) {
+        BodySubscriber<String> upstream = BodySubscribers.ofString(StandardCharsets.UTF_8);
+        // 上游读到完整字符串后,再拼接业务标记前缀
+        return responseInfo ->
+                BodySubscribers.mapping(upstream, body -> "[" + tag + "] " + body);
+    }
+
+    /**
+     * 演示使用自定义处理器发送请求。
+     *
+     * @param handler 自定义响应处理器
+     * @return 处理后的响应
+     */
+    public HttpResponse<String> sendWith(BodyHandler<String> handler)
+            throws IOException, InterruptedException {
+        return httpClient.send(buildListRequest(), handler);
+    }
+
+    /**
+     * 演示:查询不存在的用户(404)时使用按状态码分流的自定义处理器,
+     * 此时 body() 应为 null。
+     */
+    public HttpResponse<String> getNotFoundWithCustomHandler()
+            throws IOException, InterruptedException {
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users/999999999"))
+                .GET()
+                .build();
+
+        return httpClient.send(request, statusBasedHandler());
+    }
+
+    /**
+     * 主演示方法:依次展示各类响应处理器的输出。
+     */
+    public void demo() throws IOException, InterruptedException {
+        // ofString
+        System.out.println("ofString: " + abbreviate(getAsString().body()));
+        // ofByteArray
+        System.out.println("ofByteArray: " + getAsByteArray().body().length + " bytes");
+        // ofFile
+        Path file = Files.createTempFile("resp", ".json");
+        getAsFile(file);
+        System.out.println("ofFile 已写入: " + file);
+        // discarding
+        System.out.println("discarding body: " + getDiscarded().body());
+    }
+
+    /** 截断长文本 */
+    private String abbreviate(String s) {
+        return s.length() > 80 ? s.substring(0, 80) + "..." : s;
+    }
+}

+ 97 - 0
src/test/java/space/anyi/httpClient/ResponseHandlerExampleTest.java

@@ -0,0 +1,97 @@
+package space.anyi.httpClient;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 响应处理器示例的测试类
+ */
+class ResponseHandlerExampleTest {
+
+    private final ResponseHandlerExample example = new ResponseHandlerExample();
+
+    @Test
+    void getAsString() throws IOException, InterruptedException {
+        // ofString:响应体为字符串,包含包装结构 code
+        HttpResponse<String> response = example.getAsString();
+
+        assertEquals(200, response.statusCode());
+        assertTrue(response.body().contains("\"code\""));
+    }
+
+    @Test
+    void getAsByteArray() throws IOException, InterruptedException {
+        // ofByteArray:字节数组可以按 UTF-8 解码为同样的 JSON
+        HttpResponse<byte[]> response = example.getAsByteArray();
+
+        String body = new String(response.body(), StandardCharsets.UTF_8);
+        assertTrue(body.contains("\"code\""));
+    }
+
+    @Test
+    void getAsFile() throws IOException, InterruptedException {
+        // ofFile:响应体写入磁盘文件
+        Path target = Files.createTempFile("resp", ".json");
+        try {
+            HttpResponse<Path> response = example.getAsFile(target);
+
+            assertEquals(target, response.body());
+            assertTrue(Files.size(target) > 0);
+        } finally {
+            Files.deleteIfExists(target);
+        }
+    }
+
+    @Test
+    void getAsInputStream() throws IOException, InterruptedException {
+        // ofInputStream:读取输入流内容验证
+        HttpResponse<java.io.InputStream> response = example.getAsInputStream();
+
+        String body = new String(response.body().readAllBytes(), StandardCharsets.UTF_8);
+        assertTrue(body.contains("\"code\""));
+    }
+
+    @Test
+    void getDiscarded() throws IOException, InterruptedException {
+        // discarding:body() 为 null
+        HttpResponse<Void> response = example.getDiscarded();
+
+        assertEquals(200, response.statusCode());
+        assertNull(response.body());
+    }
+
+    @Test
+    void customHandler_success() throws IOException, InterruptedException {
+        // 自定义处理器:2xx 时读取响应体,带上前缀标记
+        HttpResponse<String> response = example.sendWith(example.mappingHandler("MY-TAG"));
+
+        assertTrue(response.body().startsWith("[MY-TAG] "));
+        assertTrue(response.body().contains("\"code\""));
+    }
+
+    @Test
+    void customHandler_404DiscardsBody() throws IOException, InterruptedException {
+        // 自定义处理器:404 时丢弃响应体,body() 为 null
+        HttpResponse<String> response = example.getNotFoundWithCustomHandler();
+
+        assertEquals(404, response.statusCode());
+        assertNull(response.body());
+    }
+
+    @Test
+    void statusBasedHandler_successString() throws IOException, InterruptedException {
+        // 按状态码分流的自定义处理器在 2xx 时正常返回字符串
+        HttpResponse<String> response = example.sendWith(example.statusBasedHandler());
+
+        assertEquals(200, response.statusCode());
+        assertTrue(response.body().contains("\"code\""));
+    }
+}