Я, думаю, многие уже слышали о появившихся в .NET 6 Minimal API — легковесной замене контроллеров/MVC. Кто-то уже успел ознакомиться и задался вопросом: «Ваше API в 3 строчки, это, конечно, здорово, но как это будет работать в реальном проекте с сотнями эндпоинтов, кучей фильтров, аттрибутов, расширениями OpenAPI/Swagger и прочих радостях?»
В этой статье я хочу ответить на этот вопрос: пройдемся от основ, преимуществ, недостатков, и закончим нюансами работы и проблемами, которые обязательно возникнут при миграции с контроллеров на Minimal API в крупном проекте.
А забегая чуть вперед: если думаете, стоит ли переводить проект на Mini API, вот вам сразу полезная информация: они могут жить в проекте вместе, причем даже без дублирования инфраструктуры: не обязательно переводить все разом — подробнее под катом.
Бонусом, заменим SwaggerGen на реализацию OpenAPI от Microsoft.
Зачем использовать Minimal API?
Мы с вами, (надеюсь) не гонимся за модными подходами «просто потому что», поэтому для начала разберемся, зачем переходить на Minimal API:
-
Простота и гибкость.
Контроллеры разработанны под MVC, Mini API разработаны специально под API / REPR паттерн. Это позволяет настроить эндпоинты под нужды и структуру вашего проекта, и минимизирует количество повторяющегося шаблонного кода -
Поддержка Microsoft
Референсный eShop уже перешел на Mini API, а по изменениям ASP.NET Core 9 и 10 можно заметить, что в Mini API стабильно добавляют новые возможности — куда активнее, чем для MVC. Во многом, конечно, это обусловлено добавлением функционала, который уже есть в контроллерах, но не всегда. Субъективно, контроллеры движутся в статус легаси. Очень медленно, конечно, вряд ли они станут deprecated в каком-нибудь .Net 11 (скорее примерно в .Net 15 — дам такой прогноз. И то, это для статуса легаси, без выпиливания из фреймворка). И это мое субъективное ощущение: официальных заявлений от Microsoft на эту тему не было, лишь косвенные о развитии Mini API. -
Схожесть с другими фреймворками
Нам не привыкать к контроллерам, но у большинства других крупных языков — JS (Fastify/Express), Python (Flask, FastAPI), Go (Gin), и так далее, уже давно есть эндпоинты по схожей схеме с MinimalAPI. Во-первых, переходить с языка на язык легче, а во-вторых, это говорит нам о том, что и без контроллеров можно прекрасно жить. -
Отсутствие рефлексии
Конфигурация эндпоинтов происходит через методы, явно вызывающиеся в вашем коде, а не поиске контроллеров по всему проекту и получению аттрибутов. Это позволяет настраивать проекты более гибко (например, вы не запихнете в аттрибут массив, или значение из конфига), а также из этого следуюет еще 2 плюса: -
Поддержка NativeAOT
NativeAOT — сейчас тренд в мире .Net, и вряд ли контроллеры получат ее поддержку, в отличии от Mini API, у которых она уже есть. Впрочем, бэкенд — это та область, где NativeAOT может ухудшить производительность. Вообще, это тема для отдельной статьи, но вот вам сравнение, и вот краткое объяснение почему. Но не забываем, что ситуация может измениться — .Net развивается быстро. -
Улучшенная производительность
Вот и вот вам бенчмарки. Спойлер: разница на несколько процентов по времени выполнения и на десятки процентов по выделенной памяти. В реальности, вероятнее всего бОльшую часть нагрузки будет создавать ваш код, поэтому ждать сильного увеличения RPS не стоит. Но если у вас есть какие-то эндпоинты с десятками и сотнями тысяч RPS, где вы отчаянно боретесь за производительность и каждый вызов GC, перевести их на Mini API может оказаться неплохой идеей, особенно учитывая тренд на улучшения производительности в последних .Net.
Недостатки:
-
Не весь функционал контроллеров пока реализован
В статье Microsoft можно подробнее посмотреть чего пока нет. Основное — это валидация (решаемо, напишу про это ниже) и нет поддержки View из MVC (но Razor Pages есть) -
Перенос уже существующих проектов — дни/недели работы
Даже перенести первый эндпоинт для большого проекта может оказаться задачей на денек-другой: из-за необходимости исправлять все ваши фильтры, аттрибуты контроллеров и т.п. Дальше пойдет быстрее, но внимательной работы все равно хватит. Можете попробовать Cursor натравить на это дело — есть вероятность что прокатит.
Итого, мое личное мнение: если начинаете новый проект/микросервис, особенно с прицелом на .Net 10 (или работаете над пока небольшим проектом), переходить стоит. Если у вас большой энтерпрайз проект, все и так работает, а в бэклоге и так куча задач по рефакторингу, на которые менеджеры не дают времени, то можно пока и пожить с контроллерами. Если, конечно, производительность устраивает и NativeAOT не нужен.
Hello World!
Для тех кто не знаком, в этой главе вкратце расскажу, как написать простые эндпоинты с Mini API. Более подробно можно почитать например в примере от Microsoft или статье на хабре.
var app = WebApplication.CreateBuilder(args).Build(); app.MapGet("/", () => "Hello World!"); app.Run();
Красиво, правда? Ну ладно, реализовывать эндпоинты мы в Program.cs надеюсь не планируем, поэтому усложняем. Добавлю пример под архитектуру из своей статьи про VSA. Введем интерфейс IEndpoint для удобной регистрации эндпоинтов.
public interface IEndpoint { void Register(IEndpointRouteBuilder endpointsBuilder); }
Наши классы эндпоинтов будут реализовывать этот интерфейс. Хотите реализовывать несколько эндпоинтов в классе? Просто переименуйте интерфейс, например, в IEndpoitns(Provider). Minimal API — довольно гибкая штука, реализуйте как это больше подходит к вашему проекту.
Добавим extension для регистрации:
public static class EndpointsHelper { public static IEndpointRouteBuilder Register<T>(this IEndpointRouteBuilder endpointsBuilder) where T : IEndpoint, new() { var endpointsProvider = new T(); endpointsProvider.Register(endpointsBuilder); return endpointsBuilder; } }
И, например, класс, который будет регистрировать все наши эндпоинты:
public static class EndpointsProvider { public static void RegisterAppEndpoints(RouteGroupBuilder endpointsBuilder) { endpointsBuilder.Register<GetUserCart>(); } }
Хотя, при желании, никто не мешает пробежаться по Assembly и найти все IEndpoint — как это делается в контроллерах. Но я бы не стал.
И добавим его в Program.cs:
// Все эндпоинты будут начинаться с /api var endpointsBuilder = app.MapGroup("/api"); EndpointsProvider.RegisterAppEndpoints(endpointsBuilder);
Теперь реализуем, сам эндпоинт:
public class GetUserCart : IEndpoint { public void Register(IEndpointRouteBuilder endpointsBuilder) { endpointsBuilder.MapGet("/user/cart", HandleAsync) // Аналог аттрибута [Authorize] .RequireAuthorization() // Описание эндпоинта для OpenAPI. В контроллерах обычно для этого используют комментарии XML (/// <summary>). Теперь это в коде. .WithSummary("Search all available platforms.") // Тэг для OpenAPI. Обычно это имя контроллера. .WithTags("User"); } // OpenAPI автоматически сгенерирует описание ответа на основе возвращаемого типа. private static async Task<QueryableList<UserCartItem>> HandleAsync(int skip, int take, UserContext userContext, ExampleDbContext db, CancellationToken ct) { var total = await db.CartItems.CountAsync(x => x.UserId == userContext.UserId, ct); if (total == 0) return QueryableList<UserCartItem>.Empty(); var items = await db.CartItems.Where(x => x.UserId == userContext.UserId) .Select(x => new UserCartItem { Id = x.ItemId, Name = x.Item.Name, }) .Skip(skip) .Take(take) .ToListAsync(ct); return new QueryableList<UserCartItem>(items, total); } }
(Я упущу реализацию БД и прочих радостей чтобы не раздувать статью) skip и take будут получены из query string, остальные зависимости зарезолвит DI.
Много skip-take в проекте, хочется вынести в общую модель для удобства использования? Поможет аттрибут [AsParameters]:
private static async Task<QueryableList<UserCartItem>> HandleAsync([AsParameters]PagingQuery paging, UserContext userContext, ExampleDbContext db, CancellationToken ct) ... public record PagingQuery { [FromQuery(Name = "take")] public int Take { get; init; } = 10; [FromQuery(Name = "skip")] public int Skip { get; init; } }
Подробнее о биндинге параметров можно почитать в статье Microsoft.
Движемся дальше.
Заменяем аттрибуты
В Minimal API есть важная концепция — метадата. Это то, что заменит нам атрибуты в контроллерах: добавляем эндпоинту метадату и обрабатываем ее в наших фильтрах.
Забегая вперед — аттрибуты контроллеров тоже можно получать как метадату!
Например, хотим запретить какой-то эндпоинт в продакшне. Делаем класс:
public class ProhibitInProductionMetadata;
Добавляем его в эндпоинт (подсказка — можно даже написать красивые extension):
endpointsBuilder.MapGet("/user/cart", HandleAsync) .WithMetadata(new ProhibitInProductionMetadata()))
А, чтобы реализовать логику, сразу перейдем к фильтрам
Фильтры
Для примера выше реализуем фильтр:
public class ProhibitInProductionFilter(EnvironmentConfig envConfig) : IEndpointFilter { public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var endpoint = context.HttpContext.GetEndpoint(); if (endpoint == null || envConfig.Environment != EnvironmentType.Production) return await next(context); // Проверяем наличие метадаты у эндпоинта var metadata = endpoint.Metadata.GetMetadata<ProhibitInProductionMetadata>(); if (metadata == null) return await next(context); // Метадата есть, Production окружение, возвращаем 404 return Results.NotFound(); } }
И сделаем так, чтобы он применялся ко всем эндпоинтам, модифицировав код из введения:
var endpointsBuilder = app.MapGroup("/api") .AddEndpointFilter<ProhibitInProductionFilter>(); EndpointsProvider.RegisterAppEndpoints(endpointsBuilder);
Готово. Вот и познакомились с фильтрами в Minimal API.
Правда, познакомились. Нам теперь не нужно вспоминать каждый раз вот эти все IAuthorizationFilter/IActionFilter/IExceptionFilter и так далее. Весь необходимый функционал можно реализовать наследуя от одного интерфейса.
Пара полезных нюансов:
-
Как можно заметить из примера выше, AddEndpointFilter() можно применять к одному, группе, или ко всем эндпоинтам — в зависимости от ваших нужд
-
Переходите с контроллеров? Этот же фильтр можно применить и к контроллерам:
app.MapControllers() .AddEndpointFilter(new ProhibitInProductionFilter());
А, заменив наш ProhibitInProductionMetadata на аттрибут, и добавив его в контроллер, он уже будет у нас в метадате:
var metadata = endpoint.Metadata.GetMetadata<ProhibitInProductionMetadataAttribute>();
Отсюда вывод: переделав ваши фильтры на IEndpointFilter, они смогут работать и с контроллерами, и с Mini API одновременно.
Больше деталей в документации.
Middleware
Тут все еще проще — ничего не меняется. Для наглядности пример — реализуем Middleware для логгирования запросов:
public class RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> log) { public async Task InvokeAsync(HttpContext context) { var url = context.Request.GetDisplayUrl(); log.LogTrace("Started {RequestId} | Url: {Url}", context.TraceIdentifier, url); await next(context); } } ... // В Program.cs app.UseMiddleware<RequestLoggingMiddleware>();
Варианты с app.Use() и IMiddleware доступны аналогично
Валидация
А вот тут посложнее: В Minimal API нет ModelState, соответсвенно коробочная валидация контроллеров не работает. Если вы полагаетесь на нее — придется пока переделывать. Если же у вас какой-то свой механизм через фильтры/middleware или просто в коде эндпоинтов — скорее всего получится его адаптировать.
Из коробки валидация появится в .Net 10, так что планируете реализовывать Mini API сразу на .Net 10, то написанное ниже вероятно будет не очень актуально. А пока я расскажу как это можно сделать сейчас:
Допустим, у нас есть такой эндпоинт:
endpointsBuilder.MapPut("/items/create", HandleAsync) .RequireAuthorization() .WithTags("Items"); ... // .Net поймет, что CreateItemRequest - это тело запроса, но при желании можно явно пометить аттрибутом private async Task<IResult> HandleAsync(CreateItemRequest item) ... public record CreateItemRequest { public required string Name { get; init; } public required int Price { get; init; } }
Можно, конечно, валидировать прямо в HandleAsync()
if (item.Name.Length < 3) return Results.BadRequest("Item name must be at least 3 characters long.");
Хотя тут даже если отправить null в «Name», получим NullReferenceException и 500 ошибку: включенные в проекте nullable reference types не уважаем, непорядок.
Но хорошая новость: FluentValidation, если пользуетесь, совместим с Minimal API. Реализуем для нашего эндпоинта:
public class CreateItemValidator : AbstractValidator<CreateItemRequest> { public CreateItemValidator() { RuleFor(x => x.Name).NotEmpty().Length(3, 100); RuleFor(x => x.Price).GreaterThan(0).LessThanOrEqualTo(1000); } } ... // В Program.cs: builder.Services.AddScoped<IValidator<CreateItemRequest>, CreateItemValidator>(); ... private async Task<IResult> HandleAsync(IValidator<CreateItemRequest> validator, CreateItemRequest item) { var validationResult = await validator.ValidateAsync(item); if (!validationResult.IsValid) return Results.ValidationProblem(validationResult.ToDictionary()); ...
Хотя ключевое слово required при отсутствии поля «name» в body все равно кинет свою 400 ошибку до того, как код дойдет до валидации. Ну да ладно, кейс специфичный и не всем нужен, оставлю так.
Другой подход: MiniValidation, работающий на System.ComponentModel.DataAnnotations, прямо как в контроллерах из коробки. Реализуем:
public record CreateItemRequest { [Required, MinLength(3)] public required string Name { get; init; } [Required, Range(1, 1000)] public required int Price { get; init; } } ... private async Task<IResult> HandleAsync(CreateItemRequest item) { var isValid = MiniValidator.TryValidate(item, out var errors); if (!isValid) return Results.ValidationProblem(errors);
Главный плюс MiniValidation — он реализует DataAnnotations, а это значит:
-
Совместимость с текущими контроллерами
-
В .Net 10 его можно будет просто убрать и заменить коробочным решением.
OpenAPI/Swagger и фильтры
Чуть выше я уже затронул некоторые моменты для OpenAPI, разберем поподробнее
Вообще с .Net 9 Microsoft перешла со SwaggerGen на свой пакет Microsoft.AspNetCore.OpenApi Если вы путаете Swagger(UI/Gen) и OpenAPI (эти понятия были тесно связаны между собой, и нейминг не помогал), то можно глянуть что произошло в этой issue и этой статье.
Вкратце, простыми словами:
-
SwaggerGen, как и OpenApi от Microsoft, генерируют нам тот самый .json с API спецификацией нашего проекта. Swagger UI — визуализирует этот JSON чтобы можно было удобно тыкаться ручками.
-
Вместо SwaggerGen теперь рекоммендуется использовать Microsoft.AspNetCore.OpenApi. Но никто не запрещает остаться на SwaggerGen: поддерка MinimalAPI есть. Зачем тогда переходить? В перспективе можно ожидать более качественную реализацию новых функций .Net и протокола OpenAPI. Как показывает опыт, хоть скорее всего так и будет, но это не точно.
-
Swagger UI все еще можно использовать даже без SwaggerGen
-
Активно развиваются альтернативы Swagger UI, например Scalar. На хабре уже была статья о нем.
Scalar интригует, но остановимся на пол пути: в примере буду приводить SwaggerGen и переводить его на Microsoft OpenAPI + Swagger UI.
Возьмем пример из головы: допустим, мы хотим отображать OpenAPI определенные хэдеры у конкретных эндпоинтов. Где-то, конечно, подойдет строка с аттрибутом [FromHeaders], но не всегда удобно.
Для привычного «SwaggerGen + Контроллеры» реализация будет выглядеть так:
Добавим аттрибут:
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] public class ApiShowHeadersAttribute(params string[] headers) : Attribute { public string[] Headers => headers; }
Реализуем фильтр:
public class ShowHeadersFilter : IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { // Получаем аттрибут ShowHeadersAttribute из метода контроллера var headersData = context.MethodInfo.GetCustomAttribute<ApiShowHeadersAttribute>(); if (headersData == null) return; operation.Parameters ??= new List<OpenApiParameter>(); foreach (var header in headersData.Headers) { // Добавляем описание хэдера в OpenAPI спецификацию operation.Parameters.Add(new OpenApiParameter { Name = header, In = ParameterLocation.Header, Required = false, Schema = new OpenApiSchema() // Можно еще красивую схему придумать }); } } }
И добавляем этот фильтр в наш services.AddSwaggerGen(). В OpenAPI спецификации у нас появятся нужные хэдеры.
Теперь реализуем то же самое для Mini API, причем мы переиспользуем фильтр: он будет работать и для контроллеров, и для Mini API
Добавим такую строчку к нашему энпоинту:
endpointsBuilder.MapGet("/user/cart", HandleAsync) .WithMetadata(new ApiShowHeadersAttribute("X-Custom-Header"))
И теперь просто заменяем получение аттрибута в фильтре на получение метадаты:
var headersData = context.ApiDescription.ActionDescriptor.EndpointMetadata .OfType<ApiShowHeadersAttribute>() .FirstOrDefault();
Готово! Теперь данный фильтр будет работать с контроллерами, и с Minimal API.
Тут можно обнаружить еще один небольшой плюс Minimal API: в отличие от аттрибутов, в метод можно передать наш массив хэдеров из какого-нибудь статического списка или даже конфига при желании: мы больше не ограничены этапом компиляции.
Теперь переделаем фильтр со SwaggerGen на Microsoft OpenApi:
Наш фильтр будет реализовывать не IOperationFilter, а IOpenApiOperationTransformer, при этом изменения в коде минимальны:
// Было: public class ShowHeadersFilter : IOperationFilter ... public void Apply(OpenApiOperation operation, OperationFilterContext context) ... var headersData = context.ApiDescription.ActionDescriptor.EndpointMetadata.OfType<ApiShowHeadersAttribute>().FirstOrDefault(); // Стало: public class OpenApiShowHeadersFilter : IOpenApiOperationTransformer ... public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken ct) ... var headersData = context.Description.ActionDescriptor.EndpointMetadata.OfType<ApiShowHeadersAttribute>().FirstOrDefault();
Подробнее о трансформерах можно почитать в доке. И поменяем в Program.cs:
// Было: builder.Services.AddSwaggerGen(c => c.OperationFilter<ShowHeadersFilter>()); ... app.UseSwagger(); app.UseSwaggerUI(); // Стало: builder.Services.AddOpenApi(c => c.AddOperationTransformer<ShowHeadersFilter>()); ... app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Swagger")); app.MapOpenApi();
Microsoft OpenApi делается с прицелом на Minimal API, однако с контроллерами тоже работает: убедитесь, что на ваших контроллерах есть аттрибут [ApiController]
А описание кодов ответа в Min API можно заменить так:
// Было: [HttpPut("items/create") [SwaggerResponse(StatusCodes.Status400BadRequest, Type = typeof(ErrorModel))] // Стало: endpointsBuilder.MapPut("/items/create", HandleAsync) .Produces<ErrorModel>(400);
Подробнее, что на что поменялось, можно посмотреть в документации.
Почему-то .Produces недоступен, если хотите навесить его сразу на группу эндпоинтов. Но не беда: это просто метадата, и мы можем сделать так:
var endpointsBuilder = app.MapGroup("/api").WithMetadata(new ProducesResponseTypeMetadata(400, typeof(ErrorModel))) ...
Полезно, если у вас стандартизированный ответ с ошибкой (а он, надеюсь, стандартизирован)
Обработка ошибок
В Minimal API есть встроенная обработка ошибок (документация). Можно использовать так:
app.UseExceptionHandler(exceptionHandlerApp => { exceptionHandlerApp.Run(async context => { var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>(); if (exceptionFeature?.Error is ValidationException) await Results.ValidationProblem([]).ExecuteAsync(context); else await Results.Problem().ExecuteAsync(context); }); });
Впрочем, лично мне это коробочное решение не очень понравилось: оно мне показалось не очень гибким, и оно безконтрольно залоггирует ошибку еще до того, как дойдет до хэндлера — даже если вы ее как-то обработаете.
А под капотом там — обычный Middleware с try catch, поэтому можно так и самим сделать:
public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> log) { public async Task InvokeAsync(HttpContext context) { try { await next(context); } catch (Exception exception) { // Ваша обработка тут } } }
Но, вне зависимости от вашего выбора, оба варианта (т.к. и там и там Middleware), будут работать и с контроллерами
Кэширование
ResponseCacheAttribute для Minimal API не работает (хотя, подсказка: ничто не мешает добавить его в метадату и написать фильтр для добавления HTTP хэдеров Cache-Control вручную)
Но у нас теперь есть OutputCache: без HTTP, только на уровне приложения. Реализуем:
// В Program.cs: builder.Services.AddOutputCache(); ... app.UseOutputCache(); // Эндпоинт: endpointsBuilder.MapGet("/items", HandleAsync) .CacheOutput(builder => { builder.Expire(TimeSpan.FromSeconds(60)); builder.SetVaryByQuery("*"); });
Теперь, после 1 выполнения, ответ будет закэширован у нас на сервере на 60 секунд.
Эндпоинт с таким кэшом умеет удалять кэш раньше времени при необходимости, кэшировать в Redis и даже имеет resource locking (1ый реквест будет выполняться, а остальные параллельные ждать, пока тот выполнится, чтобы не перегружать сервер) — в общем, штука весьма полезная.
Results/TypedResults
Как и с методами контроллеров Ok(), BadRequest() и т.п., у нас теперь есть (Typed)Results.Ok()/BadRequest().
И, как и с контроллерами, можно их и использовать, а можно и не использовать:
endpointsBuilder.MapGet("/cart/item-plain", HandlePlain); endpointsBuilder.MapGet("/cart/item-results", HandleResults); ... private static UserCartItem HandlePlain() => new UserCartItem(); private static Ok<UserCartItem> HandleResults() => TypedResults.Ok(new UserCartItem());
Данные варианты практически идентичны, и OpenAPI/SwaggerGen сам подхватит описание модели ответа.
Но для Results можно еще, в стиле функционального программирования, показать, что у нас возможны разные ответы сервера:
private static Results<Ok<UserCartItem>, NotFound> HandleResults() => TypedResults.Ok(new UserCartItem());
Но, как я писал выше про OpenApi, у эндпоинтов есть расширение .Produces(), позволяющее добиться того же результата, причем, при желании глобально
Итого, что же выбрать, Results.Ok() или «сырые» данные? Если у вас уже есть консенсус в контроллерах на эту тему — можно оставить как есть.
Если ответа для вас пока нет, лично мне нравится больше использовать сырые данные + обрабатывать ошибки через Exceptions: зачем привязывать логику обработки событий юзеров к имплементации API, если это особо не дает преимуществ.
Хотя, это больше вкусовщина, ООП против функционального стиля.
Впрочем, если ваш эндпоинт возвращает не просто json-ответ, Results будут полезны в любом случае:
return TypedResults.File(result, "application/x-gzip", $"sitemap-{page}.xml.gz");
Итоги
Надеюсь, ничего не забыл, и убедил вас, что MinimalAPI — гибкая и мощная технология, которая прекрасно будет работать как в небоших проектах, так и в больших энтерпрайзах уже сейчас.
Отдельного упоминания заслуживает FastEndpoints — сторонняя альтернатива MVC и Minimal API. Однако, как мне кажется, с развитием Minimal API смысла в ее использовании все меньше
ссылка на оригинал статьи https://habr.com/ru/articles/917378/
Добавить комментарий