Lombok、Record、MapStruct 与 MapStructPlus 深度实践:从样板代码消除到对象转换体系设计

欢迎你来读这篇博客,这篇博客主要是关于 Lombok、Java Record、MapStruct、MapStructPlus 的系统梳理。

这几个技术点看起来都在解决“Java 代码太啰嗦”的问题,但它们解决的问题其实并不一样:

  • Lombok:减少 JavaBean、构造器、Builder、日志对象等样板代码。
  • Record:Java 语言层面对“数据载体”的建模能力。
  • MapStruct:编译期生成对象转换代码,解决 DTO、VO、Entity、BO 之间的字段映射。
  • MapStructPlus:在 MapStruct 基础上进一步减少 Mapper 接口编写成本,提供统一转换入口。

如果把 Java 项目比作厨房,Lombok 是自动切菜机,Record 是标准餐盒,MapStruct 是配菜师傅,MapStructPlus
是自动配菜流水线。都好用,但别拿自动切菜机去炒菜,也别拿餐盒去当锅。

序言

在 Spring Boot 后端开发中,我们经常会遇到大量类似的对象:

  • Entity:数据库实体。
  • DTO:接口入参或远程调用对象。
  • VO:接口返回对象。
  • BO:业务内部对象。
  • Command:写操作命令对象。
  • Query:查询参数对象。
  • Event:事件消息对象。
  • Response:统一响应对象。

这些对象字段高度相似,却又不完全一样。于是项目中会出现大量重复代码:

1
2
3
4
5
6
UserVO vo = new UserVO();
vo.setId(entity.getId());
vo.setUsername(entity.getUsername());
vo.setNickname(entity.getNickname());
vo.setCreateTime(entity.getCreateTime());
return vo;

一开始手写没什么问题,但项目大了以后,这种代码会变成“对象转换沼泽”:

  • 字段多,容易漏。
  • 字段改名,编译不一定报错。
  • 复制粘贴多,维护成本高。
  • BeanUtils 反射转换虽然省事,但类型不安全,性能和可维护性都一般。
  • 多层对象、枚举、时间格式、集合转换一多,代码就开始发麻。

所以我们需要一套比较清晰的组合方案:

  • Lombok 负责减少普通类的样板代码。
  • Record 负责表达不可变、纯数据的数据载体。
  • MapStruct 负责严谨、可控、类型安全的对象转换。
  • MapStructPlus 负责简单转换场景下的进一步自动化。

这篇文章会先把 Lombok 和 Record 讲透,再讲 MapStruct 和 MapStructPlus,最后给出它们在 Spring Boot 项目中的整合实践。


正文

第一章:Lombok 详解

1.1 Lombok 是什么

Lombok 是一个 Java 编译期增强工具,它通过注解处理器在编译阶段生成代码,从而减少
getter、setter、构造器、toString、equals、hashCode、builder 等样板代码。

普通 Java 写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class UserDTO {

private Long id;

private String username;

private String nickname;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getUsername() {
return username;
}

public void setUsername(String username) {
this.username = username;
}

public String getNickname() {
return nickname;
}

public void setNickname(String nickname) {
this.nickname = nickname;
}
}

使用 Lombok 后:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class UserDTO {

private Long id;

private String username;

private String nickname;
}

Lombok 的价值非常直接:减少重复代码,让开发者把注意力放到业务语义上。

但它也有一个很现实的问题:Lombok 会让源码和编译后的真实代码不一致。源码里没有 getter、setter,但编译后确实存在这些方法。所以团队使用
Lombok 时,必须统一 IDE 插件、编译配置、注解处理器配置,否则就会出现“我这里能编译,你那里红一片”的经典名场面。

1.2 Lombok 的工作原理

Lombok 的核心工作发生在编译期。

简单理解:

  1. Java 编译器读取源码。
  2. Lombok 注解处理器发现类上、字段上、方法上的 Lombok 注解。
  3. Lombok 修改抽象语法树。
  4. 编译器继续编译修改后的代码。
  5. 最终生成的 class 文件里包含 getter、setter、构造器等方法。

所以 Lombok 不是运行时反射工具,它不会在程序运行时动态生成 getter、setter。它的成本主要发生在编译期。

这也是为什么 Lombok 和 MapStruct 搭配时需要特别注意注解处理器顺序。MapStruct 需要看到 Lombok 生成的 getter、setter、builder
等方法,才能正确生成映射代码。

1.3 Lombok 常用注解总览

Lombok 常用注解可以分成几类:

1.3.1 Getter / Setter 相关

1
2
3
4
5
6
7
8
@Getter
@Setter
public class User {

private Long id;

private String username;
}

也可以只作用在字段上:

1
2
3
4
5
6
7
8
public class User {

@Getter
private Long id;

@Setter
private String username;
}

推荐写法:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class UserDTO {

private Long id;

private String username;

private String nickname;
}

不推荐为了偷懒直接所有地方都上 @Data@Data 虽然方便,但它会同时生成
getter、setter、toString、equals、hashCode、RequiredArgsConstructor,在 Entity、复杂领域对象上很容易埋坑。

1.4 @Data:方便,但不要滥用

@Data 等价于组合使用:

  • @Getter
  • @Setter
  • @ToString
  • @EqualsAndHashCode
  • @RequiredArgsConstructor

示例:

1
2
3
4
5
6
7
8
9
@Data
public class UserDTO {

private Long id;

private String username;

private String nickname;
}

它适合简单 DTO、简单配置对象、临时数据对象。

但是不建议在 JPA Entity、MyBatis 复杂实体、领域聚合根上无脑使用。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
@Data
@Entity
public class UserEntity {

@Id
private Long id;

private String username;

@OneToMany(mappedBy = "user")
private List<OrderEntity> orders;
}

这类写法可能带来几个问题:

  1. toString() 可能触发懒加载。
  2. 双向关联可能导致循环调用。
  3. equals()hashCode() 如果包含关联字段,可能出现性能问题或逻辑错误。
  4. 实体对象的相等性通常不应该简单用所有字段判断。
  5. setter 全开放会削弱领域对象的封装性。

更推荐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Entity
public class UserEntity {

@Id
private Long id;

private String username;

private String nickname;
}

如果确实需要 equals/hashCode,也应该明确指定字段:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class UserEntity {

@EqualsAndHashCode.Include
private Long id;

private String username;
}

结论很简单:@Data 很爽,但不是银弹。爽文能看,系统不能全靠爽文。

1.5 @Builder:构建复杂对象

当对象字段较多时,构造器会变得非常难读:

1
User user = new User(1L, "mario", "超级马里奥", 18, "ADMIN", true);

