Start Debugging

Миграция с AutoMapper на маппинг, генерируемый исходным кодом, с Mapperly

Пошаговый чек-лист по замене Profiles, IMapper, ForMember и ProjectTo из AutoMapper 15 на мапперы, генерируемые исходным кодом Riok.Mapperly 4.3 в .NET 11.

Замена AutoMapper генератором исходного кода — это механический рефакторинг файл за файлом, а не переписывание. Для типичного сервиса с 30-80 маппингами, разбросанными по нескольким классам Profile, заложите от половины дня до дня: каждый CreateMap<Source, Dest>() превращается в метод partial в классе [Mapper], вызовы IMapper.Map<T> становятся прямыми вызовами методов, а ProjectTo<T>() превращается в сгенерированное расширение IQueryable. Ломается всё, что опиралось на гибкость AutoMapper во время выполнения: динамические вызовы Map(object), IValueResolver с внедрёнными сервисами и регистрация через сканирование сборок. Это стоит делать, если вы выше границы дохода AutoMapper в 5 000 000 USD и не хотите покупать лицензию, если вы хотите, чтобы работали Native AOT и тримминг, или если вы хотите, чтобы немаппленные свойства приводили к ошибке сборки, а не отбрасывались молча во время выполнения.

Упоминаемые версии: это руководство охватывает уход с AutoMapper 14.x (последний выпуск под MIT) и 15.0 (первый выпуск с двойной лицензией Reciprocal Public License 1.5 / коммерческой от Lucky Penny Software). Замена нацелена на <TargetFramework>net11.0</TargetFramework> с .NET 11 SDK, C# 14 и Riok.Mapperly 4.3.1 (выпущена 2025-12-22). Если вы ещё решаете, уходить ли вообще, это та же история поставщика, что и с MediatR, поэтому прочитайте миграцию с MediatR на простое внедрение зависимостей для параллельного случая; эта статья предполагает, что решение уже принято.

Почему команды уходят с AutoMapper именно сейчас

Что ломается

ОбластьИзменениеСерьёзность
Внедрение IMapperЗаменяется конкретным сгенерированным классом маппера, внедряемым напрямуювысокая
Profile + CreateMap<,>()Схлопываются в частичный класс [Mapper] с одним методом partial на направлениевысокая
ForMember(... MapFrom ...)Заменяется на [MapProperty] или приватный метод маппингавысокая
ReverseMap()Нет автоматического обратного маппинга; метод обратного направления объявляется явносредняя
IValueResolver / ITypeConverter с DIЗаменяются конструктором у маппера плюс приватным методомсредняя
ProjectTo<T>(config)Заменяется сгенерированным расширением проекции IQueryable<T>средняя
Сканирование AddAutoMapper(assembly)Заменяется явной регистрацией AddSingleton<TMapper>()средняя
mapper.Map(object, type) (динамический)Нет нетипизированной точки входа во время выполнения; каждый маппинг — типизированный методвысокая
Тест AssertConfigurationIsValid()Избыточен; сборка и есть утверждениенизкая

