接上一篇内容,这篇继续跟着《Pro ASP.NET Core 3》这本书来练习使用ASP.NET Core来编写web services中的一些进阶内容。这些内容包括,如何处理Entity Framework Core模型中的关联对象,如何支持HTTP PATCH方法,理解内容协调机制的工作原理,以及实现web services的文档化和自描述。
处理关联数据
在模型定义中,可以看到某个Supplier可以包含多个Product,那么如果在查询某个Supplier的时候同时就把对应的Product一道查出来呢?在EntityFramework中,可以使用Inculde方法来实现这一功能。
using System.Collections.Generic;
namespace WebApp.Models
{
public class Supplier
{
public long SupplierId { get; set; }
public string Name { get; set; }
public string City { get; set; }
public IEnumerable<Product> Products { get; set; }
}
}
在开始之前,新建SuppliersController,添加 GetSupplier方法。
using Microsoft.AspNetCore.Mvc;
using WebApp.Models;
using System.Threading.Tasks;
namespace WebApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class SuppliersController : ControllerBase
{
private DataContext context;
public SuppliersController(DataContext c)
{
context = c;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetSupplier(long id)
{
Supplier s = await context.Suppliers.FindAsync(x => x.SupplierId == id);
if (s == null)
{
return NotFound();
}
return Ok(s);
}
}
}
这个是在上一篇文章里讲述的方法。现在他返回的内容很简单,如下:
我们需要同时返回Product列表,所以需要修改Action方法如下:
using Microsoft.AspNetCore.Mvc;
using WebApp.Models;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class SuppliersController : ControllerBase
{
private DataContext context;
public SuppliersController(DataContext c)
{
context = c;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetSupplier(long id)
{
Supplier s = await context.Suppliers.Include(x=>x.Products).FirstAsync(x=>x.SupplierId==id);
if (s == null)
{
return NotFound();
}
return Ok(s);
}
}
}
再次运行,发现报了循环引用的错误:
这是因为,在Supplier里面包含了IEnumerable<Product>,在Product里面包含了Supplier。当Entity Framework Core创建对象的时候,他会将该对象里面,在同一个DataContext里面的属性对象也一并填充。在一些应用比如桌面应用程序中,这种方式很有用,因为在这些应用中,DataContext的生命周期很长,能用来进行多次查询。但是在ASP.NET Core应用程序中,则用处不大,因为每一个新的HTTP请求,都会重新创建一个Controller,同时也会重新创建一个DataContext。
在这个例子中,当Entity Framework Core 查询Supplier并将内部的Products属性一并查询并赋值。问题在于Entity Framework Core会查看每个Product对象,他发现每个Product对象里面也包含一个Supplier对象,于是他又试图去填充Product里面的Supplier对象,在查询数据阶段这没什么问题,但是在JSON序列化的时候,就会碰到问题。
打破关联数据的循环引用
为了能够正确返回结果,就要数据打破这种循环引用,所以我们在查询Supplier信息的时候,需要手动的将他包含的IEnumerable<Product>属性里面的每一个Product的Supplier属性设置为空,这样就切断了循环引用。
[HttpGet("{id}")]
public async Task<IActionResult> GetSupplier(long id)
{
Supplier s = await context.Suppliers.Include(x => x.Products).FirstAsync(x => x.SupplierId == id);
if (s == null)
{
return NotFound();
}
foreach (Product p in s.Products)
{
p.Supplier = null;
}
return Ok(s);
}
现在重新编译运行,可以看到结果能正常显示:
在Entity Framework Core的日志里可以看到实际执行的SQL语句为:
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (23ms) [Parameters=[@__id_0='1'], CommandType='Text', CommandTimeout='30']
SELECT [t].[SupplierId], [t].[City], [t].[Name], [p].[ProductId], [p].[CategoryId], [p].[Name], [p].[Price], [p].[SupplierId]
FROM (
SELECT TOP(1) [s].[SupplierId], [s].[City], [s].[Name]
FROM [Suppliers] AS [s]
WHERE [s].[SupplierId] = @__id_0
) AS [t]
LEFT JOIN [Products] AS [p] ON [t].[SupplierId] = [p].[SupplierId]
ORDER BY [t].[SupplierId], [p].[ProductId]
支持HTTP PATCH方法
对于简单的数据类型,编辑操作可以像上一篇文章里看到的那样,使用PUT方法来修改字段。但是如果我们只想修改很多字段里面的某一个字段,如果使用PUT方法就比较麻烦,因为你需要把所有的字段都重新传递一遍,即使某些字段并不需要修改。
解决方法就是使用PATCH请求,他只需要发送一些需要修改的字段给webservice,而不是将这个完整的对象发送过来。
JSON Patch标准
ASP.NET Core支持JSON Patch标准,JSON Patch的具体标准可以查看 JavaScript Object Notation (JSON) Patch ,这里只是演示如何使用JSON Patch来修改对象的属性。JSON Patch要求客户端以JSON格式向WebService发送类似如下数据:
[
{ "op": "replace", "path": "Name", "value": "Surf Co"},
{ "op": "replace", "path": "City", "value": “Los Angeles”},
]
每一条数据代表一个操作。每一条操作包含一个op,这里为“replace”表示替换,包含一个path,用来指示要修改的属性,这里为“Name”,value表示要操作的值,这里为“Surf Co”。在实际应用中,执行修改时,用的最多的op就是“replace”。
安装和配置JSON PATCH包
要支持JSON Patch必须安装对应的包。执行下面的语句来安装:
dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson --version 5.0.7
Microsoft实现JSON Patch的时候,在ASP.NET Core 2.x版本的时候依赖第三方的NewtonSoft JSON.NET序列化类库,但在3.0版本的ASP.NET Core的时候,替换了定制化的JSON序列化类库。接下来,在Startup.cs中,修改ConfigureService方法以启用JSON.NET序列化。
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<DataContext>(opts =>
{
opts.UseSqlServer(Configuration["ConnectionStrings:ProductConnection"]);
opts.EnableSensitiveDataLogging(true);
});
services.AddControllers().AddNewtonsoftJson();
services.AddCors();
services.Configure<MvcNewtonsoftJsonOptions>(opts =>
{
opts.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
});
// services.Configure<JsonOptions>(opts =>
// {
// opts.JsonSerializerOptions.IgnoreNullValues = true;
// });
}
AddNewtonsoftJson扩展方法启用了JSON.NET序列化器,取代了标准的ASP.NET Core序列化器。JSON.NET序列化器有自己的配置类,这里跟之前一样,设置忽略属性值为null的字段的序列化。
定义Action方法
为了支持PATCH方法,在SuppliersController类中添加如下一个Action。
using Microsoft.AspNetCore.Mvc;
using WebApp.Models;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.JsonPatch;
namespace WebApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class SuppliersController : ControllerBase
{
private DataContext context;
public SuppliersController(DataContext c)
{
context = c;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetSupplier(long id)
{
Supplier s = await context.Suppliers.Include(x => x.Products).FirstAsync(x => x.SupplierId == id);
if (s == null)
{
return NotFound();
}
foreach (Product p in s.Products)
{
p.Supplier = null;
}
return Ok(s);
}
[HttpPatch("{id}")]
public async Task<IActionResult> PatchSupplier(long id, JsonPatchDocument<Supplier> patchDoc)
{
Supplier s = await context.Suppliers.FindAsync(id);
if (s != null)
{
patchDoc.ApplyTo(s);
await context.SaveChangesAsync();
}
return Ok(s);
}
}
}
编译运行程序,然后执行HTTP PATCH请求,如下:
PS D:\Study\ASPNETCORESTUDY> Invoke-RestMethod http://localhost:5000/api/suppliers/1 -Method PATCH -ContentType "application/json" -Body '[{"op":"replace","path":"City","value":"Los Angeles"}]'
supplierId name city
---------- ---- ----
1 Splash Dudes Los Angeles
刷新之后,可以看到,结果发生了变化。
内容格式化
前面的WebService例子中,返回的内容都是JSON格式,但这不是WebService能返回的唯一格式。Action返回结果的内容的格式化选择,取决于四个因素。
- 客户端能够接收的格式
- 应用程序WebService能够提供的格式
- Action方法的返回值指定的格式
- Action方法指定的内容策略
默认的内容格式策略
如果客户端和服务端都不指定返回值的格式,那么输出的格式很简单
- 如果Action方法返回的类型是string,则返回值的Content-Type会设置为text/plain,表示普通的文本
- 对于Action方法的其他返回类型,不管是如何简单的类型,比如int,返回的内容全部为JSON格式,并且Content-Type会被设置为application/json。
对于string类型的特殊处理,原因在于,如果将string类型输出为JSON类型,则会多加一个双引号。比如对于int类型的2,JSON输出后会添加双引号为“2”。当对一个string类型进行JSON序列化时“Hello”会变成""Hello"",所以为了安全起见,对于返回类型为string的,Content-Type会被设置为text/plain。
我们可以添加一个ContentController.cs来检验这一现象。
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
using WebApp.Models;
namespace WebApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
private DataContext context;
public ContentController(DataContext ctx)
{
context = ctx;
}
[HttpGet("string")]
public string GetString() => "This is a string response";
[HttpGet("int")]
public int GetInt() => 1;
[HttpGet("object")]
public async Task<Product> GetObject() => await context.Products.FirstAsync();
}
}
现在开始测试:
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/string | select @{n='Content-Type';e={$_.Headers."Content-Type"}}, Content
Content-Type Content
------------ -------
text/plain; charset=utf-8 This is a string response
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/int | select @{n='Content-Type';e={$_.Headers."Content-Type"}}, Content
Content-Type Content
------------ -------
application/json; charset=utf-8 1
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/object | select @{n='Content-Type';e={$_.Headers."Content-Type"}}, Content
Content-Type Content
------------ -------
application/json; charset=utf-8 {"productId":1,"name":"Kayak","price":275.00,"categoryId":1,"supplierId":1}
可以看到,返回结果格式化类型,跟我们之前描述的是一致的。
内容协商Content Negotiation
大部分的客户端在发送请求时在请求头部都会附带Accept字段,他表示客户端愿意接收的格式类型。比如Chrome浏览器发送请求时,请求头里面的内容如下:
text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
他表示客户端能处理html,xhtml+xml,image/avif,image/webapp,image/apng格式。q表示接收程度,默认如果不填,就是1,表示最愿意接收。application/xml;q=0.9,表示Chrome能够接收xml格式q=0.9,但是它更喜欢html和xhtml+xml格式,因为这些格式的q=1。"*/*"表示愿意接收所有其他格式,但是q=0.8。
综上,Chrome浏览器对返回格式的喜好类型依次是:
- 最喜欢接收的格式为text/html、xhtml+xml、webapp、apng、q值没有写,默认q=1。
- 如果没有以上格式,其次喜欢接收的格式为xml、signed-exchange;v=b3 ,q=0.9。
- 如果以上格式全没有,那么接收任何其他格式,q=0.8
可能我们认为,如果我们修改请求头,将accept修改为application/xml,我们就能收到xml格式的返回值,实际上是否如此,我们可以验证一下:
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/object -Headers @{Accept="application/xml"} | select @{n='Content-Type';e={$_.Headers."Content-Type"}}, Content
Content-Type Content
------------ -------
application/json; charset=utf-8 {"productId":1,"name":"Kayak","price":275.00,"categoryId":1,"supplierId":1}
我们制定了需要xml,但是收到的格式依然是json。原因在于,默认情况下,在MVC框架中当没有客户端指定的格式化类型输出时(MVC默认没有开启XML格式化输出),他会默认采用JSON,而不是输出错误,他希望客户端能够处理JSON,虽然JSON格式并不是客户端想要的。
开启XML格式化
要开启XML序列化,首先需要修改Startup.cs里面的ConfigureServices方法:
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<DataContext>(opts =>
{
opts.UseSqlServer(Configuration["ConnectionStrings:ProductConnection"]);
opts.EnableSensitiveDataLogging(true);
});
services.AddControllers().AddNewtonsoftJson().AddXmlSerializerFormatters();
services.AddCors();
services.Configure<MvcNewtonsoftJsonOptions>(opts =>
{
opts.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
});
// services.Configure<JsonOptions>(opts =>
// {
// opts.JsonSerializerOptions.IgnoreNullValues = true;
// });
}
XML序列化有一些缺点,包括他不支持Entity Framework Core里面的导航属性,所以我们不能直接返回Product对象,这里返回的是在模型绑定时使用的ProductBindingTarget对象。ContentController的GetObject方法修改如下:
[HttpGet("object")]
public async Task<IActionResult> GetObject()
{
Product p = await context.Products.FirstAsync();
return Ok(new ProductBindingTarget()
{
Name = p.Name,
Price = p.Price,
CategoryId = p.CategoryId,
SupplierId = p.SupplierId
});
}
现在,再执行之前的测试,可以看到,返回的结果已经变成了xml了。
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/object -Headers @{Accept="application/xml"}
StatusCode : 200
StatusDescription : OK
Content : <ProductBindingTarget xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"><Name>Kayak</Name><Price>275.00</Price><Category
Id>1</CategoryId><SupplierId>1<...
RawContent : HTTP/1.1 200 OK
Content-Length: 235
Content-Type: application/xml; charset=utf-8
Date: Wed, 07 Jul 2021 08:31:11 GMT
Server: Kestrel
<ProductBindingTarget xmlns:xsi="http://www.w3.org/2001/XMLS...
Forms : {}
Headers : {[Content-Length, 235], [Content-Type, application/xml; charset=utf-8], [Date, Wed, 07 Jul 2021 08:31:11 GMT], [Server, Kestrel]}
Images : {}
InputFields : {}
Links : {}
ParsedHtml : mshtml.HTMLDocumentClass
RawContentLength : 235
可以看到,返回的类型已经是application/xml,在Content里可以看到ProductBindingTarget的xml序列化表示。
完全尊重请求头指定的格式
如果请求头里指定格式包含"*/*",那么即使请求头包含其他格式并且还具有更高的优先级,MVC框架仍然只会返回JSON格式。可以测试,如果在之前的请求头里面添加“*/*;q=0.8”可以看到返回结果就变成了json。
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/object -Headers @{Accept="application/xml,*/*;q=0.8"}
StatusCode : 200
StatusDescription : OK
Content : {"name":"Kayak","price":275.00,"categoryId":1,"supplierId":1}
RawContent : HTTP/1.1 200 OK
Content-Length: 61
Content-Type: application/json; charset=utf-8
Date: Wed, 07 Jul 2021 09:15:28 GMT
Server: Kestrel
{"name":"Kayak","price":275.00,"categoryId":1,"supplierId":1...
Forms : {}
Headers : {[Content-Length, 61], [Content-Type, application/json; charset=utf-8], [Date, Wed, 07 Jul 2021 09:15:28 GMT], [Server, Kestrel]}
Images : {}
InputFields : {}
Links : {}
ParsedHtml : mshtml.HTMLDocumentClass
RawContentLength : 61
如果请求头里指定一个服务端不能提供的格式,那么服务端也会返回默认的JSON格式。
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/object -Headers @{Accept="img/png"}
StatusCode : 200
StatusDescription : OK
Content : {"name":"Kayak","price":275.00,"categoryId":1,"supplierId":1}
RawContent : HTTP/1.1 200 OK
Content-Length: 61
Content-Type: application/json; charset=utf-8
Date: Thu, 08 Jul 2021 03:59:28 GMT
Server: Kestrel
{"name":"Kayak","price":275.00,"categoryId":1,"supplierId":1...
Forms : {}
Headers : {[Content-Length, 61], [Content-Type, application/json; charset=utf-8], [Date, Thu, 08 Jul 2021 03:59:28 GMT], [Server, Kestrel]}
Images : {}
InputFields : {}
Links : {}
ParsedHtml : mshtml.HTMLDocumentClass
RawContentLength : 61
MVC框架的这种默认处理,很可能是许多客户端不想要的。可以通过配置来告诉MVC尊重HTTP请求的Header中的信息,修改如下:
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<DataContext>(opts =>
{
opts.UseSqlServer(Configuration["ConnectionStrings:ProductConnection"]);
opts.EnableSensitiveDataLogging(true);
});
services.AddControllers().AddNewtonsoftJson().AddXmlSerializerFormatters();
services.AddCors();
services.Configure<MvcNewtonsoftJsonOptions>(opts =>
{
opts.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
});
services.Configure<MvcOptions>(opts =>
{
opts.RespectBrowserAcceptHeader = true;
opts.ReturnHttpNotAcceptable = true;
});
}
通过配置MvcOptions,设置RespectBrowserAcceptHeader为true,则表示按照请求头里面的AcceptHeader中指定的优先级格式来输出,ReturnHttpNotAcceptable为true,则表示当服务端没有客户端指定的格式时,报错,而不是返回默认的JSON,重新编译之后,再次运行上述命令,可以看到这次就是按照要求的输出了。
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/object -Headers @{Accept="application/xml,*/*;q=0.8"}
StatusCode : 200
StatusDescription : OK
Content : <ProductBindingTarget xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"><Name>Kayak</Name><Price>275.00</Price><Category
Id>1</CategoryId><SupplierId>1<...
RawContent : HTTP/1.1 200 OK
Content-Length: 235
Content-Type: application/xml; charset=utf-8
Date: Thu, 08 Jul 2021 04:03:21 GMT
Server: Kestrel
<ProductBindingTarget xmlns:xsi="http://www.w3.org/2001/XMLS...
Forms : {}
Headers : {[Content-Length, 235], [Content-Type, application/xml; charset=utf-8], [Date, Thu, 08 Jul 2021 04:03:21 GMT], [Server, Kestrel]}
Images : {}
InputFields : {}
Links : {}
ParsedHtml : mshtml.HTMLDocumentClass
RawContentLength : 235
以上指定优先接受xml格式,可以看到返回的Content-Type已经是xml了,而不是之前没有设置时返回的JSON格式。
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/object -Headers @{Accept="img/png"}
Invoke-WebRequest : 远程服务器返回错误: (406) 不可接受。
所在位置 行:1 字符: 2
+ Invoke-WebRequest http://localhost:5000/api/content/object -Headers ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest],WebException
+ FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
当客户端指定接收格式为“img/png”时,服务端无法提供该格式,所以返回了406错误,而不是之前没有设置时返回的JSON格式。
指定Action的返回格式
可以使用Produces属性来为某个Action指定返回数据的格式,如下:
[HttpGet("object")]
[Produces("application/json")]
public async Task<IActionResult> GetObject()
{
Product p = await context.Products.FirstAsync();
return Ok(new ProductBindingTarget()
{
Name = p.Name,
Price = p.Price,
CategoryId = p.CategoryId,
SupplierId = p.SupplierId
});
}
Produces属性,可以指定多个返回格式,这里指定为了"application/json",MVC在处理用户请求参考Header里面的格式偏好时,会考虑这里的Produces属性,现在我们修改请求方法:
PS D:\Study\ASPNETCORESTUDY> Invoke-WebRequest http://localhost:5000/api/content/object -Headers @{Accept="application/xml,application/json;q=0.8"}
StatusCode : 200
StatusDescription : OK
Content : {"name":"Kayak","price":275.00,"categoryId":1,"supplierId":1}
RawContent : HTTP/1.1 200 OK
Content-Length: 61
Content-Type: application/json; charset=utf-8
Date: Thu, 08 Jul 2021 06:12:47 GMT
Server: Kestrel
{"name":"Kayak","price":275.00,"categoryId":1,"supplierId":1...
Forms : {}
Headers : {[Content-Length, 61], [Content-Type, application/json; charset=utf-8], [Date, Thu, 08 Jul 2021 06:12:47 GMT], [Server, Kestrel]}
Images : {}
InputFields : {}
Links : {}
ParsedHtml : mshtml.HTMLDocumentClass
RawContentLength : 61
可以看到,在请求的Headers中,我们指定了XML和JSON两种格式,并且XML具有更高的优先级,但是MVC在处理的时候发现GetObject的Action标注了Produces属性,指定为了以JSON格式输出,所以最后的输出格式就是JSON格式。
通过请求URL指定返回格式
请求头里面的格式并不总是掌握在开发者手中,在这种情况下,使用请求的URL来指定返回的格式可能比较有用。这种方式是通过FormatFilter属性和路由配置中的format参数来实现的。
[HttpGet("object/{format?}")]
[FormatFilter]
[Produces("application/json", "application/xml")]
public async Task<IActionResult> GetObject()
{
Product p = await context.Products.FirstAsync();
return Ok(new ProductBindingTarget()
{
Name = p.Name,
Price = p.Price,
CategoryId = p.CategoryId,
SupplierId = p.SupplierId
});
}
现在,在请求的URL里加上格式参数就能得到不同的结果。
限制Action方法接收数据的格式
大多数的格式都聚焦在从服务端像客户端传输的格式上,但同样,从客户端向服务端传输数据的时候也存在反序列化的问题。这种反序列化是框架帮我们自动完成的。客户端在像服务端发送请求的时候,在body里面可以使用XML或者JSON格式,在Action上使用Consumes标签可以限制那种数据格式才会处理。比如,我们在ContentController中添加两个Action:
[HttpPost]
[Consumes("application/json")]
public string SaveProductJson(ProductBindingTarget target)
{
return $"JSON:{target.Name}";
}
[HttpPost]
[Consumes("application/xml")]
public string SaveProductXml(ProductBindingTarget target)
{
return $"XML:{target.Name}";
}
这两个方法都有Consumes属性装饰。当HttpPost的Content-Type为“application/json”的时候,会使用SaveProductJson方法来处理,当Content-Type为“application/xml”的时候,会使用SaveProductXml来处理。
PS D:\Study\ASPNETCORESTUDY> Invoke-RestMethod http://localhost:5000/api/content -Method POST -Body "<ProductBindingTarget><Name>Kayak</Name><Price>275.00</Price><CategoryId>1</CategoryId><SupplierId>1</SupplierId></ProductBindingTarget>" -ContentType "application/xml"
XML:Kayak
PS D:\Study\ASPNETCORESTUDY> Invoke-RestMethod http://localhost:5000/api/content -Method POST -Body (@{ Name="Swimming Goggles";Price=12.75; CategoryId=1; SupplierId=1} | ConvertTo-Json) -ContentType "application/json"
JSON:Swimming Goggles
PS D:\Study\ASPNETCORESTUDY> Invoke-RestMethod http://localhost:5000/api/content -Method POST -Body (@{ Name="Swimming Goggles"; Price=12.75; CategoryId=1; SupplierId=1} | ConvertTo-Json) -ContentType "img/png"
Invoke-RestMethod : 远程服务器返回错误: (415) Unsupported Media Type。
所在位置 行:1 字符: 1
+ Invoke-RestMethod http://localhost:5000/api/content -Method POST -Bod ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-RestMethod],WebException
+ FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand
可以看到,根据请求的不同的ContentType,调用了不同的Action,如果请求的ContentType不在Consumes列表内,则会返回415错误。
WebService文档化和自描述
当我们在开发web service和使用他的客户端时,每一个Action以及他返回的结果我们希望它是显而易见的。当我们在开发一个应用它使用了第三方的服务室,我们需要这个服务的文档来告知他需要哪些参数,返回那些结果,最好有文档能够描述。OpenAPI规范,比如大家熟知的Swagger,他能够以一种大家都能理解的方式来描述web service,这里就演示一下如何在web service中使用OpenAPI。
解决Action冲突
OpenAPI发现过程要求HTTP方法和URL组合起来必须是唯一的,并且这个过程不支持Consumes属性,因此我们删除SaveProductXml这个Action。
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
using WebApp.Models;
namespace WebApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
private DataContext context;
public ContentController(DataContext ctx)
{
context = ctx;
}
[HttpGet("string")]
public string GetString() => "This is a string response";
[HttpGet("int")]
public int GetInt() => 1;
[HttpGet("object/{format?}")]
[FormatFilter]
[Produces("application/json", "application/xml")]
public async Task<IActionResult> GetObject()
{
Product p = await context.Products.FirstAsync();
return Ok(new ProductBindingTarget()
{
Name = p.Name,
Price = p.Price,
CategoryId = p.CategoryId,
SupplierId = p.SupplierId
});
}
[HttpPost]
[Consumes("application/json")]
public string SaveProductJson(ProductBindingTarget target)
{
return $"JSON:{target.Name}";
}
// [HttpPost]
// [Consumes("application/xml")]
// public string SaveProductXml(ProductBindingTarget target)
// {
// return $"XML:{target.Name}";
// }
}
}
安装和配置Swashbuckle包
Swashbuckle包是最流行的实现了OpenAPI规范的包,他能够自动生成对web service的描述。这个包也包含了一些工具用来查看和测试web service。安装Swashbuckle包的命令如下,也可以使用CTRL+SHIFT+P来添加。
dotnet add package Swashbuckle.AspNetCore --version 6.1.4
安装完成之后,修改Startup.cs的ConfigureServices方法。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using WebApp.Models;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
namespace WebApp
{
public class Startup
{
public IConfiguration Configuration { get; set; }
public Startup(IConfiguration config)
{
Configuration = config;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<DataContext>(opts =>
{
opts.UseSqlServer(Configuration["ConnectionStrings:ProductConnection"]);
opts.EnableSensitiveDataLogging(true);
});
services.AddControllers().AddNewtonsoftJson().AddXmlSerializerFormatters();
services.AddCors();
services.Configure<MvcNewtonsoftJsonOptions>(opts =>
{
opts.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
});
services.Configure<MvcOptions>(opts =>
{
opts.RespectBrowserAcceptHeader = true;
opts.ReturnHttpNotAcceptable = true;
});
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo { Title = "WebApp", Version = "v1" });
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DataContext dbContext)
{
app.UseDeveloperExceptionPage();
app.UseRouting();
app.UseMiddleware<TestMiddleware>();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Hello World!");
});
//endpoints.MapWebService();
endpoints.MapControllers();
});
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "WebApp");
});
SeedData.SeedDatabase(dbContext);
}
}
}
运行程序,在浏览器输入 http://localhost:5000/swagger/v1/swagger.json 可以看到所有web service的描述:
同时,输入 http://localhost:5000/swagger/index.html 可以看到友好的UI界面。
优化API描述
在/api/Product/{id}这个服务中,可以看到他的返回状态只有Success。
而在实际的代码中,我们可以看到,他也可能返回NotFound的状态码。
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(long id)
{
Product p = await context.Products.FindAsync(id);
if (p == null)
{
return NotFound();
}
logger.LogDebug("GetProduct action invoked");
return Ok(new
{
ProductId = p.ProductId,
Name = p.Name,
Price = p.Price,
CategoryId = p.CategoryId,
SupplierId = p.SupplierId
});
}
所以,这里的API描述并没有真正反应web service的所有状态。如果第三方开发者调用这个服务,他有可能会得到404-Not Found的错误。
使用API分析器
在ASP.NET Core中包含了一个用来检查web service以上问题的分析器。可以在项目文件中添加如下配置来实现添加。
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="5.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.4" />
</ItemGroup>
<PropertyGroup>
<IncludeOpenAPIAnalyzers>true</IncludeOpenAPIAnalyzers>
</PropertyGroup>
</Project>
现在运行编译命令 dotnet build,可以看到错误提示:
PS D:\Study\ASPNETCORESTUDY\WebApp> dotnet build
用于 .NET 的 Microsoft (R) 生成引擎版本 16.10.0+4242f381a
版权所有(C) Microsoft Corporation。保留所有权利。
正在确定要还原的项目…
所有项目均是最新的,无法还原。
D:\Study\ASPNETCORESTUDY\WebApp\Controllers\SuppliersController.cs(24,17): warning API1000: Action method returns undeclared status code '404'. [D:\Study\ASPNETCORESTUDY\WebApp\WebApp.csproj]
D:\Study\ASPNETCORESTUDY\WebApp\Controllers\ProductsController.cs(36,17): warning API1000: Action method returns undeclared status code '404'. [D:\Study\ASPNETCORESTUDY\WebApp\WebApp.csproj] WebApp -> D:\Study\ASPNETCORESTUDY\WebApp\bin\Debug\net5.0\WebApp.dll
定义Action方法的返回类型
要解决上述的问题,可以在Action方法上定义ProducesResponseType标签,来列明所有可能出现的类型,上面提示在SuppliersControllers和ProductsController中都存在问题,于是修改如下:
using Microsoft.AspNetCore.Mvc;
using WebApp.Models;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace WebApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private DataContext context;
private ILogger<ProductsController> logger;
public ProductsController(DataContext ctx, ILogger<ProductsController> l)
{
context = ctx;
logger = l;
}
......
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProduct(long id)
{
Product p = await context.Products.FindAsync(id);
if (p == null)
{
return NotFound();
}
logger.LogDebug("GetProduct action invoked");
return Ok(new
{
ProductId = p.ProductId,
Name = p.Name,
Price = p.Price,
CategoryId = p.CategoryId,
SupplierId = p.SupplierId
});
}
......
}
}
using Microsoft.AspNetCore.Mvc;
using WebApp.Models;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.JsonPatch;
using Microsoft.AspNetCore.Http;
namespace WebApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class SuppliersController : ControllerBase
{
private DataContext context;
public SuppliersController(DataContext c)
{
context = c;
}
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSupplier(long id)
{
Supplier s = await context.Suppliers.Include(x => x.Products).FirstAsync(x => x.SupplierId == id);
if (s == null)
{
return NotFound();
}
foreach (Product p in s.Products)
{
p.Supplier = null;
}
return Ok(s);
}
.......
}
}
接下来,运行dotnet build,可以看到,警告提示已经没有了。重新运行程序,再次打开Swagger UI界面,可以看到,现在返回结果类型有了更新,列出了所有的可能的结果。
总结
接上文,本文讲述了使用ASP.NET Core创建web services的一些高级功能,包括如何处理Entity Framework Core查询中的关联属性,如何支持HTTP PATCH关键字来处理更新请求,内容协调机制的工作原理,以及如何使用OpenAPI来描述web service,这些对于创建一个可读的良好的web services具有重要意义。