作者🤵:三哥,j3code.cn
个人项目:社交支付项目(小老板)
1、抛砖
对象转化也即一个带有属性值的对象 A 转为对象 B 的过程。
面对此类 A 对象转为 B 对象的场景,人们一开始想的就是很简单,先创建 B 对象,然后挨个对 B 中的属性赋值,而值就是从 A 中获取。那,这种方式有什么问题吗?
在我看来,上述方法倒是没有什么问题,但是在某种场景下是却不太适用。比如,一个对象属性接近 100 个,那人为的去一个个 set 就显得非常麻烦了,而且也极容易出错。所以,人们就开始着手开发,能够快速高效的从一个对象转为另一个对象的组件,慢慢的市场上就陆续出现了非常多的对象转化工具如:MapStruct、Apache BeanUtils、Spring BeanUtils 等。
目前我们的项目基本上都是基于 Spring 框架进行项目开发,所以在需要进行对象转化的时候大家就很自然的选择使用 Spring BeanUtils 来进行对象转化,很多人就是这么干的。但我却不用,为什么?
下面我展示个案例代码,来解释一下原因:
代码
typescript
@Data
public class A {
private Long id;
}
@Data
public class B {
private Integer id;
}
public class Demo {
public static void main(String[] args) {
A a = new A();
a.setId(1L);
B b = new B();
// 属性赋值
BeanUtils.copyProperties(a, b);
// 打印
System.out.println(JSON.toJSONString(b));
}
}
解释
可以看到,我们的本意是将 A 对象的值赋到 B 对象中,但是我们却没有得到预期的结果,根本原因是 A、B 对象虽然有名称一样的属性,但是类型不同,所以赋值不上去,这个看似合理,实则在程序中会隐藏一个非常大的 BUG。
在公司项目中一个实体对象往往有很多个属性,而实体对象又需要从一系列的 PO、BO、DTO、VO 中进行转化,其中难免有个被属性的类型不一致,但名称一致的现象。在这种情况下,我们依然使用 Spring BeanUtils 那么你会发现有些实体中的属性值会在转化的过程中莫名其妙的丢失,这是我们最不想看到的,好歹也给我们报个错提示一下,对吧,避免这种数据确实问题,特别是重要数据。
我司就碰到过这种问题,排查了很久才把这个问题揪出来,从这之后就统一换上了 MapStruct 工具类进行对象转化。
2、引玉 MapStruct
2.1 介绍及基本使用
MapStruct 是一个 Java 注释处理器,用于生成类型安全的 bean 映射类。即,通过在接口和方法上定义注解,就能帮你完成对象转化功能。
文档:
基本使用教程
1、引入依赖
xml
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.4.2.Final</version>
</dependency>
2、定义映射器接口
css
@Mapper
public interface AToBConverter {
/**
* 转化方法
*/
B toB(A a);
}
3、Java 原生使用
java
public class Demo {
public static void main(String[] args) {
A a = new A();
a.setId(1L);
// 通过 Mappers 工程获取 AToBConverter 处理器接口的实现类
AToBConverter mapper = Mappers.getMapper(AToBConverter.class);
// 调用方法转化
B b = mapper.toB(a);
// 打印
System.out.println(JSON.toJSONString(b));
}
}
4、当然还可以将处理器接口交给 Spring,直接注入使用就可以
@Mapper 注解改为 @Mapper(componentModel = "spring") 表示交给 spring 管理,测试类如下:
java
@Component
public class DemoSpring {
@Autowired
private AToBConverter converter;
public void test() {
A a = new A();
a.setId(1L);
// 调用方法转化
B b = converter.toB(a);
// 打印
System.out.println(JSON.toJSONString(b));
}
}
执行之后你会发现居然可以正常的赋值,也即 MapStruct 能自动的将名字一样,类型不一致的属性值进行转化再赋值(仅限基本类型的包装类型,如果转化值适配不了对应类型会报错)。
类比 Spring BeanUtils ,虽然步骤繁琐了一点,但是给我们带来的好处确实明显的,不会出现运行过程中丢失数据问题,且会在启动阶段就将属性类型不匹配的情况提前暴露出来(报错),让开发人员修改。
2.2 原理
在项目编译的时候对定义好的处理器接口及方法生成对应的实现类,而实现方法中就是非常普通的 get/set 方式赋值,以上述案例为例,我们来看看其生成的实现类代码:
可以看到,MapStruct 是直接在编译期就给我们生成好了一个实现类,而不是在运行期间通过反射实现属性赋值,这无疑是大大提示转化性能的,当然这牺牲的代价就是编译期时间长一点,但这又有什么关系呢,只要不是运行阶段,都能接收。
2.3 一些使用技巧
1)忽略字段映射
在某些场景中我们从 A 到 B 的过程中可能有些字段并不需要赋值,那么就可以用到这个特性了。只需要在转化方法上加上如下注解即可:
@Mapping(target = "属性名称", ignore = true)
target 表示目标对象
ignore 表示忽略赋值
比如下面的从 A 转到 B ,忽略 name 赋值,使用如下
ini
@Mapping(target = "name", ignore = true)
B toB(A a);
生成的实现代码如下:
没有加注解
加了注解
2)源对象与目标对象属性名不符的映射方法
故名思意,就是 A 对象有个 k 属性,B 对象有个 w 属性,那么如何将 A 中的 k 赋值到 B 中的 w 呢,那就添加下面的注解:
@Mapping(target = "目标属性名称", source = "源属性名称")
使用案例
没有加之前
可以看到属性名称对不是,直接就是不复制了。
加了之后
3)属性为对象类型的展开与收缩
展开:将对象 B 中类型为 C 的属性中的 age 属性展开到 A 对象的 age 属性中
收缩:将 对象 A 中的 age 属性,收缩到 B 对象属性类型为 C 对象中的 age 中
实体关系如下
没加注解之前
可以看到,没加注解的时候是不会将 A 对象的 age 属性设置到 B 中 C 对象的 age 中。
加注解之后
加注解之后自动创建了一个生成 C 对象的方法并且向 C 中的 age 属性赋值。
注意:对象有多少嵌套都可以使用 对象.属性.属性... 等形式一直下去,直到源属性与与目标属性对应上
大家可以自学测试一下
3)直接设置特定值
如题,我们可以直接对要转化的目标对象设置默认值或者我们指定的常量
使用方式
如果源属性值为 null 那么直接设置我们指定的默认值:@Mapping(target = "name", source = "name", defaultValue = "三哥")
直接给目标属性赋值指定的值:@Mapping(target = "age", constant = "18")
这两个比较简单我就不带着大家测试了。
4)表达式映射
@Mapping 注解中存在 expression 与 defaultExpression 两个属性,即前者表示若用户设置了表达式则目标对象的值等于源属性值与表达式计算的最终结果;后者表示若用户设置了默认表达式则源属性值为 null,则目标对象值直接取默认表达式的值。
指定表达式用法
注意:expression 与 source 不可同时出现
默认表达式用法
表达式的用法使得我们的属性值转换变得更加灵活,但是目前表达式仅支持 java ,而且写法也是非常有讲究的,具体应该如何编写大家可以去看官方文档。
5)自定义映射
如果默认的转化依然满足不了我们的需求,MapStruct 也提供了灵活性更强的自定义映射功能来供我们使用,具体有下面三个:
- 自定义装饰器
- before-mapping
- after-mapping
我们先来说说第一个:自定义装饰器
我们需要编写一个自定义的装饰器类,这个类必须是个抽象类
less
public abstract class AToBConverterAbstract implements AToBConverter {
/**
* 将执行目标对象注入进来
*/
@Autowired
private AToBConverter delegate;
/**
* 重写转化方法
*/
@Override
public B toB(A a) {
// 先调用目标对象的方法
B b = delegate.toB(a);
// 下面做你的特殊处理
b.getC().setAge(20);
if (b.getName().equals("三哥")) {
b.setName("https://j3code.cn");
}
return b;
}
}
接着就是应用这个装饰器类,即在转化接口类上加上如下注解
@DecoratedWith(你的装饰器类.class)
ok,自此我们就将自定义的转化逻辑加上了,之后再编译阶段 MapStruct 会自动的帮我们生成如下实现类:
大家应该能看懂这个调用流程吧!就是程序注入的是 AToBConverter 接口实现类也即就是 MapStruct 自动帮我们生成的 AToBConverterImpl 类,而该类中没有任何方法,因为他还继承了我们自定一的装饰器类,该类中重写了我们需要的转化方法,并且装饰类中指定了需要注入 MapStruct 生成的 AToBConverterImpl_ 类,并且重写的方法逻辑是先调用 AToBConverterImpl_ 类中的转化方法,再处理我们自定义的转化逻辑。也即,调用流程就是下面这样:
AToBConverter 注入 AToBConverterImpl 对象
调用 AToBConverter 中的 toB 方法,会调用 AToBConverterAbstract 中的 toB 方法,而该方法会先调用 AToBConverterImpl_ 中的 toB 方法,再执行用户的自定义映射逻辑,最终将转化好的对象返回给用户。
接着再开看看 before-mapping 与 after-mapping。
这个有点类似 Spring AOP 中的前置通知与后置通知,都是标注在方法上,表明生成映射方法前执行与返回结果语句的最后一行执行。
基本使用:
生成代码如下:
大家注意看,前置方法是在转化方法处理的第一行就调用了,所以如果我们需要给他传参数的化也只能是源对象也即 A 对吧,所以我们可以这样干:
typescript
@BeforeMapping
default void preProcess(A a) {
System.out.println("执行前置处理......");
}
那前置方法都可以传参,后置方法肯定也是可以的,对吧!
那是自然,但是我们需要分析一下,后置方法是在结果返回的最后一行进行调用,所以我们可以传入目标对象进行最后的处理,可以这样干:
less
@AfterMapping
default void postProcess(@MappingTarget B b) {
System.out.println("执行后置处理......");
}
即,最终 MapStruct 帮我们生成的代码如下:
好了,以上的案例就是我在实际工作中对于 MapStruct 工具类用的比较多的功能技巧了,如果大家觉得还是不能满足你的对象转化需求可以去人家的官方文档上找一找肯定有一种方式能够满足你的业务需求。
所以,面对一个转化灵活性这么高的工具类,你是否心动想要使用呢,欢迎评论区给出你的答案。