使用 @Builder 后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Getter
@Builder
public class User {

private Long id;

private String username;

private String nickname;

private Integer age;

private String role;

private Boolean enabled;
}

创建对象:

1
2
3
4
5
6
7
8
User user = User.builder()
.id(1L)
.username("mario")
.nickname("超级马里奥")
.age(18)
.role("ADMIN")
.enabled(true)
.build();

这种写法可读性更好,也更适合测试代码、DTO 构建、命令对象构建。

@Builder.Default

需要注意,如果字段有默认值:

1
2
3
4
5
6
@Getter
@Builder
public class User {

private Boolean enabled = true;
}

使用 builder 构建时,这个默认值可能不会按你想象的方式生效。应该写成:

1
2
3
4
5
6
7
@Getter
@Builder
public class User {

@Builder.Default
private Boolean enabled = true;
}

toBuilder

如果希望基于已有对象复制并修改:

1
2
3
4
5
6
7
8
9
10
@Getter
@Builder(toBuilder = true)
public class User {

private Long id;

private String username;

private String nickname;
}

使用:

1
2
3
User newUser = oldUser.toBuilder()
.nickname("新昵称")
.build();

这在不可变对象风格里非常实用。

1.6 构造器注解

Lombok 常用构造器注解:

1
2
3
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor

@NoArgsConstructor

生成无参构造器:

1
2
3
4
5
6
7
@NoArgsConstructor
public class User {

private Long id;

private String username;
}

JPA Entity 经常需要无参构造器,但建议保护起来:

1
2
3
4
5
6
7
8
9
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class UserEntity {

@Id
private Long id;

private String username;
}

这样既满足框架要求,又避免业务代码随便 new 一个半初始化对象。

@AllArgsConstructor

生成全字段构造器:

1
2
3
4
5
6
7
@AllArgsConstructor
public class User {

private Long id;

private String username;
}

@RequiredArgsConstructor

final 字段和 @NonNull 字段生成构造器。

非常适合 Spring 构造器注入:

1
2
3
4
5
6
7
8
@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;

private final UserMapper userMapper;
}

这比字段注入更清晰,也更方便单元测试。

1.7 @Value:Lombok 的不可变对象

@Value 可以理解为 Lombok 版的不可变数据类。

1
2
3
4
5
6
7
8
9
@Value
public class UserSnapshot {

Long id;

String username;

String nickname;
}

它通常会让字段变成 private final,生成 getter、全参构造器、toString、equals、hashCode,并且不生成 setter。

适合:

  • 值对象。
  • 快照对象。
  • 查询结果对象。
  • 事件消息对象。
  • 不希望被修改的数据对象。

不过 Java 已经有了 record,所以在 JDK 16+ 的项目中,如果只是表达纯数据载体,可以优先考虑 record。

1.8 @Accessors(chain = true):链式 setter

1
2
3
4
5
6
7
8
9
@Getter
@Setter
@Accessors(chain = true)
public class UserDTO {

private Long id;

private String username;
}

使用:

1
2
3
UserDTO dto = new UserDTO()
.setId(1L)
.setUsername("mario");

这种写法在 DTO 里还算方便,但在 Entity 或领域对象中要谨慎使用,因为链式 setter 会鼓励“随便改对象状态”。

1.9 @Slf4j

日志注解非常常用:

1
2
3
4
5
6
7
8
@Slf4j
@Service
public class UserService {

public void createUser() {
log.info("create user");
}
}

等价于手写:

1
private static final Logger log = LoggerFactory.getLogger(UserService.class);

推荐使用 @Slf4j,简单、清晰、没什么争议。

1.10 Lombok 在不同层的推荐用法

Entity 层

推荐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Entity
@Table(name = "user")
public class UserEntity {

@Id
private Long id;

private String username;

private String nickname;
}

谨慎:

1
2
3
4
@Data
@Entity
public class UserEntity {
}

不建议在复杂 Entity 上直接使用 @Data

DTO / VO 层

简单 DTO 可以:

1
2
3
4
5
6
7
@Data
public class UserDTO {

private Long id;

private String username;
}

更稳一点:

1
2
3
4
5
6
7
8
@Getter
@Setter
public class UserDTO {

private Long id;

private String username;
}

如果是只读返回对象,可以考虑 record:

1
2
3
4
5
6
public record UserVO(
Long id,
String username,
String nickname
) {
}

Service 层

推荐:

1
2
3
4
5
6
7
8
9
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;

private final UserMapper userMapper;
}

配置属性类

可以使用:

1
2
3
4
5
6
7
8
9
10
11
@Getter
@Setter
@ConfigurationProperties(prefix = "app.storage")
public class StorageProperties {

private String endpoint;

private String bucket;

private String accessKey;
}

如果项目使用较新 Spring Boot,也可以结合 record 做配置属性绑定,但要注意版本兼容性。


第二章:Java Record 详解

2.1 Record 是什么

Record 是 Java 语言提供的一种特殊类,用来表达“透明的数据载体”。

普通类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class UserVO {

private final Long id;

private final String username;

private final String nickname;

public UserVO(Long id, String username, String nickname) {
this.id = id;
this.username = username;
this.nickname = nickname;
}

public Long id() {
return id;
}

public String username() {
return username;
}

public String nickname() {
return nickname;
}

@Override
public String toString() {
return "UserVO[id=" + id + ", username=" + username + ", nickname=" + nickname + "]";
}

@Override
public boolean equals(Object o) {
// 省略
}

@Override
public int hashCode() {
// 省略
}
}

Record 写法:

1
2
3
4
5
6
public record UserVO(
Long id,
String username,
String nickname
) {
}

Java 会自动生成:

  • 私有 final 字段。
  • 全参构造器。
  • 访问器方法。
  • equals。
  • hashCode。
  • toString。

注意,record 的访问器不是 getId(),而是 id()

1
2
3
4
UserVO vo = new UserVO(1L, "mario", "超级马里奥");

Long id = vo.id();
String username = vo.username();

2.2 Record 的核心语义

Record 的重点不是“少写代码”,而是表达一种语义:

这个对象就是一组数据,它的状态由声明的组件完整决定。

例如:

1
2
3
4
5
public record Money(
BigDecimal amount,
String currency
) {
}

它表达的是:

  • Money 由 amount 和 currency 组成。
  • 这两个字段就是它的完整状态。
  • 它不应该有额外隐藏状态。
  • 它天然适合作为值对象。

这和 Lombok 的 @Data 完全不一样。

@Data 是在普通类上生成方法,普通类仍然可以有复杂状态、继承、setter、额外字段、复杂生命周期。

Record 是语言层面的一种特殊类,它天生偏不可变、偏数据建模。

2.3 Record 的基本语法

