内容纲要

背景

在现代 Java 应用程序中,对象映射是一个常见的需求,尤其是在分层架构中,不同层之间的对象往往需要相互转换。为了简化对象映射,开发者可以使用多种技术方案。博客优雅处理对象映射:MapStruct 实战指南中详细讲述了 MapStruct 的优点和用法。本篇博客将介绍其他对象映射方式,包括 BeanUtils 浅拷贝、OrikaModelMapper,并对比这些方案的优缺点,探讨如何选择适合的技术方案。

对象映射方法

BeanUtils

原理

BeanUtils.copyProperties 是 Apache Commons BeanUtils 库提供的一个工具方法,基于 Java 反射 实现。它通过反射获取源对象和目标对象的属性,并将源对象的属性值复制到目标对象中。

工作流程

  1. 通过反射获取源对象的所有属性。
  2. 通过反射获取目标对象的所有属性。
  3. 将源对象的属性值复制到目标对象的对应属性中。

优点

  • 只需一行代码即可完成对象属性的复制。不需要定义映射规则或编写额外的代码。
  • 无需引入其他依赖。

缺点

  • 性能较差:基于反射实现,运行时性能开销较大。
  • 类型安全不足:如果属性类型不匹配,可能会在运行时抛出异常。
  • 灵活性低:无法处理嵌套对象、集合或自定义转换逻辑。
  • 字段名不一致问题:当源对象和目标对象字段名不同时,映射后可能会丢失部分信息。

实战

对象定义

在本示例中,我们使用用户这一业务实体作为例子,分别定义领域模型 User 和数据库实体 UserEntity。其中,hobbies 字段需要在字符串和数组之间进行转换,LocalDateTime 字段也需要正确映射。

public class User {
    private String name;
    private String nickName;
    private String email;
    private String phoneNumber;
    private String address;
    private List<String> hobbies;
    private LocalDateTime registerTime;
    private Boolean isDeleted;
}
@Table(name = "users")
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String nickName;
    private String email;
    private String phoneNumber;
    private String address;
    private String hobbies;
    private LocalDateTime registerTime;
    private Boolean isDeleted;
}
映射方法
public class BeanMapper {

    public UserEntity toEntity(User domain) {
        UserEntity entity = new UserEntity();
        BeanUtils.copyProperties(domain, entity);
        String hobbiesString = String.join(",", domain.getHobbies());
        entity.setHobbies(hobbiesString);
        return entity;
    }

    public static User toDomain(UserEntity entity) {
        User domain = new User();
        BeanUtils.copyProperties(entity, domain);
        List<String> hobbies = Arrays.stream(entity.getHobbies().split(",")).collect(Collectors.toList());
        domain.setHobbies(hobbies);
        return domain;
    }
}

在映射方法中,hobbies 字段需要手动处理,将 List<String> 转换为逗号分隔的字符串,或者将字符串拆分为 List<String>。这种方式虽然简单,但违反了封装原则,属于经典的反模式。

测试

首先我们插入一条测试数据,用于测试entity到domain的映射:

INSERT INTO users (name, nick_name, email, phone_number, address, hobbies, register_time, is_deleted) VALUES
('Alice', 'testUser', '123@gmail.com', '13112341234', 'test', 'read,travel,movie', '2025-02-26 22:40:23', 0)

紧接着我们在测试中使用BeanMapper定义的toDomain方法:

@Test
void should_get_domain_by_bean_mapper() {
    User user = service.getUserByBeanMapper();

    assertEquals("Alice", user.getName());
    assertEquals("testUser", user.getNickName());
    assertEquals("13112341234", user.getPhoneNumber());
    assertEquals("123@gmail.com", user.getEmail());
    assertEquals(3, user.getHobbies().size());
    assertEquals("read", user.getHobbies().get(0));
    assertEquals("travel", user.getHobbies().get(1));
    assertEquals("movie", user.getHobbies().get(2));
    assertNotNull(user.getRegisterTime());
}

MapStruct

原理

MapStruct 是一个基于 注解处理器 的 Java 对象映射工具。它在编译时生成映射代码,生成的代码是纯 Java 代码,没有运行时反射开销。

