ASP.NET Core 3.x RESTful API 学习笔记

ASP.NET Core 3.x RESTful API 学习笔记

什么是 REST


  1. Representional State Transfer(状态表述转换)
  2. 它描述了 Web 应用到底怎么样设计才算是优良的。这里定义了以下三点:
    • 一组网页的网络(一个虚拟状态机)
    • 在这些网页上,用户可以通过点击链接来前进(状态转换)
    • 点击链接的结果就是下一个网页(表示程序的下一个状态)被传输到用户那里,并渲染好给用户使用。

REST 是一种架构风格


  • REST 是一种架构风格,而不是规范或标准;
  • REST 需要使用一些规范、协议或标准来实现这种架构风格;
  • REST 与协议无关。JSON 并不是 REST 强制的,甚至 HTTP 都不是 REST 强制使用的,但这也仅仅是从理论上来看。

REST 的优点


  • 性能
  • 组件交互的可扩展性
  • 组件的可修改性
  • 可移植性
  • 可靠性
  • 可视性

REST 的约束


  1. 客户端-服务器
  2. 无状态
  3. 统一的资源接口/界面
    • 资源的标识
    • 通过表述来对资源进行操纵
    • 带有自我描述的信息
    • 超媒体作为应用程序状态的引擎(HATEOAS)
  4. 多层系统
  5. 可缓存
  6. 按需编码(可选约束)

Richardson 成熟度模型


  • Level 0,POX(Plain old xml)沼泽

POST(查询数据信息)
http ://host/myapi
POST (创建数据)
http ://host/myapi

URI路径混用,GET/POST混用,不区分查询还是创建

  • Level 1,资源

POST
http ://host/api/authors
POST
http ://host/api/authors/{id}

URI已经做了区分,但是方法并没有全用对
也没有返回状态码

  • Level 2,动词。

GET
http ://host/api/authors
200 Ok (authors)
POST (author representation)
http ://host/api/authors
201 Created (author)

各动词全都用对了,且状态码均正确返回了。
Level 2 虽然理论上还不能称为 RESTful,但其实已经够用了。

  • Level 3,超媒体。

GET
http ://host/api/authors
200 Ok (返回了 authors 和 驱动应用程序的超链接)

Level 3 表示实现了HATEOAS。

大部分 Web API 都不是 RESTful API

  • 根据 Roy Fielding 博士的描述,达到 Level 3 也仅仅是 RESTful API 的一个前提。

[ApiController] 注解


[ApiController] 这个注解是应用于 Controller 的,它其实并不是强制的。
它会启用以下行为:

  • 要求使用属性路由(Attribute Routing)
  • 自动 HTTP 400 响应
  • 推断参数的绑定源
  • Multipart / form-data 请求推断
  • 错误状态代码的问题详细信息

HTTP 动词


  • GET 获取资源
  • POST 创建 / 添加资源
  • DELETE 删除资源
  • PATCH 局部更新
  • PUT 替换 / 完全更新
    (PUT 可选:资源不存在即创建,类似 POST)

以上 HTTP 动作对于 CRUD 完全足够了,但是由于现实情况不只是增删改查,所以我们还是需要做出一定妥协。

HTTP 状态码


· 1xx

1xx 属于信息性的状态码,Web API 并不使用 1xx 的状态码。

· 2xx

2xx 意味着请求执行的很成功。

  • 200 - Ok,表示请求成功;
  • 201 - Created,请求成功并创建了资源;
  • 204 - No Content,请求成功,但是不应该返回任何东西,例如删除操作。

· 3xx

3xx 用于跳转。绝大多数 Web API 都不需要使用 3xx 状态码。

· 4xx

4xx 表示客户端错误。

  • 400 - Bad Request,表示 API 消费者发送到服务器的请求是有错误的
  • 401 - Unauthorized,表示没有提供授权或者授权信息不正确
  • 403 - Forbidden,表示身份认证已经成功,但是已认证用户却无法访问请求的资源
  • 404 - Not Found,表示请求的资源不存在
  • 405 - Method not allowed,表示请求的方法不被支持
  • 406 - Not Acceptable,表示 API 消费者请求的表述格式不被 Web API 所支持,并且 API 不会提供默认的表述格式。例如请求application/xml,而服务器只提供application/json
  • 409 - Conflict,表示请求与服务器当前状态冲突。例如,当你编辑某个资源的时候,该资源在服务器上又进行了更新,所以你编辑的资源版本和服务器的不一致。当然有时候也用来表示你想要创建的资源在服务器上已经存在了。它就是用来处理并发问题的状态码。
  • 415 - Unsupported media type,与406正好相反,有一些请求必须带着数据发往服务器,这些数据都属于特定的媒体类型,如果 API 不支持该媒体类型格式,415就会被返回。
  • 422 - Unprocessable entity,它是 HTTP 扩展协议的一部分。它说明服务器已经懂得了实体的 Content Type,也就是说 415 肯定不合适;此外,实体的语法也没有问题,所以 400 也不合适。但是服务器仍然无法处理这个实体数据,这时就可以返回 422。所以它通常是用来表示语义上有错误,通常就表示实体验证的错误。