1
2
3
4
5
6
public record UserRegisterRequest(
String username,
String password,
String email
) {
}

创建对象:

1
2
3
4
5
UserRegisterRequest request = new UserRegisterRequest(
"mario",
"123456",
"mario@example.com"
);

访问字段:

1
2
String username = request.username();
String email = request.email();

打印:

1
System.out.println(request);

输出类似:

1
UserRegisterRequest[username=mario, password=123456, email=mario@example.com]

2.4 Record 的构造器

Record 有规范构造器,也可以使用紧凑构造器。

规范构造器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public record UserRegisterRequest(
String username,
String password,
String email
) {

public UserRegisterRequest(String username, String password, String email) {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("username cannot be blank");
}
if (password == null || password.length() < 6) {
throw new IllegalArgumentException("password length must >= 6");
}
this.username = username;
this.password = password;
this.email = email;
}
}

紧凑构造器

更推荐这种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public record UserRegisterRequest(
String username,
String password,
String email
) {

public UserRegisterRequest {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("username cannot be blank");
}
if (password == null || password.length() < 6) {
throw new IllegalArgumentException("password length must >= 6");
}
}
}

紧凑构造器里不需要写参数列表,也不需要手动 this.username = username

2.5 Record 中的数据规范化

Record 构造器很适合做数据规范化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public record Money(
BigDecimal amount,
String currency
) {

public Money {
if (amount == null) {
throw new IllegalArgumentException("amount cannot be null");
}
if (currency == null || currency.isBlank()) {
throw new IllegalArgumentException("currency cannot be blank");
}

amount = amount.setScale(2, RoundingMode.HALF_UP);
currency = currency.toUpperCase(Locale.ROOT);
}
}

这样创建出来的对象天然就是规范状态:

1
2
3
4
Money money = new Money(new BigDecimal("10.456"), "cny");

System.out.println(money.amount()); // 10.46
System.out.println(money.currency()); // CNY

2.6 Record 的不可变性

Record 的字段是 final 的,不能重新赋值。

1
2
3
4
5
public record UserVO(
Long id,
String username
) {
}

下面这种代码不存在:

1
vo.setUsername("newName");

但要注意:record 是浅不可变,不是深不可变。

例如:

1
2
3
4
5
public record UserRoles(
Long userId,
List<String> roles
) {
}

虽然 roles 字段引用不能换,但 List 本身可能还能被修改。

1
2
3
4
5
6
7
8
List<String> roles = new ArrayList<>();
roles.add("ADMIN");

UserRoles userRoles = new UserRoles(1L, roles);

roles.add("USER");

System.out.println(userRoles.roles()); // [ADMIN, USER]

更安全的写法:

1
2
3
4
5
6
7
8
9
public record UserRoles(
Long userId,
List<String> roles
) {

public UserRoles {
roles = List.copyOf(roles);
}
}

这样可以避免外部继续修改集合内容。

2.7 Record 可以定义方法

Record 不只能放字段,也可以定义普通方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
public record Money(
BigDecimal amount,
String currency
) {

public boolean isPositive() {
return amount.compareTo(BigDecimal.ZERO) > 0;
}

public String display() {
return amount + " " + currency;
}
}

也可以定义静态方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public record Money(
BigDecimal amount,
String currency
) {

public static Money cny(BigDecimal amount) {
return new Money(amount, "CNY");
}

public static Money usd(BigDecimal amount) {
return new Money(amount, "USD");
}
}

2.8 Record 可以实现接口

1
2
3
4
public interface Identifiable {

Long id();
}
1
2
3
4
5
public record UserVO(
Long id,
String username
) implements Identifiable {
}

使用:

1
2
Identifiable identifiable = new UserVO(1L, "mario");
System.out.println(identifiable.id());

2.9 Record 支持泛型

1
2
3
4
5
6
7
public record PageResult<T>(
List<T> records,
long total,
int pageNo,
int pageSize
) {
}

使用:

1
2
3
4
5
6
PageResult<UserVO> page = new PageResult<>(
List.of(new UserVO(1L, "mario")),
1,
1,
10
);

这是接口返回值中非常好用的模式。

2.10 Record 可以作为局部类

在方法内部定义临时数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Map<String, Long> countByType(List<OrderEntity> orders) {

record OrderTypeCount(String type, Long count) {
}

List<OrderTypeCount> list = orders.stream()
.collect(Collectors.groupingBy(OrderEntity::getType, Collectors.counting()))
.entrySet()
.stream()
.map(entry -> new OrderTypeCount(entry.getKey(), entry.getValue()))
.toList();

return list.stream()
.collect(Collectors.toMap(OrderTypeCount::type, OrderTypeCount::count));
}

这个特性很适合替代临时 Pair、Tuple、小 DTO。

2.11 Record 的限制

Record 有一些明确限制:

不能继承其他类

1
2
public record UserVO(Long id) extends BaseVO {
}

这种写法不允许。

Record 隐式继承 java.lang.Record

不能声明额外的实例字段

1
2
3
4
public record UserVO(Long id, String username) {

private String nickname;
}

这种不允许。

Record 的实例状态只能来自 record 组件。

字段天然 final

不能给组件生成 setter,也不能修改组件值。

不适合复杂可变对象

例如订单聚合根:

1
2
3
4
5
6
public record Order(
Long id,
List<OrderItem> items,
OrderStatus status
) {
}

如果订单需要频繁变更状态、添加明细、取消、支付、发货,record 就不适合做领域聚合根。

2.12 Record 和 Lombok 的区别

很多人会问:有了 record,是不是 Lombok 就不用了?

答案是:不是。

它们解决的问题不同。

对比项 Lombok Record
本质 编译期代码生成工具 Java 语言特性
目标 减少样板代码 表达透明数据载体
是否可变 可变、不可变都支持 默认偏不可变
getter 风格 getXxx() xxx()
setter 可以生成 不支持
继承普通类 可以 不可以
额外实例字段 可以 不可以
适合 Entity 可以,但慎用 @Data 通常不适合作为传统 JPA Entity
适合 DTO/VO 可以 非常适合只读 DTO/VO
适合领域值对象 可以 很适合
依赖工具 需要 Lombok 依赖和 IDE 支持 JDK 原生支持

2.13 什么时候用 Lombok,什么时候用 Record

推荐规则:

使用 Lombok 的场景

  • JPA Entity。
  • MyBatis Entity。
  • 需要 setter 的 DTO。
  • 需要 builder 的复杂对象。
  • Service 构造器注入。
  • 日志对象。
  • 兼容旧框架的 JavaBean。
  • 需要无参构造器的对象。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserCommand {

private Long id;

private String username;

private String nickname;
}

