Mapping object hierarchies with AutoMapper

Сегодня, в процессе работы на текущем проекте, в который раз столкнулся с необходимостью отображать сущности друг на друга. Сущности из domain model отображаются в сущности, принадлежащие data model, а те уже в свою очередь отображаются на базу данных при помощи Entity Framework. Напрямую использовать сущности "made by Entity Framework" не получилось, а так как Entity Framework версии 1.0, и POCO там и не пахло, то возникла такая цепочка отображений: Domain Model <-> Data model <-> Persistent storage.

Однако этот пост не об Entity Framework и POCO, а о применении такой библиотеки, как AutoMapper, в целях облегчения рутинных операций отображения сущностей.

Object model

Проблема отображения усложняется еще и тем, что нужно не просто отобразить одну плоскую сущность на другую, нужно отобразить иерархии сущностей из разных слоев приложения, которые конечно же отличаются друг от друга, иначе было бы не интересно. Смотрим диаграмму классов исходных сущностей (domain model).

Иерархия сущностей(domain model)

В двух словах - описание модели. Есть сущность Album, которая представляет собой альбом с эксклюзивными фотографиями, который заказывается через интернет-магазин. Каждое событие, происходящее с альбомом в рамках его жизненного цикла, моделируется как сущность типа IAlbumEvent. Обычно каждое такое событие моделируется отдельным классом, который наследуется от IAlbumEvent + AlbumEventBase, однако есть и исключения. Эти дочерние классы содержат данные, специфичные для этого события, а также логику, скажем, для обновления статуса альбома.

С другой стороны есть иерархия сущностей, которая является целевой, и в которую необходимо переложить исходные сущности (data model). Эта модель в свою очередь 1:1 отображается на объекты базы данных при помощи ORM.

Иерархия сущностей(data layer)

Последняя модель отличается от исходной по следующим параметрам:

  1. Сущность моделируется в виде отдельного класса, только если есть специфичные данные. Таким образом, в этой модели нет таких классов как CreatedAlbumEvent, SentAlbumEvent, у которых нет собственных уникальных элементов данных.
  2. Используются элементарные типы данных. Например, вместо Album используется только AlbumID, вместо enum EventType - строковое значение. Вместо DateTime снова же используется строковое значение.
  3. Именование элементов данных.

А так как основная проблема при отображении - это отличия между исходной и целевой сущностями, то наше решение должно уметь справляться с описанными case'ами.

Custom solution

Как решается эта проблема в большинстве случаев? Пишется что-то в таком духе.

public class CustomMapper
{
    public AlbumEventTarget MapToDataRepresentation(IAlbumEvent albumEventSource)
    {
        AlbumEventTarget albumEvent;
        if(albumEventSource is AlbumFailedEvent)
        {
            albumEvent = MapFailureAlbumEvent(albumEventSource as FailureAlbumEvent);
        }
        else if(albumEventSource is ProcessingCancelledAlbumEvent)
        {
        albumEvent = MapCancellationAlbumEvent(albumEventSource as CancellationAlbumEvent);
        }
        else if(albumEventSource is RestartAlbumEvent)
        {
            albumEvent = MapReshipAlbumEvent(albumEventSource as RestartAlbumEvent);
        }
        else
        {
            albumEvent = new AlbumEventTarget();
            albumEvent.EventID = albumEventSource.EventID;
            albumEvent.EventType = albumEventSource.Type.ToString().ToUpper();
            albumEvent.AlbumStatus = albumEventSource.Status.ToString();
            albumEvent.Timestamp = albumEventSource.Timestamp.ToString("MM/dd/yyyy", CultureInfo.InvariantCulture);
        }
        return albumEvent;
    }
}

Рано или поздно механическое перекладывание данных из одной сущности в другую по крайней мере утомляет, особенно если сущности побольше и посложнее. Также, этот подход ведет к дублированию кода. К примеру, значение EventType отображается как строка в upper case. А если это system-wide правило, то нам врядли захочется везде дублировать эту логику. Частично проблему можно решить введением класса-конвертера.

public class EnumStringConverter
{
    private readonly Type _enumType;

    public EnumStringConverter(Type enumType)
    {
        _enumType = enumType;
    }

    public string ConvertEnumToString(object enumObject)
    {
        return enumObject.ToString().ToUpper();
    }

