JavaのModelMapperでLombokの@Value+@Builderを付けたクラスに変換する

Javaのクラス変換にModelMapperを使っているが、LombokのBuilderアノテーションを付与したクラスに対し、 ModelMapper#map メソッドを実行するとエラーが発生したので、対応方法をメモ。

環境

ModelMapper v2.3.2 で確認。

エラー発生状況

@Data
@AllArgsConstructor
public class MyData {
    private long id;
}

@Value
@Builder
public class MyValue {
    long id;
}

MyData data = new MyData(1L);
MyValue value = new ModelMapper().map(data, MyValue.class);
// Failed to instantiate instance of destination MyValue.
// Ensure that MyValue has a non-private no-argument constructor.

原因はエラーメッセージの通り、引数なしのコンストラクタが存在しないため。 Builderアノテーションにより、引数ありのパッケージプライベートコンストラクタが生成されている。

試しにBuilderクラスを渡してみたが、値が設定されない。

MyValue value = new ModelMapper()
        .map(data, MyValue.MyValueBuilder.class)
        .build(); // value.getId() == 0L

対応

そのまんまなissueが解決済みだった。

github.com

v2.2.0から追加された NameTransformers.builder() で設定を調整する模様。

ModelMapper modelMapper = new ModelMapper();
Configuration builderConfiguration = modelMapper.getConfiguration().copy()
        .setDestinationNameTransformer(NameTransformers.builder())
        .setDestinationNamingConvention(NamingConventions.builder());
TypeMap<MyData, MyValue.MyValueBuilder> typeMap = modelMapper
        .createTypeMap(MyData.class, MyValue.MyValueBuilder.class, builderConfiguration);

MyData data = new MyData(1L);
MyValue value = typeMap.map(data).build(); // value.getId() == 1L

調整

これで大丈夫かと思ったが、問題が発生。

ModelMapperはスレッドセーフなので、シングルトンインスタンスを使いまわしていたが、上記の方法でcreateTypeMapを同じ引数で複数回実行すると、エラーが発生した。

ModelMapper modelMapper = ModelMapperHolder.getInstance(); // シングルトンインスタンスの取得
Configuration builderConfiguration = ModelMapperHolder.getBuilderConfiguration(); // ビルダー用設定の取得

// createTypeMapを2回呼ぶとエラー
modelMapper.createTypeMap(MyData.class, MyValue.MyValueBuilder.class, builderConfiguration);
modelMapper.createTypeMap(MyData.class, MyValue.MyValueBuilder.class, builderConfiguration);
// A TypeMap already exists for class MyData and class MyValue$MyValueBuilder

ModelMapper#typeMap 系のメソッドを呼ぶと、 TypeMapStore#getOrCreate が呼ばれるようだが、Configurationを渡せるメソッドはcreateTypeMap以外に見当たらなかった。

作成済みのTypeMapは ModelMapper#getTypeMap(Class<S>, Class<D>) で取得できるので、以下のように記述して対応できた。

ModelMapper modelMapper = ModelMapperHolder.getModelMapper();
Configuration builderConfiguration = ModelMapperHolder.getBuilderConfiguration();

TypeMap<MyData, MyValue.MyValueBuilder> typeMap = modelMapper
        .getTypeMap(MyData.class, MyValue.MyValueBuilder.class);

if (typeMap == null) {
    synchronized (modelMapper) {
        typeMap = modelMapper.getTypeMap(MyData.class, MyValue.MyValueBuilder.class);

        if (typeMap == null) {
            typeMap = modelMapper.createTypeMap(MyData.class, MyValue.MyValueBuilder.class, builderConfiguration);
        }
    }
}

実際はユーティリティーとしてラップしているので、毎回こんな記述はしないが、もう少しシンプルにならないかと調べてみたところ、Builder用のModelMapperを作って解決できた。

ModelMapper builderModelMapper = new ModelMapper();
builderModelMapper.getConfiguration()
        .setDestinationNameTransformer(NameTransformers.builder())
        .setDestinationNamingConvention(NamingConventions.builder());

のように設定しておいて、

ModelMapper builderModelMapper = ModelMapperHolder.getBuilderModelMapper();
MyData data = new MyData(1L);
MyValue value = builderModelMapper
        .map(data, MyValue.MyValueBuilder.class)
        .build(); // value.getId() == 1L

とすることで、TypeMapの有無を意識せず使用できるようになった。