使用 Record 的场景

  • API 返回对象。
  • 查询结果对象。
  • 不可变请求对象。
  • 领域值对象。
  • 事件消息对象。
  • 临时组合数据。
  • Stream 分组后的中间结果。

示例:

1
2
3
4
5
6
7
public record UserDetailVO(
Long id,
String username,
String nickname,
String roleName
) {
}

不建议使用 Record 的场景

  • 传统 JPA Entity。
  • 需要框架反复修改字段的对象。
  • 生命周期复杂的领域聚合根。
  • 需要继承父类的对象。
  • 需要大量 setter 的对象。
  • 字段特别多且经常变的入参对象。

2.14 Record 在接口入参中的使用

可以这样写:

1
2
3
4
5
6
7
8
9
10
11
public record CreateUserRequest(
@NotBlank(message = "用户名不能为空")
String username,

@NotBlank(message = "密码不能为空")
String password,

@Email(message = "邮箱格式不正确")
String email
) {
}

Controller:

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {

private final UserAppService userAppService;

@PostMapping
public UserVO create(@Valid @RequestBody CreateUserRequest request) {
return userAppService.create(request);
}
}

返回对象:

1
2
3
4
5
6
7
public record UserVO(
Long id,
String username,
String nickname,
LocalDateTime createdAt
) {
}

这类用法非常清爽。


第三章:MapStruct 详解

3.1 为什么需要 MapStruct

在项目中,最常见的对象转换包括:

  • Entity 转 VO。
  • DTO 转 Entity。
  • Request 转 Command。
  • Entity 转 Event。
  • BO 转 Response。
  • List 转 List
  • Page 转 Page

手写转换最稳,但重复劳动太多。

使用 BeanUtils:

1
BeanUtils.copyProperties(entity, vo);

虽然省事,但问题也明显:

  1. 字段名错了,编译期不报错。
  2. 类型不匹配,运行时才可能发现。
  3. 复杂映射不好处理。
  4. 嵌套对象、枚举、时间格式处理麻烦。
  5. 反射性能不如直接方法调用。
  6. 出问题后排查不够直观。

MapStruct 的核心价值是:

在编译期生成类型安全、性能接近手写代码的对象转换代码。

3.2 MapStruct 的基本使用

Maven 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

<properties>
<java.version>17</java.version>
<mapstruct.version>1.6.3</mapstruct.version>
<lombok.version>1.18.46</lombok.version>
<lombok.mapstruct.binding.version>0.2.0</lombok.mapstruct.binding.version>
</properties>

<dependencies>
<!-- MapStruct API -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>

编译插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok.mapstruct.binding.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

lombok-mapstruct-binding 很关键。它用于解决 Lombok 和 MapStruct 组合时的注解处理顺序问题。

3.3 基础映射

Entity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity {

private Long id;

private String username;

private String nickname;

private String email;
}

VO:

1
2
3
4
5
6
public record UserVO(
Long id,
String username,
String nickname
) {
}

Mapper:

1
2
3
4
5
6
7
@Mapper(componentModel = "spring")
public interface UserMapper {

UserVO toVO(UserEntity entity);

List<UserVO> toVOList(List<UserEntity> entities);
}

Service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Service
@RequiredArgsConstructor
public class UserQueryService {

private final UserMapper userMapper;

public UserVO detail(Long id) {
UserEntity entity = getById(id);
return userMapper.toVO(entity);
}

private UserEntity getById(Long id) {
return UserEntity.builder()
.id(id)
.username("mario")
.nickname("超级马里奥")
.email("mario@example.com")
.build();
}
}

MapStruct 会自动生成 UserMapperImpl,并交给 Spring 管理。

3.4 字段名不同的映射

Entity:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class UserEntity {

private Long id;

private String username;

private String nickname;
}

VO:

1
2
3
4
5
6
public record UserVO(
Long userId,
String account,
String nickname
) {
}

Mapper:

1
2
3
4
5
6
7
@Mapper(componentModel = "spring")
public interface UserMapper {

@Mapping(target = "userId", source = "id")
@Mapping(target = "account", source = "username")
UserVO toVO(UserEntity entity);
}

3.5 忽略字段

1
2
3
4
5
6
@Mapper(componentModel = "spring")
public interface UserMapper {

@Mapping(target = "password", ignore = true)
UserEntity toEntity(CreateUserRequest request);
}

适合密码、状态、创建时间、ID 等不应该由外部请求直接控制的字段。

3.6 默认值和常量

1
2
3
4
5
6
7
@Mapper(componentModel = "spring")
public interface UserMapper {

@Mapping(target = "status", constant = "ENABLE")
@Mapping(target = "deleted", constant = "false")
UserEntity toEntity(CreateUserRequest request);
}

默认值:

1
2
3
4
5
6
@Mapper(componentModel = "spring")
public interface UserMapper {

@Mapping(target = "nickname", source = "nickname", defaultValue = "默认昵称")
UserEntity toEntity(CreateUserRequest request);
}

3.7 表达式映射

1
2
3
4
5
6
@Mapper(componentModel = "spring")
public interface UserMapper {

@Mapping(target = "createdAt", expression = "java(java.time.LocalDateTime.now())")
UserEntity toEntity(CreateUserRequest request);
}

表达式适合简单逻辑,不建议在表达式里写复杂业务。复杂逻辑应该放到 Service 或专门的转换方法中。

3.8 枚举映射

枚举:

1
2
3
4
5
6
7
8
9
@Getter
@RequiredArgsConstructor
public enum UserStatus {

ENABLE("启用"),
DISABLE("禁用");

private final String desc;
}

Entity:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class UserEntity {

private Long id;

private String username;

private UserStatus status;
}

VO:

1
2
3
4
5
6
7
public record UserVO(
Long id,
String username,
String status,
String statusDesc
) {
}

Mapper:

1
2
3
4
5
6
7
@Mapper(componentModel = "spring")
public interface UserMapper {

@Mapping(target = "status", source = "status")
@Mapping(target = "statusDesc", source = "status.desc")
UserVO toVO(UserEntity entity);
}

如果需要把枚举转成字符串:

1
2
3
default String map(UserStatus status) {
return status == null ? null : status.name();
}

3.9 嵌套对象映射

Entity:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class OrderEntity {

private Long id;

private String orderNo;

private UserEntity user;
}

VO:

1
2
3
4
5
6
7
public record OrderVO(
Long id,
String orderNo,
Long userId,
String username
) {
}

Mapper:

1
2
3
4
5
6
7
@Mapper(componentModel = "spring")
public interface OrderMapper {

@Mapping(target = "userId", source = "user.id")
@Mapping(target = "username", source = "user.username")
OrderVO toVO(OrderEntity entity);
}

