使用 MapStruct 转换时防止循环引用

Prevent Cyclic references when converting with MapStruct

今天我开始使用 MapStruct 为我的项目创建模型到 DTO 转换器,我想知道它是否自动处理循环引用,但事实证明它没有。

这是我用来测试的转换器:

package it.cdc.snp.services.rest.giudizio;

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import org.mapstruct.factory.Mappers;
import org.springframework.stereotype.Component;

import it.cdc.snp.dto.entita.Avvisinotifica;
import it.cdc.snp.dto.entita.Corrispondenza;
import it.cdc.snp.model.notifica.AvvisoDiNotificaModel;
import it.cdc.snp.model.notifica.NotificaModel;
import it.cdc.snp.model.procedimento.ProcedimentoModel;

@Component
@Mapper(componentModel="spring")
public interface NotificaMapper {

    NotificaMapper INSTANCE = Mappers.getMapper( NotificaMapper.class );

    @Mappings({
        @Mapping(source = "avvisinotificas", target = "avvisinotificas"),
    })
    NotificaModel<ProcedimentoModel> corrispondenzaToNotificaModel(Corrispondenza notifica);

    @Mappings({
        @Mapping(source = "corrispondenza", target = "notifica"),
    })
    AvvisoDiNotificaModel avvisinotificaToAvvisoDiNotificaModel(Avvisinotifica avvisinotifica);


}

这是测试:

        Notifica sourceObject1 = new Notifica();
        sourceObject1.setId(new Long(1));
        Avvisinotifica sourceObject2 = new Avvisinotifica();
        sourceObject2.setId(new Long(11));
        List<Avvisinotifica> tests= new ArrayList<>();
        tests.add(sourceObject2);
        sourceObject1.setAvvisinotificas(tests);
        sourceObject2.setCorrispondenza(sourceObject1);

        NotificaModel destObject1 = new NotificaModel<>();
        Avvisinotifica destObject2 = new Avvisinotifica();

        NotificaModel converted = mapper.corrispondenzaToNotificaModel(sourceObject1);

Notifica、Avvisinotifica 和它们各自的模型是带有 setter 和 getter 的简单 POJO,所以我认为不需要 post 代码(Notifica 扩展了 Corrispondenza,如果您想知道的话)

此代码进入无限循环,这里没有什么特别令人惊讶的(尽管我希望它能处理这些情况)。 虽然我认为我可以找到一种优雅的方式来手动处理它(我正在考虑使用带有 @MappingTarget 的方法来插入引用的对象),但我想知道是否有某种方法可以告诉 MapStruct 如何自动处理循环参考。

MapStruct 中还没有对此类情况的检测或特殊处理,但有一个功能请求:#469。如果您对如何处理循环有任何想法,请在该问题上发表评论。

Notifica 和 Avvisinotifica 无法帮助我理解您的模型。因此,假设您有上述子模型和父模型,

public class Child {
    private int id;
    private Father father;
    // Empty constructor and getter/setter methods omitted.
}

public class Father {
    private int x;
    private List<Child> children;
    // Empty constructor and getter/setter methods omitted.
}

public class ChildDto {
    private int id;
    private FatherDto father;
    // Empty constructor and getter/setter methods omitted.
}

public class FatherDto {
    private int id;
    private List<ChildDto> children;
    // Empty constructor and getter/setter methods omitted.
}  

你应该像这样创建一个映射器,

@Mapper
public abstract class ChildMapper {

    @AfterMapping
    protected void ignoreFathersChildren(Child child, @MappingTarget ChildDto childDto) {
        childDto.getFather().setChildren(null);
    }

    public abstract ChildDto myMethod(Child child);
}

=== Mapstuct 的初始版本

最好按照下面的方法。此解决方案假定 ChildDto::father 属性 是 Father 类型,而不是 FatherDto,这不是正确的数据架构。
@AfterMapping 注释意味着该方法将在属性映射后导入到生成的源代码中。因此,Mapper 实现将是这样的,

@Component
public class ChildMapperImpl extends ChildMapper {

    @Override
    public ChildDto myMethod(Child child) {
        if ( child == null ) {
            return null;
        }

        ChildDto childDto = new ChildDto();

        childDto.setId( child.getId() );
        childDto.setFather( child.getFather() );

        ignoreFathersChildren( child, childDto );

        return childDto;
    }
}

在此实现中,子项具有父项集。这意味着存在循环引用,但是使用 ignoreFathersChildren(child, childDto) 方法我们删除了引用(我们将其设置为 null)。

