➜ Old React website
Chung Cheuk Hang MichaelJava Web Developer
網頁抓取(一) - jsoupJava 開發筆記(六)

在 Spring Boot + JPA 項目中使用 MapStruct

Table of contents

1 背景

當我地做一個大型既 Spring project,我地有唔少 entity/DO/DTO/VO/POJO classes,而呢啲 classes 一般只有 instance fields、getters 以及 setters。我地好多時候都需要將啲不同 types 但擁有部分相同 fields 既 objects 既 field values 抄黎抄去,令到啲 service classes 有好多將 type XxxEntity 轉換成 type XxxDto 既 boilerplate code。
如果 2 個 types 既 fields 係一樣名,我地可以用以下既 libraries 去做 shallow object copy/clone:
  • Apache Commons BeanUtils
    • BeanUtils.copyProperties(dest, orig)
    • PropertyUtils.copyProperties(dest, orig)
  • cglib(code generation library)
    • BeanCopier.create(clazz, clazz, false).copy(from, to, null)
  • Spring Core / Spring Beans
    • BeanUtils.copyProperties(source, target)(Spring Beans 引入左 Apache Commons BeanUtils 既 source code)
    • BeanCopier.create(source, target)(Spring Core 引入左 cglib 既 source code)
要 deep copy/clone 既話:
  • Jackson Databind
    • new ObjectMapper().convertValue(fromValue, toValueType)
    • new ObjectMapper().readValue(new ObjectMapper().writeValueAsString(value), valueType)
Object copy/clone 既類別解釋
Shallow只會 copy values。如果有 custom types,佢地既 memory references 會係一樣。
Deep成個 object 既表層到最深果層都會 copy。如果有 custom types,佢地既 memory references 會唔一樣。
另外,非常值得注意既係我地仲要 handle one-to-one、one-to-many、many-to-many 既 has-a relationship,必須避免 circular reference 導致 serialization 既過程中出現無限既 recursion。

2 MapStruct 簡介

  • MapStruct 有啲似 Lombok,係一個 compile time 既 annotation processor library,利用 annotations 黎 generate code。
  • 有別於其他 object copy 既 libraries,MapStruct 用既係 declarative programming 既方式,所以我地只需要寫 interfaces,而 MapStruct 會自動 generate implementation classes。
  • MapStruct 可以做到唔同 field 名之間既 mapping,亦可以 copy 不同層既 fields。
  • MapStruct 可以 reuse mapper methods,或者 mapper annotations 既 rules。
  • MapStruct 所 generate 出黎既 mapper classes 可以帶有 @Component,用於 Spring projects。

3 MapStruct 例子

假如我地而家有以下既 DTO classes:
  • Author entity(ORM 用)
  • AuthorDto DTO(API 用)
  • Book entity(ORM 用)
  • BookDto DTO(API 用)
Author 裡面 has-a List<Book>,而 Book 裡面 has-a Author
相同地,AuthorDto 裡面 has-a List<BookDto>,而 BookDto 裡面 has-a AuthorDto

4 Eclipse 安裝 m2e-apt

首先,Eclipse 要安裝 m2e-apt plugin,否則唔會 generate 到 code 去 target/generated-sources/annotations

5 動手寫

Project structure:
  • src/main/java
    • /code
      • /dto
        • AuthorDto.java
        • BookDto.java
      • /entity
        • Author.java
        • Book.java

5.1 Maven dependencies

