Parcourir la source

教程点5:请求体 - ofString/ofInputStream/ofByteArray/noBody及multipart手工构建与文件上传

yangyi il y a 1 semaine
Parent
commit
0872fd7d4d

+ 75 - 1
doc.md

@@ -254,4 +254,78 @@ String contentType = response.headers()
 - `multipleHeaders`:`.headers()` 一次设置三组头;
 - `setHeaderOverrides`:`.setHeader()` 覆盖后同名字只有一个值;
 - `defaultHeaderBuilder`:工厂方法预置的公共头可正常读出;
-- `sendRequestWithHeaders`:带请求头发送 GET 返回 200。
+- `sendRequestWithHeaders`:带请求头发送 GET 返回 200。
+
+---
+
+## 五、请求体
+
+### 5.1 文字说明
+
+请求体由 **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 完全一致,服务器才能正确切分字段。
+
+### 5.2 示例代码
+
+见 `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();
+```
+
+### 5.3 测试代码
+
+见 `BodyExampleTest.java`,测试点包括:
+
+- `createUser_withJsonString`:ofString 提交 JSON 创建用户,返回 200;
+- `createUser_viaInputStream`:ofInputStream 提交 JSON 创建用户,返回 200;
+- `uploadFile`:上传文本文件,`FileVO` 返回的原始文件名与大小一致;
+- `uploadFile_empty`:上传空文件返回 400;
+- `noBodyRequest`:GET 请求对象无 bodyPublisher。

+ 184 - 0
src/main/java/space/anyi/httpClient/BodyExample.java

