Признайтесь, и вы тоже. Наверное, нет более противоречивой библиотеки в мире .NET, чем AutoMapper. Тысячи проектов его используют, а разработчики этих проектов страдают. Страдают, когда нужно быстро ответить на вопрос: «Откуда, %$&дь, тут взялось это значение?!». Ты наводишься на setter, нажимаешь на Alt+F7 и видишь то самое Usage of 'Property.set' were not found:

Еще раз выругавшись, ты понимаешь, что это свойство устанавливает AutoMapper, идёшь искать конфигурацию…
Знакомо? Пора с этим заканчивать.
TL;DR
Устанавливай плагин AutoMapper.FindUsage для Rider/Resharper, он встраивается в стандартный Find Usages (Alt+F7) и показывает AutoMapper-маппинги как обычные usages — как будто никакой магии нет.

Как всё начиналось
Добавляя AutoMapper в проект, ты всегда идешь на сделку с совестью. Да, ты сэкономишь массу времени на генерации boilerplate-кода, новые свойства будут маппиться автоматически. Но с другой стороны, ты получишь «разрыв» потока данных в проекте, когда ручеёк данных внезапно теряет прослеживаемость, т.к. копирование значений происходит где-то за кулисами через рефлексию.
На прошлой работе я принёс
AutoMapperв проект для упрощения конвертации различных моделей. И мы начали очень плотно его использовать. До сих пор считаю, что это моё худшее инженерное решение за всю карьеру.
Периодически, в разных проектах я опять начинал встречаться с этой проблемой. В фоне я думал о том, можно ли это как-то решить.
Анализатор
Поднабравшись опыта с написанием Roslyn-анализаторов для поиска всяких проблемных мест в коде, я решил, что и для проблемы AutoMapper можно применить этот же инструмент.
Опущу разные нюансы разработки и скажу, что у меня получилось найти все соответствия моделей и их свойств, которые описываются через метод CreateMap<TSource, TDestination>() и как-то их подсветить:

Но тут я столкнулся с фундаментальными ограничениями анализатора: они, как ни странно, анализируют код и могут его менять. Они не привязаны к какой-то среде разработки и не могут дать команду IDE «а перейди-ка вот сюда» хоть даже и знают, куда именно. Всё что они могут — вывести WARN при компиляции или показать какую-то подсказку в IDE. Не очень удобно.
Одним из половинчатых решений было добавление комментария к свойству, где указывается источник данных:

Но это опять же неудобно: нужно править код, засорять его комментариями, поддерживать их. Анализаторы работают в рамках компилятора и не имеют доступа к API IDE для навигации. Нужен доступ к API IDE — значит, нужен плагин.
Плагин
Функция навигации по коду — это возможность IDE, в котором ты этот код смотришь. Поэтому верным решением будет написать плагин.
Стоит сказать, что Rider/Resharper не использует для анализа кода Roslyn. У него своя собственная модель работы с исходным кодом — Rider.SDK. В целом это логично, Resharper появился намного раньше. Но подход там похож. Ещё из удивительного — все плагины пишутся под классический .net 4.7.2.
Для старта разработки нужно скачать шаблон, на основе которого будет создан новый плагин. Вместе с шаблоном поставляется полный набор инструментов для тестирования и деплоя на основе Gradle. Это сильно облегчает жизнь. Например, одной командой можно запустить тестовый Rider, в котором будет твой плагин, и можно будет проверить его работоспособность. Так же есть команда, которая собирает тебе готовый пакет локально или сразу отправляет его в Marketplace JetBrains.
Стоит отметить, что разрабатывать плагин оказалось намного сложнее, чем roslyn-анализатор. Намного меньше примеров, документации. Различные ИИ помощники теряются и генерируют нерабочий код. Пришлось много изучать код Rider.SDK, различных базовых классов.
Провозившись пару вечеров, я добился нужного мне поведения: ставишь курсор на set, открываешь контекстное меню, видишь, откуда приходят данные, и можешь перейти в один клик туда:

Если разработчик вызывает контекстное меню, проверяем, что в данный момент мы находимся именно в setter'е какого-то свойства и запускается поиск регистраций маппинга (метод CreateMap()), далее извлекается тип TSource, находится свойство с таким же именем и добавляется новый пункт в контекстное меню:

