ソースを参照

教程点9:HTTP Request 配置项 - timeout/version/expectContinue/自定义方法/copy

yangyi 1 週間 前
コミット
ebfd5b659b

+ 72 - 1
doc.md

@@ -523,4 +523,75 @@ HttpClient client = HttpClient.newBuilder()
   followRedirects / executor / cookieHandler 配置均生效;
 - `buildHttp2Client_defaultsToHttp2`:HTTP_2 客户端版本正确;
 - `sendGet_withConfiguredClient`:配置后的客户端请求返回 200;
-- `connectTimeout_returns200OrThrows`:连接不可达主机时抛出预期异常。
+- `connectTimeout_returns200OrThrows`:连接不可达主机时抛出预期异常。
+
+---
+
+## 九、HTTP Request 配置项
+
+### 9.1 文字说明
+
+`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` |
+
+### 9.2 示例代码
+
+见 `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();
+```
+
+### 9.3 测试代码
+
+见 `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`:不可达地址 + 短超时抛出预期异常。

+ 145 - 0
src/main/java/space/anyi/httpClient/RequestConfigExample.java

@@ -0,0 +1,145 @@
+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.HttpRequest.BodyPublishers;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.time.Duration;
+
+/**
+ * HTTP Request 配置项示例
+ *
+ * <p>HttpRequest 本身可通过 Builder 配置的项(区别于 HttpClient 级配置):</p>
+ * <ul>
+ *     <li>{@code .uri(URI)}:请求地址(必填)</li>
+ *     <li>{@code .timeout(Duration)}:<b>整个请求</b>的超时时间(拿完响应体)
+ *         ,与 client 的 connectTimeout(仅连接阶段)不同</li>
+ *     <li>{@code .expectContinue(true)}:发送 Expect: 100-continue 头,
+ *         请求体较大时先探询服务器是否愿意接收</li>
+ *     <li>{@code .version(HTTP_1_1/HTTP_2)}:请求级协议版本(覆盖 client 配置)</li>
+ *     <li>{@code .GET()/.POST(pub)/.PUT(pub)/.DELETE()/.method(name,pub)}:请求方法</li>
+ *     <li>{@code .copy()}:复制 Builder,修改副本不影响原 Builder</li>
+ * </ul>
+ */
+public class RequestConfigExample {
+
+    /** 服务基地址常量 */
+    private static final String BASE_URL = "http://localhost:8080";
+
+    /**
+     * 请求级超时:timeout 作用于「从发送到拿到完整响应体」的整个过程。
+     */
+    public HttpRequest buildWithTimeout() {
+        return HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                // 整个请求 3 秒内必须完成,否则抛 HttpTimeoutException
+                .timeout(Duration.ofSeconds(3))
+                .GET()
+                .build();
+    }
+
+    /**
+     * 请求级协议版本:覆盖 HttpClient 上的 version 配置。
+     */
+    public HttpRequest buildWithVersion() {
+        return HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                .version(HttpClient.Version.HTTP_1_1)
+                .GET()
+                .build();
+    }
+
+    /**
+     * 期望继续:expectContinue(true) 会附加 Expect: 100-continue 请求头。
+     *
+     * <p>典型用途:提交大请求体前先「询价」,服务器返回 100 后再真正发送请求体,
+     * 避免无谓地传输大文件。</p>
+     */
+    public HttpRequest buildWithExpectContinue() {
+        return HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                .header("Content-Type", "application/json")
+                // 声明期望服务器先返回 100-continue
+                .expectContinue(true)
+                .POST(BodyPublishers.ofString("{\"name\":\"x\",\"account\":\"y\",\"sex\":\"z\"}"))
+                .build();
+    }
+
+    /**
+     * 自定义请求方法:method(name, bodyPublisher)。
+     *
+     * <p>GET/POST/PUT/DELETE 之外的任意方法(如 PATCH、HEAD)都用这个入口。
+     * 无请求体的方法传 BodyPublishers.noBody()。</p>
+     */
+    public HttpRequest buildWithCustomMethod() {
+        return HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                // 自定义 PATCH 方法,无请求体
+                .method("PATCH", BodyPublishers.noBody())
+                .build();
+    }
+
+    /**
+     * 复制 Builder:copy() 返回独立的副本,之后修改副本不影响原 Builder。
+     *
+     * <p>JDK 的 copy() 采用「写时复制」:复制后两者的配置相互独立,
+     * 后续对任一方的修改都不会影响另一方。</p>
+     */
+    public CopyPair buildWithCopy() {
+        // 原 Builder:只带 X-Original 头
+        HttpRequest.Builder original = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                .header("X-Original", "yes");
+
+        // 复制并修改副本:加上 X-Copy 头(使用不同 header 名便于验证独立性)
+        HttpRequest copied = original.copy()
+                .header("X-Copy", "yes")
+                .GET()
+                .build();
+
+        // 修改副本后,原 Builder 依旧只含 X-Original
+        HttpRequest originalBuilt = original.GET().build();
+
+        return new CopyPair(originalBuilt, copied);
+    }
+
+    /**
+     * 携带「复制前的原请求」与「复制后修改的副本请求」的成对结果。
+     */
+    public record CopyPair(HttpRequest original, HttpRequest copied) {
+    }
+
+    /**
+     * 使用带超时的请求发送 GET,验证请求级配置不影响请求执行。
+     */
+    public HttpResponse<String> sendWithTimeout() throws IOException, InterruptedException {
+        HttpClient client = HttpClient.newHttpClient();
+        return client.send(buildWithTimeout(), BodyHandlers.ofString());
+    }
+
+    /**
+     * 演示请求级超时生效:请求体较大的场景下服务器迟迟不应答时应快速失败。
+     * 这里用一个不可达地址 + 短超时演示。
+     */
+    public void demoTimeoutExpired() {
+        HttpClient client = HttpClient.newHttpClient();
+
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create("http://localhost:9999"))
+                // 极短超时,验证超时机制
+                .timeout(Duration.ofSeconds(1))
+                .GET()
+                .build();
+
+        try {
+            client.send(request, BodyHandlers.ofString());
+        } catch (java.net.http.HttpTimeoutException e) {
+            System.out.println("请求超时: " + e.getMessage());
+        } catch (IOException | InterruptedException e) {
+            System.out.println("请求异常: " + e.getClass().getSimpleName());
+        }
+    }
+}

