可以将文章内容翻译成中文,广告屏蔽插件可能会导致该功能失效(如失效,请关闭广告屏蔽插件后再试):
问题:
ASP.NET Core API controllers typically return explicit types (and do so by default if you create a new project), something like:
[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...
}
The problem is that return null;
- it returns an HTTP 204
: success, no content.
This is then regarded by a lot of client side Javascript components as success, so there's code like:
const response = await fetch('.../api/things/5', {method: 'GET' ...});
if(response.ok)
return await response.json(); // Error, no content!
A search online (such as this question and this answer) points to helpful return NotFound();
extension methods for the controller, but all these return IActionResult
, which isn't compatible with my Task<Thing>
return type. That design pattern looks like this:
// 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);
}
That works, but to use it the return type of GetAsync
must be changed to Task<IActionResult>
- the explicit typing is lost, and either all the return types on the controller have to change (i.e. not use explicit typing at all) or there will be a mix where some actions deal with explicit types while others. In addition unit tests now need to make assumptions about the serialisation and explicitly deserialise the content of the IActionResult
where before they had a concrete type.
There are loads of ways around this, but it appears to be a confusing mishmash that could easily be designed out, so the real question is: what is the correct way intended by the ASP.NET Core designers?
It seems that the possible options are:
- Have a weird (messy to test) mix of explicit types and
IActionResult
depending on expected type.
- Forget about explicit types, they're not really supported by Core MVC, always use
IActionResult
(in which case why are they present at all?)
- Write an implementation of
HttpResponseException
and use it like ArgumentOutOfRangeException
(see this answer 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.
- Write an implementation of
HttpNoContentOutputFormatter
that returns 404
for GET requests.
- Something else I'm missing in how Core MVC is supposed to work?
- Or is there a reason why
204
is correct and 404
wrong for a failed GET request?
These all involve compromises and refactoring that lose something or add what seems to be unnecessary complexity at odds with the design of MVC Core. Which compromise is the correct one and why?
回答1:
This is addressed in ASP.NET Core 2.1 with ActionResult<T>
:
public ActionResult<Thing> Get(int id) {
Thing thing = GetThingFromDB();
if (thing == null)
return NotFound();
return thing;
}
Or even:
public ActionResult<Thing> Get(int id) =>
GetThingFromDB() ?? NotFound();
I'll update this answer with more detail once I've implemented it.
Original Answer
In ASP.NET Web API 5 there was an HttpResponseException
(as pointed out by Hackerman) but it's been removed from Core and there's no middleware to handle it.
I think this change is due to .NET Core - where ASP.NET tries to do everything out of the box, ASP.NET Core only does what you specifically tell it to (which is a big part of why it's so much quicker and portable).
I can't find a an existing library that does this, so I've written it myself. First we need a custom exception to check for:
public class StatusCodeException : Exception
{
public StatusCodeException(HttpStatusCode statusCode)
{
StatusCode = statusCode;
}
public HttpStatusCode StatusCode { get; set; }
}
Then we need a RequestDelegate
handler that checks for the new exception and converts it to the HTTP response status code:
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();
}
}
}
Then we register this middleware in our Startup.Configure
:
public class Startup
{
...
public void Configure(IApplicationBuilder app)
{
...
app.UseMiddleware<StatusCodeExceptionHandler>();
Finally actions can throw the HTTP status code exception, while still returning an explicit type that can easily be unit tested without conversion from IActionResult
:
public Thing Get(int id) {
Thing thing = GetThingFromDB();
if (thing == null)
throw new StatusCodeException(HttpStatusCode.NotFound);
return thing;
}
This keeps the explicit types for the return values and allows easy distinction between successful empty results (return null;
) and an error because something can't be found (I think of it like throwing an ArgumentOutOfRangeException
).
While this is a solution to the problem it still doesn't really answer my question - the designers of the Web API build support for explicit types with the expectation that they would be used, added specific handling for return null;
so that it would produce a 204 rather than a 200, and then didn't add any way to deal with 404? It seems like a lot of work to add something so basic.
回答2:
You can actually use IActionResult
or Task<IActionResult>
instead of Thing
or Task<Thing>
or even Task<IEnumerable<Thing>>
. If you have an API that returns JSON then you can simply do the following:
[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)
{
}
}
Update
It seems as though the concern is that being explicit in the return of an API is somehow helpful, while it is possible to be explicit it is in fact not very useful. If you're writing unit tests that exercise the request / response pipeline you are typically going to verify the raw return (which would most likely be JSON, i.e.; a string in C#). You could simply take the returned string and convert it back to the strongly typed equivalent for comparisons using Assert
.
This seems to be the only shortcoming with using IActionResult
or Task<IActionResult>
. If you really, really want to be explicit and still want to set the status code there are several ways to do this - but it is frowned upon as the framework already has a built-in mechanism for this, i.e.; using the IActionResult
returning method wrappers in the Controller
class. You could write some custom middleware to handle this however you'd like, however.
Finally, I would like to point out that if an API call returns null
according to W3 a status code of 204 is actually accurate. Why on earth would you want a 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.
I think the first sentence of the second paragraph says it best, "If the client is a user agent, it SHOULD NOT change its document view from that which caused the request to be sent". This is the case with an API. As compared to a 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.
The primary difference being one is more applicable for an API and the other for the document view, i.e.; the page displayed.
回答3:
In order to accomplish something like that(still, I think that the best approach should be using IActionResult
), you can follow, where you can throw
an HttpResponseException
if your Thing
is null
:
// 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;
}
回答4:
What you're doing with returning "Explicit Types" from the controller is isn't going to cooperate with your requirement to explicitly deal with your own response code. The simplest solution is to go with IActionResult
(like others have suggested); However, you can also explicitly control your return type using the [Produces]
filter.
Using IActionResult
.
The way to get control over the status results, is you need to return a IActionResult
which is where you can then take advantage of the StatusCodeResult
type. However, now you've got your issue of wanting to force a partiular format...
The stuff below is taken from the Microsoft Document: Formatting Response Data -Forcing a Particular Format
Forcing a Particular Format
If you would like to restrict the response formats for a specific
action you can, you can apply the [Produces]
filter. The
[Produces]
filter specifies the response formats for a specific
action (or controller). Like most Filters, this can be applied at
the action, controller, or global scope.
Putting it all together
Here's an example of control over the StatusCodeResult
in addition to control over the "explicit return type".
// GET: api/authors/search?namelike=foo
[Produces("application/json")]
[HttpGet("Search")]
public IActionResult Search(string namelike)
{
var result = _authorRepository.GetByNameSubstring(namelike);
if (!result.Any())
{
return NotFound(namelike);
}
return Ok(result);
}
I'm not a big supporter of this design pattern, but I have put those concerns in some additional answers to other people's questions. You will need to note that the [Produces]
filter will require you to map it to the appropriate Formatter / Serializer / Explicit Type. You could take a look at this answer for more ideas or this one for implementing a more granular control over your ASP.NET Core Web API.