1. 概述

Orika 是一个 Java Bean 映射框架,核心功能是递归地将数据从一个对象复制到另一个对象。在开发多层应用时,这个框架能极大简化工作。

当在不同层之间传递数据对象时,我们经常需要将对象转换为不同实例以适应不同的 API。传统实现方式包括:硬编码复制逻辑或使用类似 Dozer 的 Bean 映射器。而 Orika 通过字节码生成技术创建高性能映射器,相比基于反射的映射器(如 Dozer)速度更快,开销更小。

2. 简单示例

映射框架的核心是 MapperFactory 类。我们用它配置映射规则,并获取执行实际映射工作的 MapperFacade 实例。

创建 MapperFactory 对象的方式很简单:

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

假设有一个源数据对象 Source.java,包含两个字段:

public class Source {
    private String name;
    private int age;
    
    public Source(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    // 标准 getter 和 setter
}

以及结构相似的目标对象 Dest.java

public class Dest {
    private String name;
    private int age;
    
    public Dest(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    // 标准 getter 和 setter
}

最基础的 Orika 映射用法如下:

@Test
public void givenSrcAndDest_whenMaps_thenCorrect() {
    mapperFactory.classMap(Source.class, Dest.class);
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Source src = new Source("Baeldung", 10);
    Dest dest = mapper.map(src, Dest.class);

    assertEquals(dest.getAge(), src.getAge());
    assertEquals(dest.getName(), src.getName());
}

可以看到,我们通过映射创建了与 Source 字段完全相同的 Dest 对象。默认情况下,双向映射也是支持的:

@Test
public void givenSrcAndDest_whenMapsReverse_thenCorrect() {
    mapperFactory.classMap(Source.class, Dest.class).byDefault();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Dest src = new Dest("Baeldung", 10);
    Source dest = mapper.map(src, Source.class);

    assertEquals(dest.getAge(), src.getAge());
    assertEquals(dest.getName(), src.getName());
}

3. Maven 配置

在 Maven 项目中使用 Orika,需要在 pom.xml 中添加 orika-core 依赖:

<dependency>
    <groupId>ma.glasnost.orika</groupId>
    <artifactId>orika-core</artifactId>
    <version>1.4.6</version>
</dependency>

最新版本可在 Maven 中央仓库 查询。

4. 使用 MapperFactory

Orika 的通用映射模式包括:创建 MapperFactory 对象、配置映射规则(如需调整默认行为)、获取 MapperFacade 实例,最后执行实际映射。所有示例都会遵循这个模式,但第一个示例展示了默认行为无需任何配置。

4.1. BoundMapperFacade vs MapperFacade

⚠️ 注意:对于特定类型对的映射,使用 BoundMapperFacade 比默认的 MapperFacade 性能更好。之前的测试可优化为:

@Test
public void givenSrcAndDest_whenMapsUsingBoundMapper_thenCorrect() {
    BoundMapperFacade<Source, Dest> 
      boundMapper = mapperFactory.getMapperFacade(Source.class, Dest.class);
    Source src = new Source("baeldung", 10);
    Dest dest = boundMapper.map(src);

    assertEquals(dest.getAge(), src.getAge());
    assertEquals(dest.getName(), src.getName());
}

BoundMapperFacade 的双向映射需要显式调用 mapReverse 方法:

@Test
public void givenSrcAndDest_whenMapsUsingBoundMapperInReverse_thenCorrect() {
    BoundMapperFacade<Source, Dest> 
      boundMapper = mapperFactory.getMapperFacade(Source.class, Dest.class);
    Dest src = new Dest("baeldung", 10);
    Source dest = boundMapper.mapReverse(src);

    assertEquals(dest.getAge(), src.getAge());
    assertEquals(dest.getName(), src.getName());
}

否则测试会失败。

4.2. 配置字段映射

之前的示例中源对象和目标对象的字段名完全相同。现在处理字段名不同的情况:

假设源对象 Person 包含三个字段:namenicknameage

public class Person {
    private String name;
    private String nickname;
    private int age;
    
