MapStruct 使用

对象映射工具的由来

大型项目采用分层开发,每层的数据模型都不同:在持久化层,模型层为 PO(Persistent Object)、在开放服务层,模型为数据传输对象 DTO(Data Transfer Object)。

如果开放服务直接将 PO (持久化模型对象)对外暴露,叫开放领域模型风格。
如果开放服务只能将 DTO(数据传输对象)对外暴露,叫封闭领域模型风格。

在小型项目,和其它系统交互不多,对安全性要求不高的场景下,可以考虑使用开放领域模型风格。
在大型项目,分层开发,系统交互多,为了不暴露底层模型细节,我们推荐使用封闭领域模型风格。
这样各层数据模型交互时不可避免需要做映射处理,简单场景我们可以使用 Spring 框架提供的 BeanUtils.copyProperties,但它有局限性。
首先是不适合对性能有严苛要求的情况,因为 BeanUtils.copyProperties 是基于 Java 反射实现的。
其次不适合复杂映射场景:

比如性别在后台是通过 0 和 1,但是需要返回前端 男 或者 女,如何映射?
比如 PO <=> DTO 属性名不同,如何映射?
比如 多个 PO => DTO 如何映射?
再比如 属性名和属性类型都不同,又如何映射?

下面介绍的 MapStruct 就是专门应对为这种场景的。

MapStruct 简介

MapStruct 是一个 Java 注释处理器,用于生成类型安全的 bean 映射类。您只需定义一个 mapper接口,该接口声明任何必需的映射方法。在编译期间,MapStruct 将生成此接口的实现。此实现使用普通的 Java 方法调用在源对象和目标对象之间进行映射,注意:它不是通过反射实现的,因此效率很高,这也是我们推荐的主要原因。
与动态映射框架相比,MapStruct 具有以下优点:

映射灵活,可定制化程度高。
使用普通方法调用而不是反射,效率很好。
具备编译时类型安全性检查能力,在编译期就能规避很多映射的潜在问题。

使用示例

示例一

订单 PO 类定义:

import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 订单 PO
 * @date 2020-03-16
 */
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Data
public class Order implements Serializable {

    /**
     * 订单 Id
     */
    private Long id;

    /**
     * 买家电话
     */
    private String buyerPhone;

    /**
     * 买家地址
     */
    private String buyerAddress;

    /**
     * 订单金额
     */
    private Long amount;

    /**
     * 支付状态
     */
    private Integer payStatus;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

}

订单 DTO 类定义:

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
 * @date 2020-03-16
 */
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Data
public class OrderDTO implements Serializable {

    /**
     * 订单 Id
     */
    private Long orderId;

    /**
     * 买家电话
     */
    private String buyerPhone;

    /**
     * 买家地址
     */
    private String buyerAddress;

    /**
     * 订单金额
     */
    private Long amount;

    /**
     * 支付状态
     */
    private Integer payStatus;

    /**
     * 创建时间
     */
    private String orderTime;

}

复制代码注意到,由于业务场景的特殊:

订单Id 在 PO 对象 Order 里叫 id,在 DTO 对象 OrderDTO 里叫 orderId。
订单创建时间在 PO 对象 Order 里是 LocalDateTime 类型,且名称为 createTime,而在对应的 OrderDTO 里叫哦 orderTime,且类型为 String

面对这种情况,传统的 BeanUtils.copyProperties 方法似乎不好处理,而且前面也说过,BeanUtils.copyProperties 是基于反射实现的,效率并不高。这里我们用 MapStruct 来处理就比较简单了,首先定义一个映射接口(我们以后都将这类接口统称为 Convert 接口)。

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

/**
 * @date 2020-03-16
 */
@Mapper(componentModel = "spring")
public interface OrderConvert {