Ну а навигация — это уже дело техники, ничего сложного. Так родилась первая версия плагина, которую я опубликовал в JetBrains Marketplace. Порекламировал среди коллег, попользовался сам, собрал первый фидбэк. Но уже тогда я понимал, что это не конечный результат, который я хочу получить.
Как видите, поиск осуществляется «на лету» при открытии контекстного меню. Это не самое хорошее решение, но оно стабильно работает на достаточно больших проектах. Тем не менее, это можно отнести к недостаткам. Так же, поиск через контекстное меню — не самый прямой путь. Обычно мы ищем связи через тот самый механизм Find Usages.
Инкрементальный анализ
Поиск маппинга в момент вызова может сказываться на производительности. Более оптимальный путь — найти все маппинги заранее, сложить их в кэш и обновлять его при изменении файла. Rider.SDK предоставляет приятную абстракцию для таких сценариев — SimpleICache<T>, где T — будет наш список маппингов. В этом классе Rider/Resharper будут вызывать метод Build при обновлении файла, а мы сможем накапливать наш кэш:
public class AutoMapperMappingCache : SimpleICache<List<SerializableMapping>> { public override object Build(IPsiSourceFile sourceFile, bool isPreParent) { // анализируем файл, возвращаем маппинги } }
Дальше наши маппинги сохраняются в памяти и на диске, что позволяет не анализировать весь проект на старте. Сериализацию маппингов делаем вручную:
SerializableMapping.cs
public class SerializableMapping { public string SourceTypeClrName; public string DestinationTypeClrName; public List<string> IgnoredProperties; public bool HasReverseMap; public void Write(UnsafeWriter writer) { writer.WriteString(SourceTypeClrName); writer.WriteString(DestinationTypeClrName); writer.WriteInt32(IgnoredProperties.Count); foreach (var prop in IgnoredProperties) writer.WriteString(prop); writer.WriteBoolean(HasReverseMap); } public static SerializableMapping Read(UnsafeReader reader) { var mapping = new SerializableMapping { SourceTypeClrName = reader.ReadString(), DestinationTypeClrName = reader.ReadString() }; var count = reader.ReadInt32(); mapping.IgnoredProperties = new List<string>(count); for (var i = 0; i < count; i++) mapping.IgnoredProperties.Add(reader.ReadString()); mapping.HasReverseMap = reader.ReadBoolean(); return mapping; } }
Теперь, когда нам нужно быстро найти соответствующий маппинг, нам не придётся обходить заново весь проект, а только пройтись по сохранённому ранее кэшу:
public IEnumerable<(IPsiSourceFile, SerializableMapping)> GetMappingsForType(string typeClrName) { foreach (var (sourceFile, mappings) in Map) { if (mappings == null) continue; foreach (var mapping in mappings) { if (mapping.SourceTypeClrName == typeClrName || mapping.DestinationTypeClrName == typeClrName) { yield return (sourceFile, mapping); } } } }
Find Usages
Мне так же хотелось сделать работу с AutoMapper более явной, как будто нет той самой закулисной магии и значение прилетает в свойство одного класса из свойства другого класса. Сделать так же, как обычно это происходит без него: ты находишь нужное свойство, нажимаешь Find Usages и идёшь дальше по этой цепочке. Поэтому я стал искать варианты, как встроить свою логику в стандартный механизм Rider/Resharper.
Оказывается, такое тоже возможно.
Для этого нам нужно реализовать свой кастомный механизм поиска, унаследованный от фабрикиDomainSpecificSearcherFactoryBase и пару вспомогательных классов:

Всё это вылилось в текущую версию плагина.
Как это работает
Давайте для примера рассмотрим такой код:
using AutoMapper; using Microsoft.Extensions.Logging; var config = new MapperConfiguration(cfg => { cfg.CreateMap<UserModel, UserDto>(); }, new LoggerFactory()); var mapper = config.CreateMapper(); var user = new UserModel { Name = "John Doe", Age = 30 }; var userDto = mapper.Map<UserDto>(user); Console.WriteLine(userDto.Name);
Описание моделек
public class UserModel { public string Name { get; set; } public int Age { get; set; } }
public class UserDto { public string Name { get; set; } public int Age { get; set; } }
Как видим, свойство userDto.Name не устанавливается напрямую. Это делает AutoMapper.
Давайте выполним команду Find Usages на этом свойстве. Если найдено только одно упоминание, то мы сразу переходим к свойству-источнику:

Если упоминаний несколько, то видим стандартное окно. Обратите внимание, что искали мы UserDto.Name, а нашли в том числе UserModel.Name:

Возможности
На данный момент плагин поддерживает поиск по прямому и обратному маппингу через ReverseMap(), поддерживает исключение свойство через Ignore(). Это закрывает почти все мои потребности, но если у вас есть предложения, то пишите тут или приносите isuue. В моих ближайших планах:
Поддержка метода
MapFrom()Поддержка NamingConvention:
property_name -> PropertyName
Заключение
Можно сказать, AutoMapper — необходимое зло, которое укоренилось в наших проектах и останется ещё с нами надолго. Поэтому стоит попытаться облегчить себе жизнь, ведь явное всегда лучше неявного. Сейчас уже появились мапперы на основе Source Generator, которые работают без рефлексии, а все преобразования выполняют в явном виде.
Если ты на новом проекте — смотри в сторону Mapperly, например. Если застрял с AutoMapper — ставь плагин.
