我不会说数据传输对象 (DTO) 是每个应用程序必不可少的一部分。但如果你不能没有它们,有一种方法可以减少样板代码并简化通过 DTO 传递数据的过程——这一切都归功于 Java Records和 MapStruct 框架。
因此,下面您将找到有关将这两个强大的解决方案集成到您的项目中的教程。让我们开始吧!
什么是Records
Records最初是在 Java 14 中引入的,并在 Java 16 中最终确定。它们代表不可变的数据载体,使开发人员在定义简单数据类时可以省略大量样板代码。默认情况下,记录类中定义的所有字段都是私有的和最终的。方法(例如equals()
、hashCode()
、toString()
)已经存在,构造函数也是如此。
但是既然有了 Lombok,为什么还需要Records呢?没错,Lombok 也有助于减少样板代码。但是使用 Lombok 时,您必须向类添加大量注释,上帝保佑您不会忘记其中任何一个。我曾经忘记向@Getter
我的一个 DTO 添加注释,并花了半个小时使用调试器试图了解为什么 DTO 字段为空。
尽管如此,在某些情况下我们必须坚持使用 Lombok。JPA 实体不能转换为记录,因为除其他方面外,它们不能是最终的,并且必须提供 getter、setter 和无参数构造函数。
但是 DTO 与记录完美匹配,我们将在下面看到。
让我们首先看一下要为其创建 DTO 的 Film 类:
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
@Entity
@Table(name = "film")
public class Film {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name")
@NotNull
private String name;
@Column(name = "description", columnDefinition = "TEXT", length = 200)
@NotNull
private String description;
@Column(name = "premiere_date", columnDefinition = "DATE")
@DateTimeFormat(pattern = "yyyy-MM-dd")
@NotNull
private LocalDate premiereDate;
@ManyToMany(fetch = FetchType.LAZY, cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinTable(name = "film_genre",
joinColumns = @JoinColumn(name = "film_id"),
inverseJoinColumns = @JoinColumn(name = "genre_id"))
private Set<Genre> genres = new HashSet<>();
@Column(name = "duration")
@NotNull
@Positive
private int duration;
@ManyToOne(cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinColumn(name = "mpaa_id")
private Mpaa mpaa;
@ManyToMany(fetch = FetchType.LAZY, cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinTable(name = "film_actor",
joinColumns = @JoinColumn(name = "film_id"),
inverseJoinColumns = @JoinColumn(name = "star_id"))
private Set<FilmPerson> actors = new HashSet<>();
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "film_id")
private Set<Rating> ratings = new HashSet<>();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Film film = (Film) o;
return Objects.equals(id, film.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Builder
public record FilmResponse (String name,
String description,
LocalDate premiereDate,
int duration,
List<String> streams,
String mpaa,
List<String> actors,
double ratings) {
}
一开始,您可能没有注意到它与标准变量列表的区别。但确实有区别。看,没有访问修饰符,没有构造函数,没有样板,没有大量的 Lombok 注释(注释除外@Builder
)——非常简洁和漂亮!如果您的映射方法中没有复杂的 if 语句,您也可以将其删除@Builder
,然后使用记录提供的构造函数创建 DTO 对象。
那么新的请求呢?我们想添加验证注释,以将数据验证委托给 Spring Boot。好消息是,记录允许我们向字段添加注释!
@Builder
public record NewFilmRequest (@NotBlank String name,
@NotBlank @Size(max = 200) String description,
@NotNull LocalDate premiereDate,
@NotNull @Positive int duration,
@NotNull Set<Long> genereIds,
@NotNull Set<Long> actorIds,
@NotNull Long mpaaId) {
}
您可以以类似的方式创建用于更新Film的 DTO 或Film所需的任何其他 DTO。
DTO 已准备就绪,让我们继续映射它们!
使用 MapStruct 将实体转换为 DTO 以及反之亦然
添加 MapStruct 依赖项
首先,我们需要添加必要的 MapStruct 依赖项。如果您使用 Maven,请将以下 MapStruct 依赖项添加到pom.xml:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
我们还需要将 MapStruct 处理器添加到 Maven 插件中,该插件会在构建阶段生成映射器实现。此外,如果我们想使用 Lombok ,我们应该将lombok-mapstruct-binding依赖项添加到插件中:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
在撰写本文时,我正在使用最新版本的 MapStruct 及其处理器。您可以随时检查MapStruct Core和MapStruct Processor Maven 存储库以获取更新。
使用 MapStruct 接口进行基本映射
让我们暂时把Film实体放在一边,看看更多基本的映射示例。
在理想情况下,DTO 字段与实体的字段相同。例如,我们有一个 Post 实体:
public class Post {
private Long id;
private String text;
private Long userId;
}
在这种情况下,我们可以使用注释和两个简单的方法创建一个 Mapper 接口@Mapper
:
@Mapper
public interface PostMapper {
PostMapper INSTANCE = Mappers.getMapper(PostMapper.class);
PostDto mapPostToDto(Post post);
Post mapDtoToPost(PostDto dto);
}
当您需要在服务类中(或您喜欢进行映射的任何地方)执行映射时,可以使用 INSTANCE 字段。
就是这样!您不必创建 Mapper 实现,因为它会在您运行应用程序时自动生成。以下是 MapStruct 在target/generated-sources/annotations/com/example/demo/PostMapperImpl.java下为我们生成的实现:
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2024-06-10T13:04:45+0300",
comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.3 (BellSoft)"
)
public class PostMapperImpl implements PostMapper {
@Override
public PostDto mapPostToDto(Post post) {
if ( post == null ) {
return null;
}
Long id = null;
String text = null;
id = post.getId();
text = post.getText();
Long userId = null;
PostDto postDto = new PostDto( id, text, userId );
return postDto;
}
@Override
public Post mapDtoToPost(PostDto dto) {
if ( dto == null ) {
return null;
}
Post post = new Post();
post.setId( dto.id() );
post.setText( dto.text() );
return post;
}
}
小菜一碟,对吧?让我们看看 MapStruct 如何处理其他用例。
例如,在一个类中,我们不想映射某些字段(例如 id),因此我们的 PostDto 会略有不同:
public record PostDto(String text, Long userId) {
}
为了映射具有不同名称的字段,我们需要在 PostMapper 方法中添加 @Mapping 注释,并以以下方式配置源到目标字段:
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface PostMapper {
PostMapper INSTANCE = Mappers.getMapper(PostMapper.class);
PostDto mapPostToDto(Post post);
Post mapDtoToPost(PostDto dto);
}
此后,您只需坐下来,放松,让 MapStruct 发挥它的魔力!
使用依赖注入进行高级映射
让我们回到电影及其 DTO。FilmResponse DTO 与实体有很大不同:它不返回嵌套实体(类型、FilmPerson)的集合,而是返回其名称的列表。此外,我们必须根据实体中以集合形式提供的评分来计算电影的平均评分。
NewFilmRequest 更加复杂:它仅包含实体的 ID,我们必须在存储库中查找这些实体并将其引用添加到 Film 对象才能保存它。这意味着我们必须将存储库实例注入 Mapper。
听起来配置映射器需要做很多麻烦的事情,但实际上,没有什么是 MapStruct 无法处理的。
为了能够使用 Spring CDI 和 IoC,我们需要通过以下方式增强我们的 Mapper:
- 更改
interface
为abstract class
。这还能让我们自定义映射方法; - 将配置添加
componentModel = “spring”
到@Mapper
注释中,将我们的 Mapper 转换为 Spring Bean,并能够通过以下方式注入它@Autowired
; - 删除 INSTANCE 字段,因为 Mapper 现在是一个普通的 Spring bean,应该直接添加到使用它的类中;
- 通过 将存储库实例注入 Mapper
@Autowired
。请注意,MapStruct 不支持构造函数注入,因此您必须执行字段注入(不推荐)或 setter 注入。
因此,我们的 FilmMapper 类将如下所示
@Mapper(unmappedTargetPolicy = org.mapstruct.ReportingPolicy.IGNORE,
componentModel = "spring")
public abstract class FilmMapper {
protected ReferenceFinderRepository repository;
@Autowired
protected void setReferenceFinderRepository(ReferenceFinderRepository repository) {
this.repository = repository;
}
public FilmResponse mapToFilmResponse(Film film) {
return FilmResponse.builder()
.name(film.getName())
.description(film.getDescription())
.duration(film.getDuration())
.premiereDate(film.getPremiereDate())
.language(film.getLanguage().getName())
.mpaa(film.getMpaa().getName())
.genres(film.getGenres().stream().map(Genre::getName).toList())
.actors(film.getActors().stream().map(FilmPerson::getName).toList()).build();
}
public Film mapToFilm(NewFilmRequest filmRequest) {
Film.FilmBuilder film = Film.builder()
.name(filmRequest.name())
.description(filmRequest.description())
.premiereDate(filmRequest.premiereDate())
.duration(filmRequest.duration())
.mpaa(repository.getMpaaReference(filmRequest.mpaaId()));
film.genres(filmRequest.genreIds()
.stream()
.map(repository::getGenreReference)
.collect(Collectors.toSet()));
film.actors(filmRequest.actorIds()
.stream()
.map(repository::getFilmPersonReference)
.collect(Collectors.toSet()));
double meanRating = 0.0;
if (!film.getRatings().isEmpty()) {
meanRating = film.getRatings()
.stream()
.map(Rating::getPoints)
.mapToInt(Integer::intValue)
.summaryStatistics()
.getAverage();
}
rresponse.rating(round(meanRating, 2));
return film.build();
}
}