    @Mapping(source = "id", target = "orderId")
    @Mapping(source = "createTime", target = "orderTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
    OrderDTO from(Order order);

}

源和目标对象的属性名和属性一致的话,并不需要在转换接口中明确定义,框架会自动处理。
只有需要映射的属性名不同,或者类型不一致,或有特殊转换需求才需要明确定义。

如何使用:

@Test
public void test() {
    Order order = Order.builder()
        .id(123L)
        .buyerPhone("13707318123")
        .buyerAddress("中电软件园")
        .amount(10000L)
        .payStatus(1)
        .createTime(LocalDateTime.now())
        .build();

    OrderConvert orderConvert = Mappers.getMapper(OrderConvert.class);
    OrderDTO orderDTO = orderConvert.from(order);

    System.out.println("order:    " + order);
    System.out.println("orderDTO: " + orderDTO);
}

运行结果:

order: Order(id=123, buyerPhone=13707318123, buyerAddress=中电软件园, amount=10000, payStatus=1, createTime=2020-03-17T09:13:32.622)
orderDTO: OrderDTO(orderId=123, buyerPhone=13707318123, buyerAddress=中电软件园, amount=10000

示例二

适用于有两个,或多个 PO 对象,映射到一个 DTO 的场景。
例如我有两个 PO 对象:GoodInfo 和 GoodType,如下:

import lombok.Builder;
import lombok.Data;

/**
 * 商品信息
 * @date 2020-03-16
 */
@Builder
@Data
public class GoodInfo {
    private Long id;
    private String title;
    private double price;
    private int order;
    private Long typeId;
}
/**
 * 商品类型
 * @date 2020-03-16
 */
@Builder
@Data
public class GoodType {
    private Long id;
    private String name;
    private int show;
    private int order;
}

目标 DTO 为 GoodInfoDTO:

/**
 * @date 2020-03-16
 */
@Data
public class GoodInfoDTO {
    private String goodId;
    private String goodName;
    private double goodPrice;
    private String typeName;
}
/**
 * N Object => 1 Object
 * @date 2020-03-16
 */
@Mapper(componentModel = "spring")
public interface GoodInfoConvert {

    /** Long => String 隐式类型转换 */
    @Mapping(source = "good.id", target = "goodId")
    /** 属性名不同, */
    @Mapping(source = "type.name", target = "typeName")
    /** 属性名不同 */
    @Mapping(source = "good.title", target = "goodName")
    /** 属性名不同 */
    @Mapping(source = "good.price", target = "goodPrice")
    GoodInfoDTO from(GoodInfo good, GoodType type);

}
/**
 * N Object => 1 Object
 */
@Test
public void test() {
    GoodInfo goodInfo = GoodInfo.builder()
        .id(1L)
        .title("Mybatis技术内幕")
        .price(79.00)
        .order(100)
        .typeId(2L)
        .build();
    
    GoodType goodType = GoodType.builder()
        .id(2L)
        .name("计算机")
        .show(1)
        .order(3)
        .build();

    GoodInfoConvert convert = Mappers.getMapper(GoodInfoConvert.class);
    GoodInfoDTO goodInfoDTO = convert.from(goodInfo, goodType);
    System.out.println("goodInfo:    " + goodInfo);
    System.out.println("goodType:    " + goodType);
    System.out.println("goodInfoDTO: " + goodInfoDTO);

}

goodInfo: GoodInfo(id=1, title=Mybatis技术内幕, price=79.0, order=100, typeId=2)
goodType: GoodType(id=2, name=计算机, show=1, order=3)
goodInfoDTO: GoodInfoDTO(goodId=1, goodName=Mybatis技术内幕, goodPrice=79.0, typeName=计算机)

示例三

假设有一个 PO:Student

PO 里有个 sex 的 Integer 属性,0 表示 女,1 表示男,2 表示未知。
PO 里有个 age 的 Integer 属性,表示年龄。

目标 DTO:StudentDTO

DTO 里的 ageLevel 是根据 age 计算出来的年龄阶段描述:"少年"、"青年","中年"、"老年"。
DTO 里的 sexName 是根据 sex 属性计算出来的性别描述:"女"、"男"、"未知"。

PO:Student

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
 * @date 2020-03-16
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Student {
    private Long id;
    private String name;
    private Integer age;
    private Integer sex;

    /** 入学时间 */
    private LocalDateTime admissionTime;

}
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDate;

/**
 * @date 2020-03-16
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class StudentDTO {
    private Long studentId;
    private String studentName;
    private Integer age;
    private String ageLevel;
    private String sexName;
    private LocalDate admissionDate;
}
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

import java.time.LocalDate;
import java.time.LocalDateTime;

/**
 * @date 2020-03-16
 */
@Mapper(imports = {CustomMapping.class})
public interface StudentConvert {

    @Mapping(source = "id", target = "studentId")
    @Mapping(source = "name", target = "studentName")
    @Mapping(source = "age", target = "age")
    @Mapping(target = "ageLevel", expression = "java(CustomMapping.ageLevel(student.getAge()))")
    @Mapping(target = "sexName", expression = "java(CustomMapping.sexName(student.getSex()))")
    @Mapping(source = "admissionTime", target = "admissionDate", dateFormat = "yyyy-MM-dd")
    StudentDTO from(Student student);

    default LocalDate map(LocalDateTime time) {
        return time.toLocalDate();
    }

}
public class CustomMapping {

    static final String[] SEX = {"女", "男", "未知"};

    public static String sexName(Integer sex) {

        if (sex < 0 && sex > 2){
            throw new IllegalArgumentException("invalid sex: " + sex);
        }
        return SEX[sex];
    }

    public static String ageLevel(Integer age) {
        if (age < 18) {
            return "少年";
        } else if (age >= 18 && age < 30) {
            return "青年";
        } else if (age >= 30 && age < 60) {
            return "中年";
        } else {
            return "老年";
        }
    }

}
import lombok.extern.slf4j.Slf4j;
import org.junit.Before;
import org.junit.Test;
import org.mapstruct.factory.Mappers;

import java.time.LocalDateTime;

import static org.junit.Assert.assertEquals;

/**
 * @date 2020-03-16
 */
@Slf4j
public class Demo3Test {

    private Student student;
    private StudentDTO studentDTO;

    @Before
    public void setUp() {
        student = Student.builder().id(1L).name("John").age(18).admissionTime(LocalDateTime.now()).sex(0).build();
    }

    @Test
    public void testCarToCarDTO() {


        StudentConvert studentConvert = Mappers.getMapper(StudentConvert.class);
        studentDTO = studentConvert.from(student);

        log.info("student:    {}", student);
        log.info("studentDTO: {}", this.studentDTO);

        assertEquals(student.getId(), this.studentDTO.getStudentId());
        assertEquals(student.getName(), this.studentDTO.getStudentName());
        //assertEquals(student.getAge(), studentDTO.getAge());
        assertEquals(student.getAdmissionTime().toLocalDate(), this.studentDTO.getAdmissionDate());
    }

}

运行结果:

09:34:39.134 [main] INFO com.asiainfo.bits.core.mapstruct.demo3.Demo3Test - student: Student(id=1, name=John, age=18, sex=0, admissionTime=2020-03-17T09:34:39.126)
09:34:39.141 [main] INFO com.asiainfo.bits.core.mapstruct.demo3.Demo3Test - studentDTO: StudentDTO(studentId=1, studentName=John, age=18, ageLevel=青年, sexName=女, admissionDate=2020-03-17)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343