Миграция с устаревшего фреймворка — это не только боль, но и возможность «переизобрести» свой продукт. В результате мы не просто мигрировали, а создали универсальный шаблон, который позволяет добавлять новые эндпоинты для справочников буквально за 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<Result<TDto>> 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-запросы
Нулевое дублирование бизнес-логики между справочниками
Предсказуемое поведение для фронтенд-разработчиков
Заключение
Миграция с устаревшего фреймворка — это не только боль, но и возможность «переизобрести» свой продукт, исправив старые архитектурные ошибки. Наш подход с универсальным обработчиком отлично масштабируется и доказывает сво�� эффективность.
