Интегрируем AutoMapper с DI-контейнерами на примере Unity

TL;DR: пакет для легкой регистрации (и конфигурации) AutoMapper в Unity.

var container = new UnityContainer();
container.RegisterMappingProfile<DataModelToViewModel>();
container.RegisterMapper();

public SomeController(IMappingEngine mapper)
{
	_mapper = mapper;
}

public ViewModel SomeAction()
{
	return _mapper.Map<ViewModel>(dataModel)
}



Представлять AutoMapper, я думаю, не надо. Можно долго обсуждать, насколько он удобен или нет, на каких задачах он применим, а на каких — нет, где он тормозит, в конце концов; но в итоге — вы либо им пользуетесь, либо нет. Я пользовался; и на фоне общего более-менее удобства у меня возник ряд проблем, переродившихся в рутинные задачи. Итак, предположим, вы тоже пользуетесь AutoMapper...

Тестирование кода, использующего AutoMapper


Первое, что смущает при активном использовании AutoMapper — это то, что основной предлагаемый сценарий использования предполагает статики. Смотрите:

//конфигурация
Mapper.CreateMap<Order, OrderDto>();

//использование
OrderDto dto = Mapper.Map<OrderDto>(order);

При этом конфигурацию надо бы выполнять один раз на приложение, поэтому она, естественно, выносится куда-нибудь в область общей инициализации, где и теряется (если у вас в приложении есть модули, каждый из которых использует свою конфигурацию — в каждом из них появляется свой собственный метод-конфигуратор, который вызывается из общей инициализации).

Если вы используете юнит-тестирование, то рано (скорее всего — рано) или поздно вы столкнетесь с ситуацией, когда для тестирования какого-то фрагмента кода вам нужно проинициализировать неизвестное количество маппингов, расположенных в неизвестных местах программы, причем если маппинги эти сами опираются на внешние зависимости (об этом подробнее позже), то вы никогда не знаете конкретно, как именно эти зависимости инициализируются. Иными словами, вы попадете в тот самый маленький адик, который уготован для людей, которые используют статические зависимости при изолированном тестировании.

Выход, в общем-то, очевиден — давайте найдем способ превратить зависимость из статической в обычную, и использовать обычный экземпляр объекта (и лучше бы — по интерфейсу), а временем его жизни управлять с помощью того же инструмента, который используется для управления жизни всеми остальными зависимостями.

К счастью, статический Mapper — это всего лишь фасад, за которым, в итоге, скрывается объект с интерфейсом IMappingEngine. Остается всего лишь зарегистрировать его в нашем dependency injection container of choice, и ура.

(оговорюсь сразу, у нас в качестве DI-контейнера используется Unity, все примеры и финальное решение написаны для него, но не должно составить никакого труда перенести это решение для других контейнеров)

Итак, после недолго поиска по Stackoverflow и чтения исходников порождаем приблизительно следующее:

_configurationProvider = new ConfigurationStore(new TypeMapFactory(), MapperRegistry.Mappers);
_configurationProvider.CreateMap<Order, OrderDto>();
//намылить, смыть, повторить

//это используем в контейнере или напрямую - как хотите
_mappingEngine = new MappingEngine(_configurationProvider);

Надо заметить, что в первых трех, кажется, подходах к этому снаряду маппер создавался внутри специально написанного враппера, а враппер скрывал в себе всю конфигурацию, и враппер же регистрировался в DI. Как любой лишний уровень абстракции, этот в какой-то момент тоже оказался лишним, и мы от него успешно избавились, вынеся конфигурацию в район composition root, а сам маппер — напрямую в DI. Итого получаем:

var configurationProvider = new ConfigurationStore(new TypeMapFactory(), MapperRegistry.Mappers);
configurationProvider.CreateMap<Order, OrderDto>(); //намылить, смыть, повторить
container.RegisterInstance<IConfigurationProvider>(configurationProvider); //так мы можем всегда его достать из контейнера, например, для проверки корректности
container.RegisterInstance<IMappingEngine>(new MappingEngine(configurationProvider));


Осталось добавить механизм, который бы позволил вынести конфигурацию маппинга из composition root. Опять-таки, сначала мы делали свои велосипеды, а потом, как положено, прочитали документацию. Выяснилось, что механизм профилей вполне успешно решает нашу задачу.

Итерация 1:

var configurationProvider = new ConfigurationStore(new TypeMapFactory(), MapperRegistry.Mappers);
configurationProvider.AddProfile(new SomeProfile()); //намылить, смыть, повторить
container.RegisterInstance<IConfigurationProvider>(configurationProvider);
container.RegisterInstance<IMappingEngine>(new MappingEngine(configurationProvider));

Немного думаем — итерация 2:

var configurationProvider = new ConfigurationStore(new TypeMapFactory(), MapperRegistry.Mappers);
//теперь не надо изобретать, откуда брать список профилей - просто зарегистрируем их все в контейнере
foreach (var profile in container.ResolveAll<Profile>())
	configurationProvider .AddProfile(profile);
container.RegisterInstance<IConfigurationProvider>(configurationProvider);
container.RegisterInstance<IMappingEngine>(new MappingEngine(configurationProvider));