1<properties> 2 <org.mapstruct.version>1.4.2.Final</org.mapstruct.version> 3 <lombok.version>1.18.24</lombok.version> 4 5 <m2e.apt.activation>jdt_apt</m2e.apt.activation> 6</properties> 7 8<dependency> 9 <groupId>org.mapstruct</groupId> 10 <artifactId>mapstruct</artifactId> 11 <version>${org.mapstruct.version}</version> 12</dependency> 13 14<plugin> 15 <groupId>org.apache.maven.plugins</groupId> 16 <artifactId>maven-compiler-plugin</artifactId> 17 <version>3.8.1</version> 18 <configuration> 19 <source>1.8</source> 20 <target>1.8</target> 21 <annotationProcessorPaths> 22 <path> 23 <groupId>org.projectlombok</groupId> 24 <artifactId>lombok</artifactId> 25 <version>${lombok.version}</version> 26 </path> 27 <path> 28 <groupId>org.mapstruct</groupId> 29 <artifactId>mapstruct-processor</artifactId> 30 <version>${org.mapstruct.version}</version> 31 </path> 32 </annotationProcessorPaths> 33 <compilerArgs> 34 <compilerArg> 35 -Amapstruct.defaultComponentModel=spring 36 </compilerArg> 37 <compilerArg> 38 -Amapstruct.suppressGeneratorTimestamp=true 39 </compilerArg> 40 <compilerArg> 41 -Amapstruct.suppressGeneratorVersionInfoComment=true 42 </compilerArg> 43 </compilerArgs> 44 </configuration> 45</plugin>

5.2 寫 Java code

5.2.1 Entity

Author.java
1@Getter 2@Setter 3@ToString 4@FieldDefaults(level = PRIVATE) 5@Entity(name = "author_1toM") 6@Table(name = "`author_1toM`") 7public class Author { 8 9 @Id 10 @GeneratedValue(strategy = IDENTITY) 11 @Column(name = "`id`") 12 Long id; 13 14 @Column(name = "`first_name`", nullable = false) 15 String firstName; 16 17 @Column(name = "`last_name`", nullable = false) 18 String lastName; 19 20 @ToString.Exclude 21 @OneToMany(mappedBy = "author", cascade = ALL, orphanRemoval = true) 22 List<Book> books = new ArrayList<>(); 23 24 public void addBook(Book book) { 25 books.add(book); 26 book.setAuthor(this); 27 } 28 29 public void removeBook(Book book) { 30 books.remove(book); 31 book.setAuthor(null); 32 } 33 34 public void removeAllBooks() { 35 books.forEach(e -> e.setAuthor(null)); 36 books.clear(); 37 } 38 39 @Override 40 public boolean equals(Object obj) { 41 if (this == obj) 42 return true; 43 if (!(obj instanceof Author)) 44 return false; 45 Author other = (Author) obj; 46 return Objects.equals(id, other.id); 47 } 48 49 @Override 50 public int hashCode() { 51 return 2021; 52 } 53}
Book.java
1@Data 2@FieldDefaults(level = PRIVATE) 3@EqualsAndHashCode(onlyExplicitlyIncluded = true) 4@Entity(name = "book_1toM") 5@Table(name = "`book_1toM`") 6public class Book { 7 8 @Id 9 @GeneratedValue(strategy = IDENTITY) 10 @Column(name = "`id`") 11 Long id; 12 13 @Column(name = "`title`", nullable = false) 14 String title; 15 16 @EqualsAndHashCode.Include 17 @NaturalId 18 @Column(name = "`isbn`", nullable = false) 19 String isbn; 20 21 @ToString.Exclude 22 @ManyToOne(fetch = LAZY) 23 Author author; 24}

5.2.2 DTO

AuthorDto.java
1@Getter 2@Setter 3@FieldDefaults(level = PRIVATE) 4public class AuthorDto { 5 Long authorId; 6 String firstName; 7 String lastName; 8 9 @JsonManagedReference 10 List<BookDto> books; 11}
BookDto.java
1@Getter 2@Setter 3@FieldDefaults(level = PRIVATE) 4public class BookDto { 5 Long bookId; 6 String title; 7 String isbn; 8 9 @JsonBackReference 10 AuthorDto author; 11}

5.2.3 Mapper