3.10 集合映射

1
2
3
4
5
6
7
8
9
@Mapper(componentModel = "spring")
public interface UserMapper {

UserVO toVO(UserEntity entity);

List<UserVO> toVOList(List<UserEntity> entities);

Set<UserVO> toVOSet(Set<UserEntity> entities);
}

MapStruct 会自动循环转换集合元素。

3.11 更新已有对象:@MappingTarget

在修改场景中,不建议直接 new 一个新 Entity 覆盖旧对象,因为可能会丢失数据库已有字段。

请求对象:

1
2
3
4
5
public record UpdateUserRequest(
String nickname,
String email
) {
}

Mapper:

1
2
3
4
5
6
@Mapper(componentModel = "spring")
public interface UserMapper {

@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void updateEntity(UpdateUserRequest request, @MappingTarget UserEntity entity);
}

Service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
@RequiredArgsConstructor
public class UserCommandService {

private final UserMapper userMapper;

public void update(Long id, UpdateUserRequest request) {
UserEntity entity = getById(id);

userMapper.updateEntity(request, entity);

save(entity);
}

private UserEntity getById(Long id) {
return new UserEntity();
}

private void save(UserEntity entity) {
// 保存
}
}

NullValuePropertyMappingStrategy.IGNORE 的作用是:请求字段为 null 时,不覆盖原对象字段。

这在 PATCH 更新接口中特别有用。

3.12 严格检查未映射字段

建议在项目中开启严格策略:

1
2
3
4
5
6
7
8
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface UserMapper {

UserVO toVO(UserEntity entity);
}

这样当目标对象新增字段但 Mapper 没有处理时,编译会失败。

别怕编译失败。编译期失败是队友,运行期炸锅才是敌人。

3.13 公共 Mapper 配置

可以统一配置:

1
2
3
4
5
6
7
@MapperConfig(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
injectionStrategy = InjectionStrategy.CONSTRUCTOR
)
public interface CentralMapperConfig {
}

具体 Mapper:

1
2
3
4
5
@Mapper(config = CentralMapperConfig.class)
public interface UserMapper {

UserVO toVO(UserEntity entity);
}

这样可以避免每个 Mapper 重复写配置。

3.14 MapStruct 和 Lombok Builder 整合

对象:

1
2
3
4
5
6
7
8
@Getter
@Builder
public class UserCommand {

private final String username;

private final String nickname;
}

Mapper:

1
2
3
4
5
@Mapper(componentModel = "spring")
public interface UserCommandMapper {

UserCommand toCommand(CreateUserRequest request);
}

如果 Lombok 和 MapStruct 配置正确,MapStruct 可以识别 Lombok Builder。

如果识别失败,优先检查:

  1. 是否配置了 mapstruct-processor
  2. 是否配置了 lombok annotation processor。
  3. 是否配置了 lombok-mapstruct-binding
  4. IDE 是否开启 annotation processing。
  5. Maven 和 IDE 使用的 JDK 是否一致。

3.15 MapStruct 和 Record 整合

Record:

1
2
3
4
5
6
public record UserVO(
Long id,
String username,
String nickname
) {
}

Entity:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class UserEntity {

private Long id;

private String username;

private String nickname;
}

Mapper:

1
2
3
4
5
@Mapper(componentModel = "spring")
public interface UserMapper {

UserVO toVO(UserEntity entity);
}

MapStruct 可以调用 record 的构造器完成对象创建。

如果字段名不一致:

1
2
3
4
5
public record UserVO(
Long userId,
String accountName
) {
}

Mapper:

1
2
3
4
5
6
7
@Mapper(componentModel = "spring")
public interface UserMapper {

@Mapping(target = "userId", source = "id")
@Mapping(target = "accountName", source = "username")
UserVO toVO(UserEntity entity);
}

第四章:MapStructPlus 详解

4.1 MapStructPlus 是什么

MapStructPlus 是对 MapStruct 的增强。它的目标是继续减少 MapStruct 的样板代码。

MapStruct 的标准写法需要手动定义 Mapper 接口:

1
2
3
4
5
@Mapper(componentModel = "spring")
public interface UserMapper {

UserVO toVO(UserEntity entity);
}

MapStructPlus 的思路是:在源对象或目标对象上加注解,让框架自动生成转换器,然后通过统一的 Converter 调用。

例如:

1
2
3
4
5
6
7
8
9
10
11
@Getter
@Setter
@AutoMapper(target = UserVO.class)
public class UserEntity {

private Long id;

private String username;

private String nickname;
}

VO:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class UserVO {

private Long id;

private String username;

private String nickname;
}

使用:

1
2
3
4
5
6
7
8
9
10
@Service
@RequiredArgsConstructor
public class UserQueryService {

private final Converter converter;

public UserVO detail(UserEntity entity) {
return converter.convert(entity, UserVO.class);
}
}

这就省掉了显式 Mapper 接口。

4.2 MapStructPlus Maven 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

<properties>
<java.version>17</java.version>
<mapstruct-plus.version>1.5.0</mapstruct-plus.version>
<lombok.version>1.18.46</lombok.version>
</properties>

<dependencies>
<dependency>
<groupId>io.github.linpeilie</groupId>
<artifactId>mapstruct-plus-spring-boot-starter</artifactId>
<version>${mapstruct-plus.version}</version>
</dependency>

<dependency>
<groupId>io.github.linpeilie</groupId>
<artifactId>mapstruct-plus-processor</artifactId>
<version>${mapstruct-plus.version}</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>