    public object ConvertStringToEnum(string value)
    {
        return Enum.Parse(_enumType, value, true);
    }
}

То же самое можно сделать и для преобразования даты в определенном формате (MM/dd/yyyy), а затем везде использовать эти конвертеры, однако рутина ручного перекладывания все еще утомительна, особенно когда сущности практически идентичны. Также, при ручном перекладывании высока вероятность ненамеренно ошибиться.

AutoMapper

Что такое AutoMapper и зачем он нужен?

A convention-based object-object mapper.

AutoMapper uses a fluent configuration API to define an object-object mapping strategy. AutoMapper uses a convention-based matching algorithm to match up source to destination values. Currently, AutoMapper is geared towards model projection scenarios to flatten complex object models to DTOs and other simple objects, whose design is better suited for serialization, communication, messaging, or simply an anti-corruption layer between the domain and application layer.

Object-object mapping - то, что нам нужно. Convention-based, fluent API - ну что ж, конфигурирование отображения обещает быть немногословным и читабельным. Ничего не сказано про меппинг иерархий сущностей - попробуем прикрутить.

Посмотрим как можно с его помощью отобразить сущность CreatedAlbumEvent на AlbumEventTarget.

    Mapper.CreateMap<EventType, string>().ConvertUsing<EnumStringConverterAdapter<EventType>>();
    Mapper.CreateMap<AlbumStatusEnum, string>().ConvertUsing<EnumStringConverterAdapter<AlbumStatusEnum>>();

    Mapper.CreateMap<CreatedAlbumEvent, AlbumEventTarget>()
        .ForMember(t => t.AlbumStatus, m => m.MapFrom(s => s.Status))
        .ForMember(t => t.EventType, m => m.MapFrom(s => s.Type));

Отображение отдельного элемента данных описывается при помощи кострукции ForMember только для тех элементов, чьё именование различается в исходной и целевой сущностях. Элементы с одинаковым именованием будут отображены автоматически (convention-based). Также указано, как отображать перечисления EventType, AlbumStatusEnum в значения строкового типа (custom type converters). Более того, однажды описав custom type converter, AutoMapper будет использовать его в любом другом отображении, в нашем случае, при отображении CreatedAlbumEvent в AlbumEventTarget. Таким образом, область действия custom type converter - application-wide scope. Если необходимо выборочное применение преобразования, используется такое понятие, как custom value resolver.

Custom value resolver

Предположим, что AlbumStatus перечисление в качестве исключения не нужно преобразовывать в upper case, а просто использовать метод ToString().

     Mapper.CreateMap<CreatedAlbumEvent, AlbumEventTarget>()
        .ForMember(t => t.AlbumStatus, m => m.ResolveUsing<SimpleEnumResolver>().FromMember(s => s.Status))
        .ForMember(t => t.EventType, m => m.MapFrom(s => s.Type));

В данном случае для отображения AlbumStatus применили custom value resolver. Конечно, отдельный класс для этих целей - это overhead. Было бы неплохо обойтись lambda выражением.

public class SimpleEnumResolver : ValueResolver<AlbumStatusEnum, string>
{
    protected override string ResolveCore(AlbumStatusEnum source)
    {
        return source.ToString();
    }
}

Custom value formatter

Value formatter - этот тот же type converter, только целевой тип - это всегда string. Применим его для отображения Timestamp элемента данных (DateTime в string).

mapping.ForMember(t => t.Timestamp, m => m.AddFormatter<DateTimeFormatter>())
где DateTimeFormatter опять же отдельный класс:
public class DateTimeFormatter : ValueFormatter<DateTime>
{
    protected override string FormatValueCore(DateTime value)
    {
        return value.ToString("MM/dd/yyyy", CultureInfo.InvariantCulture);
    }
}

Отображение иерархий сущностей

Пока что все просто. Попробуем отобразить сущности, входящие в иерархию. Хочется в результате иметь возможность использовать API нашего самописного CustomMapper, переключив его реализацию на использование AutoMapper'а. Ну или вообще отказаться от этого класса, главное чтобы API был выражен в терминах базовых сущностей из обеих иерархий: IAlbumEvent, AlbumEventTarget.

