从明确类型化的 ASP.NET 核心 API 控制器(不是 IActionResult)返回 404

Returning a 404 from an explicitly typed ASP.NET Core API controller (not IActionResult)

ASP.NET 核心 API 控制器通常 return 显式类型(如果您创建新项目,默认情况下会这样做),例如:

[Route("api/[controller]")]
public class ThingsController : Controller
{
    // GET api/things
    [HttpGet]
    public async Task<IEnumerable<Thing>> GetAsync()
    {
        //...
    }

    // GET api/things/5
    [HttpGet("{id}")]
    public async Task<Thing> GetAsync(int id)
    {
        Thing thingFromDB = await GetThingFromDBAsync();
        if(thingFromDB == null)
            return null; // This returns HTTP 204

        // Process thingFromDB, blah blah blah
        return thing;
    }

    // POST api/things
    [HttpPost]
    public void Post([FromBody]Thing thing)
    {
        //..
    }

    //... and so on...
}

问题是 return null; - 它 return 是一个 HTTP 204:成功,没有内容。

然后很多客户端Javascript组件认为这是成功的,所以有这样的代码:

const response = await fetch('.../api/things/5', {method: 'GET' ...});
if(response.ok)
    return await response.json(); // Error, no content!

在线搜索(例如 and )指向有用的 return NotFound(); 控制器扩展方法,但所有这些 return IActionResult 都不兼容使用我的 Task<Thing> return 类型。该设计模式如下所示:

// GET api/things/5
[HttpGet("{id}")]
public async Task<IActionResult> GetAsync(int id)
{
    var thingFromDB = await GetThingFromDBAsync();
    if (thingFromDB == null)
        return NotFound();

    // Process thingFromDB, blah blah blah
    return Ok(thing);
}

可行,但要使用它,必须将 GetAsync 的 return 类型更改为 Task<IActionResult> - 显式类型丢失,并且所有 return控制器上的类型必须更改(即根本不使用显式类型),否则将会出现混合,其中一些操作处理显式类型,而另一些操作。此外,单元测试现在需要对序列化做出假设,并在它们具有具体类型之前显式反序列化 IActionResult 的内容。

有很多方法可以解决这个问题,但它似乎是一个很容易设计出来的令人困惑的大杂烩,所以真正的问题是:[=67 的正确方法是什么? =]核心设计师?

看来可能的选项是:

  1. 有一个奇怪的(测试混乱的)显式类型和 IActionResult 取决于预期类型的​​混合。
  2. 忘掉显式类型,Core MVC 并不真正支持它们,总是 使用 IActionResult(在这种情况下,为什么它们会出现?)
  3. 编写 HttpResponseException 的实现并像 ArgumentOutOfRangeException 一样使用它(参见 for an implementation). However, that does require using exceptions for program flow, which is generally a bad idea and also deprecated by the MVC Core team
  4. 为 GET 请求编写 HttpNoContentOutputFormatter 的 return 的 404 实现。
  5. 关于 Core MVC 应该如何工作,我还遗漏了什么?
  6. 或者对于失败的 GET 请求,204 是正确的而 404 是错误的有什么原因吗?

这些都涉及妥协和重构,丢失某些东西或添加一些看似不必要的复杂性,与 MVC Core 的设计不一致。哪一种妥协是正确的,为什么?

您实际上可以使用 IActionResultTask<IActionResult> 而不是 ThingTask<Thing> 甚至 Task<IEnumerable<Thing>>。如果你有一个 API returns JSON 那么你可以简单地执行以下操作:

[Route("api/[controller]")]
public class ThingsController : Controller
{
    // GET api/things
    [HttpGet]
    public async Task<IActionResult> GetAsync()
    {
    }

    // GET api/things/5
    [HttpGet("{id}")]
    public async Task<IActionResult> GetAsync(int id)
    {
        var thingFromDB = await GetThingFromDBAsync();
        if (thingFromDB == null)
            return NotFound();

        // Process thingFromDB, blah blah blah
        return Ok(thing); // This will be JSON by default
    }

    // POST api/things
    [HttpPost]
    public void Post([FromBody] Thing thing)
    {
    }
}

更新