    public Person(String name, String nickname, int age) {
        this.name = name;
        this.nickname = nickname;
        this.age = age;
    }
    
    // 标准 getter 和 setter
}

目标对象 Personne(由法国程序员编写)包含对应字段:nomsurnomage

public class Personne {
    private String nom;
    private String surnom;
    private int age;
    
    public Personne(String nom, String surnom, int age) {
        this.nom = nom;
        this.surnom = surnom;
        this.age = age;
    }
    
    // 标准 getter 和 setter
}

Orika 无法自动处理这种差异,但我们可以用 ClassMapBuilder API 注册自定义映射:

@Test
public void givenSrcAndDestWithDifferentFieldNames_whenMaps_thenCorrect() {
    mapperFactory.classMap(Personne.class, Person.class)
      .field("nom", "name").field("surnom", "nickname")
      .field("age", "age").register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Personne frenchPerson = new Personne("Claire", "cla", 25);
    Person englishPerson = mapper.map(frenchPerson, Person.class);

    assertEquals(englishPerson.getName(), frenchPerson.getNom());
    assertEquals(englishPerson.getNickname(), frenchPerson.getSurnom());
    assertEquals(englishPerson.getAge(), frenchPerson.getAge());
}

✅ 关键点:必须调用 register() 方法注册配置。即使只有一个字段不同,也需显式注册所有字段映射(包括相同的 age),否则未注册的字段不会被映射。

踩坑提示:如果对象有 20 个字段但只想自定义 1 个,是否需要配置所有映射?
❌ 不需要!使用 byDefault() 让未显式定义的字段使用默认映射:

mapperFactory.classMap(Personne.class, Person.class)
  .field("nom", "name").field("surnom", "nickname").byDefault().register();

这样 age 字段虽未定义映射,测试仍会通过。

4.3. 排除字段

若想排除 Personnenom 字段,使 Person 对象只接收非排除字段的新值:

@Test
public void givenSrcAndDest_whenCanExcludeField_thenCorrect() {
    mapperFactory.classMap(Personne.class, Person.class).exclude("nom")
      .field("surnom", "nickname").field("age", "age").register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Personne frenchPerson = new Personne("Claire", "cla", 25);
    Person englishPerson = mapper.map(frenchPerson, Person.class);

    assertEquals(null, englishPerson.getName());
    assertEquals(englishPerson.getNickname(), frenchPerson.getSurnom());
    assertEquals(englishPerson.getAge(), frenchPerson.getAge());
}

注意第一个断言:name 字段因被排除而保持 null

5. 集合映射

有时目标对象有独立属性,而源对象将所有属性保存在集合中。

5.1. 列表和数组

假设源对象只有一个字段——姓名列表:

public class PersonNameList {
    private List<String> nameList;
    
    public PersonNameList(List<String> nameList) {
        this.nameList = nameList;
    }
}

目标对象将 firstNamelastName 分离为独立字段:

public class PersonNameParts {
    private String firstName;
    private String lastName;