public class CustomMapper
{
    public AlbumEventTarget MapToDataRepresentation(IAlbumEvent albumEventSource)
    {
        return Mapper.Map<IAlbumEvent, AlbumEventTarget>(albumEventSource);
    }
}

В первом приближении конфигурация отображения выглядит так.

Mapper.CreateMap<IAlbumEvent, AlbumEventTarget>()
    .ForMember(t => t.AlbumStatus, m => m.ResolveUsing<SimpleEnumResolver>().FromMember(s => s.Status))
    .ForMember(t => t.EventType, m => m.MapFrom(s => s.Type))
    .Include<ProcessingCancelledAlbumEvent, AlbumEventCancelTarget>()
    .Include<FailedAlbumEvent, AlbumEventFailTarget>()
    .Include<CreatedAlbumEvent, AlbumEventTarget>()
    .Include<RestartAlbumEvent, AlbumEventRestartTarget>();

Mapper.CreateMap<ProcessingCancelledAlbumEvent, AlbumEventCancelTarget>();
Mapper.CreateMap<FailedAlbumEvent, AlbumEventFailTarget>();
Mapper.CreateMap<CreatedAlbumEvent, AlbumEventTarget>();
Mapper.CreateMap<ReshipAlbumEvent, AlbumEventReshipTarget>()
    .ForMember(s => s.AlbumID, m => m.MapFrom(s => s.SourceAlbum.AlbumID))
    .ForMember(s => s.RestartReasonName, m => m.MapFrom(s => s.Reason.Name));

Конфигурируется отображение базовых сущностей, далее идет описание отображения дочерних сущностей. А также, при помощи конструкции Include, отображение базовых сущностей теперь знает о существовании дочерних сущностей.

Однако, не все так гладко. Если попробовать отобразить ProcessingCancelledAlbumEvent в AlbumEventCancelTarget, то некоторые элементы данных в целевой сущности останутся неустановленными.

var albumEvent = new ProcessingCancelledAlbumEvent ("actor")
{
    EventID = 1,
    Status = AlbumStatusEnum.Created,
    Timestamp = new DateTime(2010, 1, 1),
    Type = EventType.Cancelled,
    Comment = "comment"
};
var target = (AlbumEventCancelTarget) Mapper.Map<IAlbumEvent, AlbumEventTarget>(albumEvent);

Итак, собственные элементы данных сущности ProcessingCancelledAlbumEvent такие как, Actor и Comment установлены. Отображение явно для них не конфигурировалось, однако ввиду одинакового именования элементов данных, они были отображены автоматически. Аналогичным образом были установлены свойства Tag, EventID, Timestamp, а вот Status и Type остались пустыми. То есть меппинг, описанный для связки IAlbumEvent-AlbumEventTarget ни коим образом не оказывает влияние на связку ProcessingCancelledAlbumEvent -AlbumEventCancelTarget. Приходим к решению повторять меппинг базовых элементов данных для каждой дочерней сущности.

Mapper.CreateMap<ProcessingCancelledAlbumEvent , AlbumEventCancelTarget>()
    .ForMember(t => t.AlbumStatus, m => m.ResolveUsing<SimpleEnumResolver>().FromMember(s => s.Status))
    .ForMember(t => t.EventType, m => m.MapFrom(s => s.Type))

Однако, повторение меппинга базовых элементов данных для каждой дочерней сущности делает это решение еще хуже, чем наша самописная реализация. Как вариант, можно выделить повторяющийся код меппинга в виде extension метода.

public static class AlbumEventMappingExtensions
{
    public static IMappingExpression<TSource, TDestination> MapGenericAlbumEvent<TSource, TDestination>(
        this IMappingExpression<TSource, TDestination> mapping)
            where TSource : IAlbumEvent
            where TDestination : AlbumEventTarget
    {
        return mapping
            .ForMember(t => t.AlbumStatus, m => m.ResolveUsing<SimpleEnumResolver>().FromMember(s => s.Status))
            .ForMember(t => t.EventType, m => m.MapFrom(s => s.Type))
            .ForMember(t => t.Timestamp, m => m.AddFormatter<DateTimeFormatter>());
    }
}

Окончательная версия конфигурации меппинга для всей иерархии будет выглядеть так.

Mapper.CreateMap<EventType, string>().ConvertUsing<EnumStringConverterAdapter<EventType>>();

