Jelajahi Sumber

教程点3:POST 请求 - JSON 请求体、Jackson 序列化与统一包装解析

yangyi 1 Minggu lalu
induk
melakukan
5904ae13c0

+ 65 - 1
doc.md

@@ -115,4 +115,68 @@ URI uri = URI.create(BASE_URL + "/api/users?account=alice01");
 - `getUsers`:GET `/api/users` 返回 200,且响应体含 `code` 包装字段;
 - `getUsers`:GET `/api/users` 返回 200,且响应体含 `code` 包装字段;
 - `getUserById_notExists`:GET `/api/users/999999999` 返回 404;
 - `getUserById_notExists`:GET `/api/users/999999999` 返回 404;
 - `buildRequestWithQuery`:验证带 `?account=alice01` 查询参数的请求
 - `buildRequestWithQuery`:验证带 `?account=alice01` 查询参数的请求
-  URI 拼接正确,且 GET 请求体为空。
+  URI 拼接正确,且 GET 请求体为空。
+
+---
+
+## 三、POST 请求
+
+### 3.1 文字说明
+
+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 还是其他格式,序列化的职责由调用方承担。
+
+### 3.2 示例代码
+
+见 `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>`(统一包装)。
+
+### 3.3 测试代码
+
+见 `PostExampleTest.java`,测试点包括:
+
+- `createUser_success`:POST 唯一账号返回 200,响应体含 code 与账号;
+- `createUserAndGetUser_success`:解析出创建后的用户对象,账号与请求一致;
+- `createUser_duplicateAccount`:相同账号重复创建返回 409。
+
+> 测试使用 `System.nanoTime()` 生成带时间戳的唯一账号,避免与历史数据冲突。

+ 97 - 0
src/main/java/space/anyi/httpClient/PostExample.java

@@ -0,0 +1,97 @@
+package space.anyi.httpClient;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+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;
+
+/**
+ * POST 请求示例:向服务器提交 JSON 数据创建用户。
+ *
+ * <p>POST 与 GET 最大的区别在于<b>携带请求体</b>。使用 JDK HTTP Client
+ * 发送 POST JSON 的标准套路:</p>
+ * <ol>
+ *     <li>用 Jackson 把对象序列化为 JSON 字符串</li>
+ *     <li>设置 Content-Type: application/json 请求头</li>
+ *     <li>用 BodyPublishers.ofString(json) 指定请求体</li>
+ *     <li>调用 .POST(bodyPublisher) 声明请求方法</li>
+ * </ol>
+ */
+public class PostExample {
+
+    /** 服务基地址常量 */
+    private static final String BASE_URL = "http://localhost:8080";
+
+    /** Jackson 对象映射器,负责对象 <-> JSON 的互相转换(线程安全,可复用) */
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 创建用户:POST /api/users,请求体为 JSON。
+     *
+     * @param user 待创建的用户信息(name、account、sex 均必填)
+     * @return 原始响应对象
+     */
+    public HttpResponse<String> createUser(UserRequest user) throws IOException, InterruptedException {
+        // 1. 使用 Jackson 把对象序列化为 JSON 字符串
+        String json = objectMapper.writeValueAsString(user);
+
+        // 2. 构造请求:设置 URI、Content-Type 请求头、JSON 请求体
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                // 告诉服务器请求体是 JSON 格式,服务器才能正确解析
+                .header("Content-Type", "application/json")
+                // POST 方法的第一个参数是 BodyPublisher,这里把 JSON 字符串包装为请求体
+                .POST(BodyPublishers.ofString(json))
+                .build();
+
+        // 3. 发送请求
+        return HttpClient.newHttpClient().send(request, BodyHandlers.ofString());
+    }
+
+    /**
+     * 创建用户并解析响应中的用户数据。
+     *
+     * <p>演示如何用 Jackson 的 TypeReference 将统一包装结构反序列化为
+     * Result&lt;UserVO&gt;,从而拿到具体的用户对象。</p>
+     *
+     * @param user 待创建的用户信息
+     * @return 创建成功后服务端返回的用户对象(含服务器生成的 id)
+     */
+    public UserVO createUserAndGetUser(UserRequest user) throws IOException, InterruptedException {
+        HttpResponse<String> response = createUser(user);
+
+        // 将包装结构 {code, message, data} 反序列化为 Result<UserVO>
+        Result<UserVO> result =
+                objectMapper.readValue(response.body(), new TypeReference<Result<UserVO>>() {
+                });
+
+        // 业务成功(code == 200)时返回 data,否则抛出异常
+        if (result.getCode() == 200) {
+            return result.getData();
+        }
+        throw new IllegalStateException("创建用户失败: " + result.getMessage());
+    }
+
+    /**
+     * 演示:声明一个 POST 请求,但不发送。
+     *
+     * @param json 已经序列化好的 JSON 字符串
+     */
+    public HttpRequest buildPostRequest(String json) throws JsonProcessingException {
+        return HttpRequest.newBuilder()
+                .uri(URI.create(BASE_URL + "/api/users"))
+                .header("Content-Type", "application/json")
+                .POST(BodyPublishers.ofString(json))
+                .build();
+    }
+}

+ 55 - 0
src/main/java/space/anyi/httpClient/model/Result.java

