从显式类型的ASP.NET Core API控制器(而不是IActionResult)返回404错误

111

ASP.NET Core API控制器通常返回明确的类型(如果您创建新项目,则默认情况下会这样做),类似于:

ASP.NET Core API控制器一般会返回明确的类型(如果你创建一个新项目,默认情况下就是这种方式),例如:

[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; - 它返回一个HTTP 204:成功但没有内容。

这被许多客户端JavaScript组件视为成功,因此有以下代码:

const response = await fetch('.../api/things/5', {method: 'GET' ...});
if(response.ok)
    return await response.json(); // Error, no content!
在线搜索(例如这个问题这个答案)指向了控制器的有用的return NotFound();扩展方法,但所有这些方法都返回不与我的Task<Thing>返回类型兼容的IActionResult。该设计模式如下:

// 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 的返回类型必须更改为 Task<IActionResult>——明确的类型丢失了,要么控制器上的所有返回类型都必须更改(即根本不使用明确的类型),要么将会有一些操作处理明确的类型,而其他操作处理 IActionResult 类型。此外,单元测试现在需要假设序列化并明确反序列化 IActionResult 的内容,在这之前,它们具有具体的类型。
有很多方法可以解决这个问题,但这似乎是一种混杂的困惑,很容易被设计出来,因此真正的问题是:ASP.NET Core 设计人员预期的正确方式是什么? 看起来可能的选项有:
1.根据预期的类型,具有明确的类型和 IActionResult 混合在一起(混乱且难以测试); 2.忘记明确的类型,它们并没有得到 Core MVC 的真正支持,总是使用 IActionResult(那么它们究竟存在的意义是什么?); 3.编写 HttpResponseException 的实现,并像使用 ArgumentOutOfRangeException 那样使用它(参见此答案中的实现)。然而,这要求使用异常进行程序流程控制,这通常是一个坏主意,并且还被 MVC Core 团队弃用。 4.编写返回 404HttpNoContentOutputFormatter 的实现,用于 GET 请求失败。 5.还有其他我在 Core MVC 中应该了解的东西吗? 6.或者,为什么对于未成功的 GET 请求,204 是正确的,而 404 是错误的?
所有这些都涉及到牺牲和重构,它们失去了一些东西或增加了似乎与 MVC Core 设计不符的不必要的复杂性。哪种妥协才是正确的,为什么呢?

1
@Hackerman 你好,你看到这个问题了吗?我特别知道 StatusCode(500) 只适用于返回 IActionResult 的操作,然后我详细介绍了一些内容。 - Keith
1
@Hackerman 不,它明确不是。那只适用于 IActionResult。我在询问具有 显式类型 的操作。我在第一个要点中询问了 IActionResult 的使用,但我并不是在问如何调用 StatusCode(404) - 我已经知道并在问题中引用了它。 - Keith
1
对于您的情况,解决方案可能是类似于 return new HttpResponseMessage(HttpStatusCode.NotFound); 的东西...此外,根据 https://learn.microsoft.com/en-us/aspnet/core/mvc/models/formatting,对于具有多个返回类型或选项的非平凡操作(例如,基于执行操作结果的不同HTTP状态代码),请优先选择IActionResult作为返回类型。 - Hackerman
1
@Hackerman,你投票将我的问题关闭为一个我在提问之前已经找到、阅读和浏览过的问题的副本,并且我在问题中指出这不是我要寻找的答案。显然,我变得有些防御性 - 我想要回答“我的”问题,而不是被指回一个圆圈。你的最后一条评论实际上是有用的,并开始解决我实际上所询问的问题 - 你应该将其充分发挥成一个完整的答案。 - Keith
1
我还可以反对IActionResult的使用:将返回类型隐式化会使您的单元测试变得繁琐,因为需要获取底层对象。 - Nickolodeon
显示剩余8条评论
7个回答

113
这是在ASP.NET Core 2.1中处理的,使用ActionResult<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(由 Hackerman指出),但它已从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状态码异常,同时仍返回一个明确的类型,可以轻松进行单元测试,无需从 IActionResult 进行转换:

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

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

    return thing;
}

