ASP.NET Core 3.x RESTful API 学习笔记
什么是 REST
- Representional State Transfer(状态表述转换)
- 它描述了 Web 应用到底怎么样设计才算是优良的。这里定义了以下三点:
- 一组网页的网络(一个虚拟状态机)
- 在这些网页上,用户可以通过点击链接来前进(状态转换)
- 点击链接的结果就是下一个网页(表示程序的下一个状态)被传输到用户那里,并渲染好给用户使用。
REST 是一种架构风格
- REST 是一种架构风格,而不是规范或标准;
- REST 需要使用一些规范、协议或标准来实现这种架构风格;
- REST 与协议无关。JSON 并不是 REST 强制的,甚至 HTTP 都不是 REST 强制使用的,但这也仅仅是从理论上来看。
REST 的优点
- 性能
- 组件交互的可扩展性
- 组件的可修改性
- 可移植性
- 可靠性
- 可视性
REST 的约束
- 客户端-服务器
- 无状态
- 统一的资源接口/界面
- 资源的标识
- 通过表述来对资源进行操纵
- 带有自我描述的信息
- 超媒体作为应用程序状态的引擎(HATEOAS)
- 多层系统
- 可缓存
- 按需编码(可选约束)
Richardson 成熟度模型
- Level 0,POX(Plain old xml)沼泽
POST(查询数据信息)
http ://host/myapi
POST (创建数据)
http ://host/myapiURI路径混用,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.cs
的 Configure
方法里写
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,但是HttpHead
与HttpGet
一样,需要执行完整个方法体,才能返回。
过滤和搜索
如何给 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
想要自定义错误信息报告,需要在 Startup
的 ConfigureService
,
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
- PropertyMappingValue
- Dictionary
- PropertyMapping
- GetPropertyMapping
() 例如从 EmployeeDto 到 Employee
- IList