似乎令人担忧的是,在 API 的 return 中 显式 在某种程度上是有帮助的,虽然有可能 explicit其实用处不大。如果您正在编写执行请求/响应管道的单元测试,您通常会验证原始 return(很可能是 JSON,即; C# 中的一个字符串)。您可以简单地使用 returned 字符串并将其转换回强类型等效字符串,以便使用 Assert 进行比较。

这似乎是使用 IActionResultTask<IActionResult> 的唯一缺点。如果你真的,真的想要明确并且仍然想设置状态代码,有几种方法可以做到这一点 - 但它不受欢迎,因为框架已经有一个内置的机制,即;在 Controller class 中使用 IActionResult returning 方法包装器。但是,您可以编写一些 custom middleware 来处理这个问题。

最后,我想指出,如果API根据W3调用returns null,状态码为204 实际上是准确的。为什么你想要一个 404?

204

The server has fulfilled the request but does not need to return an entity-body, and might want to return updated metainformation. The response MAY include new or updated metainformation in the form of entity-headers, which if present SHOULD be associated with the requested variant.

If the client is a user agent, it SHOULD NOT change its document view from that which caused the request to be sent. This response is primarily intended to allow input for actions to take place without causing a change to the user agent's active document view, although any new or updated metainformation SHOULD be applied to the document currently in the user agent's active view.

The 204 response MUST NOT include a message-body, and thus is always terminated by the first empty line after the header fields.

我觉得第二段第一句说得最好,"If the client is a user agent, it SHOULD NOT change its document view from that which caused the request to be sent"。 API 就是这种情况。与 404 相比:

The server has not found anything matching the Request-URI. No indication is given of whether the condition is temporary or permanent. The 410 (Gone) status code SHOULD be used if the server knows, through some internally configurable mechanism, that an old resource is permanently unavailable and has no forwarding address. This status code is commonly used when the server does not wish to reveal exactly why the request has been refused, or when no other response is applicable.

主要区别在于一个更适用于 API 而另一个更适用于文档视图,即;显示的页面。

为了完成类似的事情(不过,我认为最好的方法应该是使用 IActionResult),您可以遵循,如果您可以 throwHttpResponseException你的 Thingnull:

// GET api/things/5
[HttpGet("{id}")]
public async Task<Thing> GetAsync(int id)
{
    Thing thingFromDB = await GetThingFromDBAsync();
    if(thingFromDB == null){
        throw new HttpResponseException(HttpStatusCode.NotFound); // This returns HTTP 404
    }
    // Process thingFromDB, blah blah blah
    return thing;
}

这是 addressed in ASP.NET Core 2.1ActionResult<T>:

public ActionResult<Thing> Get(int id) {
    Thing thing = GetThingFromDB();

    if (thing == null)
        return NotFound();

    return thing;
}

甚至:

public ActionResult<Thing> Get(int id) =>
    GetThingFromDB() ?? NotFound();

我会在实施后更详细地更新这个答案。

原答案

在 ASP.NET Web API 5 中有一个 HttpResponseException(正如 所指出的)但它已从 Core 中删除并且没有中间件来处理它.

我认为这个变化是由于 .NET Core - ASP.NET 尝试开箱即用,ASP.NET Core 只做你明确告诉它的事情(这是一个很大的它如此快速和便携的部分原因)。

我找不到执行此操作的现有库,所以我自己编写了它。首先我们需要一个自定义异常来检查:

public class StatusCodeException : Exception
{
    public StatusCodeException(HttpStatusCode statusCode)
    {
        StatusCode = statusCode;
    }

    public HttpStatusCode StatusCode { get; set; }
}

然后我们需要一个 RequestDelegate 处理程序来检查新异常并将其转换为 HTTP 响应状态代码:

public class StatusCodeExceptionHandler
{
    private readonly RequestDelegate request;

    public StatusCodeExceptionHandler(RequestDelegate pipeline)
    {
        this.request = pipeline;
    }

    public Task Invoke(HttpContext context) => this.InvokeAsync(context); // Stops VS from nagging about async method without ...Async suffix.

    async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await this.request(context);
        }
        catch (StatusCodeException exception)
        {
            context.Response.StatusCode = (int)exception.StatusCode;
            context.Response.Headers.Clear();
        }
    }
}

然后我们在Startup.Configure:

中注册这个中间件
public class Startup
{
    ...

    public void Configure(IApplicationBuilder app)
    {
        ...
        app.UseMiddleware<StatusCodeExceptionHandler>();

最后,操作可以抛出 HTTP 状态代码异常,同时仍然 return 可以很容易地进行单元测试而无需从 IActionResult:

转换的显式类型
public Thing Get(int id) {
    Thing thing = GetThingFromDB();

    if (thing == null)
        throw new StatusCodeException(HttpStatusCode.NotFound);

    return thing;
}

这保留了 return 值的显式类型,并允许轻松区分成功的空结果 (return null;) 和由于找不到某些东西而导致的错误(我认为它就像抛出一个 ArgumentOutOfRangeException).

虽然这是问题的解决方案,但它仍然没有真正回答我的问题 - Web 的设计者 API 构建了对显式类型的支持,并期望它们会被使用,添加了特定的处理for return null; 这样它会产生一个 204 而不是 200,然后没有添加任何处理 404 的方法?添加如此基本的东西似乎需要做很多工作。

有另一个相同行为的问题 - 所有方法 return 404。问题在于缺少代码块

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
});

当我想 return 对发送到请求中的错误数据的 400 响应时,我也一直在寻找如何处理强类型响应的答案。我的项目在 ASP.NET Core Web API (.NET5.0) 中。我找到的解决方案基本上是设置对象的状态代码和 return default 版本。这是您的示例,其中更改为将状态代码设置为 404 并且 return 当 db 对象为 null 时的默认对象。

[Route("api/[controller]")]
public class ThingsController : Controller
{
    // GET api/things
    [HttpGet]
    public async Task<IEnumerable<Thing>> GetAsync()
    {
        //...
    }

    // GET api/things/5
    [HttpGet("{id}")]
    public async Task<Thing> GetAsync(int id)
    {
        Thing thingFromDB = await GetThingFromDBAsync();
        if(thingFromDB == null)
        {
            this.Response.StatusCode = Microsoft.AspNetCore.Http.StatusCodes.Status404NotFound;
            return default(Thing);
        }
        
        // Process thingFromDB, blah blah blah
        return thing;
    }

    // POST api/things
    [HttpPost]
    public void Post([FromBody]Thing thing)
    {
        //..
    }

    //... and so on...
}