我地想喺 Author entity 同 AuthorDto DTO,以及 Book entity 同 BookDto DTO 之間做 bean mapping,以便之後需要將 ORM 用既 beans 轉成我地 API 需要既樣。
MyMapper.java
1@Mapper(nullValuePropertyMappingStrategy = IGNORE) 2public interface OneToManyAuthorViewMapper { 3 4 @Mapping(source = "id", target = "authorId") 5 AuthorDto toDto(Author author, @Context BidirectionalMappingContext context); 6 7 @InheritInverseConfiguration 8 Author toEntity(AuthorDto author, @Context BidirectionalMappingContext context); 9 10 @Mapping(source = "id", target = "bookId") 11 BookDto toDto(Book book, @Context BidirectionalMappingContext context); 12 13 @InheritInverseConfiguration 14 Book toEntity(BookDto book, @Context BidirectionalMappingContext context); 15}
BidirectionalMappingContext.java
1/** 2 * This mapping context can help mapping DTO objects with bidirectional relationship to entity objects 3 * without losing the bidirectional relationship.<br> 4 * To be used with MapStruct's {@link Context} annotation in mappers. 5 * 6 * @see <a href="https://github.com/jannis-baratheon/stackoverflow--mapstruct-mapping-graph-with-cycles">MapStruct bidirectional mapping</a> 7 * @see <a href="https://github.com/mapstruct/mapstruct/issues/1347">ClassCastException for CycleAvoidingMappingContext when hierarchy is not exactly the same</a> 8 */ 9public final class BidirectionalMappingContext { 10 private final Map<Object, Object> knownInstances = new IdentityHashMap<>(); 11 12 @BeforeMapping 13 public <T> T getMappedInstance(Object source, @TargetType Class<T> targetType) { 14 final Object knownInstance = knownInstances.get(source); 15 return targetType.isInstance(knownInstance) ? targetType.cast(knownInstance) : null; 16 } 17 18 @BeforeMapping 19 public void storeMappedInstance(Object source, @MappingTarget Object target) { 20 knownInstances.put(source, target); 21 } 22}

5.2.3.1 MapStruct 生成既 implementation class

以上既 code 可以令 MapStruct annotation processor generate 出以下既 implementation class:
1@Generated( 2 value = "org.mapstruct.ap.MappingProcessor" 3) 4@Component 5public class OneToManyAuthorViewMapperImpl implements OneToManyAuthorViewMapper { 6 7 @Override 8 public AuthorDto toDto(Author author, BidirectionalMappingContext context) { 9 AuthorDto target = context.getMappedInstance( author, AuthorDto.class ); 10 if ( target != null ) { 11 return target; 12 } 13 14 if ( author == null ) { 15 return null; 16 } 17 18 AuthorDto authorDto = new AuthorDto(); 19 20 context.storeMappedInstance( author, authorDto ); 21 22 authorDto.setAuthorId( author.getId() ); 23 authorDto.setBooks( bookListToBookDtoList( author.getBooks(), context ) ); 24 authorDto.setFirstName( author.getFirstName() ); 25 authorDto.setLastName( author.getLastName() ); 26 27 return authorDto; 28 } 29 30 @Override 31 public Author toEntity(AuthorDto author, BidirectionalMappingContext context) { 32 Author target = context.getMappedInstance( author, Author.class ); 33 if ( target != null ) { 34 return target; 35 } 36 37 if ( author == null ) { 38 return null; 39 } 40 41 Author author1 = new Author(); 42 43 context.storeMappedInstance( author, author1 ); 44 45 author1.setId( author.getAuthorId() ); 46 author1.setBooks( bookDtoListToBookList( author.getBooks(), context ) ); 47 author1.setFirstName( author.getFirstName() ); 48 author1.setLastName( author.getLastName() ); 49 50 return author1; 51 } 52 53 @Override 54 public BookDto toDto(Book book, BidirectionalMappingContext context) { 55 BookDto target = context.getMappedInstance( book, BookDto.class ); 56 if ( target != null ) { 57 return target; 58 } 59 60 if ( book == null ) { 61 return null; 62 } 63 64 BookDto bookDto = new BookDto(); 65 66 context.storeMappedInstance( book, bookDto ); 67 68 bookDto.setBookId( book.getId() ); 69 bookDto.setAuthor( toDto( book.getAuthor(), context ) ); 70 bookDto.setIsbn( book.getIsbn() ); 71 bookDto.setTitle( book.getTitle() ); 72 73 return bookDto; 74 } 75 76 @Override 77 public Book toEntity(BookDto book, BidirectionalMappingContext context) { 78 Book target = context.getMappedInstance( book, Book.class ); 79 if ( target != null ) { 80 return target; 81 } 82 83 if ( book == null ) { 84 return null; 85 } 86 87 Book book1 = new Book(); 88 89 context.storeMappedInstance( book, book1 ); 90 91 book1.setId( book.getBookId() ); 92 book1.setAuthor( toEntity( book.getAuthor(), context ) ); 93 book1.setIsbn( book.getIsbn() ); 94 book1.setTitle( book.getTitle() ); 95 96 return book1; 97 } 98 99 protected List<BookDto> bookListToBookDtoList(List<Book> list, BidirectionalMappingContext context) { 100 List<BookDto> target = context.getMappedInstance( list, List.class ); 101 if ( target != null ) { 102 return target; 103 } 104 105 if ( list == null ) { 106 return null; 107 } 108 109 List<BookDto> list1 = new ArrayList<BookDto>( list.size() ); 110 context.storeMappedInstance( list, list1 ); 111 112 for ( Book book : list ) { 113 list1.add( toDto( book, context ) ); 114 } 115 116 return list1; 117 } 118 119 protected List<Book> bookDtoListToBookList(List<BookDto> list, BidirectionalMappingContext context) { 120 List<Book> target = context.getMappedInstance( list, List.class ); 121 if ( target != null ) { 122 return target; 123 } 124 125 if ( list == null ) { 126 return null; 127 } 128 129 List<Book> list1 = new ArrayList<Book>( list.size() ); 130 context.storeMappedInstance( list, list1 ); 131 132 for ( BookDto bookDto : list ) { 133 list1.add( toEntity( bookDto, context ) ); 134 } 135 136 return list1; 137 } 138}