这样可以保留返回值的显式类型,并且易于区分成功的空结果(return null;)和因找不到某些内容而导致的错误(我认为它类似于抛出 ArgumentOutOfRangeException)。虽然这是解决问题的一种方法,但它仍然没有真正回答我的问题-Web API 的设计者们构建了对显式类型的支持,并期望它们被使用,在 return null; 上添加了特定的处理方式,以便产生 204 而不是 200,然后没有添加任何处理 404 的方式?看起来为了添加如此基本的东西需要付出很多努力。

1
我认为如果路由资源是有效的,但没有返回任何内容,则应该返回204(无内容)的响应码......如果路由不存在则应返回404(未找到)的响应码......这很有道理,对吧? - Hackerman
1
@Hackerman 我怀疑这可能是一个选项,但是很多客户端API都概括了(1xx在此...2xx好的,3xx到这里,4xx你错了,5xx我们错了)- 2xx意味着一切正常,但实际上用户请求的资源不存在(就像我问题中的示例,Fetch API认为204是继续进行的OK)。我想204可能意味着正确的资源路径,错误的资源ID,但这不是我的理解。有关所需模式的任何引用吗? - Keith
2
看一下这篇文章http://racksburg.com/choosing-an-http-status-code/...我认为2xx / 3xx状态码的流程图解释得非常好...你也可以看看其他状态码 :) - Hackerman
1
@Hackerman,我真的不确定这一点——似乎是试图在API中传达有关API的信息。如果我们这样做,难道不应该对没有请求操作的有效控制器实现405(而不是404)等吗?在REST中,返回的是资源,而不是API本身。我认为这就是为什么约定是名称应该是复数形式(而不是像在数据库中那样是单数形式)——api/things/5是期望成为单个_thing_的资源。 - Keith
1
一旦我实现了它,我会更新这个答案并提供更多细节。已接近实现?/躲避并运行;^D但说实话,我对再次访问很感兴趣。那么用POST方法呢?(这不是什么高深的科学,但把它放在一个地方会很好) - ruffin
显示剩余11条评论

17

你实际上可以使用 IActionResultTask<IActionResult> 来代替 ThingTask<Thing> 甚至是 Task<IEnumerable<Thing>>。如果你有一个返回JSON的API,那么你可以简单地执行以下操作:

