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
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
:
134678
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、equals
、hashCode
@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 from | Map to | 結果 |
---|
Normal class | Normal class | ✅ 支援 |
Normal class | Java 8 Optional class | ✅ 支援 |
Java 8 Optional class | Normal class | ✅ 支援 |
Java 8 Optional class | Java 8 Optional class | ✅ 支援 |
7 參考資料