5.2.4 Mapper 使用方法

我地只需要喺一個 Spring manage 緊既 component 裡面 inject mapper 既 dependency 就得:
1import org.springframework.transaction.annotation.Transactional; 2 3@Service("oneToManyAuthorService") 4@Transactional(rollbackFor = Throwable.class) 5public class AuthorService { 6 7 @Autowired AuthorRepository authorRepository; 8 @Autowired OneToManyAuthorViewMapper authorMapper; 9 10 @GetMapping 11 public List<AuthorDto> getAuthors() { 12 return authorRepository.findAll().stream() 13 .map(e -> authorMapper.toDto(e, new BidirectionalMappingContext())) 14 .collect(toList()); 15 } 16 17 @PostMapping 18 public AuthorDto upsertAuthor(AuthorDto author) { 19 Author authorEntity = authorMapper.toEntity(author, new BidirectionalMappingContext()); 20 authorEntity = authorRepository.save(authorEntity); 21 22 return authorMapper.toDto(authorEntity, new BidirectionalMappingContext()); 23 } 24}

6 筆記

6.1 MapStruct annotations

Annotation解釋
@Mapping(source = "id", target = "authorId", qualifiedByName = "customMappingMethodName")如果有 property 名不相同就需要用呢個 annotation,將 source.id map 去 target.authroId。而 qualifiedByName 可以畀我地用自己寫既 static method 黎做客製化 mapping,例如轉換 data type 或者做一啲特別既處理。
@InheritInverseConfiguration示意 MapStruct 用相反方向既 mapper method 配置黎作為而家呢個 mapping 既配置。
@InheritConfiguration用喺 update/patch method 上,將第一個 parameter 既 object 既 fields 抄去第二個 parameter 既 object 度,規則跟返現有既 return type 係 parameter 2 既 type 而 parameter type 係 parameter 1 既 type 既 mapper method。
@Named("customMappingMethodName")如果需要做一啲特別既處理,可以寫一個 static method,例如 public static TargetType customMappingMethodName(SourceType source) { return convertSourceToTarget(source); } 可以轉換 data type,亦可以同時做啲特別既邏輯處理;夾埋 @Mapping(source = "sourceField", target = "targetField", qualifiedByName = "customMappingMethodName") 一齊用。

