图源:Fotor懒设计
在日常使用的时候,我们往往需要创建一些“仅用于传输数据的类型”,比如Web编程时候的DTO。
将特殊用途的类型限制为“只读”的一个好处是,这些类型可以安全地在多线程之间共享,并且在涉及计算哈希值的时候,不用担心这些对象因为内部属性改变导致哈希值改变。
如果要创建一个“只读”类型,通常我们需要这样做:
public class Person1 {
private final String name;
private final Integer age;
public Person1(String name, Integer age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person1 person1 = (Person1) o;
return Objects.equals(getName(), person1.getName()) && Objects.equals(getAge(), person1.getAge());
}
@Override
public int hashCode() {
return Objects.hash(getName(), getAge());
}
@Override
public String toString() {
return "Person1{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
public String getName() {
return name;
}
public Integer getAge() {
return age;
}
}
我们需要做的是:
private final
。hashCode
、equals
、toString
方法。虽然大多数工作都可以借助IDE来完成,但是仍然需要话费一点时间在“样板代码”上,并且如果这个类型需要添加一些属性,我们还需要话费时间修改相应的代码。
在之前的文章中,我介绍了一个工具 Lombok,借助它我们可以改善此类的代码:
@Value
public class Person2 {
String name;
Integer age;
}
Lombok 可以帮助我们实现之前示例中的“样板代码”,我们只需要使用一个@Value
注解。
关于 Lombok 的更多介绍,可以阅读我的另一篇文章。
从 JDK14 开始,我们多了一种选项——使用Record
:
public record Person(String name, Integer age) {
}
看起来这里用
record
代替了class
,但实际上record
并不是一个关键字,只是一个包类型,这是官方出于某种向前兼容的考虑。
查看Person
对应的字节码:
public record Person(String name, Integer age) {
public Person(String name, Integer age) {
this.name = name;
this.age = age;
}
public String name() {
return this.name;
}
public Integer age() {
return this.age;
}
}
可以看到Person
在生成字节码后,由编译器生成了构造器和Getter。实际上相应的hashCode
、toString
和equals
同样可用:
Person person = new Person("icexmoon", 12);
System.out.println(person);
Person person2 = new Person("icexmoon", 20);
System.out.println(person.equals(person2));
Person person3 = new Person("icexmoon", 12);
System.out.println(person.equals(person3));
// Person[name=icexmoon, age=12]
// false
// true
通常我们无需为Record
指定构造器,使用其默认创建的构造器即可。如果我们需要为默认生成的构造器添加某些处理逻辑,可以:
public record Person(String name, Integer age) {
public Person{
Objects.requireNonNull(name);
name = name.trim();
if ("".equals(name)){
throw new RuntimeException("name 不能为空");
}
if (age <=0 || age >=150){
throw new RuntimeException("age 的值非法");
}
}
}
生成的字节码:
public record Person(String name, Integer age) {
public Person(String name, Integer age) {
Objects.requireNonNull(name);
name = name.trim();
if ("".equals(name)) {
throw new RuntimeException("name 不能为空");
} else if (age > 0 && age < 150) {
this.name = name;
this.age = age;
} else {
throw new RuntimeException("age 的值非法");
}
}
// ...
}
这里的不带参数列表的构造器public Person {...}
可以称作“紧凑构造器”(compact constructor),虽然没有显式声明参数列表,但我们依然可以直接使用属性名称命名的参数,并且无需添加属性赋值语句(比如this.name=name
),生成字节码的时候编译器会自动添加。
当然也可以用传统方式编写构造器:
public record Person(String name, Integer age) {
public Person(String name, Integer age){
Objects.requireNonNull(name);
name = name.trim();
if ("".equals(name)){
throw new RuntimeException("name 不能为空");
}
if (age <=0 || age >=150){
throw new RuntimeException("age 的值非法");
}
this.name = name;
this.age = age;
}
}
这同样是合法的,但并不推荐。
注意,传统方式最后的属性赋值语句。
需要注意的是,紧凑构造器和传统构造器不能共存:
public record Person(String name, Integer age) {
public Person{
}
public Person(String name, Integer age){
// ...
}
}
上边的示例无法通过编译。
这是可以理解的,两个构造器本质上完全相同,编译器并不知道该使用哪一个。
当然,重载构造器以提供多样的对象创建方式是被允许的:
public record Person(String name, Integer age) {
public Person {
// ...
}
public Person(String name){
this(name, 10);
}
}
调用:
Person person3 = new Person("icexmoon");
System.out.println(person3);
// Person[name=icexmoon, age=10]
同样的,可以在record
中使用静态属性和方法:
public record Person(String name, Integer age) {
private static final int DEFAULT_AGE = 10;
private static final String DEFAULT_NAME = "icexmoon";
public Person {
// ...
}
public Person(String name) {
this(name, DEFAULT_AGE);
}
public static Person defaultPerson() {
return new Person(DEFAULT_NAME, DEFAULT_AGE);
}
}
这里看一个实际示例,如何在Web应用中使用record
:
public record Result<T>(boolean successFlag, String errorCode, String errorMsg, T data) {
private static final String SUCCESS_CODE = "success";
public Result {
Objects.requireNonNull(errorCode);
Objects.requireNonNull(errorMsg);
errorCode = errorCode.trim();
if ("".equals(errorCode)) {
throw new RuntimeException("errorCode 不能为空");
}
}
public static <T> Result<T> success(T data) {
return new Result<>(true, SUCCESS_CODE, "", data);
}
public static Result<Object> success() {
return success(null);
}
public static Result<Object> fail(String errorCode, String errorMsg) {
return new Result<>(false, errorCode, errorMsg, null);
}
}
@RestController
@RequestMapping("/person")
@Log4j2
public class PersonController {
private static record AddPersonDTO(@NotBlank String name,
@NotNull @Range(min = 1, max = 150) Integer age){
}
@PostMapping("/add")
public Result<?> addPerson(@Validated @RequestBody AddPersonDTO dto){
//调用service,执行添加动作
log.debug(dto);
return Result.success();
}
}
这里的标准返回Result
和充当DTO的AddPersonDTO
都使用record
来创建。
Result
中用于表示成功失败的属性命名为successFlag
而非通常的success
,这是因为会与静态方法success
冲突(因为record
默认产生的Getter同样以属性名命名),无法通过编译。
可以看到,Hibernate Validation 与 Record 同样可以很好地协同工作。
如果想了解更多的 Hibernate Validation 在 Spring 中使用的内容,可以阅读这里。
Record 和 Lombok 的@Value
的用途是很相似的,都可以用来表示一个"只读类型"。所以讨论它们之间的异同就很有必要了。
Record 被定义为一个“透明的数据载体”,因此它的Getter和构造器都必须是public
的,如果我们想让只读类型的Getter或构造器不是public
,那就只能使用 Lombok。比如:
@Value
@Getter(AccessLevel.PRIVATE)
public class Person3 {
String name;
Integer age;
private Person3(final String name, final Integer age) {
this.name = name;
this.age = age;
}
public static Person3 buildPerson(String name, Integer age) {
return new Person3(name, age);
}
}
此时,Lombok 生成的Getter都是private
的,自然无法被外部调用,同时构造器同样被我们改写为private
,外部只能通过静态方法buildPerson
来创建对象。
可以看到,Lombok 比 Record 更灵活,我们可以根据需要修改内部构造器和方法的可见性,这点 Record 是无法做到的。
如果类型中有多个属性,使用 Record 的代码的可读性会变差,比如:
public record Person4(String firstName,
String lastName,
Integer age,
List<String> hobbies,
String career,
String email,
String address) {
}
@SpringBootApplication
public class MyrecordApplication {
// ...
private static void testRecord4() {
var p = new Person4("Jack",
"Chen",
15,
List.of("singing", "drawing"),
"actor",
"[email protected]",
"HK");
System.out.println(p);
}
}
可以使用 Lombok 编写更具可读性的代码:
@Value
@Builder
public class Person5 {
String firstName;
String lastName;
Integer age;
List<String> hobbies;
String career;
String email;
String address;
}
@SpringBootApplication
public class MyrecordApplication {
// ...
private static void testRecord5() {
var p = Person5.builder()
.firstName("Jack")
.lastName("Chen")
.hobbies(List.of("singing", "drawing"))
.career("actor")
.address("HK")
.age(15)
.email("[email protected]")
.build();
System.out.println(p);
}
}
因此,对于拥有很多属性的类型,可以可以优先考虑使用 Lombok。
Record 是不能被继承的,Lombok 的@Value
标记的类型同样不能被继承,但我们可以组合使用 Lombok的其它注解来更灵活地构建我们需要的类型并实现继承,比如:
@Data
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
@Setter(value = AccessLevel.NONE)
@RequiredArgsConstructor
public class Person5 {
String firstName;
String lastName;
Integer age;
List<String> hobbies;
String career;
String email;
String address;
}
@Value
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Person6 extends Person5 {
private final String country;
public Person6(String firstName, String lastName, Integer age, List<String> hobbies, String career, String email, String address, String country) {
super(firstName, lastName, age, hobbies, career, email, address);
this.country = country;
}
}
当然,这里依然有很多不便,比如子类Person6
无法直接用@Value
或@RequiredArgsConstructor
生成包含所有属性的构造器,所以这里只能通过手动创建。但至少可以通过这种方式实现继承,这点 Record 是无法做到的。
总的来说,Record 的用途相对简单和直接,就是充当一个“透明的数据载体”,而 Lombok 除了直接使用@Value
注解外,还可以结合其它注解更灵活地定制一个类型。
Record 还存在一些其它限制,比如不能从其它类型扩展:
public record Person7() extends Person1 {
}
这样的代码无法通过编译,会提示“不允许 Record 从其它类型扩展”。
这是因为所有的record
类型实际上都会隐式地从Record类扩展,而 Java 本身不支持多继承。
其次,Record 的属性也不能被初始化,比如:
public record Person7(String name, Integer age = 7){
}
这样的写法不被允许,因此 Record 的属性只能是通过构造器进行初始化。
The End,谢谢阅读。
本文的所有示例代码可以通过这里获取。