Минимальные API в .NET: создание облегченных HTTP API
Если вы какое-то время создавали API с помощью ASP.NET Core, вы, вероятно, хорошо знакомы с подходом на основе контроллера: создайте класс контроллера, украсьте его атрибутами, внедрите свои сервисы через конструктор и соедините свои маршруты. Это работает, и работает хорошо, но иногда кажется, что вы пишете много церемоний, что означает «принять этот запрос, сделать что-нибудь, вернуть ответ».
Это именно та проблема, которую призваны решить минимальные API. Минимальные API, представленные в .NET 6, позволяют определять конечные точки HTTP с помощью минимального шаблонного кода. Никаких контроллеров, никаких атрибутов, никакой манипуляции с классами запуска — только прямое сопоставление маршрутов с обработчиками в чистом, функциональном стиле.
В этом посте я хочу познакомить вас со всем, что вам нужно знать о минимальных API: от вашей первой конечной точки до организации больших приложений, обработки аутентификации, проверки, документации OpenAPI и вопросов производительности. Давайте перейдем к этому.
Почему минимальные API?
Прежде чем мы начнем, давайте поговорим о том, почему вы предпочитаете минимальные API традиционному подходу на основе контроллера.
Контроллеры отлично подходят, когда вам нужна структурированная, продуманная структура. Они предоставляют вам привязку модели, фильтры, согласование контента и четкое разделение задач прямо из коробки. Для крупных корпоративных приложений с десятками разработчиков согласованность, которую обеспечивают контроллеры, может оказаться реальным преимуществом.
С другой стороны, минимальные API сияют, когда вам нужно:
- Меньше шаблонов — нет классов контроллера, нет атрибутов
[ApiController], нет отдельной конфигурации запуска. - Более быстрый запуск — меньше операций, связанных с отражением, во время загрузки.
- Упрощенная ментальная модель — маршрут сопоставляется непосредственно с обработчиком. Вот и все.
- Удобство для микросервисов — если ваш API имеет 5–10 конечных точек, полная настройка контроллера может показаться излишним.
Хорошей новостью является то, что это не решение «или-или». Вы можете смешивать контроллеры и минимальные API в одном проекте. Но как только вы освоитесь с минимальным подходом, вы, возможно, обнаружите, что будете обращаться к нему чаще, чем ожидали.
Начало работы
Давайте создадим минимальный API с нуля. Если у вас установлен .NET SDK, это так же просто, как:
[[[ТОК_1]]]
Это дает вам Program.cs, который выглядит примерно так:
[[[ТОК_3]]]
Вот и все. Это рабочий API. Нет Startup.cs, нет класса контроллера, нет конфигурации маршрутизации. Вы запускаете dotnet run, нажимаете http://localhost:5000 и получаете «Hello World!» назад. Вся суть здесь в простоте.
Давайте сделаем его немного более полезным с помощью классического API todo:
[[[ТОК_7]]]
У нас есть полный CRUD менее чем в 30 строках. В этом сила минимального подхода.
Обработчики маршрутов
Обработчики маршрутов — это функции, которые выполняются, когда запрос соответствует маршруту. У вас есть несколько вариантов их определения.
Лямбда-выражения
Самый распространенный подход и то, что вы увидите в большинстве примеров:
[[[ТОК_8]]]
Группы методов
Для более сложной логики вы можете указать именованный метод:
[[[ТОК_9]]]Это мой предпочтительный подход ко всему, что выходит за рамки однострочника. Он сохраняет раздел сопоставления маршрутов чистым и читабельным — вы можете сразу увидеть, какие конечные точки существуют, не вдаваясь в подробности реализации.
Локальные функции
Вы также можете использовать локальные функции, что полезно, если вы хотите, чтобы обработчики были близки к определениям их маршрутов:
[[[ТОК_10]]]
Привязка параметра
Одна из вещей, которые мне очень нравятся в минимальных API, — это интуитивно понятное связывание параметров. Платформа определяет, откуда брать значения, в зависимости от контекста и типа.
Параметры маршрута
[[[ТОК_11]]]
Параметры строки запроса
[[[ТОК_12]]]
Типы, допускающие значение NULL, становятся необязательными параметрами. Значения по умолчанию работают именно так, как вы ожидаете.
Тело запроса
app.MapPost("/orders", (Order order) =>
{
// 'order' is automatically deserialized from the JSON body
return Results.Created($"/orders/{order.Id}", order);
});
Заголовок и привязка службы
app.MapGet("/protected", (
[FromHeader(Name = "X-Api-Key")] string apiKey,
[FromServices] ILogger<Program> logger) =>
{
logger.LogInformation("Request with API key: {Key}", apiKey[..4] + "****");
return Results.Ok("Authorized");
});
HttpContext и HttpRequest
Когда вам нужен доступ более низкого уровня:
app.MapGet("/info", (HttpContext context) =>
{
var userAgent = context.Request.Headers.UserAgent.ToString();
var ip = context.Connection.RemoteIpAddress?.ToString();
return Results.Ok(new { userAgent, ip });
});
Пользовательская привязка с помощью BindAsync
Для сложных типов вы можете реализовать статический метод BindAsync:
public record PaginationParams(int Page, int PageSize)
{
public static ValueTask<PaginationParams?> BindAsync(HttpContext context)
{
int.TryParse(context.Request.Query["page"], out var page);
int.TryParse(context.Request.Query["pageSize"], out var pageSize);
var result = new PaginationParams(
Page: page > 0 ? page : 1,
PageSize: pageSize > 0 ? Math.Min(pageSize, 100) : 20
);
return ValueTask.FromResult<PaginationParams?>(result);
}
}
app.MapGet("/items", (PaginationParams pagination, AppDbContext db) =>
{
var items = db.Items
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToList();
return Results.Ok(items);
});
Это невероятно мощно. Вы определяете логику привязки один раз и повторно используете ее на всех своих конечных точках.
Проверка
Одна из областей, в которой минимальные API не дают вам столько возможностей по сравнению с контроллерами, — это проверка модели. Автоматического применения [Required] или [StringLength] не предусмотрено. Но есть четкие шаблоны для решения этой проблемы.
Ручная проверка
Самый простой подход — просто валидируем в обработчике:
app.MapPost("/users", (CreateUserRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Email))
return Results.BadRequest(new { Error = "Email is required" });
if (request.Name?.Length > 100)
return Results.BadRequest(new { Error = "Name must be 100 characters or less" });
// Create user...
return Results.Created($"/users/{request.Email}", request);
});
Использование библиотеки проверки
Для чего-то нетривиального я бы рекомендовал использовать FluentValidation или аналогичную библиотеку:
public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Age).InclusiveBetween(0, 150);
}
}
Вы можете подключить это к своим конечным точкам через фильтры конечных точек, что подводит нас к следующему разделу.
Фильтры конечных точек
Фильтры конечных точек — одна из лучших функций минимальных API. Думайте о них как о промежуточном программном обеспечении, но ограниченном конкретными конечными точками, а не всем конвейером. Они были представлены в .NET 7 и отлично подходят для решения сквозных задач.
Базовый фильтр
app.MapPost("/todos", (Todo todo) =>
{
// Handle the request
return Results.Created($"/todos/{todo.Id}", todo);
})
.AddEndpointFilter(async (context, next) =>
{
var todo = context.GetArgument<Todo>(0);
if (string.IsNullOrEmpty(todo.Title))
{
return Results.BadRequest(new { Error = "Title is required" });
}
return await next(context);
});
Фильтр проверки с FluentValidation
Вот где он становится действительно мощным — многоразовый фильтр проверки:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var model = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (model is null)
return Results.BadRequest(new { Error = "Request body is required" });
var result = await _validator.ValidateAsync(model);
if (!result.IsValid)
{
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return Results.ValidationProblem(errors);
}
return await next(context);
}
}
// Usage
app.MapPost("/users", (CreateUserRequest request) =>
{
// Only reached if validation passes
return Results.Created($"/users/{request.Email}", request);
})
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();
Фильтр журналов
app.MapGet("/products", (AppDbContext db) => db.Products.ToList())
.AddEndpointFilter(async (context, next) =>
{
var logger = context.HttpContext
.RequestServices.GetRequiredService<ILogger<Program>>();
var sw = Stopwatch.StartNew();
var result = await next(context);
sw.Stop();
logger.LogInformation("Endpoint executed in {Elapsed}ms", sw.ElapsedMilliseconds);
return result;
});
Вы можете объединить несколько фильтров, и они будут выполняться в том порядке, в котором они добавлены — точно так же, как промежуточное программное обеспечение.
Интеграция OpenAPI/Swagger
Хорошая документация по API больше не является обязательной. К счастью, минимальные API имеют первоклассную поддержку OpenAPI через пакет Microsoft.AspNetCore.OpenApi.
Базовая настройка
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
app.MapGet("/todos", () => new List<Todo>())
.WithName("GetTodos")
.WithDescription("Retrieves all todo items")
.WithTags("Todos");
app.Run();
Расширенные метаданные конечной точки
Вы можете предоставить подробную информацию о ваших конечных точках:
app.MapPost("/todos", (Todo todo) =>
{
return Results.Created($"/todos/{todo.Id}", todo);
})
.WithName("CreateTodo")
.WithDescription("Creates a new todo item")
.WithTags("Todos")
.Accepts<Todo>("application/json")
.Produces<Todo>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.ProducesValidationProblem();
Использование WithOpenApi для настройки
Для детального контроля над созданным документом OpenAPI:
app.MapGet("/todos/{id:int}", (int id) => Results.Ok(new Todo(id, "Sample", false)))
.WithOpenApi(operation =>
{
operation.Summary = "Get a specific todo";
operation.Description = "Retrieves a single todo item by its unique identifier.";
operation.Parameters[0].Description = "The unique identifier of the todo item";
return operation;
});
Это дает вам тот же уровень управления документацией, который вы получаете с XML-комментариями Swashbuckle к контроллерам, но более явным способом, ориентированным на код.
Аутентификация и авторизация
Защита конечных точек минимального API следует тем же шаблонам, что и остальная часть ASP.NET Core — вы просто применяете их по-другому.
Базовая настройка```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = “https://your-identity-provider.com”; options.Audience = “your-api”; });
builder.Services.AddAuthorizationBuilder() .AddPolicy(“AdminOnly”, policy => policy.RequireRole(“Admin”)) .AddPolicy(“PremiumUser”, policy => policy.RequireClaim(“subscription”, “premium”));
var app = builder.Build();
app.UseAuthentication(); app.UseAuthorization();
### Применение авторизации к конечным точкам
```csharp
// Require any authenticated user
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
Name = user.FindFirstValue(ClaimTypes.Name),
Email = user.FindFirstValue(ClaimTypes.Email)
});
}).RequireAuthorization();
// Require a specific policy
app.MapDelete("/admin/users/{id}", (int id) =>
{
// Delete user logic
return Results.NoContent();
}).RequireAuthorization("AdminOnly");
// Allow anonymous access
app.MapGet("/public/health", () => Results.Ok(new { Status = "Healthy" }))
.AllowAnonymous();
Обратите внимание, как вы можете внедрить ClaimsPrincipal непосредственно в параметры вашего обработчика — обо всем остальном позаботится инфраструктура. Это одна из тех мелочей, благодаря которым минимальные API выглядят по-настоящему элегантно.
Организация больших API
Слон в комнате с минимальными API — это организация. Когда ваш Program.cs имеет 50 конечных точек, это становится беспорядком. Вот шаблоны, которые я использую, чтобы все было управляемо.
Группы маршрутов
Группы маршрутов (представленные в .NET 7) позволяют совместно использовать конфигурацию между связанными конечными точками:
var todos = app.MapGroup("/todos")
.WithTags("Todos")
.RequireAuthorization();
todos.MapGet("/", (AppDbContext db) => db.Todos.ToListAsync());
todos.MapGet("/{id:int}", (int id, AppDbContext db) => db.Todos.FindAsync(id));
todos.MapPost("/", (Todo todo, AppDbContext db) =>
{
db.Todos.Add(todo);
db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
});
Все конечные точки в группе имеют общий префикс /todos, тег Todos и требование авторизации. Чистый.
Методы расширения
Это модель, которая действительно масштабируется. Переместите каждую группу конечных точек в отдельный статический класс:
// Endpoints/TodoEndpoints.cs
public static class TodoEndpoints
{
public static RouteGroupBuilder MapTodoEndpoints(this WebApplication app)
{
var group = app.MapGroup("/todos").WithTags("Todos");
group.MapGet("/", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create);
group.MapPut("/{id:int}", Update);
group.MapDelete("/{id:int}", Delete);
return group;
}
private static async Task<IResult> GetAll(AppDbContext db)
=> Results.Ok(await db.Todos.ToListAsync());
private static async Task<IResult> GetById(int id, AppDbContext db)
=> await db.Todos.FindAsync(id) is { } todo
? Results.Ok(todo)
: Results.NotFound();
private static async Task<IResult> Create(Todo todo, AppDbContext db)
{
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
}
private static async Task<IResult> Update(int id, Todo updated, AppDbContext db)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
todo.Title = updated.Title;
todo.IsComplete = updated.IsComplete;
await db.SaveChangesAsync();
return Results.Ok(todo);
}
private static async Task<IResult> Delete(int id, AppDbContext db)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return Results.NoContent();
}
}
// Program.cs — stays clean
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapTodoEndpoints();
app.MapUserEndpoints();
app.MapOrderEndpoints();
app.Run();
Ваш Program.cs становится оглавлением вашего API. Каждая группа конечных точек находится в своем собственном файле. Именно такой подход я рекомендую для производственных приложений.
Библиотека Картера
Если вам нужно еще больше структуры, библиотека Carter предлагает подход на основе модулей:
public class TodoModule : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/todos", async (AppDbContext db) =>
await db.Todos.ToListAsync());
app.MapPost("/todos", async (Todo todo, AppDbContext db) =>
{
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
});
}
}
Картер автоматически обнаруживает и регистрирует все модули. Это хорошая золотая середина между необработанным подходом минимального API и полноценными контроллерами.
Типизированные результаты и типы ответов
Начиная с .NET 7, вы можете использовать TypedResults вместо Results для типобезопасных ответов. Это может показаться небольшим изменением, но оно имеет реальные преимущества для документирования и тестируемости OpenAPI.
app.MapGet("/todos/{id:int}", async Task<Results<Ok<Todo>, NotFound>> (int id, AppDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is not null
? TypedResults.Ok(todo)
: TypedResults.NotFound();
});
Тип возвращаемого значения Results<Ok<Todo>, NotFound> явно сообщает платформе (и вашей документации OpenAPI), какие именно типы ответов может генерировать эта конечная точка. Больше никаких догадок, никаких ручных вызовов Produces<>() для основных случаев.
Для нескольких возможных результатов:
app.MapPost("/todos",
async Task<Results<Created<Todo>, ValidationProblem, Conflict>>
(Todo todo, AppDbContext db) =>
{
if (string.IsNullOrEmpty(todo.Title))
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
{ "Title", new[] { "Title is required" } }
});
if (await db.Todos.AnyAsync(t => t.Title == todo.Title))
return TypedResults.Conflict();
db.Todos.Add(todo);
await db.SaveChangesAsync();
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
Я начал использовать TypedResults во всех новых проектах. Компилятор выявляет несоответствия между объявленными типами возвращаемых данных и тем, что вы на самом деле возвращаете, что устраняет целый класс неожиданностей во время выполнения.
Вопросы производительности
Одним из преимуществ минимальных API является производительность, и стоит понять, почему они быстрее.
Сокращение затрат при запуске. Контроллеры в значительной степени полагаются на отражение при обнаружении конечных точек, привязке моделей и применении фильтров. Минимальные API используют генераторы исходного кода (начиная с .NET 7) для создания кода привязки во время компиляции. Это означает меньше работы при запуске и меньшее выделение памяти на запрос.
Нет конвейера MVC. API на основе контроллера проходят через полный конвейер MVC: выбор действия, привязка модели, фильтры действий, выполнение результатов. Минимальные API пропускают все это и сразу переходят от маршрутизации к вашему обработчику.
Компиляция RequestDelegate. Платформа компилирует ваши лямбда-выражения в оптимизированные экземпляры RequestDelegate . Полученный код очень близок к тому, что вы написали бы вручную, если бы работали напрямую с HttpContext.
Вот несколько практических советов по повышению производительности:
// Use AsNoTracking for read-only queries
app.MapGet("/products", async (AppDbContext db) =>
await db.Products.AsNoTracking().ToListAsync());
// Return results directly — avoid unnecessary allocations
app.MapGet("/count", async (AppDbContext db) =>
await db.Products.CountAsync());
// Use cancellation tokens for long-running operations
app.MapGet("/report", async (AppDbContext db, CancellationToken ct) =>
await db.Orders
.AsNoTracking()
.GroupBy(o => o.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync(ct));
```Также стоит отметить, что разрыв в производительности между контроллерами и минимальными API продолжает сокращаться с каждым выпуском .NET. Для большинства приложений разница будет не вашим узким местом — вашими запросами к базе данных и вызовами внешних служб. Выбирайте, основываясь на опыте разработчиков и потребностях проекта, а не на тестах.
## Заключение
Минимальные API прошли долгий путь с момента их появления в .NET 6. То, что начиналось как демонстрационная функция «привет, мир», превратилось в законный выбор для рабочих API. Благодаря фильтрам конечных точек, группам маршрутов, типизированным результатам и надежной поддержке OpenAPI у вас есть все необходимое для создания хорошо структурированных и удобных в обслуживании сервисов.
Моя рекомендация? Если вы начинаете новый проект API — особенно микросервис или специализированный внутренний API — серьезно попробуйте Minimal API. Используйте шаблон метода расширения для организации, используйте фильтры конечных точек для решения сквозных задач и используйте `TypedResults` для обеспечения безопасности типов.
Для существующих проектов на базе контроллеров мигрировать не нужно. Оба подхода работают хорошо, и вы даже можете использовать их одновременно. Но в следующий раз, когда вам понадобится добавить небольшой сервис или быстрый внутренний API, пропустите контроллеры и используйте минимум. Ты можешь не вернуться.
Приятного кодирования!