· 5xx

5xx 表示服务器端错误。

  • 500 - Internal server error,表示服务器出现了错误,客户端无能为力,只能以后再试试了。

内容协商 Content Negotiation


  • 内容协商是这样一个过程,针对一个响应,当有多种表述格式可用的时候,选取最佳的一个表述。
  • 消费者在请求的时候,在 [Accept] Header 里设置媒体类型为 application/json 或者 application/xml等。
  • 若请求的媒体类型不被服务器所接受,则应返回 406 Not Acceptable
  • 服务器输出格式在 ASP.NET Core 里对应的是 Input Formatters
services.AddControllers(setup =>
{
    //如果服务器不能接受Accept Header,则返回 406 Not Acceptable
    setup.ReturnHttpNotAcceptable = true;
}).AddXmlDataContractSerializerFormatters();

Entity Model 与 面向外部的 Model


  • Entity Model
  • Entity Framework Core 使用的 Entity Model 是用来表示数据库里面的记录的。

  • 面向外部的 Model
  • 面向外部的 Model 则表示了要传输的东西。有时候叫做 dto,有时候叫做 ViewModel

Entity Model 和 面向外部的 Model 应该分开,这样可以加强程序的 Robust 性。

  • IActionResult 与 ActionResult
  • 尽量使用 ActionResult,这有助于书写 API 文档。

  • 使用 对象映射器 (AutoMapper)
  • 创建 Profiles 文件夹,添加 CompanyProfile 类继承于 Profile 类;

  • CompanyProfile 构造函数里写

CreateMap()
    .ForMember(
        dest => dest.CompanyName,
        opt => opt.MapFrom(src => src.Name));
  • 调用的时候只需要调用 Map 函数即可。
var companyDtos = _mapper.Map>(companies);
  • 下面是 EmployeeProfile 的构造函数代码
CreateMap()
    .ForMember(dest => dest.Name,
        opt => opt.MapFrom(src => $"{src.FirstName} {src.LastName}"))
    .ForMember(dest => dest.GenderDisplay,
        opt => opt.MapFrom(src => src.Gender.ToString()))
    .ForMember(dest => dest.Age,
        opt => opt.MapFrom(src => DateTime.Now.Year - src.DateOfBirth.Year));

处理故障(异常)


Startup.csConfigure 方法里写

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler(appBuilder =>
    {
        appBuilder.Run(async context =>
        {
            context.Response.StatusCode = 500;
            await context.Response.WriteAsync("Unexpected Error!");
        });
    });
}

HTTP HEAD


  • HEAD 和 GET 几乎是一样的
  • 只是有一点重要的不同:HEAD 的 API 不应该返回响应的 Body
  • HEAD 可以用来在资源上获取一些信息

只需要在 [HttpGet] 同方法加上 [HttpHead] 即可。

虽然 HttpHead 只需返回 Head 不需要返回 Body,但是 HttpHeadHttpGet 一样,需要执行完整个方法体,才能返回。

过滤和搜索


如何给 API 传递数据
  • 数据可以通过多种方式来传递给 API。
  • Binding source Attributes 会告诉 Model 的绑定引擎从哪里找到绑定源。
Binding source Attributes
Attributes Binding Source
[FromBody] 请求的 Body
[FromForm] 请求的 Body 中的 form 数据
[FromHeader] 请求的 Header
[FromQuery] Query string 参数
[FromRoute] 当前请求中的路由数据
[FromService] 作为 Action 参数而注入的服务
[ApiController]
  • 默认情况下 ASP.NET Core 会使用 Complex Object Model Binder,它会把数据从 Value Providers 那里提取出来,而 Value Providers 的顺序是定义好的。
  • 但是我们构建 API 时通常会使用 [ApiController] 这个属性,为了更好地适应 API 它改变了上面的规则。
[ApiController] 更改后的规则
  • [FromBody] 通常是用来推断复杂类型参数的。
  • [FromForm] 通常是用来推断 IFormFile 和 IFormFileCollection 类型的 Action 参数。
  • [FromRoute] 用来推断 Action 的参数名和路由模板中的参数名一致的情况。
  • [FromQuery] 用来推断其它的 Action 参数。