编译插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>io.github.linpeilie</groupId>
<artifactId>mapstruct-plus-processor</artifactId>
<version>${mapstruct-plus.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

如果你同时直接使用 MapStruct 和 Lombok,也建议补充 lombok-mapstruct-binding

4.3 基础转换示例

Entity:

1
2
3
4
5
6
7
8
9
10
11
@Getter
@Setter
@AutoMapper(target = UserVO.class)
public class UserEntity {

private Long id;

private String username;

private String nickname;
}

VO:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class UserVO {

private Long id;

private String username;

private String nickname;
}

Service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
@RequiredArgsConstructor
public class UserQueryService {

private final Converter converter;

public UserVO detail(UserEntity entity) {
return converter.convert(entity, UserVO.class);
}

public List<UserVO> list(List<UserEntity> entities) {
return converter.convert(entities, UserVO.class);
}
}

4.4 字段名不一致

1
2
3
4
5
6
7
8
9
10
11
12
13
@Getter
@Setter
@AutoMapper(target = UserVO.class)
public class UserEntity {

@AutoMapping(target = "userId")
private Long id;

@AutoMapping(target = "accountName")
private String username;

private String nickname;
}

VO:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
public class UserVO {

private Long userId;

private String accountName;

private String nickname;
}

如果字段映射规则复杂,建议退回到原生 MapStruct 显式 Mapper。自动化很好,但别把自动化当玄学许愿池。

4.5 多目标对象转换

一个 Entity 可能要转多个对象:

  • UserListVO
  • UserDetailVO
  • UserExportVO
  • UserEvent

可以使用多个自动映射注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Getter
@Setter
@AutoMappers({
@AutoMapper(target = UserListVO.class),
@AutoMapper(target = UserDetailVO.class),
@AutoMapper(target = UserExportVO.class)
})
public class UserEntity {

private Long id;

private String username;

private String nickname;

private String email;

private LocalDateTime createdAt;
}

使用:

1
2
3
4
5
UserListVO listVO = converter.convert(entity, UserListVO.class);

UserDetailVO detailVO = converter.convert(entity, UserDetailVO.class);

UserExportVO exportVO = converter.convert(entity, UserExportVO.class);

4.6 MapStructPlus 适合什么场景

适合:

  • 字段名大部分一致。
  • 简单 DTO、VO 转换。
  • 列表转换。
  • 项目中转换对象非常多,但逻辑比较简单。
  • 想统一使用 Converter 的团队。
  • 快速开发中后台 CRUD 系统。

不适合:

  • 字段映射规则复杂。
  • 涉及大量业务逻辑。
  • 涉及多个聚合对象组装。
  • 字段差异很大。
  • 对编译速度非常敏感的大型项目。
  • 需要非常明确 Mapper 边界的核心领域模块。

推荐策略:

  • 简单转换:MapStructPlus。
  • 复杂转换:MapStruct。
  • 核心业务转换:显式 MapStruct 或手写。
  • 不确定转换:宁愿手写,也不要让自动生成逻辑变成黑盒。

第五章:Lombok、Record、MapStruct、MapStructPlus 的组合实践

5.1 推荐分层模型

一个比较清晰的后端项目可以这样分层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
controller
└── request / response

application
└── command / query / service

domain
└── entity / value object / aggregate

infrastructure
└── persistence entity / mapper / repository

interfaces
└── event / rpc dto

对象使用建议:

层级 对象类型 推荐技术
Controller 入参 Request record 或 Lombok DTO
Controller 出参 Response / VO record
Application 命令 Command record 或 Lombok @Value
Domain 值对象 Value Object record 或普通不可变类
Domain 聚合根 Aggregate 普通类 + 少量 Lombok
Persistence Entity Entity / DO Lombok
对象转换 Mapper MapStruct
简单对象批量转换 Converter MapStructPlus

5.2 一个完整示例:财务单据创建

5.2.1 Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Getter
@Setter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class FinanceBillEntity {

private Long id;

private String billNo;

private Long customerId;

private BigDecimal amount;

private BillStatus status;

private LocalDateTime createdAt;

private LocalDateTime updatedAt;
}

枚举:

1
2
3
4
5
6
7
8
9
10
@Getter
@RequiredArgsConstructor
public enum BillStatus {

WAIT_SETTLE("待结算"),
SETTLED("已结算"),
CANCELLED("已取消");

private final String desc;
}

5.2.2 Request 使用 record

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public record FinanceBillCreateRequest(
@NotNull(message = "客户ID不能为空")
Long customerId,

@NotNull(message = "金额不能为空")
@DecimalMin(value = "0.01", message = "金额必须大于0")
BigDecimal amount
) {

public FinanceBillCreateRequest {
if (amount != null) {
amount = amount.setScale(2, RoundingMode.HALF_UP);
}
}
}

5.2.3 Response 使用 record

1
2
3
4
5
6
7
8
9
10
public record FinanceBillResponse(
Long id,
String billNo,
Long customerId,
BigDecimal amount,
String status,
String statusDesc,
LocalDateTime createdAt
) {
}

5.2.4 Mapper 使用 MapStruct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
injectionStrategy = InjectionStrategy.CONSTRUCTOR
)
public interface FinanceBillMapper {

@Mapping(target = "id", ignore = true)
@Mapping(target = "billNo", ignore = true)
@Mapping(target = "status", constant = "WAIT_SETTLE")
@Mapping(target = "createdAt", expression = "java(java.time.LocalDateTime.now())")
@Mapping(target = "updatedAt", expression = "java(java.time.LocalDateTime.now())")
FinanceBillEntity toEntity(FinanceBillCreateRequest request);

@Mapping(target = "status", expression = "java(entity.getStatus() == null ? null : entity.getStatus().name())")
@Mapping(target = "statusDesc", source = "status.desc")
FinanceBillResponse toResponse(FinanceBillEntity entity);

List<FinanceBillResponse> toResponseList(List<FinanceBillEntity> entities);
}

5.2.5 Service 使用 Lombok 构造器注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
@RequiredArgsConstructor
public class FinanceBillService {

private final FinanceBillMapper financeBillMapper;

public FinanceBillResponse create(FinanceBillCreateRequest request) {
FinanceBillEntity entity = financeBillMapper.toEntity(request);

entity.setBillNo(generateBillNo());

save(entity);

return financeBillMapper.toResponse(entity);
}

private String generateBillNo() {
return "BILL" + System.currentTimeMillis();
}

private void save(FinanceBillEntity entity) {
// 保存到数据库
}
}

5.2.6 Controller

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequiredArgsConstructor
@RequestMapping("/finance-bills")
public class FinanceBillController {

private final FinanceBillService financeBillService;

@PostMapping
public FinanceBillResponse create(@Valid @RequestBody FinanceBillCreateRequest request) {
return financeBillService.create(request);
}
}

这个组合里:

  • Entity 用 Lombok,因为它需要被持久化框架操作。
  • Request 用 record,因为它是请求数据载体。
  • Response 用 record,因为它是只读返回数据。
  • Mapper 用 MapStruct,因为转换规则比较明确。
  • Service 用 Lombok 的 @RequiredArgsConstructor,减少构造器样板代码。

这就是比较舒服的现代 Java 后端写法。

5.3 修改接口:MapStruct 更新已有 Entity

Update Request:

1
2
3
4
public record FinanceBillUpdateRequest(
BigDecimal amount
) {
}