+ 80 - 0
src/test/java/space/anyi/httpClient/RequestConfigExampleTest.java

@@ -0,0 +1,80 @@
+package space.anyi.httpClient;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * HTTP Request 配置项示例的测试类
+ */
+class RequestConfigExampleTest {
+
+    private final RequestConfigExample example = new RequestConfigExample();
+
+    @Test
+    void timeout_configPresent() {
+        HttpRequest request = example.buildWithTimeout();
+
+        // 请求级超时应可读取,且为 3 秒
+        assertEquals(Duration.ofSeconds(3), request.timeout().orElse(null));
+        assertEquals("GET", request.method());
+    }
+
+    @Test
+    void version_override() {
+        HttpRequest request = example.buildWithVersion();
+
+        assertEquals(HttpClient.Version.HTTP_1_1, request.version().orElse(null));
+    }
+
+    @Test
+    void expectContinue_flagSet() {
+        HttpRequest request = example.buildWithExpectContinue();
+
+        // expectContinue 以标志位形式存储,build 后可从 getter 读取。
+        // 注意:Expect: 100-continue 请求头是在发送阶段才生成的,
+        // 并不会出现在 request.headers() 中。
+        assertTrue(request.expectContinue());
+        assertEquals("POST", request.method());
+    }
+
+    @Test
+    void customMethod() {
+        HttpRequest request = example.buildWithCustomMethod();
+
+        assertEquals("PATCH", request.method());
+        // noBody 的请求仍有一个「空请求体」发布器
+        assertTrue(request.bodyPublisher().isPresent());
+    }
+
+    @Test
+    void copyBuilder_isIndependent() {
+        // 修改副本后,原请求不被影响;副本带上了新增的 X-Copy 头
+        var pair = example.buildWithCopy();
+
+        assertEquals("yes", pair.original().headers().firstValue("X-Original").orElse(""));
+        assertTrue(pair.original().headers().firstValue("X-Copy").isEmpty());
+
+        assertEquals("yes", pair.copied().headers().firstValue("X-Copy").orElse(""));
+    }
+
+    @Test
+    void sendWithTimeout() throws IOException, InterruptedException {
+        // 带请求级超时配置的 GET 请求应正常完成
+        HttpResponse<String> response = example.sendWithTimeout();
+
+        assertEquals(200, response.statusCode());
+    }
+
+    @Test
+    void timeoutExpired_throwsExpected() {
+        // 对不可达地址 + 短超时,应抛出自定义捕获的异常(演示用,不抛未预期异常)
+        example.demoTimeoutExpired();
+    }
+}