上个月把项目里一个老模块的数据类从Lombok换成了Java原生的Record,本来只是试试水,结果代码量直接少了一半。当然也踩了几个不太明显的坑,尤其是和Hibernate、MyBatis配合的时候。今天拿一个实际开发中常见的用户注册场景,把Record怎么用、怎么避开坑说清楚。
说说为什么受够了Lombok
以前写一个DTO,你得加@Data、@Builder、@AllArgsConstructor这些注解。类本身可能就三四个字段,但代码看着一点都不清爽。最关键的是Lombok在编译期搞的“魔法”,遇到不同版本的JDK或者插件冲突,排查起来想骂人。而Java 16正式发布了Record,它就是为“只存数据”的类而生的,不用写一堆样板代码,构造函数、equals、hashCode、toString都自动生成。
举个例子,注册接口需要一个请求对象:用户名、手机号、密码。以前这么写:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RegisterRequest {
private String username;
private String phone;
private String password;
}
现在用Record,一行就完事:
public record RegisterRequest(String username, String phone, String password) {}
看着是不是舒服多了?Record的字段默认是private final的,只有getter(像username()这样),没有setter。这种不可变性在数据传输时特别安全,不怕哪里不小心改掉了。
完整的案例:把注册接口改成Record
我们的Controller原来接收一个实体类,然后手动调用getter取字段:
@PostMapping("/register")
public Result register(@RequestBody RegisterRequest request) {
// 取字段的方法以前是 request.getUsername()
String username = request.getUsername();
String phone = request.getPhone();
String password = request.getPassword();
// 调用业务层
userService.register(username, phone, password);
}
改成Record之后,取字段的方法变成了request.username(),其他逻辑不用动。Idea会帮你自动替换,所以整体改造并不麻烦。
用构造器校验参数,替代一切手动if
Record最爽的一点,是可以在官方推荐的“紧凑构造器”里做参数校验。格式非常简洁:
public record RegisterRequest(
String username,
String phone,
String password
) {
public RegisterRequest {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("用户名不能为空");
}
if (phone.length() != 11) {
throw new IllegalArgumentException("手机号格式不对");
}
if (password.length() < 6) {
throw new IllegalArgumentException("密码至少6位");
}
}
}
注意,紧凑构造器里没有写this.username = username之类的赋值语句,因为Record会自动给你赋值,你只需要写校验逻辑。这样参数在创建对象时就被“锁死”了,不符合要求的对象根本创建不出来,业务层就不用再重复校验了。
嵌套Record:组装复杂的返回结构
很多时候我们需要在接口里嵌套返回一些数据。比如注册成功后要返回用户信息+token,用Record嵌套起来特别清晰:
public record RegisterResponse(
UserInfo user,
String token
) {
public record UserInfo(
Long id,
String username,
String phone,
String avatar
) {}
}
在业务层构建这个对象时,不需要写构造器,直接这样:
RegisterResponse.UserInfo info = new RegisterResponse.UserInfo(
user.getId(),
user.getUsername(),
user.getPhone(),
user.getAvatar()
);
return new RegisterResponse(info, token);
代码是变短了,但刚开始我有个担心:Jackson能不能正常把Record序列化成JSON?后来实测Spring Boot 3.x自带的Jackson完全支持Record,不用额外配置。如果你用的还是Spring Boot 2.x,可能会遇到一点小问题,升级Jackson版本或者加一个jackson-databind-nullable即可。
和数据访问层打交道时遇到的坑
坑一:MyBatis-Plus 对Record的支持有点差
一开始我想直接把Record作为实体类映射数据库表,结果MyBatis-Plus玩不转。它默认利用无参构造函数创建对象,然后反射字段赋值。但Record没有无参构造器,所以直接报错。试了几个办法,最后放弃了,还是用传统POJO做数据库映射,只在接口出入参上用了Record。说实话,DTO层用Record够了,实体类保持原样更稳妥。
坑二:Hibernate的懒加载会报错
如果直接把Record用做JPA的实体类,绝对要出问题。Hibernate需要为实体类生成代理对象,但Record是final的,无法继承。所以一旦你用了@Entity标注在一个Record上,启动时就会直接抛异常。网上有人做了一些扩展,但生产环境建议别这么搞,老老实实用普通类做实体。
坑三:IDE自动生成的方法不一定有注释
Record会自动生成toString(),里边的输出格式是RegisterRequest[username=x, phone=y, password=z]。打印日志时可能会把敏感信息(比如密码)打出来。为了避免这种情况,我选择在Record里重写toString,只打印脱敏后的信息:
@Override
public String toString() {
return "RegisterRequest{" +
"username='" + username + ''' +
", phone='" + phone + ''' +
", password='***'" +
'}';
}
虽然代码长了点,但日志安全很重要。
坑四:无法继承Record,也不好实现某些框架的接口
Record隐含地继承java.lang.Record,所以它不能再继承其他类。如果某个场景你需要让DTO继承一个父类(比如分页查询对象),Record就不合适了。另外,有些框架要求实现某些接口(比如Spring的AttributeModel),Record也可以实现接口,只是不能继承。
坑五:Builder和Record天然不兼容
Lombok的@Builder不能用在Record上。如果你习惯了构建者模式,刚开始会觉得有点憋屈。不过Record的构造器本来就列出了所有字段,参数一多可能会太啰嗦。作为折中,我有时候会在Record内部定义静态工厂方法,比如:
public static RegisterRequest of(String username, String phone, String password) {
return new RegisterRequest(username, phone, password);
}
但为了简化,直接在构造器里传所有参数也够用了。
和JSON反序列化有关的小惊喜
Jackson在反序列化JSON到Record时,会自动使用构造器,同时会调用紧凑构造器里的校验逻辑。也就是说客户端传来的参数如果不符合要求,直接在这里就报错了。这是好事,不过我担心错误信息不够友好。后来我用了Spring的@ControllerAdvice统一捕获IllegalArgumentException并返回自定义消息,这样前端就能看到“手机号格式不对”这样的提示,而不是一堆堆栈信息。
总结一下什么情况下用Record
我个人的习惯是:接口的DTO、VO、查询参数对象,用Record非常舒服,因为它不可变、语义清晰、精简。但是数据库实体类、需要复杂继承体系的对象、需要用到MyBatis-Plus的LambdaUpdateWrapper的对象,还是继续用传统类。Record不是要完全替代所有POJO,它只是提供了一种更简洁的写法。
如果你还没用过Record,我建议在你项目里拿一个简单的接口试试:把请求参数类改成Record,然后让Controller层去接收,感受一下。等熟悉了之后再逐步扩大使用范围。踩坑的事我已经帮你踩过了,你避开上面那五个坑就行。

