Android即时通讯系列文章(4)MapStruct:分层式架构下不同数据模型之间相互转换的利器
文章开篇,让我们先来解答一下上篇文章中留下的疑问,即:
为什么要设计多个Entity?
以「分离关注点」为原则的分层式架构,是我们在进行应用架构设计时经常采用的方案,例如为人熟知的MVC/MVP/MVVM等架构设计模式下,划分出的表示层、业务逻辑层、数据访问层、持久层等。为了保持应用架构分层之后的独立性,通常需要在各个层次之间定义不同的数据模型,于是不可避免地要面临数据模型之间的相互转换问题。
常见的不同层次的数据模型包括:
VO(View Object):视图对象,用于展示层,关联某一指定页面的展示数据。
DTO(Data Transfer Object):数据传输对象,用于传输层,泛指与服务端进行传输交互的数据。
DO(Domain Object):领域对象,用于业务层,执行具体业务逻辑所需的数据。
PO(Persistent Object):持久化对象,用于持久层,持久化到本地存储的数据。
还是以即时通讯中消息收发为例:
- 客户端在会话页面编辑消息并发送后,消息相关的数据在展示层被构造为MessageVO,展示在会话页面的聊天记录中;
- 展示层将MessageVO转换为持久层对应的MessagePO后,调用持久层的持久化方法,将消息保存到本地数据库或其他地方
- 展示层将MessageVO转换为传输层所要求的为MessageDTO后,传输层将数据传输到服务端
- 至于对应的逆向操作,相信你也可以对于推理出来,这里就不再赘述了。
在上篇文章中,我们以get/set操作的方式手动编写了映射代码,这种方式不但繁琐且容易出错,考虑到后期扩展其他消息类型时又要重复做同样的事情,出于提高开发效率的考虑,经过一番调研之后,我们决定采用MapStruct库以自动化的形式帮我们完成这件事情。
MapStruct是什么?
MapStruct是一个代码生成器,用于生成类型安全、高性能、无依赖的映射代码。
我们所要做的,就是定义一个Mapper(映射器)接口,并声明需要实现的映射方法,即可在编译期利用MapStruct注解处理器,生成该接口的实现类,该实现类以自动化的方式帮我们完成get/set操作,以实现源对象与目标对象之间的映射关系。
MapStruct的使用
以Gradle的形式添加MapStruct依赖项:
在模块级别的build.gradle文件中添加:
dependencies {
...
implementation "org.mapstruct:mapstruct:1.4.2.Final"
annotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
}
如果项目中使用的是Kotlin语言则需要:
dependencies {
...
implementation "org.mapstruct:mapstruct:1.4.2.Final"
kapt("org.mapstruct:mapstruct-processor:1.4.2.Final")
}
接下来,我们会以上次定义好的MessageVO与MessageDTO为操作对象,实践如何使用MapStruct自动化完成两者之间的字段映射:
创建映射器接口
- 创建一个Java接口(也可以以抽象类的形式),并添加@Mapper注解表明是个映射器:
- 声明一个映射方法,指定入参类型和出参类型:
@Mapper
public interface MessageEntityMapper {
MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);
MessageVO dto2Vo(MessageDTO.Message messageDto);
}
这里使用MessageDTO.Message.Builder而非MessageDTO.Message的原因是,ProtoBuf生成的Message使用了Builder模式,并为了防止外部直接实例化而把构造参数设为private,这将导致MapStruct在编译的时候报错,至于原因,等你看完后面的内容就明白了。
默认场景下的隐式映射
当入参类型的字段名与出参类型字段名一致时,MapStruct会帮我们隐式映射,即不需要我们主动处理。
目前支持以下类型的自动转换:
- 基本数据类型及其包装类型
- 数值类型之间,但从较大的数据类型转换为较小的数据类型(例如从long到int)可能会导致精度损失
- 基本数据类型与字符串之间
- 枚举类型和字符串之间
- ...
这其实是一种约定优于配置的思想:
约定优于配置(convention over configuration),也称作按约定编程,是一种软件设计范式,旨在减少软件开发人员需做决定的数量,获得简单的好处,而又不失灵活性。
本质是说,开发人员仅需规定应用中不符约定的部分。如果您所用工具的约定与你的期待相符,便可省去配置;反之,你可以配置来达到你所期待的方式。
体现在MapStruct库之中即是,我们仅需针对那些MapStruct库没法帮我们完成隐式映射的字段,配置好对应的处理方式即可。
比如我们例子中的MessageVO与MessageDTO,两者的messageId, senderId, targetId, timestamp几个字段的名称和数据类型都是一致的,因而不需要我们额外处理。
特殊场景下的字段映射处理
字段名称不一致:
这种情况下,只需在映射方法之上添加@Mapping注解,标注源字段的名称以及目标字段的名称即可。
比如我们例子中在message_dto.proto文件中定义的messageType是一个枚举类型,ProtoBuf为我们生成MessageDTO.Message时,额外为我们生成了一个messageTypeValue来表示该枚举类型的值,我们用上述方法即可完成从messageType到messageTypeValue的映射:
@Mapping(source = "messageType", target = "messageTypeValue")
MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);
字段类型不一致:
这种情况下,只需为两种不同的数据类型额外声明一个映射方法,即以源字段的类型为入参类型,以目标字段的类型为出参类型的映射方法。
MapStruct会检查是否存在该映射方法,如果有,则会在映射器接口的实现类中调用该方法完成映射。
比如我们例子中,content字段被定义为bytes类型,对于生成的MessageDTO.Message类中则是用ByteString类型表示,而MessageVO中的content字段则是String类型,因此需要在映射器接口中额外声明一个byte2String映射方法与一个string2Byte映射方法:
default String byte2String(ByteString byteString) {
return new String(byteString.toByteArray());
}
default ByteString string2Byte(String string) {
return ByteString.copyFrom(string.getBytes());
}
又比如,我们不想处理上面messageType到messageTypeValue的映射,而是想直接完成messageType到枚举类型的映射,那我们就可以声明以下两个映射方法:
default int enum2Int(MessageDTO.Message.MessageType type) {
return type.getNumber();
}
default String byte2String(ByteString byteString) {
return new String(byteString.toByteArray());
}
忽略某些字段:
出于特殊的需要,某些层次的数据模型可能会新增部分字段,用于处理特定的业务,这些字段对于其他层次是没有任何意义的,所以没必要在其他层次保留这些字段,同时为了避免MapStruct隐式映射时找不到相应字段导致出错,我们可以在注解中添加ignore = true忽略这些字段:
比如我们例子中,ProtoBuf生成的MessageDTO.Message类中还额外为我们新增了三个字段mergeFrom、senderIdBytes、targetIdBytes,这三个字段对于MessageVO是没有必要的,因此需要让MapStruct帮我们忽略掉:
@Mapping(target = "mergeFrom", ignore = true)
@Mapping(target = "senderIdBytes", ignore = true)
@Mapping(target = "targetIdBytes", ignore = true)
MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);
其他场景的额外处理
前面我们说过,由于MessageDTO.Message的构造函数被设为private导致编译时报错,实际上MessageDTO.Message.Builder的构造函数也是private的,该Builder的实例化是通过MessageDTO.Message.newBuilder()方法进行的。
而MapStruct默认情况下是需要调用目标类的默认构造函数来完成映射任务的,那我们就没有办法了么?
实际上,MapStruct允许你自定义对象工厂,这些工厂将提供了工厂方法,用以调用来获取目标类型的实例。
我们要做的,只是声明该工厂方法的返回类型为我们的目标类型,然后在工厂方法中以想要的方式返回该目标类型的实例,随后在映射器接口的@Mapper注解中添加use参数,传入我们的工厂类。MapStruct就会优先自动找到该工厂方法,完成目标类型的实例化。
public class MessageDTOFactory {
public MessageDTO.Message.Builder createMessageDto() {
return MessageDTO.Message.newBuilder();
}
}
@Mapper(uses = MessageDTOFactory.class)
public interface MessageEntityMapper {
最后,我们定义一个名为INSTANCE 的成员,该成员通过调用Mappers.getMapper()方法,并传入该映射器接口类型,实现返回该映射器接口类型的单例。
public interface MessageEntityMapper {
MessageEntityMapper INSTANCE = Mappers.getMapper(MessageEntityMapper.class);
完整的映射器接口代码如下:
@Mapper(uses = MessageDTOFactory.class)
public interface MessageEntityMapper {
MessageEntityMapper INSTANCE = Mappers.getMapper(MessageEntityMapper.class);
@Mapping(source = "messageType", target = "messageTypeValue")
@Mapping(target = "mergeFrom", ignore = true)
@Mapping(target = "senderIdBytes", ignore = true)
@Mapping(target = "targetIdBytes", ignore = true)
MessageDTO.Message.Builder vo2Dto(MessageVO messageVo);
MessageVO dto2Vo(MessageDTO.Message messageDto);
@Mapping(source = "messageTypeValue", target = "messageType")
default MessageDTO.Message.MessageType int2Enum(int value) {
return MessageDTO.Message.MessageType.forNumber(value);
}
default int enum2Int(MessageDTO.Message.MessageType type) {
return type.getNumber();
}
default String byte2String(ByteString byteString) {
return new String(byteString.toByteArray());
}
default ByteString string2Byte(String string) {
return ByteString.copyFrom(string.getBytes());
}
}
自动生成映射器接口的实现类
映射器接口定义好之后,当我们重新构建项目时MapStruct就会帮我们生成该接口的实现类,我们可以在{module}/build/generated/source/kapt/debug/{包名}路径找到该类,来对其细节一探究竟:
public class MessageEntityMapperImpl implements MessageEntityMapper {
private final MessageDTOFactory messageDTOFactory = new MessageDTOFactory();
@Override
public Builder vo2Dto(MessageVO messageVo) {
if ( messageVo == null ) {
return null;
}
Builder builder = messageDTOFactory.createMessageDto();
if ( messageVo.getMessageType() != null ) {
builder.setMessageTypeValue( messageVo.getMessageType() );
}
if ( messageVo.getMessageId() != null ) {
builder.setMessageId( messageVo.getMessageId() );
}
if ( messageVo.getMessageType() != null ) {
builder.setMessageType( int2Enum( messageVo.getMessageType().intValue() ) );
}
builder.setSenderId( messageVo.getSenderId() );
builder.setTargetId( messageVo.getTargetId() );
if ( messageVo.getTimestamp() != null ) {
builder.setTimestamp( messageVo.getTimestamp() );
}
builder.setContent( string2Byte( messageVo.getContent() ) );
return builder;
}
@Override
public MessageVO dto2Vo(Message messageDto) {
if ( messageDto == null ) {
return null;
}
MessageVO messageVO = new MessageVO();
messageVO.setMessageId( messageDto.getMessageId() );
messageVO.setMessageType( enum2Int( messageDto.getMessageType() ) );
messageVO.setSenderId( messageDto.getSenderId() );
messageVO.setTargetId( messageDto.getTargetId() );
messageVO.setTimestamp( messageDto.getTimestamp() );
messageVO.setContent( byte2String( messageDto.getContent() ) );
return messageVO;
}
}
可以看到,如上文所讲,由于该实现类实际仍以普通的get/set方法调用来完成字段映射,整个过程并没有用到反射,且由于是在编译期生成该类,减少了运行期的性能损耗,故符合其“高性能”的定义。
另一方面,当属性映射出错时,能在编译期及时获知,避免了运行时的报错崩溃,且对于某些特定类型增加了非空判断等措施,故符合其“类型安全”的定义。
接下来,我们即可用该映射器实例的映射方法替换之前手动编写的映射代码:
class EnvelopeHelper {
companion object {
/**
* 填充操作(VO->DTO)
* @param envelope 信封类,包含消息视图对象
*/
fun stuff(envelope: Envelope): MessageDTO.Message? {
return envelope.messageVO?.run {
MessageEntityMapper.INSTANCE.vo2Dto(this).build()
} ?: null
}
/**
* 提取操作(DTO->VO)
* @param messageDTO 消息数据传输对象
*/
fun extract(messageDTO: MessageDTO.Message): Envelope? {
with(Envelope()) {
messageVO = MessageEntityMapper.INSTANCE.dto2Vo(messageDTO)
return this
}
}
}
}
总结
如你所见,最终结果就是我们减少了大量的样板代码,使代码整体结构的更易于理解,后期扩展其他类型的对象也只需要增加对应的映射方法即可,即同时提高了代码的可读性/可维护性/可扩展性。
MapStruct遵循约定优于配置的原则,以尽可能自动化的方式,帮我们解决了应用分层式架构下、不同数据模型之间、繁琐且易出错的相互转换工作,实在是极大提高开发人员开发效率的利器!