@@ -0,0 +1,184 @@
+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 space.anyi.httpClient.model.UserRequest;
+
+import java.io.ByteArrayInputStream;
+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.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.UUID;
+
+/**
+ * 请求体示例:BodyPublisher 的多种用法
+ *
+ * <p>请求方法(POST/PUT 等)通过 {@code .POST(BodyPublisher)} 设置请求体,
+ * BodyPublisher 是请求体的抽象,JDK 内置了多种实现:</p>
+ * <ul>
+ *     <li>{@link BodyPublishers#ofString(String)}:字符串请求体(最常用)</li>
+ *     <li>{@link BodyPublishers#ofByteArray(byte[])}:字节数组请求体</li>
+ *     <li>{@link BodyPublishers#ofInputStream}:输入流请求体</li>
+ *     <li>{@link BodyPublishers#noBody()}:空请求体(GET/DELETE 默认)</li>
+ *     <li>{@link BodyPublishers#ofFile(Path)}:文件请求体</li>
+ * </ul>
+ *
+ * <p>同时演示手工构造 <b>multipart/form-data</b> 文件上传请求体
+ * (JDK 未内置 multipart 支持,按 RFC 2046 规范手工拼接)。</p>
+ */
+public class BodyExample {
+
+    /** 服务基地址常量 */
+    private static final String BASE_URL = "http://localhost:8080";
+
+    /** Jackson 对象映射器 */
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 方式一:ofString — 用字符串作为 JSON 请求体创建用户。
+     */
+    public HttpResponse<String> createUserWithJsonString(UserRequest user)
+            throws IOException, InterruptedException {
+        String json = objectMapper.writeValueAsString(user);
+
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                .header("Content-Type", "application/json")
+                .POST(BodyPublishers.ofString(json))
+                .build();
+
+        return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
+    }
+
+    /**
+     * 方式二:ofInputStream — 以输入流作为请求体。
+     *
+     * <p>适用场景:请求体较大、不想一次性载入内存时(流式传输)。
+     * 这里用 ByteArrayInputStream 包一层作演示。</p>
+     */
+    public HttpResponse<String> createUserViaInputStream(UserRequest user)
+            throws IOException, InterruptedException {
+        String json = objectMapper.writeValueAsString(user);
+
+        // 用缓冲输入流包装 JSON 字符串,交给 ofInputStream 发送
+        ByteArrayInputStream stream =
+                new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8));
+
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                .header("Content-Type", "application/json")
+                // Supplier 会延迟到真正发送时才获取输入流
+                .POST(BodyPublishers.ofInputStream(() -> stream))
+                .build();
+
+        return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
+    }
+
+    /**
+     * 方式三:ofByteArray + 手工构建 multipart/form-data 请求体 — 文件上传。
+     *
+     * <p>multipart 请求体格式(RFC 2046),每个字段用 boundary 分隔:</p>
+     * <pre>
+     * --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
+     * </pre>
+     *
+     * @param file 待上传的文件(含文件名与内容)
+     * @return 上传响应
+     */
+    public HttpResponse<String> uploadFile(Path file) throws IOException, InterruptedException {
+        byte[] fileBytes = Files.readAllBytes(file);
+
+        // 生成唯一 boundary,客户端与请求头中必须一致
+        String boundary = "----WebKitFormBoundary" + UUID.randomUUID();
+
+        // 组装 multipart 请求体字节数组
+        byte[] body = buildMultipartBody(file.getFileName().toString(), fileBytes, boundary);
+
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/files/upload"))
+                // Content-Type 必须带 boundary,服务器据此切分各个字段
+                .header("Content-Type", "multipart/form-data; boundary=" + boundary)
+                .POST(BodyPublishers.ofByteArray(body))
+                .build();
+
+        return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
+    }
+
+    /**
+     * 组装 multipart/form-data 请求体(参考 RFC 2046 规范)。
+     *
+     * @param fileName 上传字段的原始文件名
+     * @param fileBytes 文件内容字节
+     * @param boundary 分隔标记
+     * @return 完整的请求体字节数组
+     */
+    private byte[] buildMultipartBody(String fileName, byte[] fileBytes, String boundary)
+            throws IOException {
+        // 使用可变字节流拼接:不需要一次性构造大字符串,避免大文件内存翻倍
+        java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+
+        // 第一个 part 的分隔线
+        out.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
+        // 声明该 part 是文件字段(name 对应服务端 @RequestParam("file"))
+        out.write(("Content-Disposition: form-data; name=\"file\"; filename=\""
+                + fileName + "\"\r\n").getBytes(StandardCharsets.UTF_8));
+        // 声明文件内容类型
+        out.write(("Content-Type: " + guessContentType(fileName) + "\r\n").getBytes(StandardCharsets.UTF_8));
+        // 空行分隔请求头与内容
+        out.write("\r\n".getBytes(StandardCharsets.UTF_8));
+        // 文件二进制内容
+        out.write(fileBytes);
+        // 结尾分隔线(--boundary-- 表示结束)
+        out.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
+
+        return out.toByteArray();
+    }
+
+    /**
+     * 根据文件扩展名猜测 MIME 类型,JDK 内置工具 Files.probeContentType。
+     */
+    private String guessContentType(String fileName) throws IOException {
+        String type = Files.probeContentType(Path.of(fileName));
+        return type == null ? "application/octet-stream" : type;
+    }
+
+    /**
+     * 解析上传响应,返回文件元信息(Result<FileVO> 结构)。
+     */
+    public FileVO uploadFileAndGetFileVO(Path file) throws IOException, InterruptedException {
+        HttpResponse<String> response = uploadFile(file);
+
+        Result<FileVO> result = objectMapper.readValue(
+                response.body(), new TypeReference<Result<FileVO>>() {
+                });
+
+        if (result.getCode() != 200) {
+            throw new IllegalStateException("上传失败: " + result.getMessage());
+        }
+        return result.getData();
+    }
+
+    /**
+     * 演示 noBody():构建一个不含请求体的请求(此处用于展示 GET 写法)。
+     */
+    public HttpRequest buildNoBodyGetRequest() {
+        return HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                // GET 不需要请求体,显式声明 noBody 使意图更清晰
+                .GET()
+                .build();
+    }
+}

+ 69 - 0
src/main/java/space/anyi/httpClient/model/FileVO.java

