美文网首页即时通讯
Android即时通讯系列文章(4)MapStruct:分层式架

Android即时通讯系列文章(4)MapStruct:分层式架

作者: 星际码仔 | 来源:发表于2021-06-20 15:51 被阅读0次

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

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

    为什么要设计多个Entity?

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

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

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

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

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

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

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

    聊天时序图.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遵循约定优于配置的原则,以尽可能自动化的方式,帮我们解决了应用分层式架构下、不同数据模型之间、繁琐且易出错的相互转换工作,实在是极大提高开发人员开发效率的利器!

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

    相关文章

      网友评论

        本文标题:Android即时通讯系列文章(4)MapStruct:分层式架

        本文链接:https://www.haomeiwen.com/subject/ldlsyltx.html