接上一篇内容,这篇继续跟着《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浏览器对返回格式的喜好类型依次是:

  1. 最喜欢接收的格式为text/html、xhtml+xml、webapp、apng、q值没有写,默认q=1。
  2. 如果没有以上格式,其次喜欢接收的格式为xml、signed-exchange;v=b3 ,q=0.9。
  3. 如果以上格式全没有,那么接收任何其他格式,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具有重要意义。