Start Debugging

Как организовать эндпоинты minimal API с помощью MapGroup в ASP.NET Core 11

Полное руководство по структурированию minimal API в ASP.NET Core 11 с помощью MapGroup: модули эндпоинтов на ресурс как методы расширения, вложенные группы, общие фильтры и аутентификация, префиксы с параметрами маршрута, теги OpenAPI и неожиданные правила порядка фильтров.

Чтобы minimal API не превратил Program.cs в стену из тысячи строк вызовов app.MapGet(...), группируйте связанные эндпоинты с помощью app.MapGroup("/prefix"), который возвращает RouteGroupBuilder, разделяющий префикс маршрута и любые соглашения (фильтры, аутентификацию, теги OpenAPI), которые вы к нему присоедините. Долговечный паттерн, это один метод расширения на ресурс: public static IEndpointRouteBuilder MapTodos(this IEndpointRouteBuilder routes) внутри строит собственную группу, а Program.cs сжимается до списка вызовов app.MapTodos();. MapGroup стабилен начиная с ASP.NET Core 7 и не изменился в .NET 11. Всё ниже нацелено на .NET 11 с Microsoft.NET.Sdk.Web и C# 14, но API идентичен в .NET 8, 9 и 10.

Почему Program.cs загнивает

Minimal API поставляются с соблазнительным демо: всё приложение в одном файле. Это прекрасно для примера и ужасно для настоящего сервиса. К тому моменту, когда у вас есть горстка ресурсов, каждый с пятью глаголами CRUD плюс пара подмаршрутов, Program.cs, это несколько сотен строк route handler-ов, перемежающихся с регистрацией DI, подключением middleware и app.Run(). Вы прокручиваете мимо настройки аутентификации, чтобы найти MapPut, который хотели отредактировать. Три handler-а начинаются с /api/v1/orders, и в одном из них опечатка в префиксе, которую никто не замечает до 404 в продакшене.

Решение, это не «вернуться к контроллерам». Контроллеры решают организацию через соглашение (один класс на ресурс, атрибуты для маршрутизации) ценой основанного на рефлексии конвейера MVC. Minimal API решают это с помощью MapGroup плюс обычные методы расширения C#, и вы сохраняете лёгкую, дружественную к AOT модель эндпоинтов. Если вы всё ещё выбираете между двумя моделями, компромиссы изложены в minimal API против контроллеров в ASP.NET Core 11. Это руководство предполагает, что вы выбрали minimal API и хотите, чтобы они масштабировались.

MapGroup возвращает RouteGroupBuilder

MapGroup, это метод расширения для IEndpointRouteBuilder. Он принимает префикс маршрута и возвращает RouteGroupBuilder:

// .NET 11, C# 14
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

RouteGroupBuilder todos = app.MapGroup("/todos");

todos.MapGet("/", () => "all todos");        // GET  /todos/
todos.MapGet("/{id:int}", (int id) => $"todo {id}"); // GET  /todos/42
todos.MapPost("/", () => "created");          // POST /todos/

app.Run();

Каждый вызов Map{Verb} на группе наследует префикс /todos, поэтому шаблоны, которые вы пишете на группе, относительные. RouteGroupBuilder реализует IEndpointRouteBuilder, и именно эта деталь заставляет работать всё остальное: всё, что вы можете вызвать на app (включая сам MapGroup), вы можете вызвать на группе. Так вложенность достаётся бесплатно.

Префикс комбинируется простой конкатенацией. app.MapGroup("/api").MapGroup("/v1").MapGet("/todos", ...) регистрирует GET /api/v1/todos. Никакой магии нормализации сверх соединения сегментов нет, поэтому не удваивайте слеши: префикс группы /todos/ плюс шаблон handler-а /{id} даёт /todos//{id}, который не совпадёт с тем, что вы ожидаете.

Один метод расширения на ресурс

Паттерн, который масштабируется, это дать каждому ресурсу собственный статический класс с одним методом Map, и пусть этот метод создаёт собственную группу. Тип возврата должен быть IEndpointRouteBuilder (или RouteGroupBuilder), чтобы вызовы выстраивались в цепочку:

// .NET 11, C# 14 -- Endpoints/TodoEndpoints.cs
public static class TodoEndpoints
{
    public static IEndpointRouteBuilder MapTodos(this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("/todos")
            .WithTags("Todos");

        group.MapGet("/", GetAll);
        group.MapGet("/{id:int}", GetById).WithName("GetTodoById");
        group.MapPost("/", Create);
        group.MapPut("/{id:int}", Update);
        group.MapDelete("/{id:int}", Delete);

        return routes;
    }

    private static async Task<Ok<List<Todo>>> GetAll(TodoDb db) =>
        TypedResults.Ok(await db.Todos.ToListAsync());

