Android即时通讯系列文章(4)MapStruct:分层式架构下不同数据模型之间相互转换的利器

时间:2021-7-3 作者:qvyue

「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!

文章开篇,让我们先来解答一下上篇文章中留下的疑问,即:

为什么要设计多个Entity?

以「分离关注点」为原则的分层式架构,是我们在进行应用架构设计时经常采用的方案,例如为人熟知的MVC/MVP/MVVM等架构设计模式下,划分出的表示层、业务逻辑层、数据访问层、持久层等。为了保持应用架构分层之后的独立性,通常需要在各个层次之间定义不同的数据模型,于是不可避免地要面临数据模型之间的相互转换问题。

常见的不同层次的数据模型包括:

VO(View Object):视图对象,用于展示层,关联某一指定页面的展示数据。

DTO(Data Transfer Object):数据传输对象,用于传输层,泛指与服务端进行传输交互的数据。

DO(Domain Object):领域对象,用于业务层,执行具体业务逻辑所需的数据。

PO(Persistent Object):持久化对象,用于持久层,持久化到本地存储的数据。

还是以即时通讯中消息收发为例:

Android即时通讯系列文章(4)MapStruct:分层式架构下不同数据模型之间相互转换的利器
聊天时序图.png
  • 客户端在会话页面编辑消息并发送后,消息相关的数据在展示层被构造为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自动化完成两者之间的字段映射:

创建映射器接口

  1. 创建一个Java接口(也可以以抽象类的形式),并添加@Mapper注解表明是个映射器:
  2. 声明一个映射方法,指定入参类型和出参类型:
@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遵循约定优于配置的原则,以尽可能自动化的方式,帮我们解决了应用分层式架构下、不同数据模型之间、繁琐且易出错的相互转换工作,实在是极大提高开发人员开发效率的利器!

「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:qvyue@qq.com 进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。