从零开始 Spring Boot 35:Lombok
图源:简书 (jianshu.com)
Lombok是一个java项目,旨在帮助开发者减少一些“模板代码”。其具体方式是在Java代码生成字节码(class文件)时,根据你添加的相关Lombok注解或类来“自动”添加和生成相应的字节码,以补完代码所需的“模板代码”。
实际上 Lombok 和 Spring 并没有关联关系,你开发任何Java应用都可以选择使用 Lombok,只不过日常的 Spring 开发中很容易看到 Lombok 的使用,所以这里就归类到这个系列博客。
为什么要使用 Lombok
我们先看一个Spring 开发中很常见的 POJO 类是什么样的:
public class Book {
private Long id;
private String name;
private Long userId;
private Long publisherId;
public Book() {
}
public Book(String name, Long userId, Long publisherId) {
this.name = name;
this.userId = userId;
this.publisherId = publisherId;
}
public void setId(Long id) {
this.id = id;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getPublisherId() {
return publisherId;
}
public void setPublisherId(Long publisherId) {
this.publisherId = publisherId;
}
}
实际上这种有一个空构造器和Getter/Setter的 Java 类,被称作 Java Bean,最早是为了开发 Java桌面应用提出的标准,不过目前已经被第三方 Java 框架广泛采纳和使用。
为了能让框架获取或修改我们的自定义类中的属性,我们需要提供Getter/Setter,以及可能需要的包含各种参数的构造器。显然为了让一个类变成 Java Bean所添加的代码,都是“模板代码”,是可以通过自动化手段取代的,这里我们就是 Lombok 的用武之地了。
如果上边的示例中 Lombok 改写,会变成这样:
@NoArgsConstructor
@Setter
@Getter
public class Book {
private Long id;
private String name;
private Long userId;
private Long publisherId;
public Book(String name, Long userId, Long publisherId) {
this.name = name;
this.userId = userId;
this.publisherId = publisherId;
}
}
这样做的好处有:
- 减少了不必要的模板代码,提高效率,以及让代码更简洁。
- 如果新添加了属性,无需手动添加相应的Getter/Setter。
当然,要使用 Lombok,需要在项目中添加相应的依赖:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
下面一一介绍Lombok的功能。
var
在 Python3 或者 Go 这类“新语言”中,“自动类型推断”是一个很常见的语言级别功能,这个功能或多或少都会让你的编码工作更顺畅一些。Java 自 Java 10 起,也支持类似的功能:
- JEP 286: Local-Variable Type Inference (openjdk.org)
直接看示例:
package com.example.lombok;
// ...
@SpringBootApplication
public class LombokApplication {
public static void main(String[] args) {
SpringApplication.run(LombokApplication.class, args);
testVar();
}
private static void testVar() {
var names = new ArrayList<String>();
names.add("Li Lei");
System.out.println(names.get(0));
var students = new HashMap<Integer, String>();
students.put(12, "Li Lei");
students.put(20, "Han Meimei");
for (var s : students.entrySet()) {
System.out.println("number is %d, name is %s.".formatted(s.getKey(), s.getValue()));
}
students = new HashMap<>();
}
}
在示例中,局部变量和for
的条件语句中都用var
取代了具体类型。
代码编译成字节码后,var
会被相应的具体类型取代:
// ...
private static void testVar() {
ArrayList<String> names = new ArrayList();
names.add("Li Lei");
System.out.println((String)names.get(0));
HashMap<Integer, String> students = new HashMap();
students.put(12, "Li Lei");
students.put(20, "Han Meimei");
Iterator var2 = students.entrySet().iterator();
while(var2.hasNext()) {
Entry<Integer, String> s = (Entry)var2.next();
System.out.println("number is %d, name is %s.".formatted(new Object[]{s.getKey(), s.getValue()}));
}
new HashMap();
}
// ...
val
val
是 Lombok 引入的一个类型,其功能相当于 final var
:
import lombok.val;
// ...
private static void testVal() {
val names = new ArrayList<String>();
names.add("Li Lei");
System.out.println(names.get(0));
val students = new HashMap<Integer, String>();
students.put(12, "Li Lei");
students.put(20, "Han Meimei");
for (var s : students.entrySet()) {
System.out.println("number is %d, name is %s.".formatted(s.getKey(), s.getValue()));
}
}
//...
注意,要通过import lombok.val
导入val
类型到当前命名空间,否则就要用lombok.val
声明变量。
与var
的区别是,这里用val
声明的局部变量都是final
的,因此不能被重新赋值。此外,val
是 Lombok 的类型,因此,即使是 Java10以下的版本,也可以使用。
@NonNull
在从零开始 Spring Boot 33:Null-safety - 红茶的个人站点 (icexmoon.cn)中,我讨论过Spring框架对Null安全的支持,但那些支持都不是强制性的,仅能借助IDE的相关工具在编码阶段提供一些警告信息。
相比之下,可以借助Lombok的@NonNull
注解,实现对属性或方法参数的强制性检查:
@NoArgsConstructor
@Setter
@Getter
public class Book {
private Long id;
private String name;
@NonNull
private Long userId;
private Long publisherId;
public Book(@NonNull String name, Long userId, Long publisherId) {
this.name = name;
this.userId = userId;
this.publisherId = publisherId;
}
}
注意,这里使用的是
lombok.NonNull
,而非Spring框架或者别的库的NonNull
注解。
观察对应的字节码:
public class Book {
// ...
@NonNull
private Long userId;
// ...
public Book(@NonNull String name, Long userId, Long publisherId) {
if (name == null) {
throw new NullPointerException("name is marked non-null but is null");
} else {
this.name = name;
this.userId = userId;
this.publisherId = publisherId;
}
}
// ...
public void setUserId(@NonNull final Long userId) {
if (userId == null) {
throw new NullPointerException("userId is marked non-null but is null");
} else {
this.userId = userId;
}
}
// ...
@NonNull
public Long getUserId() {
return this.userId;
}
}
可以看到,使用@NonNull
注解标记参数的方法体中被自动添加了if
语句检查相应的参数是否为null
,如果是就抛出NullPointerException
异常。
如果用@NonNull
标记属性,则相应由 Lombok 自动生成的方法(这里是setUserId
)中会添加对该属性的null
检查语句。
对于Getter,仅会用
@NonNull
标记,表示返回的是一个非Null值,不会添加其他的语句。
和 Spring 框架的@NonNull
不同,Lombok 的@NonNull
主要用于标记方法参数和属性,但如果用于方法也不会报错,只不过不会自动生成任何语句。
@Cleanup
在使用外部资源时,我们往往需要在最后手动关闭(这通常是使用try...catch...finally
语句实现)。但是有时候我们会因为忘记添加关闭语句而导致bug。而 Lombok 提供一个@Cleanup
注解,可以帮助我们。
go语言在语言层级提供关键字以关闭相应的资源。
直接看示例:
private static void testCleanUp() throws IOException {
ClassPathResource classPathResource = new ClassPathResource("application.properties");
@Cleanup InputStream inputStream = classPathResource.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
do {
var line = bufferedReader.readLine();
if (line == null){
break;
}
System.out.println(line);
}
while (true);
}
这里通过Spring的Resource
获取了class:application.properties
文件对应的InputStream
,并且逐行读取后输出。最后并没有显式调用inputStream.close()
,这是因为我们用@Cleanup
标记了inputStream
变量。所以 Lombok 会自动添加上相应的关闭语句,字节码可以说明这一点:
private static void testCleanUp() throws IOException {
ClassPathResource classPathResource = new ClassPathResource("application.properties");
InputStream inputStream = classPathResource.getInputStream();
try {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
while(true) {
String line = bufferedReader.readLine();
if (line == null) {
return;
}
System.out.println(line);
}
} finally {
if (Collections.singletonList(inputStream).get(0) != null) {
inputStream.close();
}
}
}
我不清楚为什么
finally
中使用了Collections.singletonList
而非直接的inputStream
,有清楚的朋友可以在下面留言。
一般来说,资源的关闭方法都会使用close
命名,但如果不是,我们也可以通过@Cleanup
的value
属性进行指定。
假设我们自定义一个关闭方法是destroy
的BufferedReader
:
public class MyBufferedReader {
private BufferedReader bufferedReader;
public MyBufferedReader(Reader reader) {
bufferedReader = new BufferedReader(reader);
}
public void destroy() throws IOException {
bufferedReader.close();
}
public String readLine() throws IOException {
return bufferedReader.readLine();
}
}
用@Cleanup
来关闭相应的资源:
@Cleanup("destroy") MyBufferedReader bufferedReader = new MyBufferedReader(new InputStreamReader(inputStream));
生成的字节码:
bufferedReader.destroy();
@Cleanup
存在一个潜在问题——如果字节码中的try
块中出现异常,且finally
中(对应关闭方法)也出现异常,那么前边的异常会被后边的异常“吞掉”。
比如上边的示例,我们强制让readLine
和destroy
都抛出异常:
public class MyBufferedReader {
// ...
public void destroy() throws IOException {
throw new RuntimeException("destory is called");
}
public String readLine() throws IOException {
throw new RuntimeException("readLine is called");
}
}
最后我们只会得到destory
调用时产生的异常,readLine
调用时产生的异常被“吞掉”了。
这可能与使用@Cleanup
的一般性预期不符,但目前因为Java语义的关系无法解决,相应的详细说明可以看@Cleanup (projectlombok.org)。
@Getter 和 @Setter
可以借助 Lombok 的@Getter
和@Setter
注解生成属性的 getter 和 setter。
最简单的方式是直接在属性上使用,生成对应的 getter 和 setter:
public class User {
@Getter
@Setter
private Long id;
@Getter
@Setter
private String name;
@Getter
@Setter
private Boolean isAdmin;
@Getter
@Setter
private boolean delFlag;
}
对应的字节码:
public class User {
private Long id;
private String name;
private Boolean isAdmin;
private boolean delFlag;
public User() {
}
public Long getId() {
return this.id;
}
public void setId(final Long id) {
this.id = id;
}
public String getName() {
return this.name;
}
public void setName(final String name) {
this.name = name;
}
public Boolean getIsAdmin() {
return this.isAdmin;
}
public void setIsAdmin(final Boolean isAdmin) {
this.isAdmin = isAdmin;
}
public boolean isDelFlag() {
return this.delFlag;
}
public void setDelFlag(final boolean delFlag) {
this.delFlag = delFlag;
}
}
注意,对一般性的属性,生成的getter命名是getXXX
,但如果其类型是boolean
(不是Boolean
),其命名是isXXX
。
修改访问权限
默认情况下生成的 Getter 和 Setter 的访问标识符都是public
,可以通过@Getter
或@Setter
的value
属性修改:
public class User {
@Getter
@Setter(AccessLevel.NONE)
private Long id;
@Getter
@Setter(AccessLevel.PRIVATE)
private String name;
@Getter
@Setter(AccessLevel.PACKAGE)
private Boolean isAdmin;
@Getter
@Setter(AccessLevel.PROTECTED)
private boolean delFlag;
}
对应的字节码:
public class User {
// ...
private void setName(final String name) {
this.name = name;
}
// ...
void setIsAdmin(final Boolean isAdmin) {
this.isAdmin = isAdmin;
}
// ...
protected void setDelFlag(final boolean delFlag) {
this.delFlag = delFlag;
}
}
AccessLevel
枚举对应的用途:
AccessLevel.NONE
:不会生成对应的 Getter 或 Setter。AccessLevel.PRIVATE
:生成的 Getter 或 Setter 对应的访问修饰符是private
。AccessLevel.PACKAGE
:生成的 Getter 或 Setter 对应拥有包访问权限(即没有访问修饰符)。AccessLevel.PROTECTED
:生成的 Getter 或 Setter 对应的访问修饰符是protected
。
可以在类上使用@Getter
或@Setter
,相当于对所有属性都使用。比如上边的示例可以改写为:
@Getter
@Setter
public class User {
@Setter(AccessLevel.NONE)
private Long id;
@Setter(AccessLevel.PRIVATE)
private String name;
@Setter(AccessLevel.PACKAGE)
private Boolean isAdmin;
@Setter(AccessLevel.PROTECTED)
private boolean delFlag;
}
对应的字节码与之前的示例完全一致。
可以看到,在类上使用的@Getter
和@Setter
可以被属性上使用的@Getter
和@Setter
的设置覆盖。
Setter 的级联调用
默认情况下用@Setter
生成的 Setter 返回的是void
,所以不能用于“级联调用”,如果需要,可以用@Accessors
注解来实现Setter的级联调用:
@Getter
@Setter
@Accessors(chain = true)
public class User {
private Long id;
private String name;
private Boolean isAdmin;
private boolean delFlag;
}
这里设置了@Accessors
的属性chain=true
,现在生成的字节码中 Setter 将返回this
,而不是void
:
public class User {
// ...
public User setId(final Long id) {
this.id = var1;
return this;
}
// ...
}
所以可以用级联调用的方式使用 Setter:
User user = new User()
.setId(1L)
.setDelFlag(false)
.setIsAdmin(true)
.setName("icexmoon");
System.out.println(user.getName());
fluent
默认情况下 Lombok 生成的 Setter 命名都是setXXX
, 生成的 Getter 命名都是getXXX
或isXXX
,如果想要更简洁的命名,比如直接用属性名,可以这样:
@Getter
@Setter
@Accessors(fluent = true, chain = true)
public class Publisher {
private Long id;
private String name;
private LocalDate createDate;
}
通过设置@Accessors
的属性fluent=ture
,可以让 Lombok 生成的Setter 和 Getter 使用简洁的命名。
对应的字节码:
public class Publisher {
// ...
public Long id() {
return this.id;
}
public Publisher id(final Long id) {
this.id = id;
return this;
}
// ...
}
相应的调用示例:
private static void testAccessor2() {
Publisher publisher = new Publisher()
.id(1L)
.name("海南出版社")
.createDate(LocalDate.of(1991, 10, 1));
System.out.println(publisher.name());
}
boolean 属性
从很早以前我学习 Java 开始,我就习惯于将boolean
属性命名为isXXX
,但如果使用 Lombok,这就可能会产生一些潜在问题,比如:
@Getter
public class BoolExample {
private boolean isVal1;
}
这里的属性名为isVal1
,类型是boolean
,按照前边所说,生成的Getter应该是isIsVal1()
,这样命名多少有些古怪,实际上 Lombok 会考虑这样的问题,所以生成的真实的字节码是:
public class BoolExample {
private boolean isVal1;
public BoolExample() {
}
public boolean isVal1() {
return this.isVal1;
}
}
可以看到,对于命名为isXXX
的boolean
属性,Lombok 生成的 Getter 会命名为isXXX
。
乍一看这样并没有说明问题,但如果这样:
@Getter
public class BoolExample {
private boolean isVal1;
private boolean val1;
}
按照已经说过的规则,isVal1
对应的Getter应该是isVal1
,但val1
对应的Getter也应该命名为isVal1
,这无疑会产生冲突,实际上最后生成的字节码是:
public class BoolExample {
private boolean isVal1;
private boolean val1;
public BoolExample() {
}
public boolean isVal1() {
return this.isVal1;
}
}
可以看到,val1
属性的Getter并没有生成。
所以,最好在Java中不要将bool
或Boolean
类型的属性命名为isXXX
。
@ToString
使用@ToString
可以让 Lombok 自动生成toString
方法:
@Getter
@Setter
@Accessors(fluent = true, chain = true)
@ToString
public class Publisher {
private Long id;
private String name;
private LocalDate createDate;
}
System.out.println(publisher);
输出:
Publisher(id=1, name=海南出版社, createDate=1991-10-01)
默认的输出包含类名、属性名和属性值。
exclude
如果不需要输出属性名,可以:
@ToString(includeFieldNames = false)
输出:
Publisher(1, 海南出版社, 1991-10-01)
如果你不希望打印某些属性,可以:
@ToString(includeFieldNames = false, exclude = {"id"})
输出:
Publisher(海南出版社, 1991-10-01)
也可以在不希望输出的属性上使用@ToString.Exclude
注解,效果和上边的等同。比如:
@ToString(includeFieldNames = false)
public class Publisher {
@ToString.Exclude
private Long id;
// ...
}
include
如果你只希望输出某些属性,可以:
@ToString(includeFieldNames = false, onlyExplicitlyIncluded = true)
public class Publisher {
private Long id;
@ToString.Include
private String name;
@ToString.Include
private LocalDate createDate;
}
现在toString
方法只会输出name
和createDate
属性,如果有新加入的属性,也不会输出。
callSuper
默认情况下 Lombok 生成的toString
方法并不会调用父类的toString
方法,比如:
@Setter
@Getter
@Accessors(chain = true, fluent = true)
@ToString
public class SpecialPublisher extends Publisher{
private String admin;
}
测试:
private static void testToString() {
Publisher publisher = new SpecialPublisher()
.admin("icexmoon")
.name("海南出版社")
.id(1L)
.createDate(LocalDate.of(1991, 10, 1));
System.out.println(publisher);
}
输出:
SpecialPublisher(admin=icexmoon)
输出只包含了子类SpecialPublisher
中的属性。
如果需要包含父类的输出,可以:
@ToString(callSuper = true)
public class SpecialPublisher extends Publisher{
private String admin;
}
输出:
SpecialPublisher(super=Publisher(海南出版社, 1991-10-01), admin=icexmoon)
输出方法返回值
如果希望 Lombok 生成的toString
方法输出中包含某些方法的返回值,可以:
@ToString(callSuper = true)
public class SpecialPublisher extends Publisher {
private String admin;
@ToString.Include
private String hello() {
return "欢迎来到" + this.name() + "出版社";
}
}
输出:
SpecialPublisher(super=Publisher(海南出版社, 1991-10-01), admin=icexmoon, hello=欢迎来到海南出版社出版社)
最后的输出中包含了@ToString.Include
标记的方法的返回值。需要注意的是,用于输出的方法不能是静态(static)的,且不能包含任何参数(空参数列表)。
属性展示名称
可以用@ToString.Include
的name
属性修改toString
输出时的属性名称:
@ToString
public class Publisher {
@ToString.Include(name = "编号")
private Long id;
@ToString.Include(name = "出版社名称")
private String name;
@ToString.Include(name = "创建时间")
private LocalDate createDate;
}
输出:
Publisher(编号=1, 出版社名称=海南出版社, 创建时间=1991-10-01)
排序
可以用@ToString.Include
的rank
属性修改toString
输出属性的顺序:
@ToString
public class Publisher {
@ToString.Include(name = "编号")
private Long id;
@ToString.Include(name = "出版社名称", rank = 100)
private String name;
@ToString.Include(name = "创建时间", rank = 99)
private LocalDate createDate;
}
输出:
Publisher(出版社名称=海南出版社, 创建时间=1991-10-01, 编号=1)
rank
越大,在输出时越靠前。默认情况下rank
是0
,且rank
可以为负数。
@EqualsAndHashCode
可用注解@EqualsAndHashCode
生成equals
和hashCode
方法:
@EqualsAndHashCode
public class Publisher {
@ToString.Include(name = "编号")
private Long id;
@ToString.Include(name = "出版社名称", rank = 100)
private String name;
@ToString.Include(name = "创建时间", rank = 99)
private LocalDate createDate;
}
生成的字节码:
public class Publisher {
// ...
public boolean equals(final Object o) {
// ...
}
public int hashCode() {
// ...
}
}
equals
和hashCode
的详细代码可以下载文末的完整示例后自己编译查看。
Include
默认情况下生成的equals
和hashCode
会使用所有的非static
属性,换言之,调用equals
方法进行比较时,所有属性都相等才能返回true
。
有时候我们仅希望比较某些作为“主键”的属性,比如:
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Publisher {
@EqualsAndHashCode.Include
@ToString.Include(name = "编号")
private Long id;
@ToString.Include(name = "出版社名称", rank = 100)
private String name;
@ToString.Include(name = "创建时间", rank = 99)
private LocalDate createDate;
}
现在只要id
属性相等,两个Publisher
对象就相等(equals
返回true
)。
Exclude
和@ToString
类似,也可以使用“排除模式”:
@EqualsAndHashCode
public class Publisher {
@ToString.Include(name = "编号")
private Long id;
@ToString.Include(name = "出版社名称", rank = 100)
@EqualsAndHashCode.Exclude
private String name;
@ToString.Include(name = "创建时间", rank = 99)
@EqualsAndHashCode.Exclude
private LocalDate createDate;
}
callSuper
如果要将@EqualsAndHashCode
应用于子类,通常需要考虑父类的equals
和hashCode
方法,这可以用@EqualsAndHashCode
的callSuper
属性实现:
@EqualsAndHashCode(callSuper = true)
public class SpecialPublisher extends Publisher {
@EqualsAndHashCode.Exclude
private String admin;
@ToString.Include
private String hello() {
return "欢迎来到" + this.name() + "出版社";
}
}
在这个示例中,我们仅希望用Publisher.id
这个属性来作为比较和生成哈希值的依据,所以子类的admin
属性也被我们排除了。
生成构造器
Lombok 提供一些注解用于自动生成构造器:
@NoArgsConstructor
@RequiredArgsConstructor
@AllArgsConstructor
@NoArgsConstructor
@NoArgsConstructor
可以生成一个空的构造器:
@NoArgsConstructor
public class User {
private Long id;
private String name;
private Boolean isAdmin;
private boolean delFlag;
}
字节码:
public class User {
public User() {
}
}
如果有final
属性,这样做会导致一个编译错误:
@NoArgsConstructor
public class User {
private final Long id;
private String name;
private Boolean isAdmin;
private boolean delFlag;
}
错误信息:
java: 可能尚未初始化变量id
这时候可以:
@NoArgsConstructor(force = true)
Lombok 生成的字节码中会将final
属性用零值强制初始化:
public class User {
private final Long id = null;
// ...
}
不过这样做似乎没有什么意义,且可能造成潜在bug,所以尽量还是不要这么做。
@RequiredArgsConstructor
@RequiredArgsConstructor
可以为“需要的属性”生成一个用于初始化的构造器。
这里“需要的属性”,指用
final
或@NonNull
修饰且没有被初始化的属性。
示例:
@RequiredArgsConstructor
public class User {
private final Long id;
@NonNull
private String name;
@NonNull
private Boolean isAdmin = false;
private boolean delFlag;
}
字节码:
public class User {
// ...
public User(final Long id, @NonNull final String name) {
if (name == null) {
throw new NullPointerException("name is marked non-null but is null");
} else {
this.id = id;
this.name = name;
}
}
}
构造器中也会加入对
@NonNull
字段null
检查的if
语句,这点在之前的@NonNull
中有过介绍。
@AllArgsConstructor
@AllArgsConstructor
会为所有属性生成一个构造器:
@AllArgsConstructor
public class User {
private final Long id;
@NonNull
private String name;
@NonNull
private Boolean isAdmin = false;
private boolean delFlag;
}
字节码:
public class User {
// ...
public User(final Long id, @NonNull final String name, @NonNull final Boolean isAdmin, final boolean delFlag) {
if (name == null) {
throw new NullPointerException("name is marked non-null but is null");
} else if (isAdmin == null) {
throw new NullPointerException("isAdmin is marked non-null but is null");
} else {
this.id = id;
this.name = name;
this.isAdmin = isAdmin;
this.delFlag = delFlag;
}
}
}
staticName
上边的构造器都提供另外一种形式——将构造器本身定义为private
,并提供一个static
方法进行调用。
比如下面的示例:
@AllArgsConstructor(staticName = "of")
public class User {
private final Long id;
@NonNull
private String name;
@NonNull
private Boolean isAdmin = false;
private boolean delFlag;
}
对应的字节码:
public class User {
// ...
private User(final Long id, @NonNull final String name, @NonNull final Boolean isAdmin, final boolean delFlag) {
// ...
}
public static User of(final Long id, @NonNull final String name, @NonNull final Boolean isAdmin, final boolean delFlag) {
return new User(id, name, isAdmin, delFlag);
}
}
@Data
@Data
注解相当于同时使用了以下注解:
@Setter
@Getter
@RequiredArgsConstructor
@ToString
@EqualsAndHashCode
比如下面的示例:
@Setter
@Getter
@RequiredArgsConstructor
@ToString
@EqualsAndHashCode
public class Employee {
private final Long id;
@NonNull
private String name;
@NonNull
private Boolean delFlag;
}
和下面的是等效的:
@Data
public class Employee {
private final Long id;
@NonNull
private String name;
@NonNull
private Boolean delFlag;
}
实际上@Data
通常用来为实体类(POJO)提供基本的构造器、Setter和Getter,以及equals
和hashCode
方法。
如果我们需要为某个注解提供更详细的设置,比如将Employee
的id
视作主键,用于比较和生成哈希值,以及输出的toString
方法不包含键名和delFlag
,可以在使用@Data
注解的基础上使用对应的注解来设置:
@Data
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(includeFieldNames = false)
public class Employee {
@EqualsAndHashCode.Include
private final Long id;
@NonNull
private String name;
@NonNull
@ToString.Exclude
private Boolean delFlag;
}
staticConstructor
类似于@AllArgsConstructor
等,@Data
同样可以将构造器设置为私有的,同时提供一个static
方法用于调用构造器,比如:
@Data
public class Student<T> {
private final Long id;
@NonNull
private String name;
@NonNull
private Integer age;
@NonNull
private T something;
}
需要用以下方式创建对象:
Student<String> s = new Student<>(1L, "icexmoon", 20, "hello");
可以修改为:
@Data(staticConstructor = "of")
public class Student<T> {
private final Long id;
@NonNull
private String name;
@NonNull
private Integer age;
@NonNull
private T something;
}
此时这样调用:
Student<String> s = Student.of(1L, "icexmoon", 20, "hello");
因为静态方法会通过传入参数的类型来确定泛型参数,所以在使用Student.of
时并不需要指定方法的泛型参数。
@Value
用@Value
可以创建一些“只读”性质的类型:
@Value
public class BookCategory {
Long id;
String name;
String desc;
}
对应的字节码:
public final class BookCategory {
private final Long id;
private final String name;
private final String desc;
public BookCategory(final Long id, final String name, final String desc) {
this.id = id;
this.name = name;
this.desc = desc;
}
public Long getId() {
return this.id;
}
public String getName() {
return this.name;
}
public String getDesc() {
return this.desc;
}
public boolean equals(final Object o) {
// ...
}
public int hashCode() {
// ...
}
public String toString() {
// ...
}
}
可以看到,在字节码中,BookCategory
的非static
属性被private final
修饰,且只生成了Getter,没有生成Setter。所以BookCategory
的属性只能在生成的构造器中被初始化,且不能通过其他方式修改。
此外,BookCategory
本身也被final
修饰,也就是说被@Value
标记的类不能被继承。
所有上边这些特性,都标识——被@Value
标记的类可以作为一个只读的“数据类”来使用。
等效写法
实际上@Value
相当于下面的写法:
@ToString
@EqualsAndHashCode
@AllArgsConstructor
@Getter
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
public final class BookCategory {
Long id;
String name;
String desc;
}
示例
通常,在进行Web编程时,我们可以利用@Value
来创建DTO,因为这些DTO类用于传递数据,他们的属性在初始化后就不应该被修改。
比如下面这个示例:
@Value
public class BookCategory {
Long id;
String name;
String desc;
public static BookCategory newInstance(BookCategoryController.AddCategoryDTO dto) {
return new BookCategory(null, dto.getName(), dto.getDesc());
}
}
@Value
public class Result<T> {
boolean success;
String errorCode;
String errorMsg;
T data;
public static <T> Result<T> success(T data) {
return new Result<T>(true, "", "", data);
}
public static Result<Object> success() {
return success(null);
}
}
@RestController
@RequestMapping("/book/category")
public class BookCategoryController {
@Value
public static class AddCategoryDTO {
@NotBlank String name;
@NotBlank String desc;
}
@PostMapping
public Result<Object> addCategory(@Validated @RequestBody AddCategoryDTO addCategoryDTO) {
System.out.println(addCategoryDTO);
//用DTO生成POJO
BookCategory bookCategory = BookCategory.newInstance(addCategoryDTO);
System.out.println(bookCategory);
//用POJO在持久层添加新的图书类别
//这里省略持久层调用
return Result.success();
}
}
这里充当POJO的BookCategory
、充当DTO的AddCategoryDTO
,以及用于标准化返回的Result
,都用@Value
标识。因为这些类实际上都充当了传递数据的角色,并不涉及会改变内部属性的复杂业务逻辑。
事实上这些用于简单传递数据的类,从Java 10开始,可以用标准库的
Record
来实现,这点在之后的文章说明。
@Builder
利用@Builder
可以为类创建一个“创建器”,利用这个创建器可以创建对象。
比如下面这个示例:
@Builder
@Getter
@ToString
public class Person {
private final String name;
private final String city;
private final String job;
}
生成的字节码如下:
public class Person {
private final String name;
private final String city;
private final String job;
Person(final String name, final String city, final String job) {
this.name = name;
this.city = city;
this.job = job;
}
public static Person.PersonBuilder builder() {
return new Person.PersonBuilder();
}
// ... 这里是一些Getter 和 toString ...
public static class PersonBuilder {
private String name;
private String city;
private String job;
PersonBuilder() {
}
public Person.PersonBuilder name(final String name) {
this.name = name;
return this;
}
public Person.PersonBuilder city(final String city) {
this.city = city;
return this;
}
public Person.PersonBuilder job(final String job) {
this.job = job;
return this;
}
public Person build() {
return new Person(this.name, this.city, this.job);
}
public String toString() {
return "Person.PersonBuilder(name=" + this.name + ", city=" + this.city + ", job=" + this.job + ")";
}
}
}
@Builder
会为类创建一个包含所有非静态属性的构造器和一个静态的内嵌类xxxBuilder
,这个内嵌类包含所有外部类的非静态属性,并且可以利用这个内嵌类的一系列方法来一步步生成外部类的对象。
比如下面这样:
private static void testBuilder() {
var p = Person.builder().name("icexmoon")
.city("NanJin")
.job("Programmer")
.build();
System.out.println(p);
}
这样做的好处在于,虽然外部类Person
的属性都是final
的,并且只有Getter没有Setter,但是我们可以借助内部类PersonBuilder
来灵活地设置属性和生成对象。这在我们想用一个“只读”类,但是又不想用死板的构造器一次性初始化的情况下会格外有用。
要注意,生成的内嵌类
xxxBuilder
仅会为外部类未初始化的属性添加对应的内嵌类属性,并生成对应的内嵌类的Setter方法,并最终用于构建外部类对象。外部类被显式初始化的属性不在此列。
@Singular
默认情况下容器类型的属性的处理与其他属性一致,比如:
@Builder
@Getter
@ToString
public class Person {
private final String name;
private final String city;
private final String job;
private final List<String> hobbies;
}
调用示例:
private static void testBuilder() {
var p = Person.builder().name("icexmoon")
.city("NanJin")
.job("Programmer")
.hobbies(List.of("play games", "travel"))
.build();
System.out.println(p);
}
这里PersonBuilder.hobbies
仅是简单地用传入的List
作为最终的外部类对象的hobbies
属性。
@Builder
还提供一种模式:
@Builder
@Getter
@ToString
public class Person {
private final String name;
private final String city;
private final String job;
@Singular
private List<String> hobbies;
}
注意,这里的
hobbies
没有被final
修饰,实际测试时如果有final
,就无法生成PersonBuilder.hobbies
等相关的Setter方法,不知道是不是Bug。
生成的字节码:
// ...
public class Person {
// ...
public static class PersonBuilder {
// ...
public Person.PersonBuilder hobby(final String hobby) {
if (this.hobbies == null) {
this.hobbies = new ArrayList();
}
this.hobbies.add(hobby);
return this;
}
public Person.PersonBuilder hobbies(final Collection<? extends String> hobbies) {
if (hobbies == null) {
throw new NullPointerException("hobbies cannot be null");
} else {
if (this.hobbies == null) {
this.hobbies = new ArrayList();
}
this.hobbies.addAll(hobbies);
return this;
}
}
// ...
}
}
可以看到,PersonBuilder.hobbies
的行为改变了,变成用传入的List
与已有List
合并,此外还有一个新的PersonBuilder.hobby
方法,可以用这个方法逐一向List
添加元素。
调用示例:
private static void testBuilder() {
var p = Person.builder().name("icexmoon")
.city("NanJin")
.job("Programmer")
.hobbies(List.of("play games", "travel"))
.hobbies(List.of("draw"))
.hobby("music")
.hobby("movie")
.build();
System.out.println(p);
}
@Value
@Builder
可以和@Value
一同使用,比如之前的示例可以改写为:
@Builder
@Value
public class Person {
String name;
String city;
String job;
@Singular
List<String> hobbies;
}
要注意的是,@Value
会生成一个包含了所有属性的public
构造器,而@Builder
会生成一个包含所有属性的包访问权限的构造器,两者会发生冲突,此时后者会产生而前者不会。
对应的字节码和调用示例与之前的几乎一致,这里不再展示。
奇怪的是这里
@Value
会让hobbies
变成final
的,但是依然可以正常生成PersonBuilder.hobbies
。只能认为之前的是个Bug。
@Builder.Default
可以用@Builder.Default
为Builder
构建外部类时提供默认值(如果没有设置相应的值的话):
@Builder
@Value
public class Person {
String name;
String city;
String job;
@Singular
List<String> hobbies;
@Builder.Default
LocalDateTime createTime = LocalDateTime.now();
}
对应的字节码:
public final class Person {
// ...
private final LocalDateTime createTime;
private static LocalDateTime $default$createTime() {
return LocalDateTime.now();
}
// ...
public static class PersonBuilder {
// ...
private boolean createTime$set;
private LocalDateTime createTime$value;
public Person.PersonBuilder createTime(final LocalDateTime createTime) {
this.createTime$value = createTime;
this.createTime$set = true;
return this;
}
public Person build() {
// ...
LocalDateTime createTime$value = this.createTime$value;
if (!this.createTime$set) {
createTime$value = Person.$default$createTime();
}
return new Person(this.name, this.city, this.job, hobbies, createTime$value);
}
}
}
@SneakyThrows
如果我们的代码中包含方法声明中有throws
指明会抛出一个“被检查异常”的代码,那我们只有两种解决方式:
- 在当前方法声明中添加
throws
语句,指明当前方法也可能抛出该类型的“被检查异常”。 - 用
try...catch
捕获该异常,并处理(通常是将其包装成一个RuntimeException
并抛出。
之所以Java会这样设计,是因为早期Java的设计者认为某些异常必须要被调用放显式处理才行。但实际运用中,一层层调用过程中都要抛出一个“被检查异常”是相当繁琐的,且必须在每一层方法声明中都添加对应的throws
语句,所以将异常转化成RuntimeException
并抛出的解决方案使用频率反而更多。
但是,这种方式需要我们编写一些额外代码(try...catch
语句),因此 Lombok 提供一个@SneakyThrows
注解,可以帮助我们更简单的实现一个替代解决方案,并只需要添加一个注解。
看下面这个示例:
private static void callTestThrow() throws IOException{
testThrow();
}
private static void testThrow() throws IOException{
@Cleanup InputStream inputStream = new ClassPathResource("application.properties").getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
while (true){
String line = bufferedReader.readLine();
if (line == null){
break;
}
System.out.println(line);
}
}
创建输入流的相关代码可能会产生一个被检查异常IOException
,因此我们需要在testThrow
方法声明中添加throws
语句,这是Java语法强制要求的。并且,调用该方法的其他方法,比如callTestThrow
,同样需要处理这个被检查异常。
当然我们可以利用try...catch
将其转换为“非检查异常”:
private static void callTestThrow() {
testThrow();
}
private static void testThrow() {
try {
@Cleanup InputStream inputStream = new ClassPathResource("application.properties").getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
while (true) {
String line = bufferedReader.readLine();
if (line == null) {
break;
}
System.out.println(line);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@SneakyThrows
给了我们第三种选择:
private static void callTestThrow() {
testThrow();
}
@SneakyThrows(IOException.class)
private static void testThrow() {
@Cleanup InputStream inputStream = new ClassPathResource("application.properties").getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
while (true) {
String line = bufferedReader.readLine();
if (line == null) {
break;
}
System.out.println(line);
}
}
可以看到,@SneakyThrows(IOException.class)
的效果与使用try...catch
转换异常是一样的,调用方同样不需要显式处理异常。
我们看对应的字节码:
private static void callTestThrow() {
testThrow();
}
private static void testThrow() {
try {
// ...
} catch (IOException var7) {
throw var7;
}
}
这样看起来很奇怪,testThrow
捕获了IOException
异常并原样抛出,并且callTestThrow
中也没有处理这个“被检查异常”,这样并不符合Java语法。
Lombok 官方文档对此的说法是 Lombok 通过某种方式在JVM层面“欺骗”了编译器,所以可以实现类似的效果。
- 官方文档中的示例对应的字节码实现与这里我实际测试中产生的字节码有出入(
throw Lombok.sneakyThrow(t)
),原因不明。- Sneaky 一词在英语中有“悄悄地”意思,因此
@SneakyThrows
的用途可以被理解为“悄悄地抛出一个被检查异常”。- 关于Java异常的更多内容,可以阅读Java编程笔记10:异常 - 红茶的个人站点 (icexmoon.cn)。
@Synchronized
在Java中,可以通过synchronized
给方法调用“加锁”,并且这种方式可以和用synchronized
语句块用this
作为临界区的写法是可以协同工作的,比如:
public class ShareData {
public synchronized void func1() {
for (int i = 0; i < 5; i++) {
System.out.println("func1() is called.");
Thread.yield();
}
}
public void func2() {
synchronized (this) {
for (int i = 0; i < 5; i++) {
System.out.println("func2() is called.");
Thread.yield();
}
}
}
}
@SpringBootApplication
public class LombokApplication {
// ...
private static void testSyncronize() {
var sd = new ShareData();
new Thread(() -> sd.func1()).start();
new Thread(() -> sd.func2()).start();
}
}
输出:
func1() is called.
func1() is called.
func1() is called.
func1() is called.
func1() is called.
func2() is called.
func2() is called.
func2() is called.
func2() is called.
func2() is called.
这是一种特性,但有时候你或许不希望使用它。比如你可能担心某些用this
作为synchronized(...){}
语句临界区的代码其本意并非是与synchronized
方法互斥。
这种问题可以通过使用@Synchronized
注解来解决,比如:
public class ShareData {
@Synchronized
public void func1() {
for (int i = 0; i < 5; i++) {
System.out.println("func1() is called.");
Thread.yield();
}
}
public void func2() {
synchronized (this) {
for (int i = 0; i < 5; i++) {
System.out.println("func2() is called.");
Thread.yield();
}
}
}
}
输出:
func1() is called.
func2() is called.
func2() is called.
func2() is called.
func2() is called.
func1() is called.
func1() is called.
func1() is called.
func1() is called.
func2() is called.
可以看到func1
和func2
的相关代码实际上是并行的,并非互斥。
对应的字节码:
public class ShareData {
private final Object $lock = new Object[0];
public ShareData() {
}
public void func1() {
synchronized(this.$lock) {
for(int i = 0; i < 5; ++i) {
System.out.println("func1() is called.");
Thread.yield();
}
}
}
public void func2() {
synchronized(this) {
for(int i = 0; i < 5; ++i) {
System.out.println("func2() is called.");
Thread.yield();
}
}
}
}
可以看到,实际上@Synchronized
同样是使用synchronized(...){}
语句实现的,不过临界快并非使用的this
,而是 Lombok 自己添加的静态属性$lock
。因此,和使用this
作为临界区的synchronized
块并不互斥。
更多
synchronized
和并发内容可以阅读Java学习笔记21:并发(1) - 红茶的个人站点 (icexmoon.cn)。
如果对静态方法使用@Synchronized
,Lombok 会创建一个$LOCK
属性作为临界区:
public class ShareData {
// ...
@Synchronized
public static void func3(){
for (int i = 0; i < 5; i++) {
System.out.println("func3() is called.");
Thread.yield();
}
}
}
对应的字节码:
public class ShareData {
// ...
private static final Object $LOCK = new Object[0];
// ...
public static void func3() {
synchronized($LOCK) {
for(int i = 0; i < 5; ++i) {
System.out.println("func3() is called.");
Thread.yield();
}
}
}
}
- 值得注意的是,作为临界区的
$lock
和$LOCK
都是new Object[0]
(即一个元素类型为Object
且长度为0
的数组),而不是一般会使用的new Object
。这样做的好处是前者是可以序列化的,而后者不行。而序列化的时候必须确保所有属性都可以被序列化,因此前者不会阻止所在的类变成一个可序列化的类(implements Serializable
),而后者会,所以使用前者会更好一些。- 关于序列化的更多内容可以阅读Java编程笔记18:I/O(续) - 红茶的个人站点 (icexmoon.cn)中的序列化部分。
指定临界区
使用@Synchronized
时也可以自己指定一个属性作为临界区,比如:
public class ShareData {
private final Object lock1 = new Object[0];
private static final Object lock2 = new Object[0];
@Synchronized("lock1")
public void func1() {
//...
}
//...
@Synchronized("lock2")
public static void func3(){
//...
}
}
对应的字节码:
public class ShareData {
private final Object lock1 = new Object[0];
private static final Object lock2 = new Object[0];
public void func1() {
synchronized(this.lock1) {
// ...
}
}
// ...
public static void func3() {
synchronized(lock2) {
// ...
}
}
}
可以看到,此时 Lombok 不会再添加$lock
或$LOCK
,而是使用指定的属性作为synchronized
块的临界区。
@With
有时候虽然是一个“只读”的类,我们依然希望修改其中的某个属性,比如:
@Value
public class Dog {
String name;
Integer age;
}
Dog
的属性都是private final
的,显然它也不可能有setter。所以正常情况下我们是没法修改其中的属性的,但是我们可以选择创建一个新的和对象,只不过该对象中的属性都与原来对象一致,除了一个我们想变更的属性。比如下面的示例:
private static void testWith() {
Dog dog = new Dog("audi",11);
Dog dog2 = new Dog(dog.getName(), 2);
System.out.println(dog);
System.out.println(dog2);
}
不过上面的写法多少有点冗余,这时候自然是 Lombok 派上用场的时候了:
@Value
@With
public class Dog {
String name;
Integer age;
}
对应的字节码:
public final class Dog {
// ...
public Dog withName(final String name) {
return this.name == name ? this : new Dog(name, this.age);
}
public Dog withAge(final Integer age) {
return this.age == age ? this : new Dog(this.name, age);
}
}
- 要注意的是,Lombok 生成的
withXXX
方法的处理逻辑是用==
(不是equals
)比较属性值,如果与原始值一样,就返回原始对象(this
),否则创建新对象。withXXX
方法是用构造器创建新对象,因此@With
标记的类必须有一个包含所有属性的构造器(可以用@AllArgsConstructor
创建)。
此时上面的调用示例就可以改写为:
private static void testWith() {
Dog dog = new Dog("audi",11);
Dog dog2 = dog.withAge(2);
System.out.println(dog);
System.out.println(dog2);
}
只有两个属性的Dog
并不能说明便利性,但假如属性很多,使用@With
就会省很多事。
特定属性
可以只对特定属性生成withXXX
方法而非所有属性:
@Value
public class Dog {
String name;
@With
Integer age;
}
生成的字节码中只会有withAge
方法,而不会有withName
方法。
访问权限
默认情况下@With
生成的withXXX
方法的访问权限是public
,也可以指定其他访问权限,比如:
@Value
public class Dog {
String name;
@With(AccessLevel.PACKAGE)
Integer age;
}
此时生成的withAge
是包访问权限。
@Getter(lazy=true)
有时候,对于final
属性,会在声明时进行一些复杂(消耗时间)的初始化工作,比如:
@Getter
public class LazyExample {
private final long bigFibnacci = fibonacci(30);
private static long fibonacci(int n) {
if (n <= 2) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
如果对这个属性的使用并不是在对象创建后立即进行,我们可以将这种初始化动作延后,以减少对象创建时所消耗的时间。比如:
public class LazyExample {
private Long bigFibnacci;
private static long fibonacci(int n) {
if (n <= 2) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
public Long getBigFibnacci() {
if (bigFibnacci == null){
bigFibnacci = fibonacci(30);
}
return bigFibnacci;
}
}
这里的优化方案实际上并没有考虑到多线程调用的情况,因此是线程不安全的。
实际上 Lombok 的@Getter(lazy=true)
可以帮助我们更容易地实现类似的代码:
public class LazyExample {
@Getter(lazy = true)
private final Long bigFibnacci = fibonacci(30);
private static long fibonacci(int n) {
if (n <= 2) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
对应的字节码:
public class LazyExample {
private final AtomicReference<Object> bigFibnacci = new AtomicReference();
public LazyExample() {
}
private static long fibonacci(int n) {
return n <= 2 ? 1L : fibonacci(n - 1) + fibonacci(n - 2);
}
public Long getBigFibnacci() {
Object value = this.bigFibnacci.get();
if (value == null) {
synchronized(this.bigFibnacci) {
value = this.bigFibnacci.get();
if (value == null) {
Long actualValue = fibonacci(30);
value = actualValue == null ? this.bigFibnacci : actualValue;
this.bigFibnacci.set(value);
}
}
}
return (Long)(value == this.bigFibnacci ? null : value);
}
}
可以看到,Lombok 自动帮助我们实现了类似的代码,且使用了原子操作的相关类AtomicReference
,以及synchronized
语句,所以用@Getter(lazy=true)
实现的类似优化(延迟初始化)是可以用于多线程的,是线程安全的。
潜在问题
就像我们看到的,如果你用了@Getter(lazy=true)
,那么在类中调用该字段时就必须用getXXX
获取属性值,否则你获取到的就是一个AtomicReference<Object>
类型的对象,并且该对象还没有进行过“初始化”。
@Log
使用@Log
注解可以更方便地输出调试信息,比如:
@RestController
@RequestMapping("/book/category")
@Log
public class BookCategoryController {
// ...
@PostMapping
public Result<Object> addCategory(@Validated @RequestBody AddCategoryDTO addCategoryDTO) {
System.out.println(addCategoryDTO);
log.log(Level.INFO, addCategoryDTO.toString());
// ...
}
}
输出:
// ...
BookCategoryController.AddCategoryDTO(name=文学, desc=包括中国文学,外国文学等)
2023-06-04T10:42:03.928+08:00 INFO 17536 --- [nio-8080-exec-1] c.e.l.controller.BookCategoryController : BookCategoryController.AddCategoryDTO(name=文学, desc=包括中国文学,外国文学等)
// ...
可以看到,通过日志输出比起直接通过System.out
输出会显示更多信息,比如时间、线程编号和名称、日志级别等。
除了以上好处外,还包括:
- 可以通过设置方便地输出到文件。
- 可以通过设置让不同的运行环境(开发环境、测试环境等)输出不同包下不同的日志级别的日志。
当然,具体使用时还和你的Java应用使用的框架以及日志模块相关,比如 Spring Boot 默认使用 logback 作为日志模块,且支持多种方式的日志调用API,实际上上边的@Log
就是导入了java.util.logging
的相关日志API。这点在字节码中有体现:
package com.example.lombok.controller;
// ...
import java.util.logging.Logger;
// ...
public class BookCategoryController {
private static final Logger log = Logger.getLogger(BookCategoryController.class.getName());
// ...
}
当然也可以使用别的API,比如l4j2
的:
@Log4j2
public class BookCategoryController {
// ...
@PostMapping
public Result<Object> addCategory(@Validated @RequestBody AddCategoryDTO addCategoryDTO) {
System.out.println(addCategoryDTO);
log.debug(addCategoryDTO);
// ...
}
}
对应的字节码:
package com.example.lombok.controller;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
// ...
public class BookCategoryController {
private static final Logger log = LogManager.getLogger(BookCategoryController.class);
// ...
}
Spring Boot 默认不输出 DEBUG 级别日志,所以这里还需要在配置文件中添加
logging.level.com.example.lombok=debug
。
最后,总结一下,通过使用 Lombok 日志相关注解,可以更方便的引入和调用不同日志模块的API。
- 如果想了解其它日志模块对应的 Lombok 注解,可以阅读@Log (and friends) (projectlombok.org)。
- 如果想了解 Spring Boot 中的日志使用,可以阅读从零开始 Spring Boot 10:日志 - 红茶的个人站点 (icexmoon.cn)和从零开始 Spring Boot 34:日志 II - 红茶的个人站点 (icexmoon.cn)。
如果想了解 Lombok 的更多用法和说明,可以前往官方文档。
The End,谢谢阅读。
本文的完整示例可以从这里获取。
参考资料
- JEP 286: Local-Variable Type Inference (openjdk.org)
- var (projectlombok.org)
- @NonNull (projectlombok.org)
- Introduction to Project Lombok
- @Cleanup (projectlombok.org)
- @Getter and @Setter (projectlombok.org)
- @ToString (projectlombok.org)
- @EqualsAndHashCode (projectlombok.org)
- @Data (projectlombok.org)
- Introduction to Project Lombok | Baeldung
- @Value (projectlombok.org)
- @Builder (projectlombok.org)
- @Synchronized (projectlombok.org)
- @With (projectlombok.org)
- @Getter(lazy=true)(lazy=true) (projectlombok.org)
- 从零开始 Spring Boot 10:日志 - 红茶的个人站点 (icexmoon.cn)
- 从零开始 Spring Boot 34:日志 II - 红茶的个人站点 (icexmoon.cn)
- @Log (and friends) (projectlombok.org)