过滤
  • 过滤集合的意思就是根据条件限定返回的集合。
  • 例如我想返回所有类型为国有企业的欧洲公司。则 URI 为:
    GET /api/companies?type=State-owned®ion=Europe
  • 所以过滤就是指:我们把某个字段的名字以及想要让该字段匹配的值一起传递给 API,并将这些作为返回的集合的一部分。
搜索
  • 针对集合进行搜索是指根据预定义的一些规则,把符合条件的数据添加到集合里面。
  • 搜索实际上超出了过滤的范围。针对搜索,通常不会把要匹配的字段名传递过去,通常会把要搜索的值传递给 API,然后 API 自行决定应该对哪些字段来查找该值。经常会是全文搜索
  • 例如: GET /api/companies?q=xxx
过滤 vs 搜索
  • 过滤: 首先是一个完整的集合,然后根据条件把匹配/不匹配的数据项移除。
  • 搜索: 首先是一个空的集合,然后根据条件把匹配/不匹配的数据项往里面添加。

注意:过滤和搜索这些参数并不是资源的一部分。只允许针对资源的字段进行过滤。

安全性 和 幂等性


  • 安全性 是指方法执行后并不会改变资源的表述。
  • 幂等性 是指方法无论执行多少次都会得到同样的结果。
HTTP 方法 安全? 幂等?
GET
OPTIONS
HEAD
POST
DELETE
PUT
PATCH

(PATCH 不幂等,主要原因是:假设有一个 PATCH,提交是向一个数组类型插入数据,则多次运行就会多次插入,自然就不幂等了)

创建资源


虽然偶尔输出用的 Dto 和 输入用的 Dto 是一样的,但是最好不要共用一个 Dto 类。因为不知道以后需求更改之后,两个 Dto 还是否一致。

在使用 [ApiController] 修饰的 Controller 类里,不需要手动判定输入参数是否为 null 了。

心得:EF Core 会将所有名为 Id || {entityName}Id 的字段自动标记为主键,将声明为主键和外键均自动加上索引。如果主键是 Guid 类型,插入数据的时候,如果未提供主键,EF Core 会自动 new 一个Guid 作为主键。

自定义 ModelBinder

在 Helpers 文件夹下建立 ArrayModelBinder.cs:

public class ArrayModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (!bindingContext.ModelMetadata.IsEnumerableType)
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return Task.CompletedTask;
        }

        var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).ToString();
        if (string.IsNullOrWhiteSpace(value))
        {
            bindingContext.Result = ModelBindingResult.Success(null);
            return Task.CompletedTask;
        }
            
        var elementType = bindingContext.ModelType.GetTypeInfo().GenericTypeArguments[0];
        var converter = TypeDescriptor.GetConverter(elementType);

        var values = value.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries)
            .Select(x => converter.ConvertFromString(x.Trim())).ToArray();
        var typedValues = Array.CreateInstance(elementType, values.Length);
        values.CopyTo(typedValues, 0);
        bindingContext.Model = typedValues;
        bindingContext.Result = ModelBindingResult.Success(bindingContext.Model);
        return Task.CompletedTask;
    }
}

然后就可以这样用:

[FromRoute]
[ModelBinder(BinderType = typeof(ArrayModelBinder))]

HTTP OPTIONS


API 消费者如何知道某个 API 是否允许被访问?

答案是 HTTP OPTIONS

  • OPTION 请求,可以获取针对某个 Web API 的通信选项的信息。
[HttpOptions]
public IActionResult GetCompaniesOptions()
{
    Response.Headers.Add("Allow", "GET,POST,OPTIONS");
    return Ok();
}

输入验证 Data Annotations


验证三部曲

  • 定义验证规则
  • 按验证规则进行检查
  • 报告验证的错误。

定义验证规则

  • Data Annotations。例如 [Required][MaxLength] 等。
  • 自定义 Attribute。
  • 实现 IValidatableObject 接口。
验证什么?
  • 验证的是输入数据,而不是输出数据。

按验证规则进行检查

  • ModelState 对象是一个 Dictionary 字典,它既包含 model 的状态,又包含 model 的绑定验证信息。
  • 它也包含针对每个提交的属性值的错误信息的集合。每当有请求进来的时候,定义好的验证规则就会被检查。
  • 验证不通过 | 类型不正确:ModelState.IsValid 就会是 false

返回 422 Unprocessable Entity

然后在响应的 body 里面包含验证错误信息。
查看对应的标准 Validation Problem Details RFC,ASP.NET Core 内置了对这个标准的支持。

实现 IValidatableObject 接口