=== 更新 1

使用 mapstruct 版本 1.2。0.Final 你可以做得更好,

@Mapper
public interface ChildMapper {

    @Mappings({
//         @Mapping(target = "father", expression = "java(null)"),
         @Mapping(target = "father", qualifiedByName = "fatherToFatherDto")})
    ChildDto childToChildDto(Child child);

    @Named("fatherToFatherDto")
    @Mappings({
         @Mapping(target = "children", expression = "java(null)")})
    FatherDto fatherToFatherDto(Father father);
}

=== 更新 2

使用 mapstruct 版本 1.4。2.Final 你可以做得更好,

@Named("FatherMapper")
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface FatherMapper {

    @Named("toDto")
    @Mappings
    FatherDto toDto(Father father);

    @Named("toDtoWithoutChildren")
    @Mappings({
         @Mapping(target = "children", expression = "java(null)")})
    FatherDto toDtoWithoutChildren(Father father);
}

@Named("ChildMapper")
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE, uses = {FatherMapper.class})
public interface ChildMapper {

    @Named("toDto")
    @Mappings({
         @Mapping(target = "father", qualifiedByName = {"FatherMapper", "toDtoWithoutChildren"})})
    ChildDto toDto(Child child);

    @Named("toDtoWithoutFather")
    @Mappings({
         @Mapping(target = "father", expression = "java(null)")})
    ChildDto toDtoWithoutFather(Child child);
}

至少在 mapstruct 1.3 中您可以使用以下内容:

该解决方案受到 https://github.com/mapstruct/mapstruct-examples/tree/master/mapstruct-mapping-with-cycles/src/main/java/org/mapstruct/example/mapper

的广泛启发

