Pull to refresh

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

Reading time5 min
Views16K
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 приходит ссылка на существующий маппер.

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

Комментарии, советы, исправления приветствуются.
Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments9

Articles