MapStruct provides a rich set of features for mapping Java types. The technical documentation describes extensively the classes and annotations provided by MapStruct and how to use them. More complex use cases are described in several community written articles all over the web. To complement the pool of available articles, this article will focus on mapping inheritance hierarchies and provide a possible solution that offers simplicity and reusability. I assume that the reader has a basic knowledge of MapStruct. If you're interested in a running example, feel free to check out this repo and try things out.
To demonstrate the capabilities of MapStruct in a simple way, we will use a very small and thus useless domain model for which the use of MapStruct seems overly complex, but which allows the code snippets to remain simple throughout the article. The real benefits of MapStruct become apparent especially with larger models.
// Source classes public class SourceProject { private String name; private LocalDate dueDate; // getters setters omitted throughout the code } // Target classes public class TargetProject { private ProjectInformation projectInformation; } public class ProjectInformation { private String projectName; private LocalDate endDate; }
As you can see, the source and target entities express the same information but are structured slightly differently. A mapper can be defined like this...
@Mapper public interface ProjectMapper { @Mapping(target = "projectInformation.projectName", source = "name") @Mapping(target = "projectInformation.endDate", source = "dueDate") TargetProject mapProject(SourceProject source); }
...and MapStruct will generate code that will look like this:
public class ProjectMapperImpl implements ProjectMapper { @Override public TargetProject mapProject(SourceProject source) { if ( source == null ) { return null; } TargetProject targetProject = new TargetProject(); targetProject.setProjectInformation( sourceProjectToProjectInformation( source ) ); return targetProject; } protected ProjectInformation sourceProjectToProjectInformation(SourceProject sourceProject) { if ( sourceProject == null ) { return null; } ProjectInformation projectInformation = new ProjectInformation(); projectInformation.setProjectName( sourceProject.getName() ); projectInformation.setEndDate( sourceProject.getDueDate() ); return projectInformation; } }
Now let's introduce some new entities that use inheritance:
// Source classes @Data public class SourceScrumProject extends SourceProject { private Integer velocity; } // Target classes @Data public class TargetScrumProject extends TargetProject { private Velocity velocity; } @Data public class Velocity { private Integer value; }
If we want to use the parent mapper universally to map both the parent entity and child entities, we can use the @SubclassMapping annotation, which generates a dispatching via instanceof checks to the mapping of possible child classes.
@Mapper public interface ProjectMapper { @Mapping(target = "projectInformation.projectName", source = "name") @Mapping(target = "projectInformation.endDate", source = "dueDate") @SubclassMapping(source = SourceScrumProject.class, target = TargetScrumProject.class) TargetProject mapProject(SourceProject source); @Mapping(target = "velocity.value", source = "velocity") @Mapping(target = "projectInformation.projectName", source = "name") @Mapping(target = "projectInformation.endDate", source = "dueDate") TargetScrumProject mapScrumProject(SourceScrumProject source); }
This generates the following code.
public class ProjectMapperImpl implements ProjectMapper { @Override public TargetProject mapProject(SourceProject source) { if ( source == null ) { return null; } if (source instanceof SourceScrumProject) { return mapScrumProject( (SourceScrumProject) source ); } else { TargetProject targetProject = new TargetProject(); targetProject.setProjectInformation( sourceProjectToProjectInformation( source ) ); return targetProject; } } @Override public TargetScrumProject mapScrumProject(SourceScrumProject source) { if ( source == null ) { return null; } TargetScrumProject targetScrumProject = new TargetScrumProject(); targetScrumProject.setVelocity( sourceScrumProjectToVelocity( source ) ); targetScrumProject.setProjectInformation( sourceScrumProjectToProjectInformation( source ) ); return targetScrumProject; } protected ProjectInformation sourceProjectToProjectInformation(SourceProject sourceProject) { if ( sourceProject == null ) { return null; } ProjectInformation projectInformation = new ProjectInformation(); projectInformation.setProjectName( sourceProject.getName() ); projectInformation.setEndDate( sourceProject.getDueDate() ); return projectInformation; } protected Velocity sourceScrumProjectToVelocity(SourceScrumProject sourceScrumProject) { if ( sourceScrumProject == null ) { return null; } Velocity velocity = new Velocity(); velocity.setValue( sourceScrumProject.getVelocity() ); return velocity; } protected ProjectInformation sourceScrumProjectToProjectInformation(SourceScrumProject sourceScrumProject) { if ( sourceScrumProject == null ) { return null; } ProjectInformation projectInformation = new ProjectInformation(); projectInformation.setProjectName( sourceScrumProject.getName() ); projectInformation.setEndDate( sourceScrumProject.getDueDate() ); return projectInformation; } }
We can already see some problems here:
With only these two fields this doesn't seem terrible, but imagine what the generated code would look like if we had more child classes containing more fields. The effect would be much bigger.
Let's try to tackle problem #1. MapStruct offers the annotation @InheritConfiguration which allows us to reuse mapping configuration from either the same class or the mapping configuration class used:
@Mapper public interface ProjectMapper { @Mapping(target = "projectInformation.projectName", source = "name") @Mapping(target = "projectInformation.endDate", source = "dueDate") @SubclassMapping(source = SourceScrumProject.class, target = TargetScrumProject.class) TargetProject mapProject(SourceProject source); @Mapping(target = "velocity.value", source = "velocity") @InheritConfiguration(name = "mapProject") TargetScrumProject mapScrumProject(SourceScrumProject source); }
This at least saves us a lot of duplicate configuration. Spoiler: We will not want to use this anymore at a later stage. But let's first tackle problem #2 and #3.
Since we could have potentially wide interfaces with a lot of duplicated code, using, understanding and debugging of the generated code could become more difficult. It would be easier if we had a mapper for each subclass that is self-contained and only either dispatches to a child mapper or performs a mapping, but not both. So let's move the mapping of the Scrum projects to a separate interface.
@Mapper(uses = ScrumProjectMapper.class) public interface ProjectMapper { @Mapping(target = "projectInformation.projectName", source = "name") @Mapping(target = "projectInformation.endDate", source = "dueDate") @SubclassMapping(source = SourceScrumProject.class, target = TargetScrumProject.class) TargetProject mapProject(SourceProject source); } @Mapper public interface ScrumProjectMapper { @Mapping(target = "velocity.value", source = "velocity") @InheritConfiguration(name = "mapProject") // not working TargetScrumProject mapScrumProject(SourceScrumProject source); }
We tell ProjectMapper to dispatch the mapping of ScrumProjects to ScrumProjectMapper via the uses-clause. The problem here is that the configuration from the mapProject-method is no longer visible to the ScrumProjectMapper. We could of course let it extend ProjectMapper, but then we have the problem of the wide interface and duplicated code again, as all methods are merged into ScrumProjectMapper. We could instead make ProjectMapper a config using the @MapperConfig annotation and reference it in ScrumProjectMapper, but since it also uses ScrumProjectMapper in the uses-clause to enable the dispatching, MapStruct would complain about the circular dependency. Furthermore, if we have an inheritance hierarchy with a height > 1, we quickly notice that MapStruct does not pass the config down the mapper hierarchy more than one level, making the config at level 0 unavailable on levels 2 and beyond.
Fortunately, there is a solution. The @Mapping annotation can be applied to other annotations. By declaring an annotation ProjectMappings, which basically wraps all the mapping information for Projects, we can reuse it anywhere we want. Let's see what this could look like.
@Mapper(uses = ScrumProjectMapper.class) public interface ProjectMapper { @Mappings @SubclassMapping(source = SourceScrumProject.class, target = TargetScrumProject.class) TargetProject mapProject(SourceProject source); @Mapping(target = "projectInformation.projectName", source = "name") @Mapping(target = "projectInformation.endDate", source = "dueDate") @interface Mappings { } } @Mapper public interface ScrumProjectMapper { @Mapping(target = "velocity.value", source = "velocity") @ProjectMapper.Mappings TargetScrumProject mapScrumProject(SourceScrumProject source); }
Imagine that we have more child classes than just ScrumProject. By simply bundling the mapping information in the shared annotation we can centralize the information and avoid all the pitfalls that come with duplication. This also works for deeper inheritance hierarchies. I just need to annotate my mapping method with the @Mappings-annotation of the parent mapper that uses the annotation of its parent mapper, and so on.
We can see in the generated code now that the mappers either dispatch or do the mapping only for the classes they're built for:
public class ProjectMapperImpl implements ProjectMapper { private final ScrumProjectMapper scrumProjectMapper = Mappers.getMapper( ScrumProjectMapper.class ); @Override public TargetProject mapProject(SourceProject source) { if ( source == null ) { return null; } if (source instanceof SourceScrumProject) { return scrumProjectMapper.mapScrumProject( (SourceScrumProject) source ); } else { TargetProject targetProject = new TargetProject(); targetProject.setProjectInformation( sourceProjectToProjectInformation( source ) ); return targetProject; } } protected ProjectInformation sourceProjectToProjectInformation(SourceProject sourceProject) { if ( sourceProject == null ) { return null; } ProjectInformation projectInformation = new ProjectInformation(); projectInformation.setProjectName( sourceProject.getName() ); projectInformation.setEndDate( sourceProject.getDueDate() ); return projectInformation; } } public class ScrumProjectMapperImpl implements ScrumProjectMapper { @Override public TargetScrumProject mapScrumProject(SourceScrumProject source) { if ( source == null ) { return null; } TargetScrumProject targetScrumProject = new TargetScrumProject(); targetScrumProject.setVelocity( sourceScrumProjectToVelocity( source ) ); targetScrumProject.setProjectInformation( sourceScrumProjectToProjectInformation( source ) ); return targetScrumProject; } protected Velocity sourceScrumProjectToVelocity(SourceScrumProject sourceScrumProject) { if ( sourceScrumProject == null ) { return null; } Velocity velocity = new Velocity(); velocity.setValue( sourceScrumProject.getVelocity() ); return velocity; } protected ProjectInformation sourceScrumProjectToProjectInformation(SourceScrumProject sourceScrumProject) { if ( sourceScrumProject == null ) { return null; } ProjectInformation projectInformation = new ProjectInformation(); projectInformation.setProjectName( sourceScrumProject.getName() ); projectInformation.setEndDate( sourceScrumProject.getDueDate() ); return projectInformation; } }
I'd say this makes understanding and debugging a single mapper easier. As we've already covered a lot of ground, let's wrap things up at this point. There are still some edge cases that can lead to further problems, but these will be covered in the next part.
Writing mappers with MapStruct for inheritance hierarchies should be a common task and easy to achieve, but you can quickly get stuck in some of MapStruct's quirks. Mapping the entire hierarchy in one class results in large classes implementing wide interfaces that are difficult to read and debug. When splitting the mappers into one per class, we want to reuse the mapping information from the parent mappers to avoid duplicating mapping information. Extending the parent mapper to make its mapping configuration visible for use in @InheritConfiguration is not desirable, as we will again have the problem of a wide interface with a lot of duplicated code. Using the parent mapper as a config is also not possible due to circular dependencies. We could see that the creating a custom annotation that bundles the mapping information for use in child mappers solves the problem. By additionally using SubclassMapping, the parent mapper provides the bundled information on how to map the entity it knows, contains only the mapping for exactly that class and dispatches the mapping of any other child entities down the hierarchy of mappers.
부인 성명: 제공된 모든 리소스는 부분적으로 인터넷에서 가져온 것입니다. 귀하의 저작권이나 기타 권리 및 이익이 침해된 경우 자세한 이유를 설명하고 저작권 또는 권리 및 이익에 대한 증거를 제공한 후 이메일([email protected])로 보내주십시오. 최대한 빨리 처리해 드리겠습니다.
Copyright© 2022 湘ICP备2022001581号-3