Mapper:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface FinanceBillMapper {

@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@Mapping(target = "id", ignore = true)
@Mapping(target = "billNo", ignore = true)
@Mapping(target = "customerId", ignore = true)
@Mapping(target = "status", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", expression = "java(java.time.LocalDateTime.now())")
void updateEntity(FinanceBillUpdateRequest request, @MappingTarget FinanceBillEntity entity);
}

Service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
@RequiredArgsConstructor
public class FinanceBillService {

private final FinanceBillMapper financeBillMapper;

public FinanceBillResponse update(Long id, FinanceBillUpdateRequest request) {
FinanceBillEntity entity = getById(id);

financeBillMapper.updateEntity(request, entity);

save(entity);

return financeBillMapper.toResponse(entity);
}

private FinanceBillEntity getById(Long id) {
return new FinanceBillEntity();
}

private void save(FinanceBillEntity entity) {
// 保存
}
}

这比直接 BeanUtils.copyProperties 稳得多。

5.4 查询列表:MapStructPlus 简化简单转换

如果只是简单列表查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Getter
@Setter
@AutoMapper(target = FinanceBillListVO.class)
public class FinanceBillEntity {

private Long id;

private String billNo;

private Long customerId;

private BigDecimal amount;

private BillStatus status;

private LocalDateTime createdAt;
}

VO:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Getter
@Setter
public class FinanceBillListVO {

private Long id;

private String billNo;

private Long customerId;

private BigDecimal amount;

private BillStatus status;

private LocalDateTime createdAt;
}

Service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
@RequiredArgsConstructor
public class FinanceBillQueryService {

private final Converter converter;

public List<FinanceBillListVO> list() {
List<FinanceBillEntity> entities = queryList();

return converter.convert(entities, FinanceBillListVO.class);
}

private List<FinanceBillEntity> queryList() {
return List.of();
}
}

这种简单转换就可以交给 MapStructPlus。

5.5 更推荐的组合边界

写操作:优先 MapStruct

写操作涉及状态变更、字段忽略、默认值、安全字段保护,因此推荐显式 MapStruct。

1
2
3
4
CreateRequest -> Entity
UpdateRequest -> @MappingTarget Entity
Command -> Entity
Entity -> Event

读操作:简单场景可用 MapStructPlus

读操作很多时候只是 Entity 转 VO:

1
2
3
Entity -> ListVO
Entity -> DetailVO
Entity -> ExportVO

如果字段基本一致,可以用 MapStructPlus 提效。

核心领域:少用自动转换

核心领域逻辑不要过度依赖自动映射。

例如:

1
OrderEntity -> OrderAggregate

这种转换可能涉及:

  • 订单主表。
  • 订单明细。
  • 支付信息。
  • 优惠信息。
  • 状态机。
  • 金额计算。
  • 领域规则。

这类场景建议手写装配逻辑,或者使用非常明确的 MapStruct Mapper,不建议用过度自动化的转换。


第六章:项目级配置建议

6.1 Maven 推荐配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

<properties>
<java.version>17</java.version>
<lombok.version>1.18.46</lombok.version>
<mapstruct.version>1.6.3</mapstruct.version>
<lombok.mapstruct.binding.version>0.2.0</lombok.mapstruct.binding.version>
<mapstruct-plus.version>1.5.0</mapstruct-plus.version>
</properties>

<dependencies>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>

<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>

<!-- MapStructPlus -->
<dependency>
<groupId>io.github.linpeilie</groupId>
<artifactId>mapstruct-plus-spring-boot-starter</artifactId>
<version>${mapstruct-plus.version}</version>
</dependency>
</dependencies>

编译插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<path>
<groupId>io.github.linpeilie</groupId>
<artifactId>mapstruct-plus-processor</artifactId>
<version>${mapstruct-plus.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok.mapstruct.binding.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

6.2 IDE 配置

IntelliJ IDEA 中需要开启:

1
2
3
4
5
Settings
-> Build, Execution, Deployment
-> Compiler
-> Annotation Processors
-> Enable annotation processing

否则 IDE 可能会报红,但 Maven 编译正常。这个问题特别烦,就像代码没错但 IDE 在旁边阴阳怪气。

6.3 MapStruct 统一配置

1
2
3
4
5
6
7
@MapperConfig(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
injectionStrategy = InjectionStrategy.CONSTRUCTOR
)
public interface GlobalMapperConfig {
}

使用:

1
2
3
4
5
@Mapper(config = GlobalMapperConfig.class)
public interface UserMapper {

UserVO toVO(UserEntity entity);
}

6.4 Lombok 配置文件

项目根目录可以增加 lombok.config

1
2
3
4
5
config.stopBubbling = true

lombok.addLombokGeneratedAnnotation = true

lombok.anyConstructor.addConstructorProperties = true

如果团队希望限制某些注解,也可以配置:

1
2
lombok.data.flagUsage = warning
lombok.val.flagUsage = warning

这样可以提醒团队不要滥用 @Data


第七章:常见踩坑

7.1 Lombok 和 MapStruct 编译报错

典型问题:

1
No property named "xxx" exists in source parameter

可能原因:

  • Lombok 没有生效。
  • IDE 没有开启 annotation processing。
  • Maven compiler plugin 没有配置 Lombok processor。
  • 缺少 lombok-mapstruct-binding
  • getter/setter 生成规则和 MapStruct 识别规则不一致。

解决:

1
2
3
4
5
6

<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>

7.2 Entity 上乱用 @Data

不推荐:

1
2
3
4
5
6
7
@Data
@Entity
public class OrderEntity {

@OneToMany(mappedBy = "order")
private List<OrderItemEntity> items;
}

推荐:

1
2
3
4
5
6
7
8
9
10
11
12
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class OrderEntity {

@Id
private Long id;

@OneToMany(mappedBy = "order")
private List<OrderItemEntity> items;
}

如果需要 equals/hashCode,明确指定 ID:

1
2
3
4
5
6
7
8
9
10
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@Entity
public class OrderEntity {

@EqualsAndHashCode.Include
@Id
private Long id;
}

7.3 Record 不是 JavaBean

Record 的访问器是:

1
2
user.id();
user.username();

不是:

1
2
user.getId();
user.getUsername();

大多数现代框架已经支持 record,但如果你用的是老框架、老版本 JSON 库、老版本 ORM,就要先确认兼容性。

7.4 Record 不适合做传统 JPA Entity

传统 JPA Entity 通常需要:

  • 无参构造器。
  • 可变字段。
  • 代理增强。
  • 生命周期回调。
  • 关联关系。
  • 延迟加载。

Record 天然 final、字段 final、没有 setter,也不能继承普通类,所以不适合做传统 JPA Entity。

但在较新的 Hibernate 中,record 可以在部分值对象、Embeddable、查询投影场景中使用。实际项目里建议谨慎使用,先验证框架版本和运行行为。

7.5 MapStruct 表达式不要塞业务逻辑

不推荐:

1
2
3
4
5
@Mapping(
target = "amount",
expression = "java(request.getPrice().multiply(request.getQuantity()).subtract(request.getDiscount()).setScale(2, java.math.RoundingMode.HALF_UP))"
)
OrderEntity toEntity(CreateOrderRequest request);

推荐把复杂逻辑放到业务服务中:

1
2
3
4
5
6
7
8
9
10
@Service
public class AmountCalculator {

public BigDecimal calculate(CreateOrderRequest request) {
return request.price()
.multiply(BigDecimal.valueOf(request.quantity()))
.subtract(request.discount())
.setScale(2, RoundingMode.HALF_UP);
}
}

Mapper 只做映射,不要变成业务垃圾桶。

7.6 MapStructPlus 不要过度自动化

MapStructPlus 很适合简单转换,但如果项目里几百个对象都自动生成转换,有可能导致生成代码膨胀、编译速度变慢、转换关系不够清晰。

建议:

  • 简单列表 VO:用 MapStructPlus。
  • 复杂详情 VO:用 MapStruct。
  • 写操作转换:用 MapStruct。
  • 领域模型转换:手写或显式 MapStruct。
  • 跨微服务 DTO:显式 MapStruct。

7.7 字段新增后没有处理

建议统一使用:

1
unmappedTargetPolicy = ReportingPolicy.ERROR

这样目标类新增字段后,Mapper 没处理就会编译失败。

这不是麻烦,这是保护。编译器像一个严厉的老员工:嘴毒,但能救命。


第八章:最佳实践总结

8.1 总体原则

一句话总结:

Lombok 减少普通类样板代码,Record 表达不可变数据载体,MapStruct 负责严肃对象转换,MapStructPlus 负责简单转换自动化。

8.2 推荐用法清单

Entity

1
2
3
4
5
6
7
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class XxxEntity {
}

不要默认 @Data

DTO

1
2
3
4
@Getter
@Setter
public class XxxDTO {
}

或者:

1
2
3
4
5
public record XxxDTO(
Long id,
String name
) {
}

VO / Response

优先 record:

1
2
3
4
5
public record XxxResponse(
Long id,
String name
) {
}

Service

1
2
3
4
5
6
7
@Slf4j
@Service
@RequiredArgsConstructor
public class XxxService {

private final XxxMapper xxxMapper;
}

Mapper

复杂转换用 MapStruct:

1
2
3
@Mapper(config = GlobalMapperConfig.class)
public interface XxxMapper {
}

简单转换用 MapStructPlus:

1
2
3
@AutoMapper(target = XxxVO.class)
public class XxxEntity {
}

8.3 技术选型建议

场景 推荐
普通 JavaBean 减少 getter/setter Lombok
构造器注入 Lombok @RequiredArgsConstructor
日志对象 Lombok @Slf4j
不可变 DTO / VO Record
领域值对象 Record 或普通不可变类
JPA Entity Lombok,但慎用 @Data
Entity 转 VO MapStruct
简单 Entity 转 VO MapStructPlus
更新已有 Entity MapStruct @MappingTarget
复杂聚合转换 手写或 MapStruct
高性能类型安全转换 MapStruct
快速 CRUD 转换 MapStructPlus

第九章:一个推荐的项目落地方案

如果是一个 Spring Boot 3 + JDK 17 的中后台项目,我会这样定规范:

9.1 Controller 层

入参优先 record:

1
2
3
4
5
public record CreateUserRequest(
@NotBlank String username,
@NotBlank String password
) {
}

出参优先 record:

1
2
3
4
5
6
public record UserResponse(
Long id,
String username,
String nickname
) {
}

9.2 Application 层

Command 可以用 record:

1
2
3
4
5
public record CreateUserCommand(
String username,
String password
) {
}

如果字段多且构建复杂,可以用 Lombok:

1
2
3
4
5
6
7
8
9
10
@Getter
@Builder
public class CreateUserCommand {

private final String username;

private final String password;

private final String source;
}

9.3 Domain 层

值对象可以用 record:

1
2
3
4
5
6
7
8
9
10
public record PhoneNumber(
String value
) {

public PhoneNumber {
if (value == null || !value.matches("^1\\d{10}$")) {
throw new IllegalArgumentException("手机号格式错误");
}
}
}

聚合根使用普通类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Getter
public class User {

private Long id;

private String username;

private String password;

private UserStatus status;

public void disable() {
if (status == UserStatus.DISABLED) {
return;
}
this.status = UserStatus.DISABLED;
}
}

不要为了省 setter 把领域对象强行写成 record。领域对象有行为,record 更适合数据。

9.4 Infrastructure 层

持久化实体使用 Lombok:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class UserDO {

private Long id;

private String username;

private String password;

private String status;
}

9.5 转换层

写操作:

1
2
3
4
5
6
7
@Mapper(config = GlobalMapperConfig.class)
public interface UserCommandMapper {

User toDomain(CreateUserCommand command);

UserDO toDO(User user);
}

读操作:

1
2
3
4
5
6
7
@Mapper(config = GlobalMapperConfig.class)
public interface UserQueryMapper {

UserResponse toResponse(UserDO userDO);

List<UserResponse> toResponseList(List<UserDO> list);
}

简单 CRUD 可以补充 MapStructPlus:

1
2
3
@AutoMapper(target = UserResponse.class)
public class UserDO {
}

结论

Lombok、Record、MapStruct、MapStructPlus 不是互相替代的关系,而是一套可以互补的工具链。

比较成熟的使用方式是:

  1. Lombok 用在普通类、实体类、服务类中,减少样板代码。
  2. Record 用在不可变 DTO、VO、值对象、查询结果中,表达清晰的数据语义。
  3. MapStruct 用在复杂、关键、需要编译期检查的对象转换中。
  4. MapStructPlus 用在简单、大量、重复的对象转换中,提高开发效率。
  5. 核心领域逻辑不要过度自动化,必要时手写更清晰。

最终目标不是“代码越少越好”,而是:

  • 简单对象少写代码。
  • 核心逻辑表达清楚。
  • 对象转换类型安全。
  • 字段变化编译期发现。
  • 项目成员一看就知道该在哪里写什么。

工具只是工具,架构边界才是重点。代码不是越短越高级,而是越不容易误解、越不容易出错、越容易维护,才是真的高级。

参考资料

  • Project Lombok 官方文档。
  • Java Record / JEP 395 官方说明。
  • MapStruct 官方参考文档。
  • MapStructPlus 官方文档与 GitHub 示例。
  • Lombok 与 Record 相关实践文章。
  • MapStruct 与 MapStructPlus 项目实践经验。

启示录

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


Lombok、Record、MapStruct 与 MapStructPlus 深度实践:从样板代码消除到对象转换体系设计
https://allendericdalexander.github.io/2026/06/04/lombok/
作者
AtLuoFu
发布于
2026年6月4日
许可协议