Предполётный чек-лист

  1. Установите .NET 11 SDK и убедитесь, что dotnet --version сообщает 11.0.x.
  2. Проведите инвентаризацию маппингов. Запустите grep по CreateMap<, чтобы подсчитать конфигурации, и по \.Map< и ProjectTo<, чтобы подсчитать места вызовов. Это объём вашей миграции.
  3. Найдите динамические маппинги. Сделайте grep по вызовам Map(, которые передают аргумент Type или источник object. У них нет прямого эквивалента в Mapperly, им нужен типизированный метод или ручной switch; займитесь ими в первую очередь.
  4. Найдите резолверы. Сделайте grep по IValueResolver, ITypeConverter и IMappingAction. Каждому нужен приватный метод у маппера.
  5. Особый бэкап не нужен, создайте ветку как обычно. Это изменение обратимо файл за файлом (см. План отката), так что достаточно ветки функциональности.

Шаги миграции

1. Добавьте Mapperly рядом с AutoMapper

Установите оба пакета, чтобы мигрировать по одному profile за раз:

# .NET 11 SDK, run from the project directory
dotnet add package Riok.Mapperly --version 4.3.1

Mapperly поставляет только анализатор и атрибуты из Riok.Mapperly.Abstractions, так что он не добавляет зависимости времени выполнения. Убедитесь, что сборка по-прежнему проходит: dotnet build без новых ошибок. Оставьте пакет AutoMapper в ссылках, пока не исчезнет последний profile.

2. Преобразуйте простой profile в класс [Mapper]

Возьмите сначала самый маленький profile. До:

// AutoMapper 15.0
public class CarProfile : Profile
{
    public CarProfile()
    {
        CreateMap<Car, CarDto>();
    }
}

После — частичный класс с частичным методом. Mapperly заполняет тело:

// .NET 11, C# 14, Riok.Mapperly 4.3.1
using Riok.Mapperly.Abstractions;

[Mapper]
public partial class CarMapper
{
    public partial CarDto ToDto(Car car);
}

Проверьте: соберите проект и откройте сгенерированный файл (он появляется в выводе анализатора, или задайте <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> в .csproj, чтобы записать его на диск). Убедитесь, что каждое свойство CarDto присваивается. Если какое-то не присваивается, Mapperly выдаёт диагностику вроде RMG020 для немаппленного члена назначения; это и есть возможность, а не сбой.

3. Переведите ForMember в [MapProperty]

Настройка по членам из AutoMapper отображается на атрибуты Mapperly. Переименование:

// AutoMapper 15.0
CreateMap<Car, CarDto>()
    .ForMember(d => d.ModelName, o => o.MapFrom(s => s.Model));

превращается в атрибут [MapProperty], называющий члены источника и назначения:

// .NET 11, C# 14, Riok.Mapperly 4.3.1
[MapProperty(nameof(Car.Model), nameof(CarDto.ModelName))]
public partial CarDto ToDto(Car car);

Для вычисляемого значения встроенная лямбда MapFrom из AutoMapper превращается в приватный метод маппинга, на который ссылаются через Use:

// .NET 11, C# 14, Riok.Mapperly 4.3.1
[Mapper]
public partial class CarMapper
{
    [MapProperty(nameof(Car.Price), nameof(CarDto.Price), Use = nameof(FormatPrice))]
    public partial CarDto ToDto(Car car);

    private string FormatPrice(decimal price) => price.ToString("C");
}

Чтобы намеренно отбросить свойство, замените Ignore() из AutoMapper на [MapperIgnoreTarget(nameof(CarDto.Internal))] или [MapperIgnoreSource(nameof(Car.Secret))]. Проверьте каждое преобразование, убедившись, что сгенерированное тело присваивает именно те члены, которые вы ожидаете.

4. Замените ReverseMap() явным методом

ReverseMap() из AutoMapper — это один вызов. У Mapperly нет автоматического обратного маппинга, поэтому объявите обратное направление как отдельный метод у того же маппера:

// .NET 11, C# 14, Riok.Mapperly 4.3.1
[Mapper]
public partial class CarMapper
{
    public partial CarDto ToDto(Car car);
    public partial Car ToEntity(CarDto dto);
}

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

5. Перенесите резолверы с зависимостями в конструктор маппера

IValueResolver из AutoMapper, которому нужен внедрённый сервис:

// AutoMapper 15.0
public class PriceResolver : IValueResolver<Car, CarDto, string>
{
    private readonly ICurrencyService _currency;
    public PriceResolver(ICurrencyService currency) => _currency = currency;
    public string Resolve(Car s, CarDto d, string m, ResolutionContext ctx)
        => _currency.Format(s.Price);
}

превращается в конструктор у маппера плюс приватный метод. Mapperly генерирует конструктор, который пробрасывает объявленные вами параметры, так что маппер участвует в обычном внедрении через конструктор:

// .NET 11, C# 14, Riok.Mapperly 4.3.1
[Mapper]
public partial class CarMapper
{
    private readonly ICurrencyService _currency;
    public CarMapper(ICurrencyService currency) => _currency = currency;

    [MapProperty(nameof(Car.Price), nameof(CarDto.Price), Use = nameof(FormatPrice))]
    public partial CarDto ToDto(Car car);

    private string FormatPrice(decimal price) => _currency.Format(price);
}

Проверьте, разрешив маппер из контейнера в тесте и проверив отформатированную цену.

6. Преобразуйте ProjectTo<T> в сгенерированную проекцию

ProjectTo<T>() из AutoMapper строит дерево Expression, чтобы EF Core мог перевести маппинг в SQL. Mapperly генерирует такое же расширение IQueryable, когда вы объявляете метод, принимающий и возвращающий IQueryable<T>:

// .NET 11, C# 14, Riok.Mapperly 4.3.1
[Mapper]
public static partial class CarQueryMapper
{
    public static partial IQueryable<CarDto> ProjectToDto(this IQueryable<Car> q);
}

Место вызова меняется с .ProjectTo<CarDto>(_config) на .ProjectToDto():

// .NET 11, EF Core 11
var dtos = await db.Cars
    .Where(c => c.NumberOfSeats > 4)
    .ProjectToDto()
    .ToListAsync();

Проверьте, захватив сгенерированный SQL (db.Cars...ToQueryString() или логирование EF Core) и убедившись, что проекция выполняется в базе данных, а не в памяти. Учтите, что путь проекции Mapperly не запускает object factories или пользовательские методы создания объектов, потому что он должен произвести переводимое дерево выражений.

7. Замените регистрацию DI

Удалите регистрацию AutoMapper и сканирование сборок:

// AutoMapper 15.0 - remove this
builder.Services.AddAutoMapper(typeof(CarProfile).Assembly);

Регистрируйте каждый маппер явно. Маппер без внедрённых зависимостей не имеет состояния и потокобезопасен, поэтому регистрируйте его как singleton; маппер со scoped-зависимостью должен соответствовать этому жизненному циклу:

// .NET 11
builder.Services.AddSingleton<CarMapper>();        // no dependencies
builder.Services.AddScoped<InvoiceMapper>();       // depends on a scoped service

Затем переключите потребителей с IMapper на конкретный маппер. _mapper.Map<CarDto>(car) превращается в _carMapper.ToDto(car). Статическим мапперам регистрация вообще не нужна; вызывайте CarQueryMapper.ProjectToDto(query) напрямую. Проверьте, что приложение запускается: отсутствующая регистрация теперь — это InvalidOperationException при запуске, именно тот ранний сбой, который вам нужен.

8. Удалите AutoMapper

Как только grep по using AutoMapper и CreateMap< ничего не возвращает, удалите пакет:

dotnet remove package AutoMapper

Проверьте чистым dotnet build и полным прогоном dotnet test.

Проверка

Пройдите этот чек-лист после исчезновения последнего profile:

План отката

Эта миграция обратима файл за файлом, что является её главным свойством безопасности. Поскольку вы держали AutoMapper в ссылках вплоть до шага 7, любой отдельный маппер, который ведёт себя неправильно, можно откатить, восстановив его Profile и переключив этого одного потребителя обратно на IMapper; две системы сосуществуют без конфликта. Единственный необратимый момент — это шаг 8, удаление пакета. Не удаляйте AutoMapper, пока каждый потребитель не преобразован и весь набор тестов не зелёный. Если вы нервничаете, поставьте преобразования мапперов в одном релизе, а удаление пакета — в следующем.

Подводные камни, с которыми мы столкнулись

Если вы хотите понять, как генератор делает это во время компиляции, прежде чем доверять ему в продакшене, механика та же, что описана в как написать генератор исходного кода для INotifyPropertyChanged.

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад