Start Debugging

Typed results (Results<>) против IResult против IActionResult в ASP.NET Core 11

В ASP.NET Core 11 возвращайте Results<T1, TN> с TypedResults для minimal API и ActionResult<T> для контроллеров. Относитесь к голым IResult и IActionResult как к аварийным выходам: они компилируются для любого ответа, но ничего не сообщают OpenAPI, так что за них вы расплачиваетесь написанными вручную атрибутами ProducesResponseType.

Если у вашей конечной точки есть один возможный ответ, объявите этот один конкретный тип и двигайтесь дальше. Если их несколько, точный ответ в ASP.NET Core 11 таков: возвращайте Results<TResult1, TResultN> с TypedResults из minimal API и ActionResult<T> из контроллера. Оба варианта дают проверку на этапе компиляции того, что обработчик возвращает только то, что объявляет, и оба бесплатно передают генератору OpenAPI метаданные ответа. Два интерфейсных типа, голый IResult и голый IActionResult, являются аварийными выходами: они компилируются независимо от того, что вы возвращаете, и именно поэтому они ничего не сообщают фреймворку и вынуждают вас писать вручную [ProducesResponseType] или .Produces, чтобы получить точную спецификацию. Всё, что описано ниже, ориентировано на .NET 11 с Microsoft.NET.Sdk.Web и C# 14; типы HttpResults ведут себя одинаково начиная с .NET 7, так что тот же код без изменений работает на .NET 10 GA.

Три претендента из заголовка очереди относятся к двум разным мирам. IActionResult — это мир контроллеров MVC. IResult и его типизированное объединение Results<> — это мир minimal API, построенный на пространстве имён Microsoft.AspNetCore.Http.HttpResults. Нюанс, ради которого стоит писать это сравнение, в том, что начиная с .NET 7 типы HttpResults работают и в контроллерах, так что в действии контроллера у вас теперь есть настоящий выбор между типами результатов MVC и типами minimal API. Выбрать правильно — значит понимать, что каждый тип несёт, а что нет.

Матрица возможностей

ВозможностьIActionResultActionResult<T>IResult (голый)Results<T1, TN>
Основной домКонтроллерыКонтроллерыMinimal API + контроллерыMinimal API + контроллеры
Самоописание для OpenAPIНетЧастично (выводит T)НетДа
Нужен [ProducesResponseType] / .ProducesДа, обильноДля статус-кодов, отличных от TДаНет
Проверка возвращаемого типа при компиляцииНетНетНетДа
Согласование содержимого / форматтерыДаДаНетНет
Неявное приведение из типа полезной нагрузкиНет (интерфейс)Да (T в ActionResult<T>)НетДа (каждый аргумент объединения)
Результат, напрямую пригодный для модульного тестированияТребуется приведениеТребуется приведениеТребуется приведениеКонкретный .Result

Прочитайте матрицу сверху вниз, и закономерность станет ясна. Две интерфейсные строки дают “Нет” в каждом столбце про метаданные и безопасность. Две типизированные строки оправдывают свою многословность, превращая “Нет” в “Да”. Единственный столбец, где интерфейсы и ActionResult<T> выигрывают у типов HttpResults, — это согласование содержимого, и именно эта строка иногда делает выбор за вас. Подробнее об этом ниже.

Когда выбирать Results<> (и TypedResults)

Тянитесь к объединению всякий раз, когда конечная точка minimal API может ответить более чем одной формой.

Вот каноническая форма minimal API:

// .NET 11, C# 14 -- Program.cs
using Microsoft.AspNetCore.Http.HttpResults;

app.MapGet("/todos/{id}", async Task<Results<Ok<Todo>, NotFound>> (int id, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(todo);
});

Никаких .Produces, а сгенерированный документ OpenAPI перечисляет 200 со схемой Todo и 404 без тела, оба выведены из возвращаемого типа. Пошаговое преобразование, потолок в шесть типов и выигрыш при тестировании подробно рассмотрены в как вернуть типизированное объединение Results из конечной точки minimal API; этот пост о том, когда выбирать его вместо альтернатив, а не как его подключить.

Когда выбирать ActionResult

Тянитесь к ActionResult<T>, когда вы пишете действие контроллера с основной полезной нагрузкой успеха и одной или несколькими ветками ошибок.

// .NET 11, C# 14 -- ProductsController.cs
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Product> GetById(int id)
{
    var product = _db.Products.Find(id);
    return product is null ? NotFound() : product;   // implicit cast T -> ActionResult<T>
}

Причина, по которой ActionResult<T> существует, а IActionResult не может его заменить, — это правило C#, а не решение фреймворка: C# не допускает операторы неявного приведения на интерфейсах. ActionResult<T> — конкретный обобщённый тип, поэтому он может определить неявное преобразование из T, которое позволяет вам написать return product;. IActionResult — интерфейс, поэтому он никогда не сможет. В этом и заключается весь эргономический разрыв между этими двумя.

Когда голый IActionResult или IResult на самом деле правильный выбор

Ни один из интерфейсов не является неправильным, они просто узкие. Используйте их обдуманно, а не по умолчанию.

Голая версия IResult в контроллере выглядит так, и обратите внимание, что атрибуты вернулись:

// .NET 11, C# 14 -- ProductsController.cs
[HttpGet("{id}")]
[ProducesResponseType<Product>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IResult GetById(int id)
{
    var product = _db.Products.Find(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
}

Каждый помощник Results.* возвращает IResult, так что компилятор выводит IResult для обеих веток и никогда не жалуется, а ApiExplorer видит интерфейс, который ничего не говорит о статус-кодах. Вот почему две строки [ProducesResponseType] здесь обязательны и отсутствуют в версии Results<>: метаданным больше неоткуда взяться.

Загвоздка, которая делает выбор за вас: согласование содержимого

Если ваш API должен учитывать заголовки Accept и возвращать XML, CSV или любой формат, отличный от того, который жёстко задан в результате, семейство HttpResults отпадает, и это решение перекрывает всё сказанное выше. Документация прямо утверждает, что типы HttpResultsне задействуют настроенные форматтеры”, и разъясняет следствие: “Некоторые возможности, такие как Content negotiation, недоступны” и “Выдаваемый Content-Type определяется реализацией HttpResults.” TypedResults.Ok(product) будет сериализовать JSON независимо от того, что запросил клиент. Так что внутренний API только для JSON волен использовать Results<> в контроллере и пользоваться самоописывающими метаданными, но публичный API с зарегистрированным форматтером XML вынужден оставаться на ActionResult<T> / IActionResult для конечных точек, которые ведут согласование. Это стена возможностей, а не предпочтение, и именно поэтому она относится к вершине вашего решения, а не к его основанию.

Вторая вынуждающая функция — ваша модель хостинга. Если конечная точка живёт в minimal API, IActionResult и ActionResult<T> вам даже недоступны; это типы MVC, которые зависят от конвейера контроллеров. Выбор там всегда только между IResult и Results<>, и Results<> выигрывает для любой конечной точки с несколькими ответами. Полный компромисс между двумя моделями хостинга изложен в minimal API против контроллеров в ASP.NET Core 11.

Почему типизированные версии не компилируются случайно

Есть одна точка трения, с которой люди сталкиваются с Results<>, и её стоит назвать, чтобы она не читалась как баг. Вывод типов не построит объединение за вас. Это не компилируется:

// .NET 11, C# 14 -- does NOT compile
app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is null
        ? TypedResults.NotFound()   // NotFound
        : TypedResults.Ok(todo);    // Ok<Todo>
});

TypedResults.NotFound() и TypedResults.Ok(todo) — это разные конкретные типы, так что компилятор не может найти общий тип для тернарного оператора, и у лямбды нет выводимого возвращаемого типа. Голая версия IResult компилировалась только потому, что каждый помощник Results.* уже является IResult, что даёт веткам очевидный общий тип. С TypedResults вы расплачиваетесь за более богатые метаданные, объявляя возвращаемый тип сами: Results<Ok<Todo>, NotFound> для синхронного обработчика или Task<Results<Ok<Todo>, NotFound>> для асинхронного. Это объявление — не шаблонный код, который можно сократить. Это в точности та строка, которую фреймворк читает, чтобы построить спецификацию, в чём и весь смысл.

Та же логика объясняет, почему ActionResult<IEnumerable<Product>> работает, но ActionResult<T> не может обернуть интерфейс, который вы возвращаете напрямую: неявное приведение определено из T, а C# запрещает неявные приведения на интерфейсах, так что возврат экземпляра IEnumerable требует явной обёртки Ok(...). Небольшое правило, иногда удивляющее.

Рекомендация, переформулированная с полной картиной

Ментальная модель, которую стоит держать: интерфейсный возвращаемый тип принимает что угодно и ничего не документирует, так что фреймворк заставляет вас заново утверждать контракт в атрибутах. Типизированный возвращаемый тип, Results<> или ActionResult<T>, и есть контракт, так что компилятор его обеспечивает, а генератор OpenAPI его читает. Выбирайте типизированный, если только конкретная возможность, почти всегда согласование содержимого, не вынуждает к интерфейсу. Для веток, которые возвращают отказ валидации, подача ProblemHttpResult в объединение сохраняет форму согласованной со встроенным конвейером, описанным в как настроить ответы об ошибках валидации minimal API с помощью IProblemDetailsService.

Похожие материалы

Источники

Comments

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

< Назад