AddDto类 或者 ViewModel类 继承 IValidatableObject接口,并实现 Validate 方法,在里面进行验证。

public IEnumerable Validate(ValidationContext validationContext)
{
    if (FirstName == LastName)
    {
        yield return new ValidationResult("姓和名不能一样",
            new[] { nameof(FirstName), nameof(LastName) });
    }
}

自定义 Attribute

自定义 Attribute 可以针对类这个级别,也可以针对类的属性。

public class EmployeeNoMustDifferentFromFirstNameAttribute : ValidationAttribute
{
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var addDto = (EmployeeAddDto)validationContext.ObjectInstance;
        if (addDto.EmployeeNo == addDto.FirstName)
        {
            return new ValidationResult("员工编号和姓名不能一样", new[] { nameof(EmployeeAddDto) });
        }

        return ValidationResult.Success;
    }
}

自定义 Attribute 的 错误信息

要使用传入的 ErrorMessage,将上面改为:

return new ValidationResult(ErrorMessage, new[] ....

即可。

错误信息的报告

以下是 ASP.NET Core 自带的错误报告样式:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-218d293052d0aa4bb1e15ad93b892aac-5c8faea2430ec942-00",
    "errors": {
        "EmployeeAddDto": [
            "The field employee is invalid."
        ]
    }
}

其中 traceId 是可以在后台日志中查询到的一个标志。

有一个标准 RFC (7807)

  • Problem details for HTTP APIs RFC (7807)
    • 为所需错误信息的应用,定义了通用的错误格式
    • 可以识别出问题属于哪个 API

想要自定义错误信息报告,需要在 StartupConfigureService
service.AddControllers 后面加上 ConfigureApiBehaviorOptions

services.AddControllers(setup =>
{
    setup.ReturnHttpNotAcceptable = true;
}).AddXmlDataContractSerializerFormatters()
.ConfigureApiBehaviorOptions(setup =>
{
    setup.InvalidModelStateResponseFactory = context =>
    {
        var problemDetails = new ValidationProblemDetails(context.ModelState)
        {
            Type = "http://www.baidu.com",
            Title = "有错误!!!",
            Status = StatusCodes.Status422UnprocessableEntity,
            Detail = "请看详细信息",
            Instance = context.HttpContext.Request.Path
        };
        problemDetails.Extensions.Add("traceId", context.HttpContext.TraceIdentifier);
        return new UnprocessableEntityObjectResult(problemDetails)
        {
            ContentTypes = { "application/problem+json" }
        };
    };
});

其他验证方式

  • 还可以使用第三方的验证库 FluentValidation
    • 很容易创建复杂的验证规则
    • 验证规则与 Model 分离
    • 容易进行单元测试

整体更新/替换:PUT


更新分为两种:PUT vs PATCH

  • PUT 整体更新/替换
    资源所有的字段都被重写了,或者是设置为该字段的默认值。
  • PATCH 局部更新
    使用 JsonPatchDocument 发送变更的数据,对资源指定的字段进行更新
更新或新增
  • PUT 也可以这样用:在资源不存在的时候新增。

局部更新 PATCH


  • HTTP PATCH 是用来做局部更新的
  • PATCH 请求 Body 里面的数据格式为 JSON PATCH(RFC 6902)
  • PATCH 请求的 media type 是 application/json-patch+json
JSON PATCH Operations
  • Add
[{
  "op": "add",
  "path": "/biscuits/1",
  "value": {
    "name": "Ginger Nut"
  }
}]
  • Replace
[{
  "op": "replace",
  "path": "/biscuits/0/name",
  "value": {
    "name": "Chocolate Digestive"
  }
}]
  • Remove
[{
  "op": "remove",
  "path": "/biscuits"
},
{
  "op": "remove",
  "path": "/biscuits/0"
}]
  • Copy
[{
  "op": "copy",
  "from": "/biscuits/0",
  "path": "/best_biscuit"
}]
  • Move
[{
  "op": "move",
  "from": "/biscuits",
  "path": "/cookies"
}]
  • Test
[{
  "op": "test",
  "path": "/best_biscuit/name",
  "value": "Choco Leibniz"
}]

处理 Patch 的 Action

[HttpPatch("{employeeId}")]
public async Task PartiallyUpdateEmployeeForCompany(
    Guid companyId,
    Guid employeeId,
    JsonPatchDocument patchDocument)
{
    if (!await _companyRepository.CompanyExistsAsync(companyId))
    {
        return NotFound();
    }
    var employeeEntity = await _companyRepository.GetEmployeeAsync(companyId, employeeId);
    if (employeeEntity == null)
    {
        return NotFound();
    }
    var dtoToPatch = _mapper.Map(employeeEntity);

    patchDocument.ApplyTo(dtoToPatch, ModelState);
    if (!TryValidateModel(dtoToPatch))
    {
        return ValidationProblem(ModelState);
    }

    _mapper.Map(dtoToPatch, employeeEntity);
    _companyRepository.UpdateEmployee(employeeEntity);
    await _companyRepository.SaveAsync();
    return NoContent();
}

