跳转至

序列化

1. 先理解两个方向

序列化是把程序中的对象转换成可保存、传输的形式;反序列化是把这些数据重新转换成对象。

Java 对象 → 序列化 → JSON 文本或二进制数据
Java 对象 ← 反序列化 ← JSON 文本或二进制数据

JSON 是一种数据格式,Jackson 是处理 JSON 的 Java 库。Serializable 则是 Java 原生对象序列化的标记接口;实现它不等于“自动支持所有 JSON 转换”,JSON 转换通常也不要求实现它。

在 Web 接口中,@RequestBody 把请求 JSON 转成 Java 参数,返回的 DTO 再由消息转换器写成 JSON。不是让客户端直接接收 Java 内存中的对象。

字节、文本、对象与数据格式

  • 对象:程序运行时的一份数据及其类型,例如一个用户简介实例;内存地址不能直接给另一台电脑使用。
  • 字节(byte):存储和网络传输的基本单位;文本也要按字符编码(如 UTF-8)变成字节才能发送。
  • JSON(JavaScript Object Notation):一种文本数据格式,字段名及字符串使用双引号;对象用 {},数组用 []。不要在 JSON 中写 Java 注释。
  • 二进制格式:按约定编码的字节,不保证能用文本编辑器直接阅读。
  • 序列化不等于加密、哈希或压缩。JWT 中可读的载荷也不能因为“看起来是一串字符”就存入秘密。
方案 基础特点 何时考虑
Java 原生序列化(JDK 提供) Serializable 配合对象流,和 Java 类型及版本耦合 理解既有 Java 框架持久化;不作为公开 API 的任意对象输入
JSON + Jackson 数据可读,便于不同语言按接口约定读写 hhjava 的普通 HTTP 请求和业务响应
Protocol Buffers(Protobuf,协议缓冲区) Google 提供的结构化数据序列化机制,通常用 .proto 描述消息并生成代码 已明确协议与兼容策略的跨语言通信;不是只换依赖就能替换所有 JSON
Protostuff 独立的 Java 序列化库,支持 schema、运行时 schema 和相关二进制编码 已有系统确实选用时学习其类型和编码约定;不把它与 Google 的 Protobuf 当同一个项目

Schema 是“字段名称、类型、编号等结构约定”。采用二进制格式要讨论字段演进、双方兼容和测量结果,不能笼统断言一定比 JSON 更快。当前 hhjava 业务 JSON 不需要为了学概念额外引入 Protostuff。Protobuf 官方概览Protostuff 官方项目

2. hhjava 的业务 JSON 使用 Jackson

当前项目采用 Spring Boot 3.3 系列的 JSON 支持,具体版本以根 POM 为准。Boot 可以自动提供 ObjectMapper 并汇总相关配置和模块。Spring Boot JSON 文档

  • ObjectMapper:执行 JSON 读写、管理转换配置。
  • ObjectWriter:固定一组写出规则,便于复用。
  • DTO:描述传输的数据结构,不负责选择 JSON 库或输出调试日志。

Servlet Controller 直接返回 ResponseResult<T>,交给框架序列化。hhjava-unify 的响应 DTO 不需要依赖 Fastjson,也不需要在类中放 main 方法演示 JSON 转换。

当前 POM 不再直接声明或管理 Fastjson 来完成这些业务 JSON 操作。这不表示所有第三方 SDK 的传递依赖中都不可能出现其他 JSON 库;需要时用 mvn dependency:tree 检查实际依赖树。

3. 网关怎样写 JSON 错误响应

WebFlux 安全处理器需要直接写 HTTP 响应,所以 CustomAuthenticationHandler 通过构造器接收框架的 ObjectMapper,在 Bean 创建时生成专用 writer:

// 代码节选:只调整网关错误响应的空值策略,不修改全局配置。
this.responseWriter = objectMapper.copy()
        .setSerializationInclusion(JsonInclude.Include.NON_NULL)
        .writerFor(ResponseResult.class);

请求到来后,复用 writer 的 writeValueAsBytes(...) 生成 UTF-8 JSON 字节,通过响应式链写出。不要每次请求都创建 mapper,也不要调用 toString() 冒充 JSON。

这里的 NON_NULL 用于保留网关错误响应省略空 data 的接口约定。例如:

{"code":401,"message":"未认证或认证已过期"}

这不是整个项目的全局空值规则。改动 JSON 库或配置时,仍需检查中文编码、字段名称、空字段、状态码和错误传播,不能只检查“能输出一个字符串”。

4. 认证身份持久化为什么要特别小心

user 服务的 AuthenticatedUser 保存认证时加载的 userId、用户名和权限快照,还短暂持有供密码 Provider 校验的 BCrypt 哈希。

这里同时涉及两条序列化路径:Java 对象序列化,以及 OAuth2 JDBC 授权记录中的 Jackson JSON。一个注解或关键字不能想当然地保护两条路径。

代码措施 当前用途
transient String password 排除默认 Java 对象序列化中的密码字段
getPassword() 上的 @JsonIgnore 排除 Jackson JSON 中的密码属性
CredentialsContainer.eraseCredentials() 认证成功后擦除该 principal 中暂存的密码哈希
@JsonCreator restore(...) 从持久化 JSON 恢复身份与权限,不恢复密码
明确的类型信息与 Security Jackson 模块 支持确定的认证类型,不放开全局任意类型反序列化

反序列化后能够识别“这个用户是谁”,不意味着可以从记录恢复密码重新登录。也不要为解决自定义 principal 的读取错误而关闭类型限制。

transient@JsonIgnore 都不是加密;对象在内存中持有秘密时,日志、调试输出和显式读取仍需要单独约束。toString() 也不应输出密码或 Token。