6.2 Lombok、equalshashCode

  • @ToString.Exclude
    • 因為 Lombok 既 @Data 包括左 @ToString,默認情況下 generate 出黎既 toString method 會包括曬所有 instance fields。
    • 我地唔可以畀 toString method 包括有 relationship 既 fields,因為當 call toString 果陣就會不知不覺間令 entity 既 proxy object 背後 trigger JPA/Hibernate 去自動執行 SQL 去 fetch 有 relationship 既 table records。
    • 呢個 annotation 可以令 Lombok generate toString method 果陣唔包括呢個 field。
  • @EqualsAndHashCode(onlyExplicitlyIncluded = true)@EqualsAndHashCode.Include
    • 因為 Lombok 既 @Data 包括左 @EqualsAndHashCode,默認情況下 generate 出黎既 equals 以及 hashCode methods 會包括曬所有 instance fields。
    • 如果係 @OneToMany 既 relationship,我地會用 List(容許重複)或者 Set(唔容許重複)。
      • 因為我地既 entity objects 係 mutable,如果用左 Set,而 equals 以及 hashCode methods 用左會變既 fields,咁就有可能會出問題,因為 HashSet 內部係用 HashMap 黎實現。
    • 我地喺 JPA/Hibernate project 裡面寫既 equals 以及 hashCode methods 應該要喺唔同既 Hibernate states 都返回一致既結果。
      • Hibernate states
        • Transient/new
        • Managed/persistent
        • Detached
        • Removed
      • Database auto-increment 既 primary key(surrogate key)column 唔適合用喺 equals 以及 hashCode methods。
        • 當個 entity object 處於 transient/new state 既時候呢個 field 既 value 係 null
        • 當個 entity object 變成 managed/persistent state 既時候 database 會賦予呢個 field 一個 auto-increment 出黎既 value。
        • 如果咁做,我地會放唔到 2 個未 persist 既 entity objects 喺一個 Set 入面,因為單靠呢個 field 而佢地又係 null 既情況下根本分唔到佢地係咪一樣。
        • 如果咁做,當我地放個新既 entity object 喺一個 Set 入面,然後 persist 佢,佢既呢個 field value 會由 null 變成 non-null,當我地用 Set#contains 就會搵唔返佢出黎,咁係因為 hashCode 唔同左,佢搵既 bucket 就唔同左,自然就會搵唔到。
      • equals 以及 hashCode methods 應該要用 1 個業務層面上 value 唔會重複既 column(business key)。
        • 例子
          • tbl_user table 可以用 email column。
          • tbl_book table 可以用 isbn column。
        • 如果冇既話,equals method 可以用 surrogate key(必須 check null),而 hashCode method 可以用一個不變值(例如 2021)。
          • 用不變值既話,如果 entity objects 放喺 Set 或者 Map 裡面,而 Set 或者 Map 好大既話,有可能會會影響性能。
            • 其實點都好,用得 JPA/Hibernate,我地都唔應該一次過喺 database 查詢太多紀錄。
    • 2 個 annotations 一齊用可以令 Lombok generate equals 以及 hashCode methods 果陣只包括 @EqualsAndHashCode.Include 既 fields。

6.3 其他 object copy 方法