注意这里的 ValidationProblem 调用的是 ControllerBase 里写的默认方法,默认返回 400 Bad Request,而且也没有走上面 Startup.cs 里自定义的错误信息报告ConfigureApiBehaviorOptions 里的 InvalidModelStateResponseFactory

要返回自定义的 422,需要 Override 一下 ValidationProblem 方法

public override ActionResult ValidationProblem(ModelStateDictionary modelStateDictionary)
{
    var options = HttpContext.RequestServices.GetRequiredService>();
    return (ActionResult)options.Value.InvalidModelStateResponseFactory(ControllerContext);
}

删除资源 DELETE


正常写即可。

翻页


针对集合资源翻页
  • 集合资源的数量通常比较大

    • 需要对它们进行翻页查询
  • 能避免性能问题

  • 参数通过 QueryString 进行传递

    • api/companies?pageNumber=1&pageSize=5
  • 每页的笔数需要进行控制

  • 默认就应该进行分页

  • 应该对底层的数据存储进行分页

返回翻页信息
  • 应该包含前一页和后一页的链接
  • 其他信息:PageNumber,PageSize,总记录数,总页数…

以下是某些人的实现,

{
  "items": [{company}, {company}...],
  "pagination": {"pageNumber": 1, "pageSize": 5, "previous": ...}
}

这样做是没有问题的,但是:

  • 响应的 body 不符合请求的 Accept Header,这不是 application/json,它应该是一个新的 media type
  • 破坏了自我描述性信息这个约束:API 消费者不知道如何使用 application/json 这个 media type 来解释这个响应

所以,

  • 当使用 application/json 请求的时候,翻页的信息元数据并不应该是资源表述的一部分
  • 通常情况下,应该放在自定义的 Header里,通常叫 X-Pagination
实现自定义类
PagedList
  • CurrentPage, TotalPages, HasPrevious, HasNext
  • 可以复用
  • 再使用它来创建翻页信息
public class PagedList : List
{
    public int CurrentPage { get; private set; }
    public int TotalPages { get; private set; }
    public int PageSize { get; private set; }
    public int TotalCount { get; private set; }
    public bool HasPrevious => CurrentPage > 1;
    public bool HasNext => CurrentPage < TotalPages;

    public PagedList(List items, int count, int pageNumber, int pageSize)
    {
        TotalCount = count;
        PageSize = pageSize;
        CurrentPage = pageNumber;
        TotalPages = (int)Math.Ceiling(count / (double)pageSize);
        AddRange(items);
    }

    public static async Task> Create(IQueryable source, int pageNumber, int pageSize)
    {
        var count = await source.CountAsync();
        var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync();
        return new PagedList(items, count, pageNumber, pageSize);
    }
}

为资源排序


通常是这样写的:

  • api/companies?orderBy=companyName
  • api/companies?orderBy=companyName desc
  • api/companies?orderBy=companyName desc, id
问题:针对谁来排序?

应该是针对面向外部的 Model 来进行排序。

排序遇到的问题
  • 映射问题

    • 需要从Name → FirstName + LastName
  • 应用排序问题

    • 传入的是字符串,而 OrderBy() 参数是 lambda 表达式;
    • 需要写一堆 switch 进行判断;
    • 幸好有 System.Linq.Dynamic.Core 这个 Linq 扩展库。
  • 复用性

    • 我们不想针对每一个资源都写一堆排序的代码;
    • 所以我们考虑写一个针对 IQueryable 的一个扩展方法。
属性映射服务
  • 一个资源(DTO)的属性可以映射到 Entity 上面多个属性
    • Name → FirstName + LastName
  • 映射可能需要反转顺序
    • Age asc → DateOfBirth desc

思路:

  • PropertyMappingService: IPropertyMappingService
    • IList propertyMappings 例如 EmployeeDto : Employee
      • PropertyMapping: IPropertyMapping
        • Dictionary
          • PropertyMappingValue
            • DestinationProperties 例如 FirstName LastName
            • Revert 例如 true:Age → DateOfBirth
    • GetPropertyMapping() 例如从 EmployeeDto 到 Employee

你可能感兴趣的:(ASP.NET Core 3.x RESTful API 学习笔记)