定义上下文 class(受到 https://github.com/mapstruct/mapstruct-examples/blob/master/mapstruct-mapping-with-cycles/src/main/java/org/mapstruct/example/mapper/CycleAvoidingMappingContext.java 的广泛启发):

/**
 * An implementation to track cycles in graphs to be used as {@link Context} parameter.
 *
 */
public class CycleAvoidingMappingContext {
    private Map<Object, Object> knownInstances = new IdentityHashMap<Object, Object>();

    /**
     * Gets an instance out of this context if it is already mapped.
     * 
     * @param source
     *        given source
     * @param targetType
     *        given target type.
     * @return Returns the resulting type.
     */
    @BeforeMapping
    public <T> T getMappedInstance(Object source, @TargetType Class<T> targetType) {
        return targetType.cast(knownInstances.get(source));
    }

    /**
     * Puts an instance into the cache, so that it can be remembered to avoid endless mapping.
     * 
     * @param source
     *        given source
     * @param target
     *        given target
     */
    @BeforeMapping
    public void storeMappedInstance(Object source, @MappingTarget Object target) {
        knownInstances.put( source, target );
    }
}

在每个使用循环引用映射 classes 的映射器中,添加此 org.mapstruct.Context:

/**
 * Mapper. Automatically implemented by mapstruct.
 * 
 */
@Mapper
public interface SomeObjWithCyclesMapper {

    /**
     * instance.
     */
    SomeObjWithCyclesMapper INSTANCE = Mappers.getMapper(SomeObjWithCyclesMapper.class);

    /**
     * Mapper method to map entity to domain. Automatically implemented by mapstruct.
     * 
     * @param entity
     *        given entity.
     * @param context
     *        context to avoid cycles.
     * @return Returns the domain object.
     */
    SomeObjWithCycles entityToDomain(SomeObjWithCyclesEntity entity, @Context CycleAvoidingMappingContext context);

    /**
     * Mapper method to map domain object to entity. Automatically implemented by mapstruct.
     * 
     * @param domain
     *        given domain object.
     * @param context
     *        context to avoid cycles.
     * @return Returns the entity.
     */
    SomeObjWithCyclesEntity domainToEntity(SomeObjWithCycles domain, @Context CycleAvoidingMappingContext context);
    
}

用法(2021-09-21 添加):

然后你可以调用映射器方法:

SomeObjWithCyclesMapper.INSTANCE.domainToEntity(objWithCycles, new CycleAvoidingMappingContext());

其中 objWithCycles 是您要映射的 class SomeObjWithCycles 的对象。

提示(添加于 2022-05-27)

显而易见:这解决了使用 mapstruct 转换对象时出现的递归问题。 如果您在使用其他技术时遇到递归问题,例如RestEasy,你必须参考他们的手册来解决特定于这些技术的问题。

实际上,这种使用 CycleAvoidingMappingContext 的方法对我使用 MapStruct 版本 1.3.1 不起作用。由于我找不到太多工作示例,因此我致力于 post 我的解决方案在这里供其他人查找。

在 bi-directional 关系的情况下,由于循环引用,此类映射可能会触发 WhosebugError。

示例:类食谱、书籍和成分双向相关 1-to-many 和许多-to-many.

  • 一个食谱有很多成分,但只在一本书中提到。
  • 一本书里有很多食谱。
  • 一种成分仅用于 1 个配方(假设一种成分也具有固定其数量、计量单位等的属性,因此它确实特定于一个仅限食谱)。
    public class Recipe {
        Long id;
        // ... Other recipe properties go here
        Book book;
        Set<Ingredient> ingredients;
    }
    
    public class Book {
        Long id;
        // ... Other book properties go here
        Set<Recipe> recipes;
    }
    
    public class Ingredient {
        Long id;
        // ... Other ingredient properties go here
        Recipe recipe;
    }

我假设你也有 DTO 类 具有相同的属性,但当然指的是它们相应的 DTO 类。

这些将是从您的实体 类 映射到您的 DTO 类:

的默认映射器设置(在这种情况下不依赖于 Spring)
// MapStruct can handle primitive and standard classes like String and Integer just fine, but if you are using custom complex objects it needs some instructions on how it should map these
    @Mapper(uses = {BookMapper.class, IngredientMapper.class})
    public interface RecipeMapper {
        RecipeMapper INSTANCE = Mappers.getMapper( RecipeMapper.class );

        RecipeDTO toDTO(Recipe recipe);

        Recipe toEntity(RecipeDTO recipeDTO);
    }

    @Mapper(uses = {RecipeMapper.class, IngredientMapper.class})
    public interface BookMapper {
        BookMapper INSTANCE = Mappers.getMapper( BookMapper.class );

        BookDTO toDTO(Book book);

        Book toEntity(BookDTO book);
    }

    @Mapper(uses = {RecipeMapper.class, BookMapper.class})
    public interface IngredientMapper {
        IngredientMapper INSTANCE = Mappers.getMapper( IngredientMapper.class );

        IngredientDTO toDTO(Ingredient ingredient);

        Ingredient toEntity(IngredientDTO ingredientDTO);
    }

如果您停在那里并尝试以这种方式映射 类,由于您现在定义的循环引用,您将被 WhosebugError 击中(食谱包含具有 属性 的成分有配料的食谱……)。 这种默认的 Mapper 设置只能在没有双向关系的情况下使用,这也会触发反向映射。

你可以这样写 A -> B -> A -> B -> A ... 关于对象映射,我的经验表明您应该能够将其映射为:A -> B -> A(这次不包括关系以打破循环) 对于实体到 DTO 和 DTO 到实体的映射。这使您能够:

  • 深入了解前端中的关联对象:例如。显示食谱的成分列表
  • 保存对象时保持反向关系:例如。如果您只映射 A -> B。 RecipeDTO 中的 IngredientDTO 将没有配方 属性 并且在保存成分时您需要将配方 ID 作为参数传递并跳过一些环以关联成分在将成分实体保存到数据库之前使用配方实体对象的实体对象。

定义像 A -> B -> A 这样的映射(这次排除关系以打破循环)将归结为定义单独的映射,以便在该点从映射中排除相关的复杂对象你想打破循环的地方。

@IterableMapping(qualifiedByName = "") 用于映射复杂对象的集合,指的是对单个复杂对象的映射。

@Mapping(target = "PropertyName", qualifiedByName = "") 可用于指向替代映射,该映射在映射复杂对象的集合时(当您想要打破循环时)排除反向关系

@Mapping(target = "[.]", ignore = true) 可用于指示对象的 属性 根本不应映射。因此,这可用于完全忽略一个(集合)复杂对象或直接忽略单个(非集合)相关复杂对象内部的属性,以防它们不需要。

如果您不使用 qualifiedByName 属性和匹配的 @Named() 注释,您的映射将无法编译如果您在 Mapper 接口中有多个具有相同 return 类型和输入参数类型的方法,则会出现有关 模糊映射 的错误。

如果您使用命名映射,最好使用与@Named 注释值匹配的方法名称。

所以,我们先记下想要的行为,然后再编码:

1. When mapping a Recipe, we will need to map the book property in such a way that its inverse relation to recipes is mapped without the book property the second time
    Recipe A -> Book X  -> Recipe A (without book property value as this would close the cycle)
        -> Recipe B (without book property value, as same mapping is used for all these recipes unfortunately as we don't know up front which one will cause the cyclic reference)...
            -> Ingredients I (without recipe property value as they would all point back to A)
                             
2. When mapping a Book, we will need to map the recipes property in such a way that its inverse relation to book isn't mapped as it will point back to the same book.
        Book X -> Recipe A (without book property as this would close the cycle)
                    -> Ingredients (without recipe property as all these will point back to Recipe A)
                        -> Recipe B (without book property, as same mapping is used for all these and all could potentially close the cycle)
                        -> Recipe C
                
3. When mapping an Ingredient, we will need to map the recipe property in such a way that its inverse relation to ingredient isn't mapped as one of those ingredients will point back to the same ingredient

食谱中的书 属性 需要在没有食谱 属性 的情况下进行映射,因为其中一个也会循环回食谱。

    @Mapper(uses = {BookMapper.class, IngredientMapper.class})
    public interface RecipeMapper {
        RecipeMapper INSTANCE = Mappers.getMapper( RecipeMapper.class );

        @Named("RecipeSetIgnoreBookAndIngredientChildRecipes")
        @IterableMapping(qualifiedByName = "RecipeIgnoreBookAndIngredientChildRecipes")
        Set<RecipeDTO> toDTOSetIgnoreBookAndIngredientChildRecipes(Set<Recipe> recipes);

        @Named("RecipeSetIgnoreIngredientsAndBookChildRecipe")
        @IterableMapping(qualifiedByName = "RecipeIgnoreIngredientsAndBookChildRecipe")
        Set<RecipeDTO> toDTOSetIgnoreIngredientsAndBookChildRecipe(Set<Recipe> recipes);
                                
        // In this mapping we will ignore the book property and the recipe property of the Ingredients to break the mapping cyclic references when we are mapping a book object
        // Don't forget to add the matching inverse mapping from DTO to Entity, this is basically just a copy with switch input parameter and return types
        @Named("RecipeIgnoreBookAndIngredientChildRecipes")
        @Mappings({
            @Mapping(target = "book", ignore = true),                                               // book is a single custom complex object (not a collection), so we can directly ignore its child properties from there
            @Mapping(target = "ingredients", qualifiedByName = "IngredientSetIgnoreRecipes"),       // ingredients is a collection of complex objects, so we can't directly ignore its child properties as in the end, a Mapper needs to be defined to Map a single POJO into another
        })
        RecipeDTO toDTOIgnoreBookAndIngredientChildRecipes(Recipe recipe);

        @Named("RecipeIgnoreIngredientsAndBookChildRecipe")
        @Mappings({
            @Mapping(target = "book.recipes", ignore = true),
            @Mapping(target = "ingredients", ignore = true),
        })
        RecipeDTO toDTOIgnoreIngredientsAndBookChildRecipe(Recipe recipe);

        // Don't forget to add the matching inverse mapping from DTO to Entity, this is basically just a copy with switch input parameter and return types
        @Mappings({
            @Mapping(target = "book.recipes", ignore = true),                                       // book is a single custom complex object (not a collection), so we can directly ignore its child properties from there
            @Mapping(target = "ingredients", qualifiedByName = "IngredientSetIgnoreRecipes"),       // ingredients is a collection of complex objects, so we can't directly ignore its child properties as in the end, a Mapper needs to be defined to Map a single POJO into another
        })
        RecipeDTO toDTO(Recipe recipe);
        
        @Named("RecipeSetIgnoreBookAndIngredientChildRecipes")
        @IterableMapping(qualifiedByName = "RecipeIgnoreBookAndIngredientChildRecipes")
        Set<Recipe> toEntitySetIgnoreBookAndIngredientChildRecipes(Set<RecipeDTO> recipeDTOs);
        
        @Named("RecipeSetIgnoreIngredientsAndBookChildRecipe")
        @IterableMapping(qualifiedByName = "RecipeIgnoreIngredientsAndBookChildRecipe")
        Set<Recipe> toEntitySetIgnoreIngredientsAndBookChildRecipe(Set<RecipeDTO> recipeDTOs);
        
        @Mappings({
            @Mapping(target = "book.recipes", ignore = true),                                       // book is a single custom complex object (not a collection), so we can directly ignore its child properties from there
            @Mapping(target = "ingredients", qualifiedByName = "IngredientSetIgnoreRecipes"),       // ingredients is a collection of complex objects, so we can't directly ignore its child properties as in the end, a Mapper needs to be defined to Map a single POJO into another
        })
        Recipe toEntity(RecipeDTO recipeDTO);
        
        @Named("RecipeIgnoreBookAndIngredientChildRecipes")
        @Mappings({
            @Mapping(target = "book", ignore = true),                                               // book is a single custom complex object (not a collection), so we can directly ignore its child properties from there
            @Mapping(target = "ingredients", qualifiedByName = "IngredientSetIgnoreRecipes"),       // ingredients is a collection of complex objects, so we can't directly ignore its child properties as in the end, a Mapper needs to be defined to Map a single POJO into another
        })
        Recipe toEntityIgnoreBookAndIngredientChildRecipes(RecipeDTO recipeDTO);
        
                                @Named("RecipeIgnoreIngredientsAndBookChildRecipe")
        @Mappings({
            @Mapping(target = "book.recipes", ignore = true),
            @Mapping(target = "ingredients", ignore = true),
        })
        Recipe toEntityIgnoreIngredientsAndBookChildRecipe(RecipeDTO recipeDTO);
        
    }



    @Mapper(uses = {RecipeMapper.class, IngredientMapper.class})
    public interface BookMapper {
        BookMapper INSTANCE = Mappers.getMapper( BookMapper.class );
        
        @Mappings({
            @Mapping(target = "recipes", qualifiedByName = "RecipeSetIgnoreBookAndIngredientChildRecipes"),
        })
        BookDTO toDTO(Book book);

        @Mappings({
            @Mapping(target = "recipes", qualifiedByName = "RecipeSetIgnoreBookAndIngredientChildRecipes"),
        })
        Book toEntity(BookDTO book);
    }



    @Mapper(uses = {RecipeMapper.class, BookMapper.class})
    public interface IngredientMapper {
        IngredientMapper INSTANCE = Mappers.getMapper( IngredientMapper.class );

        // Don't forget to add the matching inverse mapping from DTO to Entity, this is basically just a copy with switch input parameter and return types
        @Named("IngredientSetIgnoreRecipes")
        IterableMapping(qualifiedByName = "IngredientIgnoreRecipes")                                // Refer to the mapping for a single object in the collection
        Set<IngredientDTO> toDTOSetIgnoreRecipes(Set<Ingredient> ingredients);

        // Don't forget to add the matching inverse mapping from DTO to Entity, this is basically just a copy with switch input parameter and return types
        @Named("IngredientIgnoreRecipes")
        @Mappings({
            @Mapping(target = "recipes", ignore = true),                                            // ignore the recipes property entirely
        })
        IngredientDTO toDTOIgnoreRecipes(Ingredient ingredient);

        @Mappings({
            @Mapping(target = "recipes", qualifiedByName = "RecipeSetIgnoreIngredientsAndBookChildRecipe")
        })
        IngredientDTO toDTO(Ingredient ingredient);

        @Named("IngredientSetIgnoreRecipes")
        IterableMapping(qualifiedByName = "IngredientIgnoreRecipes")                                // Refer to the mapping for a single object in the collection
        Set<Ingredient> toEntitySetIgnoreRecipes(Set<IngredientDTO> ingredientDTOs);

        @Named("IngredientIgnoreRecipes")
        @Mappings({
            @Mapping(target = "recipes", ignore = true),
        })
        Ingredient toEntityIgnoreRecipes(IngredientDTO ingredientDTO);

        @Mappings({
            @Mapping(target = "recipes", qualifiedByName = "RecipeSetIgnoreIngredientsAndBookChildRecipe")
        })
        Ingredient toEntityIgnoreRecipes(IngredientDTO ingredientDTO);
    }

用法

<ENTITY_NAME>DTO <eNTITY_NAME>DTO = <ENTITY_NAME>Mapper.INSTANCE.toDTO( <eNTITY_NAME> );`

在此页面上不太容易找到答案,因此我将 post 什么在我的案例中起作用以防止循环引用。

来自 George Siggoouroglou 的回答使用以下方法工作正常:

@Mapping(target = "primaryObject.secondaries", expression = "java(null)"),
SecondaryObjectDto toSecondaryObjectDto(SecondaryObject source);

Ivo Eersel 的回答非常完整,但我在第一次阅读时仍然设法错过了解决方案。

所以这是我最终使用的:

@Mapping(target = "primaryObject.secondaries", ignore = true)
SecondaryObjectDto toSecondaryObjectDto(SecondaryObject source);