Миграция с устаревшего фреймворка — это не только боль, но и возможность «переизобрести» свой продукт. В результате мы не просто мигрировали, а создали универсальный шаблон, который позволяет добавлять новые эндпоинты для справочников буквально за 5 минут, обеспечивая им из коробки пагинацию, фильтрацию, сортировку и валидацию
? Предисловие
Всем привет! Сегодня хочу поделиться нашим опытом масштабной миграции API для работы со справочными данными с устаревшего Loopback 3 на современный стэк — .NET WEB API на .Net Core 8.0.
Наш проект — это большое сельскохозяйственное приложение с десятками сложных справочников (культуры, техника, поля и т.д.). Изначально эти данные обслуживались Loopback, но с ростом проекта его ограничения и устаревающая кодовая база стали серьезным тормозом развития.
? Часть 1: Основные цели миграции
? Программа-минимум
? Уход с Loopback — полная замена устаревшего фреймворка на современный .NET стэк
? Желаемые характеристики решения
? Экономическая эффективность
✅ Создание оптимального, поддерживаемого и удобного решения
⚖️ Баланс между стоимостью разработки и качеством
? При значительном удорожании — возможность переноса без оптимизаций
?️ Архитектурные требования
? Единообразие API: единый принцип работы всех эндпоинтов
? Универсальность клиентов: поддержка "тонких" и "толстых" клиентов
? Серверная логика: вынос операций фильтрации и сортировки на сервер
? Технические требования
? Минимизация эндпоинтов: один эндпоинт на сущность
?️ Статус записей: явное указание
isDeletedдля каждой записи⚡ Динамические операции: сортировка и фильтрация как бонус
? Стратегия развития
? Эволюционный подход: возможность улучшения эндпоинтов
? Масштабируемость: постепенное добавление функциональности
? Часть 2: Диагностика проблемы — что не так со старым Loopback API?
«Работало, но...» — классическая история технического долга
? Ключевые проблемы:
? Сложность кастомизации — для отличных от коробочных решений приходилось использовать довольно рискованные приемы с оверрайдом и хуками
? Ограниченная функциональность — отсутствие курсорной пагинации для больших наборов данных
?️ Устаревание стэка — слабеющая поддержка Loopback 3 сообществом
? Цель: Создать решение, где создание нового справочника сводилось бы к минимуму кода, а основные механики были вынесены в переиспользуемую базу.
? Часть 3: Поиск решения — от требований к архитектуре
? Ключевые требования и ограничения:
? Единообразие — один контракт для всех эндпоинтов
? Поддержка двух клиентов — для "тонких" и "толстых" клиентов
? Экономия на поддержке — идеал: один эндпоинт на справочник
⚡ Производительность — трансляция запросов в эффективный SQL
? Очевидность статуса — явное поле
isDeletedв каждой записи
?️ Технологический стэк:
ASP.NET Core — основа для Web API
Entity Framework Core — работа с БД
FluentValidation — валидация параметров
System.Linq.Dynamic.Core — парсинг строковых фильтров и сортировок
MediatR.IMediator - посредничество между контроллером, запросом, валидатором, обработчиком и генератором результата
Исходя из предложенных требований и ограничений, был разработан подход на основе паттерна проектирования "Шаблонный метод" ("Template Method"). Его достоинство в том, что он предоставляет стандартную последовательность действий в рамках алгоритма выборки данных:
Валидация входных параметров
Конструирование запроса к базе данных
Обработка запроса, применение фильтров и генерация ответа
Для создания нового эндопойнта разработчику по большому счету нужно всего лишь подготовить структуру выходных данных (Data Transfer Object - DTO) и сконструировать запрос к данным. Всем остальным займется шаблон.
Разработчик вправе расширить кол-во входных параметров и применить для них свои правила валидации. Кроме того, программист может переписать базовую реализацию обработчика, реализовав логику на свое усмотрение, при этом не нарушив базовый контракт.
?️ Часть 4: Реализация — сердце универсального API
1. ? API Контракт: Один для всех
? Параметры запроса:
? Пагинация:
pageNumber,pageSize? Курсоры:
after,before? Сортировка и фильтрация:
sort,filter(в формате Dynamic LINQ)
> ⚠️ Важное правило: Пагинация и курсоры не могут быть использованы одновременно!
✅ * Пример запроса:
GET /api/cropsV2?pageNumber=1&pageSize=50&sort=updatedAt desc&filter=name=="Пшеница озимая"
✅ Пример ответа:
{
"meta": {
"totalCount": 1234,
"totalPages": 25,
"currentPage": 1,
"pageSize": 50
},
"links": {
"self": "https://api.example.com/api/cropsV2?pageNumber=1&pageSize=50&sort=updatedAt desc&filter=name=="Пшеница озимая"",
"next": "https://api.example.com/api/cropsV2?pageNumber=2&pageSize=50&sort=updatedAt desc&filter=name=="Пшеница озимая"",
"prev": null
},
"data": [
{
"id": "dc0e4f96-feba-11e4-9412-78acc0f90ffe",
"name": "Пшеница озимая",
"isDeleted": false,
"updatedAt": 1650835441002
},
{
"id": "ba4e4d78-feba-5faa-b317-92acd0f9078a",
"name": "Пшеница озимая",
"isDeleted": false,
"updatedAt": 1650835440035
},
...
]
}
2. ✅ *Базовые классы — фундамент системы
Четыре основных абстрактных класса:
GetPagesQuery - параметры запроса
GetPagesQueryValidator - валидация
GetPagesResponse - формат ответа
GetPagesQueryHandler - обработчик (шаблон "Template Method")
Cursor where TId : IComparable - базовый класс для DTO, поддерживающих курсоры
3. ✅ *Обработчик (Handler) — где происходит обработка
Алгоритм работы:
public virtual async Task> Handle(TQuery query, CancellationToken cancellationToken)
{
Func<TQuery, CancellationToken, Task<Result<TDto>>> handlingMethod = query.IsPaging
? HandlePagingAsync
: HandleBatchingAsync;
return await handlingMethod(query, cancellationToken);
}
? HandlePagingAsync:
Применяет динамические Where и OrderBy
Выполняет пагинацию через Skip и Take
Рассчитывает totalCount и totalPages
Генерирует навигационные ссылки
? HandleBatchingAsync:
Использует курсоры для эффективной загрузки больших данных
Применяет сортировку по UpdatedAt, а потом - по Id
Выполняет пагинацию через Skip и Take
Рассчитывает totalCount и totalPages
Рассчитывает курсор. Формат курсора: Base64(updatedAt + "_" + id)
Генерирует навигационные ссылки
4. ✅ *Процесс добавления нового справочника
Чтобы применить подход к новой сущности, необходимо:
Создать DTO, унаследовав класс Cursor
Создать query-класс, унаследовав GetPagesQuery
Создать response-класс, унаследовав GetPagesResponse
Создать validator, унаследовав GetPagesQueryValidator
Создать handler, унаследовав GetPagesQueryHandler
? Минимальная реализация для нового справочника (пример):
public sealed record CropDto : Cursor
{
public required string Name { get; init; }
public required bool? IsDeleted { get; init; }
}
public sealed record GetCropsQuery : GetPagesQuery
{
public GetCropsQuery(
int? PageNumber,
string? After,
string? Before,
string? Sort,
string? Filter,
int PageSize)
: base(
PageNumber,
After,
Before,
Sort,
Filter,
PageSize
) { }
}
public class GetCropsQueryValidator : GetPagesQueryValidator;
public sealed record GetCropsResponse : GetPagesResponse
{
public GetCropsResponse(Meta meta, Links links, CropDto[] data)
: base(meta, links, data) { }
public class GetCropsQueryHandler : GetPagesQueryHandler<CropDto, Guid, GetCropsResponse, GetCropsQuery>
{
public GetCropsQueryHandler(EkocropDbContext context, KestrelConfiguration kestrelConfiguration, IHttpContextAccessor httpContextAccessor)
: base(context, kestrelConfiguration, httpContextAccessor) { }
protected override IQueryable<CropDto> GetBaseQuery(GetCropsQuery request)
{
return _context.Crops.Select(
crop => new CropDto
{
Id = c.Id, // inherited from base class Cursor<T>
Name = c.Name,
IsDeleted = c.IsDeleted,
UpdatedAt = c.UpdatedAt.ToUnixTimeMilliseconds() // inherited from base class Cursor<T>
});
}
}
? Для сложных кейсов можно переопределить методы обработки:
protected override async Task<Result<TDto>> HandlePagingAsync(TQuery request, CancellationToken cancellationToken)
{
var query = GetBaseQuery();
// Особая фильтрация для конкретного справочника
query = query.Where(c => c.Name.StartsWith("A"));
return await base.HandlePagingAsync(request, cancellationToken);
}
? Часть 5: Подводные камни
Само собой, не обошлось без трудностей и выявленных ограничений.
? Производительность LINQ
Проблема: Не все LINQ-запросы эффективно транслируются в SQL, особенно при сложных проекциях
Решение: В крайних случаях выносили логику фильтрации на сторону БД через хранимки, но обычно хватало оптимизации через
Select
? Ограничения фильтрации
Проблема: Dynamic LINQ хорошо работает только со свойствами верхнего уровня
Решение: Для фильтрации по вложенным объектам добавляли кастомные параметры в запрос
? Жесткие требования к модели
Проблема: Курсорная пагинация требует
IdиUpdatedAtв каждой сущностиРешение: Пока принимаем это ограничение, но планируем кастомизацию курсоров
?️ Архитектурные компромиссы
Перегруженность DTO: Метаданные пагинации нерелевантны для курсоров и наоборот
Зависимость от MediatR: Пока не видим простого пути отказаться от него
? Часть 6: Дальнейшее развитие
Необходимо как следует проработать выявленные ограничения и добавить разработке гибкости:
Возможно, разбить базовый метод на еще более мелкие составляющие для обеспечения переопределения более мелких составляющих алгоритма, например, применение фильтра и сортировки, генерация метаданных и т.д.
Кастомизация курора. Отвзязаться от необходимости наличия свойств Id и UpdatedAt в доменной медели.
Отказ от Mediatr.
Динамический DTO в зависимости от типа запроса (курсор или пагинация).
? Часть 7: Результаты и выводы
? Что мы получили в итоге:
⚡ Скорость разработки - добавление нового справочника теперь занимает минуты вместо часов
? Единообразие - все эндпоинты ведут себя предсказуемо по единому контракту
? Гибкость - поддержка двух типов пагинации для разных сценариев использования
? Производительность - вся фильтрация и сортировка выполняется на стороне СУБД
✅ Снижение количества ошибок - централизованная валидация предотвращает некорректные запросы
? Экономия на поддержке - один эндпоинт на сущность вместо 2-3
? Легкость масштабирования - добавление нового фильтра/параметра тривиально и вписывается в базовый паттерн
? Простота кастомизации - любую составляющую шаблонного метода можно кастомизировать под специфический кейс без нарушения логики основного алгоритма
? Сравнительная таблица: Loopback 3 vs Наше решение
Критерий |
? Loopback 3 |
? Новый .NET API |
|---|---|---|
Типы пагинации |
|
|
Динамическая фильтрация |
|
|
Расширяемость |
Миксины, часто хрупкие |
Четкое наследование, шаблон "Template Method" |
Поддержка |
Сложная |
Простая, за счет переиспользования кода |
Кастомизация |
Оверрайдинг, хуки |
Переопределение составляющих "Template Method" |
Валидация |
Валидация для фильтров |
Валидация входящих параметров и кастомных параметров через FluentValidation |
? Примеры использования фильтров в реальных сценариях:
? Фильтрация по строковому свойству
GET /api/cropsV2?filter=name.Contains("Пшеница")
Комбинированный фильтр
GET /api/cropsV2?filter=name.Contains("Пшеница") && updatedAt > 1726652785811
Сортировка с фильтрацией
GET /api/cropsV2?sort=name asc,updatedAt desc&filter=isDeleted==false
? Ключевые метрики успеха:
90% сокращение времени на добавление нового справочника
Единый код валидации для всех эндпоинтов
Прямая трансляция фильтров в SQL-запросы
Нулевое дублирование бизнес-логики между справочниками
Предсказуемое поведение для фронтенд-разработчиков
Заключение
Миграция с устаревшего фреймворка — это не только боль, но и возможность «переизобрести» свой продукт, исправив старые архитектурные ошибки. Наш подход с универсальным обработчиком отлично масштабируется и доказывает свою эффективность.
iamkisly
Ощущение, что поток сознания пропустили через deepseek.chat ..у меня он так же засирает текст эмодзи, если его явно не просить не делать этого.