После третьего повтора этого кода мы вынесли его в метод-расширение, а еще через две недели я сделал nuget-пакет, чтобы больше никогда не задаваться вопросом «как это сделать». Ура.

Использование внешних зависимостей при маппинге


На самом деле, не делайте так. Нет, серьезно, не надо так делать, маппинг становится слишком сложным, его нужно тестировать отдельно, теряется вся идея — в общем, не делайте так. Однако, если вам это все-таки нужно (например, вы маппите из DTO, в котором передаются коды справочных значений, на сущность БД, где нужны идентификаторы, и вы хотите доставать идентификаторы из БД по кодам во время маппинга), то для этого, как ни странно, тоже есть стандартный механизм.

Шаг 1: создаем value resolver (полнофункциональный, в виде отдельного класса, метода недостаточно). Например, так:

private class OKEIResolver: ValueResolver<string,int>
{
	private readonly Func<ISomeDbContext> _contextFactory;

	public OKEIResolver(Func<ISomeDbContext> contextFactory)
	{
		_contextFactory = contextFactory;
	}

	protected override int ResolveCore(string source)
	{
		using(var dc = _contextFactory())
		{
			//Обработка ошибок намеренно исключена
			return dc.Set<OKEI>()
					.Where(s => s.Code == source)
					.Single(s => s.Id)
		}
	}
}

Шаг 2 глазастые уже заметили сами: вбрасываем нужную нам зависимость в резолвер.
Шаг 3: учим AutoMapper создавать резолверы (и прочие полезные вещи) из контейнера:
configuration.ConstructServicesUsing(t => container.Resolve(t));

Тут есть трюк: на той версии AutoMapper, на которой я это реализовывал, ConstructServicesUsing нужно было вызывать до первой конфигурации, использующей резолвер, иначе он создавался минуя контейнер.

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

Надо ли говорить, что этот маленький, но полезный вызов мы тоже включили внутрь нашего метода-расширения?

Комментарии, советы, исправления приветствуются.

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 9

    +1
    Поясните, пожалуйста, если мы не используем внешних зависимостей при маппинге (они 100% отправляют нас в ад), то чем не подходит тривиальная инициализания маппингов в bootstrapper-классе? Пока нам не нужно тестировать сами маппинги мне не видно причины, заставляющей нас иметь разную реализацию для теста и приложения той части бутстраппера, которая отвечает за маппинги. Подскажите, где я не прав.
      0
      Если вкратце — то вы правы.

      Нюансы: если вы используете в тестах ту же реализацию маппинга, что и при основном выполнении, то вам нужно не забывать ее инициализировать перед каждым тестом (ну или один раз на выполнение тестов, если ваша тестовая среда это позволяет). Это типичный пример операции, от которой пользы нет, но делать ее надо; особенно это заметно, если маппингов много и они разбросаны по нескольким местам.

      Опять-таки, если маппинги заковыристые (что, заметим, тоже не очень правильно, но иногда без этого не обойтись), то для теста бывает полезнее их исключить (чтобы не подбирать исходные данные так, чтобы после маппинга получилось то, что нам нужно), и тогда удобно просто замокать весь маппер целиком.
      0
      Добавьте пример того, как в итоге используете этот механизм.
        0
        А чем тот пример кода, который над хабракатом, не подходит?
          0
          тогда не понял. подумал OKEIResolver это некий хелпер, который непонятно где вызывается.
            0
            OKEIResolver — это обработчик, который вызывается при маппинге конкретного атрибута. Вас интересует пример для него?
              0
              Да, интересно.
                +1
                CreateMap<Dto, Data>()
                .ForMember(p => p.Id, m => m.Ignore())
                .ForMember(p => p.Quantity, m => m.MapFrom(d => d.Amount))
                .ForMember(p => p.UnitId, m => m.ResolveUsing<OKEIResolver>().FromMember(p => p.okeiCode))
        +1
        Поскольку прошло больше пяти лет, возможно кому-то пригодится небольшая добавка для случая .Net Core.
        Тут всё очень просто и функционально — всего лишь надо установить nuget package
        AutoMapper.Extensions.Microsoft.DependencyInjection, добавить профили с маппингом
        в проекты решения
        public class DataAccessMappingProfile : Profile
        {
           public DataAccessMappingProfile()
           {
              Mapper.Initialize(cfg =>
              {
                 cfg.CreateMap<PolicyItemDBEntity, PolicyItemDto>();
                 cfg.CreateMap<PolicyTargetDBEntity, PolicyTargetDto>();
              });
           }
        }
        

        и вызвать в Startup.cs вашего микросервиса метод расширения
        services.AddAutoMapper()


        Вот и всё. Теперь можно объявлять конструкторы вида:
        public GetApplicationRolesHandler(IMapper mapper)
        {
            _mapper = mapper;
        }
        

        Метод расширения найдет профили во всех проектах решения, добавит конфигурацию из них и обеспечит биндинг IMapper на дефолтную реализацию.
        Естественно в юнит-тестах IMapper можно подменить моком.

        Всё вышеописанное для нативного DI .Net Core. Но при желании можно добавить и более продвинутые DI Frameworks, включая Unity.

        Only users with full accounts can post comments. Log in, please.