【Java 脚手架】封装通用工具类-1

开此次博客栏目是为了记录我在学习制作 Java 脚手架项目时遇到的问题和知识点,以便后续复盘学习~❄️

Util 类

Util 是工具类,只提供 static 方法,不需要创建对象。如果不手动写构造方法,Java 会自动生成一个无参构造方法,外部就可以创建没有实际用途的对象,故一般在每个 util 类上都会构造一个私有的实例化方法,这样能禁止从外部创建新对象

java 复制代码
private JsonUtil() {}

Json

底层用的是 Jackson 的 ObjectMapper,封装了一个通用的 JsonUtil 工具类。序列化没什么问题,writeValueAsString 直接把对象变成 JSON 字符串就行。但反序列化的时候碰到了 Java 泛型擦除的问题,这里记一下当时的场景和解决方式。

泛型擦除

最基本的反序列化方法 string2Obj(String, Class<T>) 只能处理单层对象,比如传入 User.class,Jackson 知道目标是 User,没有歧义。但如果想反序列化成 List<User> 或者 Map<String, User>,因为 JVM 在运行时已经擦除了泛型参数,根本不存在 List<User>.class 这个东西,传给 Jackson 的只有 List.class。Jackson 拿到之后只知道目标是一个 List,但里面的元素该是什么类型,它没有任何信息

所以只能用默认策略,把 JSON 对象映射成 LinkedHashMap,最后拿到的其实是 List<LinkedHashMap>,不是 List<User>,如图所示