[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返回值的显式性,虽然可以显式地返回,但实际上并不是很有用。如果您正在编写单元测试以测试请求/响应管道,则通常会验证原始返回值(最可能是JSON,即C#中的字符串)。您只需将返回的字符串转换回强类型等效项进行比较即可使用Assert

这似乎是使用IActionResultTask<IActionResult>唯一的缺点。如果您确实非常想要明确表示并仍然希望设置状态代码,则有几种方法可以做到这一点, 但是这被认为是不好的。框架已经有了内置机制,即使用Controller类中的IActionResult返回方法包装器。您可以编写一些自定义中间件来处理此问题,但是。

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

204

服务器已处理请求,但不需要返回实体正文,并可能希望返回更新的元信息。响应可以包括新的或更新的元信息,以实体头的形式呈现,如果存在,则应与请求的变体相关联。

如果客户端是用户代理,则不应从导致发送请求的文档视图更改其文档视图。该响应主要用于允许进行操作的输入,而不会导致对用户代理的活动文档视图进行更改,虽然任何新的或更新的元信息都应应用于当前在用户代理中的活动视图的文档。

204响应不得包含消息正文,因此始终在标题字段之后的第一行中止。

我认为第二段第一句话最好表述了这一点,“如果客户端是用户代理,则不应更改其从中发出请求的文档视图”。这适用于API,与404相比:

服务器未找到与请求URI匹配的任何内容。不给出条件是临时还是永久的指示。如果服务器通过某些内部可配置机制知道旧资源永久不可用且没有转发地址,则应使用410(已删除)状态代码。当服务器不想透露拒绝请求的确切原因或没有其他可用响应时,通常使用此状态代码。

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


2
谢谢你的回复,但是这并不是我问题中的直接引用。同时,根据我的问题,“我不想要NotFound();作为答案——这些仅适用于IActionResult……这似乎使显式返回类型毫无意义。”这才是我真正想问的,而不是“我该如何使用NotFound()”,而是“我应该如何使用显式类型返回404”。你的回答似乎是“不要使用显式类型”,但如果是这样的话,那么它就缺少了一个关键细节,即“为什么显式类型是默认的(或者为什么它被支持)”。 - Keith
我们一直在讨论 GET api/things/5 如果找不到应该返回 404。那么 POST api/things/5 呢?如果成功了,它应该返回 204(这就是 public void Postpublic async Task PostAsync 的作用),但如果由于没有第五个物品而更新失败,那么它不应该也返回 404 吗?当它失败时,肯定不能返回成功状态吧? - Keith
1
这应该是正确的答案...关于状态码,根据你想要实现什么以及如何实现,有不同的实现方式(有些使用一个代码,有些使用另一个)...如果你想在某些情况下使用 404 并且你的 API 很好地记录了,那就不应该有问题。 - Hackerman
12
这不应该是正确的答案。如果你想要/get/{id},而服务器上没有该id的元素,则404 -未找到是正确的响应。关于显式类型-方法的类型信息实际上在像swagger这样的工具中使用,它们依赖于控制器声明以生成正确的swagger文件。因此,在这种情况下,IActionResult缺少了很多信息。 - Sebastian P.R. Gingter
1
太好了,这个答案没有被接受。感谢你的评论。 :) - David Pine
显示剩余7条评论

4

从ASP.NET Core 7开始,控制器可以返回HttpResults类型。然后您可以:

public async Task<Results<Ok<Product>, NotFound>> GetAsync(int id)
{
    Thing thingFromDB = await GetThingFromDBAsync();
    if(thingFromDB == null)
        return TypedResults.NotFound();

    ...
    return TypedResults.Ok(thingFromDB);
}

我喜欢这个语法,因为它明确地指示返回API。但实际上,OpenAPI规范生成器并不管理此事。您可以从此Github工单中跟踪进展: TypedResults metadata are not inferred for API Controllers

4
为了实现这样的功能(不过,我认为最好的方法应该是使用 IActionResult),您可以按照以下步骤进行操作,在其中如果您的Thing为空,则可以throw一个HttpResponseException
// 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;
}

干杯(+1) - 我认为这是朝着正确方向迈出的一步,但在测试中,HttpResponseException及其处理中间件似乎不在.NET Core 1.1中。下一个问题是:我应该自己编写中间件还是已经存在(最好是微软的)包可以完成此任务? - Keith
看起来这是在ASP.NET Web API 5中完成此操作的方法,但它已在Core版中删除,考虑到Core版手动处理的方式,这也是有道理的。 在大多数情况下,当Core版删除默认的ASP行为时,有一个新的可选中间件可以在“Startup.Configure”中添加,但在这里似乎没有。 代替它似乎并不太难从头开始编写一个。 - Keith
好的,我已经根据这个问题提供了一个可行的答案,但仍然没有回答原始问题:https://dev59.com/c1gR5IYBdhLWcg3wV78y#41484262 - Keith

1
我也曾经寻找过如何处理强类型响应的答案,当我想要返回一个400响应以表示请求中的数据有误时。我的项目是基于ASP.NET Core Web API (.NET5.0)。我找到的解决方案基本上是设置状态码并返回对象的默认版本。以下是您的示例,在数据库对象为null时更改状态码为404并返回默认对象的代码。
[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...
}

0

ASP.NET Core 3.1引入了过滤器

在ASP.NET Core中,过滤器允许代码在请求处理管道的特定阶段之前或之后运行。

您可以定义一个结果过滤器,将空结果转换为未找到的结果:

public class NullAsNotFoundResultFilter : IResultFilter
{
    public void OnResultExecuted(ResultExecutedContext context)
    { }

    public void OnResultExecuting(ResultExecutingContext context)
    {
        if(context.Result is ObjectResult result && result.Value == null)
        {
            context.Result = new NotFoundResult();
        }
    }
}

最后,您需要在MVC管道中添加过滤器:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(o => o.Filters.Add<NullAsNotFoundResultFilter>());

-4

遇到了另一个相同行为的问题 - 所有方法都返回404。问题在于缺少代码块。

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接