@@ -0,0 +1,69 @@
+package space.anyi.httpClient.model;
+
+/**
+ * 文件上传响应模型,对应 OpenAPI 中的 FileVO 结构
+ */
+public class FileVO {
+
+    /** 客户端提交时的原始文件名 */
+    private String originalFileName;
+
+    /** 服务端存储的文件名(通常为 UUID 生成) */
+    private String storedFileName;
+
+    /** 文件大小(字节) */
+    private long size;
+
+    /** MIME 内容类型 */
+    private String contentType;
+
+    /** 相对存储路径 */
+    private String storagePath;
+
+    public String getOriginalFileName() {
+        return originalFileName;
+    }
+
+    public void setOriginalFileName(String originalFileName) {
+        this.originalFileName = originalFileName;
+    }
+
+    public String getStoredFileName() {
+        return storedFileName;
+    }
+
+    public void setStoredFileName(String storedFileName) {
+        this.storedFileName = storedFileName;
+    }
+
+    public long getSize() {
+        return size;
+    }
+
+    public void setSize(long size) {
+        this.size = size;
+    }
+
+    public String getContentType() {
+        return contentType;
+    }
+
+    public void setContentType(String contentType) {
+        this.contentType = contentType;
+    }
+
+    public String getStoragePath() {
+        return storagePath;
+    }
+
+    public void setStoragePath(String storagePath) {
+        this.storagePath = storagePath;
+    }
+
+    @Override
+    public String toString() {
+        return "FileVO{originalFileName='" + originalFileName + "', storedFileName='"
+                + storedFileName + "', size=" + size + ", contentType='" + contentType
+                + "', storagePath='" + storagePath + "'}";
+    }
+}

+ 87 - 0
src/test/java/space/anyi/httpClient/BodyExampleTest.java

@@ -0,0 +1,87 @@
+package space.anyi.httpClient;
+
+import org.junit.jupiter.api.Test;
+import space.anyi.httpClient.model.UserRequest;
+
+import java.io.IOException;
+import java.net.http.HttpRequest;
+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.*;
+
+/**
+ * 请求体示例的测试类
+ */
+class BodyExampleTest {
+
+    private final BodyExample bodyExample = new BodyExample();
+
+    /** 生成唯一用户 */
+    private UserRequest buildUser() {
+        return new UserRequest("文件上传测试", "file_" + System.nanoTime(), "female");
+    }
+
+    @Test
+    void createUser_withJsonString() throws IOException, InterruptedException {
+        // ofString 方式:POST JSON 创建用户成功(200)
+        HttpResponse<String> response = bodyExample.createUserWithJsonString(buildUser());
+
+        assertEquals(200, response.statusCode());
+        assertTrue(response.body().contains("\"code\""));
+    }
+
+    @Test
+    void createUser_viaInputStream() throws IOException, InterruptedException {
+        // ofInputStream 方式:POST JSON 创建用户成功(200)
+        HttpResponse<String> response = bodyExample.createUserViaInputStream(buildUser());
+
+        assertEquals(200, response.statusCode());
+    }
+
+    @Test
+    void uploadFile() throws IOException, InterruptedException {
+        // 在临时目录里生成一个测试文本文件
+        Path tempDir = Files.createTempDirectory("httptest");
+        Path file = tempDir.resolve("report.txt");
+        Files.writeString(file, "hello, http client tutorial\n", StandardCharsets.UTF_8);
+
+        // 上传并解析文件元信息
+        var fileVO = bodyExample.uploadFileAndGetFileVO(file);
+
+        // 服务端应保留原始文件名与大小
+        assertEquals("report.txt", fileVO.getOriginalFileName());
+        assertEquals(file.toFile().length(), fileVO.getSize());
+        assertFalse(fileVO.getStoredFileName().isEmpty());
+        assertFalse(fileVO.getStoragePath().isEmpty());
+
+        // 清理临时文件
+        Files.deleteIfExists(file);
+        Files.deleteIfExists(tempDir);
+    }
+
+    @Test
+    void uploadFile_empty() throws IOException, InterruptedException {
+        // 上传空文件,服务端应返回 400(文件为空)
+        Path tempDir = Files.createTempDirectory("httptest");
+        Path file = tempDir.resolve("empty.txt");
+        Files.writeString(file, "", StandardCharsets.UTF_8);
+
+        HttpResponse<String> response = bodyExample.uploadFile(file);
+
+        assertEquals(400, response.statusCode());
+
+        Files.deleteIfExists(file);
+        Files.deleteIfExists(tempDir);
+    }
+
+    @Test
+    void noBodyRequest() {
+        // GET 请求默认不携带请求体,buildNoBodyGetRequest 应无 bodyPublisher
+        HttpRequest request = bodyExample.buildNoBodyGetRequest();
+
+        assertTrue(request.bodyPublisher().isEmpty());
+    }
+}