工作流程

  1. 开发者定义 Mapper 接口,并使用 @Mapper@Mapping 注解指定映射规则。
  2. 在编译时,MapStruct 的注解处理器会解析这些注解,并生成对应的映射实现类。
  3. 生成的实现类直接调用目标对象的 setter 方法和源对象的 getter 方法,完成属性复制。

实战

关于 MapStruct 的详细用法,请参考博客:优雅处理对象映射:MapStruct 实战指南https://lemonpie.asia/archives/167)

Orika

原理

Orika 是一个基于 字节码生成 的 Java 对象映射工具。它在运行时动态生成字节码,并通过生成的字节码完成对象映射。

工作流程

  1. 开发者通过 MapperFactory 注册映射规则。
  2. 在运行时,Orika 使用字节码生成技术动态生成映射类的字节码。
  3. 生成的字节码直接操作对象的属性,完成映射。

优点

  • 基于字节码生成,性能接近手动实现。

  • 支持嵌套对象、集合和自定义转换逻辑。

缺点

  • 需要熟悉 Orika 的配置和 API。

  • 需要在运行时生成字节码,可能增加启动时间。

  • 对于需要自定义映射方法的字段,需要自定义Converter实现。在业务场景复杂的情况下会造成Converter类过多且冗余的状况。

实战

引入依赖
implementation 'ma.glasnost.orika:orika-core:1.5.4'
对象定义

同上

映射方法
public class OrikaMapper {

    private static final MapperFacade ORIKA_MAPPER;

    static {
        MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();

        // 注册自定义转换器(用于 hobbies 字段)
        mapperFactory.getConverterFactory().registerConverter(new HobbyToDomainConverter());
        mapperFactory.getConverterFactory().registerConverter(new HobbyToEntityConverter());

        // 注册 LocalDateTime 的 PassThroughConverter(避免 Orika 默认不支持 LocalDateTime 的问题)
        mapperFactory.getConverterFactory().registerConverter(new PassThroughConverter(LocalDateTime.class));

        ORIKA_MAPPER = mapperFactory.getMapperFacade();
    }

    public static UserEntity toEntity(User user) {
        return ORIKA_MAPPER.map(user, UserEntity.class);
    }

    public static User toDomain(UserEntity userEntity) {
        return ORIKA_MAPPER.map(userEntity, User.class);
    }

}

考虑到这个场景中存在日期格式的字段转换,所以需要注册PassThroughConverter转换器。同时定义字符串类型hobbies与数组类型hobbies字段之间转换的转换器,如下:

public class HobbyToDomainConverter extends CustomConverter<String, List<String>> {
    @Override
    public List<String> convert(String hobbyString, Type<? extends List<String>> type, MappingContext mappingContext) {
        return Arrays.stream(hobbyString.split(",")).collect(Collectors.toList());
    }
}
public class HobbyToEntityConverter extends CustomConverter<List<String>, String> {
    @Override
    public String convert(List<String> hobbies, Type<? extends String> type, MappingContext mappingContext) {
        return hobbies != null ? String.join(",", hobbies) : null;
    }
}
  • Orika使用MapperFactory来配置和创建映射器
  • MapperFacade是一个核心接口,提供了对象映射的功能
  • 当需要自定义映射时需要实现CustomConverter并将转换器注册到映射器中
测试

测试用数据同上,用于测试将数据库实体映射为领域对象。

@Test
void should_get_domain_by_orika_mapper() {
    User user = service.getUserByOrika();

    assertEquals("Alice", user.getName());
    assertEquals("testUser", user.getNickName());
    assertEquals("13112341234", user.getPhoneNumber());
    assertEquals("123@gmail.com", user.getEmail());
    assertEquals(3, user.getHobbies().size());
    assertEquals("read", user.getHobbies().get(0));
    assertEquals("travel", user.getHobbies().get(1));
    assertEquals("movie", user.getHobbies().get(2));
    assertNotNull(user.getRegisterTime());
}
多态场景

Orika 支持多态映射,可以通过 MapperFactoryclassMap 方法实现:

mapperFactory.classMap(Source.class, Target.class)
                .field("sourceField", "targetField")
                .register();

ModelMapper

原理

ModelMapper 是一个利用反射原理的 Java 对象映射工具。它通过反射和智能匹配算法,自动推断源对象和目标对象之间的映射关系。ModelMapper工作时首先在匹配过程中分析源对象和目标对象,根据匹配策略将属性相互匹配。接着在映射过程中根据匹配进行数据映射,将源对象的属性值转化为目标对象的对应字段。