Mapper.CreateMap<IAlbumEvent, AlbumEventTarget>()
    .MapGenericAlbumEventData()
    .Include<ProcessingCancelledAlbumEvent , AlbumEventCancelTarget>()
    .Include<FailedAlbumEvent, AlbumEventFailTarget>()
    .Include<CreatedAlbumEvent, AlbumEventTarget>()
    .Include<RestartAlbumEvent, AlbumEventRestartTarget>();

Mapper.CreateMap<ProcessingCancelledAlbumEvent , AlbumEventCancelTarget>().MapGenericAlbumEventData();
Mapper.CreateMap<FailedAlbumEvent, AlbumEventFailTarget>().MapGenericAlbumEventData();
Mapper.CreateMap<CreatedAlbumEvent, AlbumEventTarget>().MapGenericAlbumEventData();
Mapper.CreateMap<RestartAlbumEvent, AlbumEventReshipTarget>().MapGenericAlbumEventData()
    .ForMember(s => s.AlbumID, m => m.MapFrom(s => s.SourceAlbum.AlbumID))
    .ForMember(s => s.ReshipReasonID, m => m.MapFrom(s => s.Reason.ReasonID));

Polymorphic element types in collections

Особая ценность меппинга иерархии сущностей проявляется тогда, когда работа с сущностями происходит полиморфно. К примеру, у нас может быть коллекция сущностей IAlbumEvent[] (все события в жизненном цикле отдельного заказа), которую мы хотим отобразить на коллекцию AlbumEventTarget[]. При этом все дочерние сущности должны быть также корректно отображены.
var album = new Album();
album.Events.Add(createdEvent);
album.Events.Add(failedEvent);
album.Events.Add(cancellationEvent);

var eventsMapped = Mapper.Map<IList<IAlbumEvent>, IList<AlbumEventTarget>>(album.Events);

Mapping hierarchy to DTO

Рассмотрим такой case, как отображение иерархии в DTO (Data Transfer Object). С одной стороны уже знакомая иерархия объектов (domain model).

Иерархия сущностей(domain model)

С другой стороны, простой плоский DTO.

Предполагается, что все специфические поля таких событий, как Cancellation, Failure, Restart, будут отображаться на один элемент данных Details. За счет этого иерархия "схлопнется" в плоский DTO.

Mapper.CreateMap<IAlbumEvent, DTO>()
    .MapGenericAlbumEventDataToDTO()
    .Include<ProcessingCancelledAlbumEvent, DTO>()
    .Include<FailedAlbumEvent, DTO>()
    .Include<RestartAlbumEvent, DTO>();

Mapper.CreateMap<ProcessingCancelledAlbumEvent, DTO>()
    .MapGenericAlbumEventDataToDTO()
    .ForMember(s => s.Details, m => m.MapFrom(s => "Actor: " + s.Actor + "; Comment: " + s.Comment));
Mapper.CreateMap<FailedAlbumEvent, DTO>()
    .MapGenericAlbumEventDataToDTO()
    .ForMember(s => s.Details, m => m.MapFrom(s => "Reason: " + s.Reason + "; Origin: " + s.Origin));
Mapper.CreateMap<RestartAlbumEvent, DTO>()
    .MapGenericAlbumEventDataToDTO()
    .ForMember(s => s.Details, m => m.MapFrom(s => "Reason: " + s.Reason));
                

var albumEvent = new ProcessingCancelledAlbumEvent("actor")
{
    EventID = 1,
    Status = AlbumStatusEnum.Created,
    Timestamp = new DateTime(2010, 1, 1),
    Type = EventType.Restart,
    Comment = "comment"
};

var target = Mapper.Map<IAlbumEvent, DTO>(albumEvent);

К сожалению, этот код не работает как ожидается. Вместо меппинга связки "ProcessingCancelledAlbumEvent-DTO" происходит меппинг связки "IAlbumEvent-DTO". По всей видимости Include ожидает, что оба и TSource и TDestination будут дочерними сущностями. В нашем же случае CancellationAlbumEvent наследуется от IAlbumEvent, а DTO один единственный, и тут просто нет иерархии и нет дочерних сущностей.

На мой взгляд, меппинг "Hierarchy - DTO" более распространенный case, чем "Hierarchy - Hierarchy", а он, к сожалению, не поддерживается.

Resources

0 comments:

Post a Comment