    private static async Task<Results<Ok<Todo>, NotFound>> GetById(int id, TodoDb db) =>
        await db.Todos.FindAsync(id) is { } todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound();

    private static async Task<Created<Todo>> Create(Todo todo, TodoDb db)
    {
        db.Todos.Add(todo);
        await db.SaveChangesAsync();
        return TypedResults.Created($"/todos/{todo.Id}", todo);
    }

    private static async Task<Results<NoContent, NotFound>> Update(int id, Todo input, TodoDb db)
    {
        var todo = await db.Todos.FindAsync(id);
        if (todo is null) return TypedResults.NotFound();
        todo.Title = input.Title;
        todo.Done = input.Done;
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    private static async Task<Results<NoContent, NotFound>> Delete(int id, TodoDb db)
    {
        var rows = await db.Todos.Where(t => t.Id == id).ExecuteDeleteAsync();
        return rows > 0 ? TypedResults.NoContent() : TypedResults.NotFound();
    }
}

Теперь Program.cs, это оглавление:

// .NET 11, C# 14 -- Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(o => o.UseSqlServer(/* ... */));
builder.Services.AddOpenApi();

var app = builder.Build();

app.MapTodos();
app.MapOrders();
app.MapCustomers();

app.MapOpenApi();
app.Run();

Две вещи делают эту идиому приятной. Во-первых, handler-ы, это именованные методы private static вместо лямбд, поэтому у каждого есть реальное имя в трассировках стека, и его тривиально тестировать изолированно. Во-вторых, возврат IEndpointRouteBuilder из MapTodos позволяет продолжать цепочку, если хотите (app.MapTodos().MapOrders();), хотя один вызов на строку читается лучше.

Замечание о типе возврата: если единственная задача метода, построить группу, и вы хотите, чтобы вызывающий присоединял к этой группе дополнительные соглашения, возвращайте RouteGroupBuilder. Если метод регистрирует эндпоинты, и вызывающий не должен трогать группу после, возвращайте IEndpointRouteBuilder. Смешение этих двух, самый частый источник путаницы «почему мой .RequireAuthorization() не компилируется».

Общие фильтры, аутентификация и метаданные одним вызовом

Выигрыш от группы в том, что соглашение, применённое к группе, применяется к каждому эндпоинту под ней. Именно здесь MapGroup оправдывает себя по сравнению с копированием .RequireAuthorization() на двадцать handler-ов:

// .NET 11, C# 14
var admin = app.MapGroup("/admin")
    .RequireAuthorization("AdminPolicy")   // every endpoint requires the policy
    .WithTags("Admin")                     // one OpenAPI tag for the whole group
    .AddEndpointFilter<AuditFilter>();     // runs for every endpoint in the group

admin.MapGet("/users", ListUsers);
admin.MapDelete("/users/{id:int}", DeleteUser);

RequireAuthorization, WithTags, WithMetadata, RequireRateLimiting, AddEndpointFilter и AddEndpointFilterFactory, все это методы IEndpointConventionBuilder, а RouteGroupBuilder реализует этот интерфейс, поэтому все они стекают на участников. Если вам нужно ограничение частоты на эндпоинт, секционированное по маршруту внутри группы, руководство по ограничению частоты на эндпоинт описывает, как RequireRateLimiting сочетается с политикой группы.

Одна тонкость, которую стоит усвоить: AddEndpointFilter на группе, это не middleware. Он не выполняется один раз на запрос к префиксу. Он применяется к конвейеру фильтров каждого эндпоинта по отдельности, во время сборки. Практическая разница в том, что фильтр выполняется только тогда, когда эндпоинт в группе действительно совпадает. Запрос к /admin/nonexistent, дающий 404, никогда не вызывает AuditFilter, тогда как middleware на сегменте пути /admin вызвал бы. Если вам нужен настоящий middleware, ограниченный путём, используйте app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/admin"), ...).

Вложенные группы и параметры маршрута в префиксе

Поскольку группа сама является IEndpointRouteBuilder, вы вкладываете её, вызывая MapGroup на группе. Префиксы могут содержать параметры маршрута и ограничения, и handler на любой глубине может привязать параметры, объявленные в любом предковом префиксе:

// .NET 11, C# 14
var orgs = app.MapGroup("/orgs/{orgId:int}");
var projects = orgs.MapGroup("/projects/{projectId:int}");

projects.MapGet("/issues", (int orgId, int projectId) =>
    $"issues for org {orgId}, project {projectId}");
// matches GET /orgs/7/projects/3/issues

Handler привязывает и orgId, и projectId, хотя каждый объявлен на уровень выше. Это чистый способ моделировать иерархические ресурсы: каждый уровень URL, это группа, а конечные handler-ы остаются короткими, потому что общие значения маршрута захватываются структурой, а не повторяются в каждом шаблоне.

Префикс также может быть пустым. app.MapGroup("") не добавляет сегмент пути, и это приём для присоединения соглашения или фильтра к полосе эндпоинтов без изменения их URL:

// .NET 11, C# 14
var versioned = app.MapGroup("").WithOpenApi();
var v1 = versioned.MapGroup("/v1");
var v2 = versioned.MapGroup("/v2");

Правило порядка фильтров, на котором спотыкаются

Когда фильтры живут на вложенных группах, порядок их выполнения определяется вложенностью групп, самый внешний первым, а не порядком, в котором вы написали вызовы AddEndpointFilter. Это самое контринтуитивное поведение групп маршрутов, оно задокументировано, но его легко упустить.

// .NET 11, C# 14
var outer = app.MapGroup("/outer");
var inner = outer.MapGroup("/inner");

inner.AddEndpointFilter((ctx, next) =>
{
    app.Logger.LogInformation("inner group filter");
    return next(ctx);
});

outer.AddEndpointFilter((ctx, next) =>   // added second, runs first
{
    app.Logger.LogInformation("outer group filter");
    return next(ctx);
});

inner.MapGet("/", () => "Hi!").AddEndpointFilter((ctx, next) =>
{
    app.Logger.LogInformation("endpoint filter");
    return next(ctx);
});

Запрос к /outer/inner/ записывает в журнал, по порядку:

outer group filter
inner group filter
endpoint filter

Внешний фильтр выполняется раньше внутреннего, хотя был добавлен вторым, потому что фильтры применяются от самой внешней группы внутрь, а собственные фильтры эндпоинта, последними. Относительный порядок вызовов AddEndpointFilter важен только тогда, когда они присоединены к одному и тому же builder-у. Между разными группами побеждает вложенность. Если у вас есть фильтр в стиле аутентификации, который должен выполняться до фильтра логирования, поместите аутентификацию на внешнюю группу, а не просто выше в файле.

Версионирование minimal API с помощью групп

Частая причина прибегнуть к вложенным группам, это версионирование API по сегменту URL. Каждая версия, это группа, и каждый модуль ресурса монтируется под тем builder-ом версии, который ему передан:

// .NET 11, C# 14
public static class TodoEndpoints
{
    public static IEndpointRouteBuilder MapTodos(this IEndpointRouteBuilder routes, int version)
    {
        var group = routes.MapGroup($"/todos").WithTags($"Todos v{version}");
        group.MapGet("/", GetAll);
        // v2-only endpoint
        if (version >= 2) group.MapGet("/search", Search);
        return routes;
    }
}

// Program.cs
var v1 = app.MapGroup("/api/v1");
var v2 = app.MapGroup("/api/v2");

v1.MapTodos(version: 1);
v2.MapTodos(version: 2);

Для всего, что выходит за рамки ручного версионирования по URL (версионирование по media-type или заголовку, заголовки устаревания, version sets), пакет Asp.Versioning интегрируется с MapGroup через HasApiVersion на группе, но для частого случая «префиксы пути v1 и v2» простой вложенности выше достаточно, и она не приносит никакой дополнительной зависимости.

Группировка в документе OpenAPI

WithTags на группе, это то, что создаёт сворачиваемые разделы в Swagger UI или Scalar. Пометьте группу один раз, и каждый эндпоинт попадёт под этот тег:

// .NET 11, C# 14
var todos = app.MapGroup("/todos").WithTags("Todos");

Без тега встроенный генератор OpenAPI откатывается к использованию первого сегмента маршрута в качестве тега, что обычно работает, но стоит задать его явно, чтобы рефакторинг префикса не переименовал молча разделы вашего API. Если вы также раскрываете потоки аутентификации в спецификации, группа, правильное место для присоединения требования безопасности, чтобы оно отображалось при каждой операции, см. добавление потоков аутентификации OpenAPI в Swagger UI в .NET 11 для подключения WithOpenApi и схем безопасности.

Подводные камни, которые стоит знать до рефакторинга

Несколько острых углов, на которые легко наткнуться, когда вы впервые перестраиваете плоский файл в группы:

Ментальная модель, которую стоит унести: RouteGroupBuilder, это просто IEndpointRouteBuilder с запомненным префиксом и запомненным набором соглашений. Каждый организационный приём, модули, вложенность, версионирование, общая аутентификация, следует из этого единственного факта. Начните с извлечения одного метода расширения MapXxx на ресурс, сократите Program.cs до списка вызовов и прибегайте к вложенным группам только тогда, когда иерархия URL действительно вкладывается.

Связанное

Источники

Comments

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

< Назад