工作流程:

  1. 开发者创建 ModelMapper 实例。
  2. ModelMapper 使用反射获取源对象和目标对象的属性。
  3. 通过智能匹配算法推断属性之间的映射关系。
  4. 根据推断结果完成属性复制。

优点

  • 基于约定,无需显式配置映射规则。

  • 支持嵌套对象和集合。

缺点

  • 基于反射实现,性能不如 MapStruct 和 Orika。

  • 对于复杂的映射场景,可能需要额外配置。

实战

引入依赖
implementation 'org.modelmapper:modelmapper:2.4.4'
对象定义

同上

映射方法
public class ModelMapperUtil {

    private static final ModelMapper MODEL_MAPPER;

    static {
        // 初始化 ModelMapper
        MODEL_MAPPER = new ModelMapper();

        // 配置 ModelMapper(可选)
        MODEL_MAPPER.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);

        // 注册自定义转换器(用于 hobbies 字段)
        MODEL_MAPPER.createTypeMap(User.class, UserEntity.class)
                .addMappings(mapper -> {
                    mapper.using(ctx -> String.join(",", ((List<String>) ctx.getSource())))
                            .map(User::getHobbies, UserEntity::setHobbies);
                });

        MODEL_MAPPER.createTypeMap(UserEntity.class, User.class)
                .addMappings(mapper -> {
                    mapper.using(ctx -> List.of(((String) ctx.getSource()).split(",")))
                            .map(UserEntity::getHobbies, User::setHobbies);
                });
    }

    public static UserEntity toEntity(User user) {
        return MODEL_MAPPER.map(user, UserEntity.class);
    }

    public static User toDomain(UserEntity userEntity) {
        return MODEL_MAPPER.map(userEntity, User.class);
    }
}
  • 使用 modelMapper.createTypeMap() 方法为 UserUserEntity 创建类型映射。
  • 通过 addMappings() 方法为 hobbies 字段注册自定义映射逻辑:
    • UserUserEntity:将 List<String> 转换为逗号分隔的字符串。
    • UserEntityUser:将逗号分隔的字符串拆分为 List<String>
测试

测试用数据同上,用于测试将数据库实体映射为领域对象。

@Test
void should_get_domain_by_model_mapper() {
    User user = service.getUserByModelMapper();

    assertEquals("Alice", user.getName());
    assertEquals("testUser", user.getNickName());
    assertEquals("13112341234", user.getPhoneNumber());
    assertEquals("123@gmail.com", user.getEmail());
    assertEquals(3, user.getHobbies().size());
    assertEquals("read", user.getHobbies().get(0));
    assertEquals("travel", user.getHobbies().get(1));
    assertEquals("movie", user.getHobbies().get(2));
    assertNotNull(user.getRegisterTime());
}
多态场景

ModelMapper 本身对多态映射的支持较弱,但可以通过自定义转换器实现。通过实现转换器Converter,并在转换器中根据if-elseswitch判断多态类型进行转换。如果按照Animal示例进行演示,则是:

public class AnimalConverter implements Converter<Animal, AnimalDTO> {
    @Override
    public AnimalDTO convert(MappingContext<Animal, AnimalDTO> context) {
        Animal source = context.getSource();
        if (source instanceof Dog) {
            DogDTO dogDTO = new DogDTO();
            dogDTO.setName(source.getName());
            dogDTO.setSound(((Dog) source).getBarkSound());
            return dogDTO;
        } else if (source instanceof Cat) {
            CatDTO catDTO = new CatDTO();
            catDTO.setName(source.getName());
            catDTO.setSound(((Cat) source).getMeowSound());
            return catDTO;
        }
        return null;
    }
}

总结

在上述示例中,我们详细介绍了多种 Java 对象映射工具的用法。这些工具各有优缺点,具体使用需根据场景选择:

  • 如果场景简单且性能要求不高,可以使用 BeanUtils,但需要手动处理特殊字段。
  • 如果需要高性能和复杂映射,亦或者是多态场景下的映射,推荐使用 MapStructOrika,它们都支持自定义字段映射逻辑。
  • 如果更注重易用性和快速开发,可以选择 ModelMapper,但需要注意其性能较低。
最后修改日期: 2025年3月2日

留言

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。