@@ -0,0 +1,55 @@
+package space.anyi.httpClient.model;
+
+/**
+ * 统一 API 响应包装结构,对应 OpenAPI 中的 ResultXxxVO 系列。
+ *
+ * <p>服务端所有接口都返回形如 {@code {code, message, data}} 的结构,
+ * 其中 data 的实际类型由泛型 T 决定,例如:</p>
+ * <ul>
+ *     <li>查询单个用户:Result&lt;UserVO&gt;</li>
+ *     <li>查询用户列表:Result&lt;List&lt;UserVO&gt;&gt;</li>
+ *     <li>删除用户:Result&lt;Void&gt;</li>
+ * </ul>
+ *
+ * @param <T> data 字段的泛型类型
+ */
+public class Result<T> {
+
+    /** 业务状态码,200 表示成功 */
+    private int code;
+
+    /** 响应消息,如 "OK" */
+    private String message;
+
+    /** 响应数据载荷 */
+    private T data;
+
+    public int getCode() {
+        return code;
+    }
+
+    public void setCode(int code) {
+        this.code = code;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    public T getData() {
+        return data;
+    }
+
+    public void setData(T data) {
+        this.data = data;
+    }
+
+    @Override
+    public String toString() {
+        return "Result{code=" + code + ", message='" + message + "', data=" + data + "}";
+    }
+}

+ 51 - 0
src/main/java/space/anyi/httpClient/model/UserRequest.java

@@ -0,0 +1,51 @@
+package space.anyi.httpClient.model;
+
+/**
+ * 创建/更新用户的请求体模型
+ *
+ * <p>对应 OpenAPI 中的 UserRequest 结构:name、account、sex 均为必填。</p>
+ */
+public class UserRequest {
+
+    /** 用户姓名(必填) */
+    private String name;
+
+    /** 唯一账号标识(必填,重复创建会返回 409) */
+    private String account;
+
+    /** 性别(必填),如 male / female */
+    private String sex;
+
+    public UserRequest() {
+    }
+
+    public UserRequest(String name, String account, String sex) {
+        this.name = name;
+        this.account = account;
+        this.sex = sex;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public String getAccount() {
+        return account;
+    }
+
+    public void setAccount(String account) {
+        this.account = account;
+    }
+
+    public String getSex() {
+        return sex;
+    }
+
+    public void setSex(String sex) {
+        this.sex = sex;
+    }
+}

+ 60 - 0
src/main/java/space/anyi/httpClient/model/UserVO.java

@@ -0,0 +1,60 @@
+package space.anyi.httpClient.model;
+
+/**
+ * 用户响应模型,对应 OpenAPI 中的 UserVO 结构
+ */
+public class UserVO {
+
+    /** 唯一用户 id */
+    private long id;
+
+    /** 用户姓名 */
+    private String name;
+
+    /** 唯一账号标识 */
+    private String account;
+
+    /** 性别 */
+    private String sex;
+
+    public UserVO() {
+    }
+
+    public long getId() {
+        return id;
+    }
+
+    public void setId(long id) {
+        this.id = id;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public String getAccount() {
+        return account;
+    }
+
+    public void setAccount(String account) {
+        this.account = account;
+    }
+
+    public String getSex() {
+        return sex;
+    }
+
+    public void setSex(String sex) {
+        this.sex = sex;
+    }
+
+    @Override
+    public String toString() {
+        return "UserVO{id=" + id + ", name='" + name + "', account='" + account
+                + "', sex='" + sex + "'}";
+    }
+}

+ 62 - 0
src/test/java/space/anyi/httpClient/PostExampleTest.java

@@ -0,0 +1,62 @@
+package space.anyi.httpClient;
+
+import org.junit.jupiter.api.Test;
+import space.anyi.httpClient.model.UserRequest;
+
+import java.io.IOException;
+import java.net.http.HttpResponse;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * POST 请求示例的测试类
+ *
+ * <p>测试依赖本地运行的 User Management API 服务(http://localhost:8080)。</p>
+ */
+class PostExampleTest {
+
+    private final PostExample postExample = new PostExample();
+
+    /**
+     * 生成一个带时间戳的账号,保证每次运行测试使用的账号唯一,
+     * 避免与历史数据冲突。
+     */
+    private UserRequest buildUser(String suffix) {
+        return new UserRequest("测试用户", "alice_" + suffix, "female");
+    }
+
+    @Test
+    void createUser_success() throws IOException, InterruptedException {
+        // 使用唯一账号创建用户,应返回 200,且响应体包含新生成的 id
+        UserRequest user = buildUser(String.valueOf(System.nanoTime()));
+        HttpResponse<String> response = postExample.createUser(user);
+
+        assertEquals(200, response.statusCode());
+        // 响应体应包含 code 包装字段与创建的用户账号
+        assertTrue(response.body().contains("\"code\""));
+        assertTrue(response.body().contains(user.getAccount()));
+    }
+
+    @Test
+    void createUserAndGetUser_success() throws IOException, InterruptedException {
+        // 解析包装结构,验证返回的用户对象字段与请求一致
+        UserRequest user = buildUser(String.valueOf(System.nanoTime()));
+        var created = postExample.createUserAndGetUser(user);
+
+        assertNotNull(created.getId());
+        assertNotNull(created.getAccount());
+        assertEquals(user.getAccount(), created.getAccount());
+    }
+
+    @Test
+    void createUser_duplicateAccount() throws IOException, InterruptedException {
+        // 先创建一个用户
+        UserRequest user = buildUser(String.valueOf(System.nanoTime()));
+        postExample.createUser(user);
+
+        // 使用相同账号再次创建,业务上应返回 409(账号已存在)
+        HttpResponse<String> response = postExample.createUser(user);
+
+        assertEquals(409, response.statusCode());
+    }
+}