    public PersonNameParts(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

假设索引 0 始终是 firstName,索引 1 始终是 lastName。Orika 支持用括号表示法访问集合元素:

@Test
public void givenSrcWithListAndDestWithPrimitiveAttributes_whenMaps_thenCorrect() {
    mapperFactory.classMap(PersonNameList.class, PersonNameParts.class)
      .field("nameList[0]", "firstName")
      .field("nameList[1]", "lastName").register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    List<String> nameList = Arrays.asList(new String[] { "Sylvester", "Stallone" });
    PersonNameList src = new PersonNameList(nameList);
    PersonNameParts dest = mapper.map(src, PersonNameParts.class);

    assertEquals(dest.getFirstName(), "Sylvester");
    assertEquals(dest.getLastName(), "Stallone");
}

即使源对象是 PersonNameArray(数组而非列表),相同测试也能通过。

5.2. 映射(Map)

假设源对象包含一个 Map,其中键 first 对应目标对象的 firstName,键 last 对应 lastName

public class PersonNameMap {
    private Map<String, String> nameMap;

    public PersonNameMap(Map<String, String> nameMap) {
        this.nameMap = nameMap;
    }
}

同样使用括号表示法,但传入键名而非索引。Orika 支持单引号或双引号(需转义):

@Test
public void givenSrcWithMapAndDestWithPrimitiveAttributes_whenMaps_thenCorrect() {
    mapperFactory.classMap(PersonNameMap.class, PersonNameParts.class)
      .field("nameMap['first']", "firstName")
      .field("nameMap[\"last\"]", "lastName")
      .register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Map<String, String> nameMap = new HashMap<>();
    nameMap.put("first", "Leornado");
    nameMap.put("last", "DiCaprio");
    PersonNameMap src = new PersonNameMap(nameMap);
    PersonNameParts dest = mapper.map(src, PersonNameParts.class);

    assertEquals(dest.getFirstName(), "Leornado");
    assertEquals(dest.getLastName(), "DiCaprio");
}

6. 映射嵌套字段

假设源对象内部嵌套了另一个 DTO,我们需要访问其属性:

public class PersonContainer {
    private Name name;
    
    public PersonContainer(Name name) {
        this.name = name;
    }
}
public class Name {
    private String firstName;
    private String lastName;
    
    public Name(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

使用点表示法访问嵌套属性:

@Test
public void givenSrcWithNestedFields_whenMaps_thenCorrect() {
    mapperFactory.classMap(PersonContainer.class, PersonNameParts.class)
      .field("name.firstName", "firstName")
      .field("name.lastName", "lastName").register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    PersonContainer src = new PersonContainer(new Name("Nick", "Canon"));
    PersonNameParts dest = mapper.map(src, PersonNameParts.class);

    assertEquals(dest.getFirstName(), "Nick");
    assertEquals(dest.getLastName(), "Canon");
}

7. 映射空值

有时需要控制是否映射 null 值。默认情况下 Orika 会映射 null

@Test
public void givenSrcWithNullField_whenMapsThenCorrect() {
    mapperFactory.classMap(Source.class, Dest.class).byDefault();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Source src = new Source(null, 10);
    Dest dest = mapper.map(src, Dest.class);

    assertEquals(dest.getAge(), src.getAge());
    assertEquals(dest.getName(), src.getName());
}

可在不同级别自定义此行为:

7.1. 全局配置

在创建 MapperFactory 时全局配置是否映射 null

MapperFactory mapperFactory = new DefaultMapperFactory.Builder()
  .mapNulls(false).build();

测试验证 null 未被映射:

@Test
public void givenSrcWithNullAndGlobalConfigForNoNull_whenFailsToMap_ThenCorrect() {
    mapperFactory.classMap(Source.class, Dest.class);
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Source src = new Source(null, 10);
    Dest dest = new Dest("Clinton", 55);
    mapper.map(src, dest);

    assertEquals(dest.getAge(), src.getAge());
    assertEquals(dest.getName(), "Clinton");
}

默认行为下,源对象的 null 会覆盖目标对象的非空值。此配置后,目标字段不会被覆盖。

7.2. 局部配置

ClassMapBuilder 上使用 mapNulls(true|false) 控制正向映射,或 mapNullsInReverse(true|false) 控制反向映射。配置会影响后续所有字段:

@Test
public void givenSrcWithNullAndLocalConfigForNoNull_whenFailsToMap_ThenCorrect() {
    mapperFactory.classMap(Source.class, Dest.class).field("age", "age")
      .mapNulls(false).field("name", "name").byDefault().register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Source src = new Source(null, 10);
    Dest dest = new Dest("Clinton", 55);
    mapper.map(src, dest);

    assertEquals(dest.getAge(), src.getAge());
    assertEquals(dest.getName(), "Clinton");
}

反向映射的 null 控制示例:

@Test
public void 
  givenDestWithNullReverseMappedToSourceAndLocalConfigForNoNull_whenFailsToMap_thenCorrect() {
    mapperFactory.classMap(Source.class, Dest.class).field("age", "age")
      .mapNullsInReverse(false).field("name", "name").byDefault()
      .register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Dest src = new Dest(null, 10);
    Source dest = new Source("Vin", 44);
    mapper.map(src, dest);

    assertEquals(dest.getAge(), src.getAge());
    assertEquals(dest.getName(), "Vin");
}

7.3. 字段级配置

使用 fieldMap 在字段级别控制:

mapperFactory.classMap(Source.class, Dest.class).field("age", "age")
  .fieldMap("name", "name").mapNulls(false).add().byDefault().register();

此配置仅影响 name 字段:

@Test
public void givenSrcWithNullAndFieldLevelConfigForNoNull_whenFailsToMap_ThenCorrect() {
    mapperFactory.classMap(Source.class, Dest.class).field("age", "age")
      .fieldMap("name", "name").mapNulls(false).add().byDefault().register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    Source src = new Source(null, 10);
    Dest dest = new Dest("Clinton", 55);
    mapper.map(src, dest);

    assertEquals(dest.getAge(), src.getAge());
    assertEquals(dest.getName(), "Clinton");
}

8. Orika 自定义映射

之前使用 ClassMapBuilder API 的简单自定义已不能满足所有场景。现在通过 Orika 的 CustomMapper 类实现复杂转换。

假设两个对象都有 dtob 字段表示出生时间:

  • 一个对象使用 ISO 格式的日期时间字符串:2007-06-26T21:22:39Z
  • 另一个使用 Unix 时间戳长整型:1182882159000

内置转换器无法处理这种转换,需编写 CustomMapper

public class Person3 {
    private String name;
    private String dtob;
    
    public Person3(String name, String dtob) {
        this.name = name;
        this.dtob = dtob;
    }
}
public class Personne3 {
    private String name;
    private long dtob;
    
    public Personne3(String name, long dtob) {
        this.name = name;
        this.dtob = dtob;
    }
}

自定义映射器实现双向转换:

class PersonCustomMapper extends CustomMapper<Personne3, Person3> {

    @Override
    public void mapAtoB(Personne3 a, Person3 b, MappingContext context) {
        Date date = new Date(a.getDtob());
        DateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
        String isoDate = format.format(date);
        b.setDtob(isoDate);
    }

    @Override
    public void mapBtoA(Person3 b, Personne3 a, MappingContext context) {
        DateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
        Date date = format.parse(b.getDtob());
        long timestamp = date.getTime();
        a.setDtob(timestamp);
    }
};

测试正向映射:

@Test
public void givenSrcAndDest_whenCustomMapperWorks_thenCorrect() {
    mapperFactory.classMap(Personne3.class, Person3.class)
      .customize(customMapper).register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    String dateTime = "2007-06-26T21:22:39Z";
    long timestamp = new Long("1182882159000");
    Personne3 personne3 = new Personne3("Leornardo", timestamp);
    Person3 person3 = mapper.map(personne3, Person3.class);

    assertEquals(person3.getDtob(), dateTime);
}

测试反向映射:

@Test
public void givenSrcAndDest_whenCustomMapperWorksBidirectionally_thenCorrect() {
    mapperFactory.classMap(Personne3.class, Person3.class)
      .customize(customMapper).register();
    MapperFacade mapper = mapperFactory.getMapperFacade();
    String dateTime = "2007-06-26T21:22:39Z";
    long timestamp = new Long("1182882159000");
    Person3 person3 = new Person3("Leornardo", dateTime);
    Personne3 personne3 = mapper.map(person3, Personne3.class);

    assertEquals(person3.getDtob(), timestamp);
}

9. 总结

本文深入探讨了 Orika 映射框架的核心特性。虽然还有更高级的功能,但覆盖的场景已能满足绝大多数需求。完整项目代码和示例可在 GitHub 项目 查看。建议同时了解 Dozer 映射框架,两者解决的问题类似但实现方式不同。


原始标题:Mapping with Orika