RootLayer.java
1@Data 2@NoArgsConstructor 3@AllArgsConstructor 4@FieldDefaults(level = PRIVATE) 5public class RootLayer { 6 SecondLayer second; 7}
SecondLayer.java
1@Data 2@NoArgsConstructor 3@AllArgsConstructor 4@FieldDefaults(level = PRIVATE) 5public class SecondLayer { 6 ThirdLayer third; 7}
ThirdLayer.java
1@Data 2@NoArgsConstructor 3@AllArgsConstructor 4@FieldDefaults(level = PRIVATE) 5public class ThirdLayer { 6 String data; 7}
ObjectCopyTest.java
1import org.apache.commons.beanutils.BeanUtils; 2import net.sf.cglib.beans.BeanCopier; 3 4public class ObjectCopyTest { 5 private static final RootLayer SOURCE = new RootLayer(new SecondLayer(new ThirdLayer("abc"))); 6 7 @Test 8 public void test_ApacheCommonsBeanUtils() throws Exception { 9 final RootLayer result = new RootLayer(); 10 BeanUtils.copyProperties(result, SOURCE); 11 12 System.out.println("BeanUtils: " + result); 13 14 Assert.assertNotNull(result); 15 Assert.assertNotNull(result.getSecond()); 16 Assert.assertNotNull(result.getSecond().getThird()); 17 Assert.assertEquals("abc", result.getSecond().getThird().getData()); 18 19 // shallow copy 20 Assert.assertTrue(result.getSecond()==SOURCE.getSecond()); 21 Assert.assertTrue(result.getSecond().getThird()==SOURCE.getSecond().getThird()); 22 } 23 24 @Test 25 public void test_cglib() { 26 final RootLayer result = new RootLayer(); 27 BeanCopier.create(SOURCE.getClass(), result.getClass(), false).copy(SOURCE, result, null); 28 29 System.out.println("cglib: " + result); 30 31 Assert.assertNotNull(result); 32 Assert.assertNotNull(result.getSecond()); 33 Assert.assertNotNull(result.getSecond().getThird()); 34 Assert.assertEquals("abc", result.getSecond().getThird().getData()); 35 36 // shallow copy 37 Assert.assertTrue(result.getSecond()==SOURCE.getSecond()); 38 Assert.assertTrue(result.getSecond().getThird()==SOURCE.getSecond().getThird()); 39 } 40 41 @Test 42 public void test_jacksonDatabind_convertValue() { 43 final RootLayer result = new ObjectMapper().convertValue(SOURCE, RootLayer.class); 44 45 System.out.println("Jackson (convertValue): " + result); 46 47 Assert.assertNotNull(result); 48 Assert.assertNotNull(result.getSecond()); 49 Assert.assertNotNull(result.getSecond().getThird()); 50 Assert.assertEquals("abc", result.getSecond().getThird().getData()); 51 52 // deep copy 53 Assert.assertFalse(result.getSecond()==SOURCE.getSecond()); 54 Assert.assertFalse(result.getSecond().getThird()==SOURCE.getSecond().getThird()); 55 } 56 57 @Test 58 public void test_jacksonDatabind_serializeAndDeserialize() throws Exception { 59 final ObjectMapper mapper = new ObjectMapper(); 60 final RootLayer result = mapper.readValue(mapper.writeValueAsString(SOURCE), RootLayer.class); 61 62 System.out.println("Jackson (serialize and deserialize): " + result); 63 64 Assert.assertNotNull(result); 65 Assert.assertNotNull(result.getSecond()); 66 Assert.assertNotNull(result.getSecond().getThird()); 67 Assert.assertEquals("abc", result.getSecond().getThird().getData()); 68 69 // deep copy 70 Assert.assertFalse(result.getSecond()==SOURCE.getSecond()); 71 Assert.assertFalse(result.getSecond().getThird()==SOURCE.getSecond().getThird()); 72 } 73}

6.4 支援 Java 8 Optional 既 mapper

我地可以寫一個 mapper base interface:
1public interface BaseMapper { 2 3 default <T> Optional<T> wrap(T val) { 4 return Optional.ofNullable(val); 5 } 6 7 default <T> T unwrap(Optional<T> val) { 8 return val==null ? null : val.orElse(null); 9 } 10}
然後實際既 mapper interfaces 要 extend 佢:
@Mapper public interface MyMapper extends BaseMapper {
咁就可以做到:
Map fromMap to結果
Normal classNormal class✅ 支援
Normal classJava 8 Optional class✅ 支援
Java 8 Optional classNormal class✅ 支援
Java 8 Optional classJava 8 Optional class✅ 支援

7 參考資料