这就是泛型擦除造成的。Java 的泛型是编译期做类型检查用的,到了运行期,JVM 不保留泛型的具体类型参数。List<User>List<String> 在运行时对 JVM 来说都只是 List,里面的 <User><String> 被"擦除"了。这个设计是 Java 5 引入泛型时为了向后兼容做的妥协。像老版本代码里到处都是裸类型的 ListMap,如果泛型在运行时改变了类的结构(像 C# 那样做真泛型),老代码就全部不兼容了。

日常业务代码里通常没影响,因为编译器已经检查过了

但在反序列化场景下,Jackson 需要在运行时知道目标类型的完整泛型信息才能正确映射,这时候泛型擦除就成了问题。

JavaType

对于 List<T>Map<String, T> 这种结构固定、泛型只有一层的场景,可以通过 Jackson 的 TypeFactory 手动构建一个带泛型信息的 JavaType,绕过擦除。

string2List ,通过 constructParametricType 告诉 Jackson "我要的是一个 List,里面的元素类型是 clazz":

java 复制代码
public static <T> List<T> string2List(String str, Class<T> clazz) {
    if (!StringUtils.hasLength(str) || clazz == null) return null;
    JavaType javaType = OBJECT_MAPPER.getTypeFactory()
            .constructParametricType(List.class, clazz);
    try {
        return OBJECT_MAPPER.readValue(str, javaType);
    } catch (JsonProcessingException e) {
        log.error("JSON字符串转换为对象列表时发生异常", e);
        return null;
    }
}

此方法在运行时重新把被擦除的类型参数补回去,构造出一个等价于 List<User> 的完整类型描述。

对反序列的 Map 思路完全一样,换成 constructMapType,同时指定 key 和 value 的类型:

java 复制代码
public static <T> Map<String,T> string2Map(String str, Class<T> clazz) {
    if (!StringUtils.hasLength(str) || clazz == null) return null;
    JavaType javaType = OBJECT_MAPPER.getTypeFactory()
            .constructMapType(LinkedHashMap.class, String.class, clazz);
    try {
        return OBJECT_MAPPER.readValue(str, javaType);
    } catch (JsonProcessingException e) {
        log.error("JSON字符串转换为对象列表时发生异常", e);
        return null;
    }
}

这里用 LinkedHashMap 而不是 HashMap,是为了可以保持 JSON 中 key 的原始顺序,小巧思

到这里,一层泛型的问题解决了。但如果泛型是嵌套的呢?比如 Map<String, List<User>> 这种两层结构,用 constructParametricType 一层一层手动构建就会变得很繁琐,方法签名也没法通用地表达"任意嵌套深度的泛型"。

嵌套泛型

Jackson 提供了 TypeReference<T> 来解决这个问题。它利用了泛型擦除的一个例外:虽然普通变量的泛型信息在运行时会被擦除,但类定义上的泛型信息会保留在字节码里 。当你写 new TypeReference<Map<String, List<User>>>() {} 的时候,实际上是创建了一个匿名内部类继承 TypeReference,这个匿名类在编译时就把 Map<String, List<User>> 这个完整类型写进了字节码的类元数据中。Jackson 在运行时通过反射读取这个类元数据,就能拿到完整的嵌套泛型信息了~

所以封装了一个重载的 string2Obj 方法,接收 TypeReference 参数:

java 复制代码
public static <T> T string2Obj(String str, TypeReference<T> typeRef) {
    if (!StringUtils.hasLength(str) || typeRef == null) return null;
    try {
        return OBJECT_MAPPER.readValue(str, typeRef);
    } catch (JsonProcessingException e) {
        log.error("JSON字符串转换为对象时发生异常", e);
        return null;
    }
}

调用的时候,在调用处直接用匿名内部类传入完整的泛型信息。我的测试用例里的场景是 Map<String, List<User>>,developers 和 designers 两组各带一个 User 列表:

java 复制代码
String json = "{\"developers\":[{\"name\":\"Julien\",\"age\":18}],"
        + "\"designers\":[{\"name\":\"Luna\",\"age\":20}]}";

Map<String, List<User>> users = JsonUtil.string2Obj(
        json, new TypeReference<Map<String, List<User>>>() {});

assertEquals(List.of(new User("Julien", 18, null)), users.get("developers"));
assertEquals(List.of(new User("Luna", 20, null)), users.get("designers"));

这样无论嵌套多深,TypeReference 都能把完整的类型树带过去,Jackson 就知道最外层是 Map、value 是 ListList 里面是 User

核心思路都是一样的。泛型擦除让运行时丢了类型信息,那就想办法把类型信息补回来,要么通过 JavaType 手动构建,要么通过 TypeReference 利用匿名类的字节码元数据来保留~

Bean 拷贝

从数据库查询出来的对象,和接口最终要返回的对象,通常不是同一个类。比如查询结果是 User,接口返回的是 UserVO,这两个类的字段可能大部分相同。如果每个字段都手动调用 set,代码会比较重复,所以可以手动写一个 copyProperties 方法做必要的属性拷贝。

但是对不同的对象拷贝时会遇到一个问题:方法只知道目标类型是泛型 T,但 Java 不能直接通过泛型创建对象。

方法需要调用方把"如何创建目标对象"传进来,这里就用到了 Supplier<T>

java 复制代码
public static <S, T> List<T> copyListProperties( List<S> sources, Supplier<T> targetSupplier) {

    List<T> targets = new ArrayList<>(sources.size());

    for (S source : sources) {
        T target = targetSupplier.get(); // 获取目标对象
        BeanUtils.copyProperties(source, target);
        targets.add(target);
    }

    return targets;
}

Supplier<T> 的作用是:告诉 Bean 拷贝工具"如何创建一个目标对象"。它是 Java 提供的函数式接口,不接收参数,只通过 get() 返回一个 T 类型的对象。

调用时可以把目标类的构造方法传进去:

java 复制代码
List<UserVO> result = copyListProperties( users, UserVO::new);

这里的 UserVO::new 是构造方法引用,等价于:

java 复制代码
() -> new UserVO()

也可以直接写成 Lambda:

java 复制代码
List<UserVO> result = copyListProperties( users, () -> new UserVO() );

调用这个方法时,编译器会根据参数推断出 STS 是源对象类型,T 是目标对象类型。比如传入 List<User>UserVO::new,就相当于完成 UserUserVO 的批量拷贝。

这里需要注意,Supplier 每次调用 get() 都应该返回一个新的对象,否则多个源对象可能会被拷贝到同一个目标对象中。另外,BeanUtils.copyProperties 主要适合拷贝同名、类型兼容的属性,嵌套对象通常不会自动进行深层拷贝。

深拷贝与浅拷贝

在 Java 中对象拷贝分为深拷贝和浅拷贝两种方式,它们的核心区别在于对引用类型字段的处理方式不同。

**浅拷贝

浅拷贝只复制对象本身和它的基本类型字段,对于引用类型的字段,只复制引用地址,不复制引用指向的对象。这意味着原对象和拷贝对象会共享同一个引用类型的实例。

java 复制代码
public class Address {
    private String city;
    // getter/setter
}

public class User {
    private String name;
    private Integer age;
    private Address address; // 引用类型
    // getter/setter
}

// 浅拷贝示例
User original = new User();
original.setName("Julien");
original.setAge(18);
original.setAddress(new Address("Guangzhou"));

User shallowCopy = new User();
BeanUtils.copyProperties(original, shallowCopy);

// 修改拷贝对象的引用类型字段
shallowCopy.getAddress().setCity("Shenzhen");

// 原对象也受到影响!
System.out.println(original.getAddress().getCity()); // 输出:Shenzhen

常见的浅拷贝方式:

  • Spring 的 BeanUtils.copyProperties()
  • Apache Commons 的 BeanUtils.copyProperties()
  • Object.clone()(需要实现 Cloneable 接口)

**深拷贝

深拷贝会递归复制对象的所有层级,包括引用类型字段指向的对象,创建出完全独立的副本。原对象和拷贝对象完全隔离,互不影响。

java 复制代码
User original = new User();
original.setName("Julien");
original.setAge(18);
original.setAddress(new Address("Guangzhou"));

User deepCopy = new User();
deepCopy.setName(original.getName());
deepCopy.setAge(original.getAge());
// 关键:创建新的 Address 对象
Address newAddress = new Address();
newAddress.setCity(original.getAddress().getCity());
deepCopy.setAddress(newAddress);

// 修改拷贝对象的引用类型字段
deepCopy.getAddress().setCity("Shenzhen");

// 原对象不受影响
System.out.println(original.getAddress().getCity()); // 输出:Guangzhou

常见的深拷贝实现方式:

  1. 手动拷贝:为每个引用类型字段创建新对象并赋值(适合层级简单的场景)
  2. 序列化 :将对象序列化后再反序列化(对象必须实现 Serializable
  3. JSON 序列化:通过 JSON 转换实现(性能开销较大,但通用性强)
  4. 第三方库 :如 Apache Commons 的 SerializationUtils、Dozer、MapStruct 等

深拷贝工具封装

在脚手架中,可以基于已有的 JsonUtil 封装一个通用的深拷贝工具方法,利用 JSON 序列化和反序列化实现:

java 复制代码
/**
 * 深拷贝单个对象
 * 通过 JSON 序列化和反序列化实现完全独立的副本
 */
public static <T> T deepCopy(T source, Class<T> clazz) {
    if (source == null) return null;
    String json = obj2String(source);
    return string2Obj(json, clazz);
}

/**
 * 深拷贝列表
 */
public static <T> List<T> deepCopyList(List<T> sources, Class<T> clazz) {
    if (sources == null || sources.isEmpty()) return new ArrayList<>();
    String json = obj2String(sources);
    return string2List(json, clazz);
}

这种方式的优点是实现简单、通用性强,适合大部分业务场景。缺点是性能开销比手动拷贝大,不适合高频调用。如果对象包含不能被序列化的字段(如 transientThreadOutputStream 等),这种方式会丢失这些字段的值。

选择建议

在脚手架项目中如何选择拷贝方式:

  • VO 转换 :实体类到视图对象的转换,通常只需要拷贝同名的简单字段,用浅拷贝(BeanUtils.copyProperties)就够了。即使有嵌套对象,只要不修改嵌套对象的内容,浅拷贝也是安全的

  • 缓存隔离:从缓存中取出对象后需要修改,但不能影响缓存中的原对象,这时必须用深拷贝
  • 消息传递:在多线程或消息队列场景中,如果对象会被多个消费者修改,应该用深拷贝确保数据隔离
  • 测试数据:单元测试中需要多次使用同一个初始对象,可以用深拷贝避免测试之间相互影响

性能方面,浅拷贝的开销很小,深拷贝的开销取决于对象的复杂度和实现方式。如果对象层级深、字段多,JSON 序列化的方式会比较慢。对于高频调用的场景,可以考虑用 MapStruct 这种编译期生成代码的工具,或者手动实现拷贝逻辑。

日常开发中,默认用浅拷贝就够了,只有明确需要隔离引用类型字段时才考虑深拷贝

字符串

这次在脚手架里补了一个 StringUtil,需求是判断 URL 是否符合某条规则,以及判断它是否符合规则列表中的任意一条。刚开始可以想到 JDK 自带的 Pattern,但如果规则主要长这样:/api/users/*/api/**,它其实更像路径匹配,不太适合写成正则。所以这里改用 Spring 的 AntPathMatcher

Pattern

Pattern 属于 JDK 的,规则是完整的正则表达式,适合数字、参数格式、复杂文本等场景:

java 复制代码
Pattern.matches("^/api/users/\\d+$", "/api/users/123");

这里的 \\d+ 表示一个或多个数字,^$ 让规则从字符串开头匹配到结尾。Pattern.matches() 使用的是整段匹配;如果需要在一段文本中寻找局部内容,则要使用 Matcher.find()

java 复制代码
Pattern pattern = Pattern.compile("/api/users/\\d+");
Matcher matcher = pattern.matcher("request: /api/users/123");

matcher.find();    // true,找到局部内容
matcher.matches(); // false,整段字符串没有完全符合规则

使用 Pattern 时要注意 Java 字符串本身还会处理一次转义,所以正则里的 \\d 在 Java 字符串中通常要写成 "\\d"

AntPathMatcher

AntPathMatcher 属于 Spring,参数顺序是"规则在前,实际路径在后":

java 复制代码
AntPathMatcher matcher = new AntPathMatcher();

matcher.match("/api/users/*", "/api/users/123");
matcher.match("/api/**", "/api/users/123/detail");

它的规则主要看路径层级:

  • ?:匹配一个字符。
  • *:匹配当前路径层级中的任意字符,不跨 /
  • **:匹配零层或多层路径,可以跨 /

所以 /api/users/* 可以匹配 /api/users/123,但不能匹配 /api/users/123/detail/api/** 则可以匹配后面多级路径。相比正则,Ant 规则更适合网关白名单、接口路径排除规则和 URL 路径权限判断,规则也更容易读。

区别

  • AntPathMatcher 匹配的是路径规则,不负责判断字符串是不是合法 URL。若需要校验协议、域名和端口,应该先用 URI 解析,再取出 getPath() 进行匹配。直接拿完整 URL 匹配也能工作,但查询参数会成为待匹配字符串的一部分,规则设计时要把这一点考虑进去。
  • PatternAntPathMatcher 的规则不能混用。/api/** 是 Ant 规则,不是合法的正则;^/api/\\d+$ 是正则,也不能按 Ant 规则理解。项目里选定一种规则后,配置文件、测试用例和工具方法需要保持一致。

如果规则列表很长,还可以考虑提前编译或缓存规则;当前脚手架的规则数量不大,使用 Listforeach 逐条判断已经足够,也能保持和单条 URL 匹配方法一致。

还有后续更新...

相关推荐
关于不上作者榜就原神启动那件事1 小时前
从 MDC 到 Agent:我手搓的文档路由协议,在 Spring AI Alibaba 里找到了正式实现
java·人工智能·spring·ai·agent
ACP广源盛139246256732 小时前
Qwen3.8-Max 开源预期下@ACP#企业级终端硬件演进机遇与 PCIe 交换芯片落地分析
大数据·人工智能·分布式·单片机·嵌入式硬件
亦暖筑序2 小时前
重新认识 AgentScope-Java 2.0:ReActAgent 负责推理,HarnessAgent 负责运行
java·后端·agent
六点_dn2 小时前
RabbitMQ常见知识点总结
分布式·rabbitmq
会博通·代码搬运工2 小时前
会博通API对接实战:工程企业文档分布式采集系统的技术实现与Python SDK详解
开发语言·分布式·python·线性代数·矩阵·架构·电子档案合规
IKUN家族2 小时前
Spring MVC(三)
运维·服务器·spring boot·spring·servlet·java-ee
木井巳2 小时前
【DFS解决floodfill算法】岛屿的最大面积
java·算法·leetcode·深度优先
她说可以呀2 小时前
Spring-ai-alibaba视频生成
人工智能·spring·音视频
逐光老顽童2 小时前
第 2 章:彻底搞懂 K8s Pod——从 YAML 到调度全流程
分布式·云原生