5. 如何选择与测试

普通 HTTP 请求优先使用清晰的 DTO 和 JSON;Java 原生序列化只在确有框架或内部存储需求时使用,不接收不可信的任意对象字节流。

当前需要验证的两类契约:

  1. 网关 401/403 的 JSON 字段、UTF-8 中文和空值策略保持一致。
  2. OAuth2 JDBC 写入与读回自定义 principal 后,用户 ID 和权限保持正确,密码属性不存在;Java 序列化同样不携带密码。

登录链中的身份复用详见 认证和授权,网关响应写入见 网关、过滤器与拦截器

6. 动手:比较 Java 对象流和 Jackson JSON

前置条件:完成 Spring Boot 入门练习,已有 JDK 17 和 test 依赖。不需要数据库,也不需要额外 JSON 依赖。全部文件放在 Mac 独立学习工程的 src/test/java,不在生产 DTO 中新增 main 方法。

6.1 写入并读回 Java 对象

文件:src/test/java/com/example/learning/JavaSerializationTest.java

package com.example.learning;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

class JavaSerializationTest {
    static class LearningProfile implements Serializable {
        @Serial
        private static final long serialVersionUID = 1L;
        private final String name;
        private transient String temporaryNote;

        LearningProfile(String name, String temporaryNote) {
            this.name = name;
            this.temporaryNote = temporaryNote;
        }
    }

    @Test
    void shouldSkipTransientField() throws Exception {
        LearningProfile original = new LearningProfile("Java 学习者", "只存在于内存的备注");
        byte[] bytes;
        try (ByteArrayOutputStream buffer = new ByteArrayOutputStream();
             ObjectOutputStream output = new ObjectOutputStream(buffer)) {
            output.writeObject(original);
            output.flush();
            bytes = buffer.toByteArray();
        }

        // 只读回本测试刚生成的数据;不要把网络上传的任意字节直接交给 readObject。
        try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            input.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
                    "maxdepth=5;maxrefs=20;maxbytes=4096;"
                    + LearningProfile.class.getName() + ";!*"));
            LearningProfile restored = (LearningProfile) input.readObject();
            assertEquals("Java 学习者", restored.name);
            assertNull(restored.temporaryNote);
        }
    }
}

按顺序理解:

  1. Serializable 是标记接口,没有让你必须实现的方法;默认对象流会处理对象的非 static、非 transient 字段及其引用。
  2. ObjectOutputStream.writeObject 把对象写成字节;ByteArrayOutputStream 让实验留在内存,不生成需要清理的文件。
  3. ObjectInputStream.readObject 重建对象,返回值是 Object,本例转成已知的 LearningProfile 类型。
  4. transient 排除默认 Java 序列化中的临时字段,恢复后此引用为 null。显式自定义序列化逻辑仍需自行审查,关键字不是通用安全保险。
  5. serialVersionUID 是类型序列化兼容标识,不是用户 ID、数据库主键或加密密钥;固定数值也不意味着任意字段变更都兼容。
  6. 输入过滤器限制允许类型及部分资源规模;仍只处理可信数据,过滤器不能把任意反序列化变成安全操作。

执行 mvn -Dtest=JavaSerializationTest test,预期通过,说明 name 保留、temporaryNote 没有被带回。Java Serializable 文档ObjectInputFilter 文档

6.2 用 Jackson 读写 JSON,并排除一个字段

文件:src/test/java/com/example/learning/JacksonSerializationTest.java

package com.example.learning;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;

@SpringBootTest
class JacksonSerializationTest {
    record LearningProfile(String name, @JsonIgnore String internalNote) {}

    @Autowired
    private ObjectMapper mapper;

    @Test
    void shouldWriteAndReadOnlyPublicFields() throws Exception {
        LearningProfile original = new LearningProfile("Java 学习者", "内部备注");
        String json = mapper.writeValueAsString(original);

        // 比较 JSON 节点,避免依赖字段顺序或空白格式。
        assertEquals("Java 学习者", mapper.readTree(json).get("name").asText());
        assertFalse(mapper.readTree(json).has("internalNote"));

        LearningProfile restored = mapper.readValue(json, LearningProfile.class);
        assertEquals("Java 学习者", restored.name());
        assertNull(restored.internalNote());
    }
}

学习工程根目录运行 mvn -Dtest=JacksonSerializationTest test,预期通过。writeValueAsString 产生 JSON 字符串;readValue 指定目标类型后恢复对象;readTree 用于按节点查看 JSON。这个 record 没有实现 Serializable,也能进行 JSON 读写,证明两者是不同机制。

本例复用 Boot 管理的 ObjectMapper,不临时创建一套绕过应用配置的 mapper。@JsonIgnore 只针对 Jackson 规则,不能保护日志、数据库或其他序列化工具中的字段。

6.3 常见报错

现象 检查与处理
NotSerializableException 对象图中存在未支持 Java 序列化的类型;先判断是否真的需要保存它,而不是给所有类机械添加接口
InvalidClassException 写入与读回的类型版本或过滤规则不兼容;检查声明及升级兼容策略,不直接放开全部类型
JSON 转换失败 检查 JSON 语法、目标类型、构造器/访问器与 Jackson 配置;Java 的 toString() 一般不是 JSON
应隐藏的字段仍出现 确认走的是哪条序列化路径、实际使用的 mapper、字段与 getter 注解,不打印真实秘密来调试

学完后再读前文 AuthenticatedUser 的 transient、JsonIgnore 和密码擦除,就能分别回答“存不存”“输出不输出”“认证后还留不留”三个问题。