用ASP.NET Core 2.0 建立规范的 REST API -- DELETE, UPDATE, PATCH 和 Log
本文所需的一些预备知识可以看这里: 和
建立Richardson成熟度2级的POST和 GET的RESTful API请看这里:
之前一篇文章介绍了POST和GET,这篇要介绍建立Richardson成熟度2级的DELETE, PUT, PATCH.
本文需要用到的代码(右键另存,后缀改为zip):
DELETE 删除资源
这个很简单,以删除City为例:
首先查找Country,没找到就返回404 Not Found;然后查找City,没找到也返回 404 Not Found;如果找到了,删除保存的时候失败,则返回 500 Internal Server Error;如果删除成功,则不需要返回什么内容,返回204 No Content即可。
测试:
如果再次执行该请求的话,不出意外的会返回 404 Not Found:
DELETE并不具有安全性,因为在方法执行后会改变资源(把资源删除了)。
但是DELETE是具有幂等性的,这个你可能会有疑问,我执行多次DELETE后返回的状态码不一样为什么还具有幂等性。
之前我提过幂等性的简单定义,那个定义多少有点模糊,我们再来看一下幂等性定义里关键的一句话:“the side-effects of N > 0 identical requests is the same as for a single request”,意思是多次请求的副作用和单次请求的副作用是一样的。幂等性的核心概念可以理解为:"你可以发送多于一次的同样请求,但是不会对服务器造成额外的改变"。也就是说每次发送了DELETE请求之后,服务器的状态都是一样的。
一起删除主从资源
这种情况也很常见,在删除Country资源的同时,把它的子资源City也删掉。
这个很简单,由于EFCore做了很多工作,就不需要在删除主资源的时候手动去删除它所有的子资源了。
测试:
删除集合资源
DELETE "http://localhost:5000/api/countries",这个请求是合理的。但是确实很少这么做,因为这么做的破坏性还是挺大的。。。
PUT 更新资源
Put应该用来对资源的整体更新。
由于PUT是对资源的整体修改,请求body中应该带着更新对象,所以先建立这个对象:
本身City这个Model就只有两个字段,而id的应该作为路由的参数传递进来,所以在CityUpdateResource里面就不需要id属性了;如果有Id的话,你可能还要与路由参数里的id进行比较,如果不同会带来麻烦,所以这个对象里不带id。
这时你也可以发现CityUpdateResource和CityAddResource所含有的属性是一样的,那么为什么不使用同一个类型呢?因为这两个对象的目的不同,责任不同,一个类只应该有一个责任(SRP)。但是你可以使用某个父类把相同的属性抽取出去,然后分别继承,但是我就不这样做了。
下面看这个PUT的Action方法:
这个方法也很简单,其中有两点需要注意:怎么把传递进来的对象的所有属性值都传递给EFCore的Model?这里使用AutoMapper即可,上面红框的方法就是把第一个参数对象的属性映射到第二个参数对象上。
再有就是应该返回什么?我认为Ok和NoContent都是可以的,如果在Action的方法里某些属性的值是在这里改变的,那么可以使用Ok把最新的对象传递回去;但是如果在Action方法里没有再修改其它属性的值,也就是说更新之后和传递进来的对象的属性值是一样的,那就没有必要再把最新的对象传递回去了,这时就应该使用NoContent。
再看一下Repository里面:
注意这个是DbContext的方法而不是DbSet的方法,它会追踪city,然后把它的ModelState设置为Modified。
测试:
OK.
下面做另一个测试,如果body里面的对象缺少某些属性呢?(由于对象本身只有一个属性,我就传递一个无属性对象吧- -!):
操作结果依然是没问题的,使用GET反查一下:
name属性就变成了null,这不难理解,PUT是整体性更新,如果传递的参数对象缺少某些属性,那么这些属性的值就相当于是null,也会整体更新给Model。
由于这种原因,PUT用的就比较少,不可能为了更新对象中的一个属性而把对象所有的属性值都传递回去。
所以PATCH(局部更新)就应用的比较广泛了。
PUT不具有安全性,因为每次执行PUT都会改变资源。
但是PUT具有等幂性,这个很好理解,多次执行同一个PUT请求后,结果是一样的。
更新集合资源
跟删除集合资源一样,针对某个路由进行集合请求是合法的,但是这也意味着传进来的集合要整体代替原有的集合,也就是说原有集合里面的对象都应该删除,然后传进来集合的对象挨个再添加进去。但是这样的话是有副作用的,每次执行的结果其实是不一样的。此外这种集合更新也是具有较大的破坏性,所以一般不这么做。
更新或创建资源
我记得好像在使用老版本Entity Framework做种子数据的时候,经常使用一个扩展方法叫做AddOrUpdate(),也就是如果数据存在那就更新它,否则就创建它。
在REST API里,我们有时也会遇到这样的需求。我们暂时把这个方法叫做Upsert (Update + Insert) 。那么问题来了应该使用POST还是PUT呢?
PUT请求会发送到现有资源的URI上,如果资源不存在就返回404。
而POST用于创建资源,所以肯定不知道该资源的URI(是指GET的URI)。
但是如果API的消费者可以创建资源,那么,PUT请求可以被发送到一个暂时不存在的资源的URI上;如果资源不存在,那就创建它,否则就修改它。
所以感觉使用PUT作为Upsert的HTTP方法比较合适一些。
但是如果使用自增类主键Id的话,这种情况就不适合了。
下面我们假设City的Id不是自增的,那么我们可以这样修改一下Update方法:
由于我的例子主键是自增的,所以不适合Upsert。我就不测试了。
但是总体的思路就是这样,注意里面新增和修改返回的结果略有不同。
PATCH 局部更新资源
使用PUT最整体更新,缺点还是很明显的,所以我更多使用的是PATCH局部更新。
HTTP PATCH请求的body部分需要使用RFC 6902 (JSOn Patch)这个标准来进行描述。
而PATCH请求的media type应该设定为 "application/json-patch+json"。
PATCH请求的body是一个操作的数组:
这个例子里面有两个操作:
第一个是“replace”操作(op的值就是操作的类型),path代表着资源的属性名,value表示的是更新后的值。
第二个操作类型是“remove”,表示要删除资源的某个属性的值,例子里是name属性。
JSON PATCH的操作类型主要有六种:
- 添加:{“op”: "add", "path": "/xxx", "value": "xxx"},如果该属性不存,那么就添加该属性,如果属性存在,就改变属性的值。这个对静态类型不适用。
- 删除:{“op”: "remove", "path": "/xxx"},删除某个属性,或把它设为默认值(例如空值)。
- 替换:{“op”: "replace", "path": "/xxx", "value": "xxx"},改变属性的值,也可以理解为先执行了删除,然后进行添加。
- 复制:{“op”: "copy", "from": "/xxx", "path": "/yyy"},把某个属性的值赋给目标属性。
- 移动:{“op”: "move", "from": "/xxx", "path": "/yyy"},把源属性的值赋值给目标属性,并把源属性删除或设成默认值。
- 测试:{“op”: "test", "path": "/xxx", "value": "xxx"},测试目标属性的值和指定的值是一样的。
注意,path属性可能具有层级结构,而value属性也不必非得是字符串。
看下代码:
传递进来的body参数需要使用JsonPatchDocument<T>这个类型,在这里我把它叫做patchDoc。首先要把EFCore的City映射成CityUpdateResource,这样这个CityUpdateResource就有了该City在数据库里最新的属性值。然后通过patchDoc.ApplyTo()这个方法把patchDoc的操作依次附加给这个CityUpdateResource,这时候所有需要更新的值都体现在CityUpdateResource里了,而该对象其它的属性值则是数据库里的最新值,也就是不需要更新的值。最后再把它的值映射给EFCore的City,进行更新就可以了。最后EFCore做的操作肯定是整体更新,但是之前我们把最新值都放在CityUpdateResource里了,所以就相当于只做了局部更新。
测试:
请求的Content-Type应该是"application/json-patch+json",但是如果之写成application/json好像也可以。
结果:
(为了更好的测试,我又为City添加了Description属性)
下面remove的测试:
反查:
在测试一下多个操作:
结果就不看了,都是OK的。
PATCH用来局部更新或创建资源
可以修改相关代码来支持局部更新或创建资源的操作:
这个我就不测试了,自增Id不适合这种操作。
HTTP方法适用总结
常用的5中HTTP方法都介绍了,下面总结一下:
GET(获取资源):
- GET api/countries,返回200,集合数据;找不到数据返回 404。
- GET api/countries/{id}, 返回200,单个数据;找不到返回 404.
DELETE(删除资源)
- DELETE api/countries/{id},成功204;没找到资源 404。
- DELETE api/countries,很少用,也是204或者404.
POST (创建资源):
- POST api/countries, 成功返回 201 和单个数据;如果资源没有创建则返回 404
- POST api/countries/{id},肯定不会成功,返回 404或409.
- POST api/countrycollections,成功返回 201 和集合;没创建资源则返回 404
PUT (整体更新):
- PUT api/countries/{id}, 成功可以返回200,204;没找到资源则返回 404
- PUT api/countries,集合操作很少见,返回 200,204或404
PATCH(局部更新):
- PATCH api/countries/{id},200单个数据,204或者404
- PATCH api/countries, 集合操作很少见,返回 200集合,204或404.
验证
为了进行输入验证(不验证输出),我们需要做以下三方面工作:
- 定义验证规则
- 检查验证规则
- 把验证错误信息发送给API的消费者
之前的文章也提到的ASP.NET Core里面定义验证规则的方式:
- Data annotations 数据注解,就是那种在属性上面的中括号样式的属性标签
- 如何数据注解无法满足要求,则可以使用自定义的验证方式
- 可以自定义数据注解
- 也可以让被验证类实现IValidatableObject接口
- 也可以使用像FluentApi这样的第三方验证库
检查验证规则的方式:
- 使用 ModelState
- 它是一个字典,包含了Model的状态以及Model所绑定的验证
- 对于提交的每个属性,它都包含了一个错误信息的集合
- ModelState.IsValid(),如果出现任何一个错误,ModelState.IsValid属性就会变成false。
报告验证错误信息:
- 返回的状态吗应该是 422 Unprocessable Entity (上文讲过,422表示请求的格式没问题,但是语义有错误,例如实体验证错误)
- 除了状态码之外,还需要把验证错误信息在响应的body里面带回去
为EFCore的Model添加约束
我之前还没有为EFCore的model添加约束,这里我添加上(由于我使用的是内存数据库,所以下面的约束是不起作用的,这些约束只有在关系型数据库才起作用):
对于EFCore的实体约束和验证,我不愿意使用注解的方式(因为Model类应该只干自己的活),更喜欢使用fluent api。
然后把这两个类添加到DbContext里面的OnModelCreating方法里即可:
虽然上面的代码对内存数据库没有用,但是我还是添加上吧。
如果一个HTTP请求造成了EFCore model的验证失败,如果返回500的话,感觉就不太正确。因为如果是500错误的话,就意味着是服务器出现了错误,而这实际上是API消费者(客户端)提交的数据有问题,是客户端的错误。所以返回的状态码应该是 4xx 系列。
此外,目前这些验证规则是处于EFCore 的实体上的,而报告给API消费者的验证错误信息应该定义在Resource这一层面上,所以下面就为Resource model定义验证规则:
所有的验证注解可以查看官方文档:
(这种方式比较简单,但是把验证和Model混合到了一起,所以很多人还是不采用这种方式的)。
验证规则定义完了,下面来实施规则检查。这时就需要使用ModelState了。
每当请求进入到这个方法的时候,都会验证我们刚刚定义在Resource上的这些约束,如果其中一个约束没有达标,则ModelState的IsValid属性就会是false;此外如果传进来的属性类型和定义的不符,IsValid属性也会是false。
这里返回状态码 422 是正确的选择,但是 422 要求请求的body的语法必须是正确的,不能是null,所以前面检查是否为null的代码还需要保留。
由于ASP.NET Core并没有内置的帮助方法可以返回422和验证错误信息,所以我们先建立一个类用于返回 422 和验证错误信息,它继承于ObjectResult:
其中的SerializableError定义了一个可以被串行化的容器,该容器可以以Key-Value对的形式来保存ModelState的信息。
回到CityController的POST的Action方法,只添加这部分代码即可:
下面进行测试:
可以看到验证的错误信息都按预期返回了。
再试试另外一组测试:
下面考虑下如果据注解无法满足验证要求的情况,这时就需要写自定义的验证。
之前文章讲过,有几种方法可以写自定义验证逻辑:
- 自定义验证属性标签(数据注解),编写一个继承于ValidationAttribute的类
- 让Resource类实现IValidatableObject接口
- 使用FluentValidation以及类似的第三方库
- 直接在方法里写验证逻辑
我比较倾向于后两种方法,尤其是第三种。但是由于本文主要是讲RESTful API相关的,所以我先避免过多的使用第三方库,我暂时先采用第四种方法。
假设我要求City的name属性值不可以是“中国”:
这里要用到ModelState的AddModelError方法。
测试:
OK.
下面看一下PUT的验证。
大部分情况下,PUT的验证可能和POST是一样的,但是有时还是不一样的,所以分别写两个ResourceModel对应POST和PUT的优势就体现出来了。
但是这两个类的大部分代码还是一样的,所以可以采取使用抽象父类的方法来去掉重复的代码,建立CityResource:
注意属性一定要使用virtual关键字,因为在子类里我们可能会重写属性。
在这里我把Description的Required约束去掉了。
再看CityAddResource:
继承抽象类即可,属性和验证完全一样。
再看CityUpdateResource:
这里,我对Description属性添加了Required约束,而其它约束和父类保持一致。
最后修改PUT的Action方法:
测试,POST:
OK。
再测试PUT,尤其是Description属性:
子类里Description的约束进行了检查。
再测试父类里Description的约束:
OK, 说明子类里Description的约束和父类里Description的约束都起作用。
在子类CityUpdateResource里,还可以这样写:
这样或许更清晰。
到目前为止,我使用的是数据注解的方式来为ResourceModel添加验证规则,这样做其实不是很好,没有关注点分离(Soc,Seperation of Concerns)。
而且,我们的自定义验证代码也是到处重复的写,这样也不对。
所以尽管数据注解看起来很简单,少写了一些代码,但是开发软件应该更加注重可维护性,要尽量遵循那些设计原则,适当使用设计模式,写单元测试和E2E测试,尽管这样会造成看起来多写了一些代码,但是考虑到软件的质量以及更重要的后期维护,实际上这样做是大大的节省了成本。综上原因,我推荐使用第三方库,FluentValidation:。
使用FluentValidation
安装FluentValidation,可以通过Nuget,Package Manager Console 或者 .net cli:
直接安装这个就可以:
然后会自动安装依赖的库:
把那些ResourceModel的数据注解验证约束都去掉,把Controller里面自定义验证的代码也去掉,然后为每一个类添加一个验证器Validator:
首先是Country的,这个简单:
其中大括号里面的字符串是参数(占位符),{PropertyName}就是属性的名字如果使用了WithName()方法,那就是WithName里面设定的别名;{MaxLength}就是指设定的最大长度约束的值。有很多这种占位符,还是需要看官方文档。
下面看看City相关的验证,这里有个继承的关系,首先是把共有的验证提取出来作为父类:
这里使用泛型比较好。
然后CityUpdateResource:
由于父子关系,父类的构造函数先执行,然后执行CityUpdateResourceValidator的构造函数。
最后还要为ASP.NET Core配置FluentValidation,在Startup的ConfigureServices方法里:
首先使用扩展方法AddFluentValidation();然后为每一个Resource Model 配置验证器。如果你不想挨个添加配置验证器的话,可以使用:
来把某个Assembly里的验证器全部添加进来,但是我还是比较喜欢一个一个写,重构的时候有什么错误能立即发现,但是也容易忘记添加。
然后测试一下,效果和之前是一样的。
使用FluentValidation,做到了很好的分离,我个人感觉非常好,虽然多写了些代码,但是更灵活,也更易于维护。
PATCH的验证
PATCH与POST和PUT的验证稍微有一点不同,首先看一个例子,删除一个不存在的属性的值:
这个会导致返回500错误,这是不对的。
这时,可已使用patchDoc.ApplyTo的一个重载方法,它可以接受ModelState作为参数,所以patchDoc里面有任何验证错误都会在ModelState里面体现出来,(注意是PatchDoc的验证错误而不是CityUpdateResource):
然后重新测试:
我之前已经设定了CityUpdateResource的Description属性是必填的,那我再做一个PATCH测试,把该属性的值去掉(设为null):
它返回了 204, 也就是说被成功的执行了,那么肯定是有些地方没有做约束检查遗漏了。
因为我们只检查了patchDoc,而没有检查手动建立的那个CityUpdateResource(cityToPatch),所以这里可已使用TryValidateModel(xx),来手动检查cityToPatch:
测试:
这次OK了。
Log
在预备知识文章里,我已经介绍了Log相关的内容,所以这里就不再重复叙述了()。
看我们之前写的捕获异常的代码,在Startup的Configure方法里:
现在的代码是为API的消费者返回了500状态码,并返回了一些错误信息。这样做我们就把异常信息给丢掉了,但是又不应该把异常信息传递给API消费者,而我们确实需要这个异常信息,所以我们把异常记录到日志。
有多种方式可以得到Logger,这里我使用ILoggerFactory:
然后在Configure方法里面相应的位置创建Logger并记录日志:
整个应用的日志还是做分类比较好,这里我使用LoggerFactory的CreateLogger方法创建了Logger,其分类是“Global Exception Logger”。
这里使用了500作为Log的EventId比较合适,毕竟是500错误。
我认为可以把Action里面返回500状态码的部分改成抛出异常。
然后我修改一下PATCH,以便能抛出一个异常:
测试:
异常被正常的抛出,在看一下控制台的Log:
Log信息也被正确的打印。
下面在看看如何在Controller里面记录日志,首先注入Logger:
ILogger<T>,T就是日志分类的名字,这里建议使用Controller的名字。
然后在Action里正常记录日志就可以了:
就不测试了。
使用Serilog
在实际应用中只把日志记录到控制台或Debug窗口是没用的,最好的办法还是记录到文件或者数据库等。
支持ASP.NET Core的第三方Log提供商有很多,NLog,Serilog等等。这里我使用Serilog()。
Nuget安装:
提示安装的依赖:
然后在Program.cs里使用扩展方法UseSerilog()使用Serilog即可,我就不做其它配置了:
Serilog支持把日志写入到各种的Sinks里,可以把sink看做媒介(文件,数据库等)。
我需要写入到文件,那么就安装:
Serilog的配置信息是这样写的,可以把它放到程序比较靠前执行的地方:
这里配置的意思是:全局最低记录日志级别是Debug,但是针对以Microsoft开头的命名空间的最低级别是Information。
使用Enruch.FromLogContext()可以让程序在执行上下文时动态添加或移除属性(这个需要看文档)。
按日生成记录文件,日志文件名后会带着日期,并放到./logs目录下。
这就是生成的日志文件:
注意使用了其它Log提供商之后,在它之前配置的Log提供商就不起作用了,所以控制台不输出Log的异常信息了:
所以还是为Serilog添加一个控制台的Sink吧:
这样控制台和文件的Log都可以输出了:(注意windows下的命令行有时候会卡住,需要按一下回车才能继续)
这次就写到这里,下次写一些翻页和过滤的东西。
完成后的源码:
推荐阅读
-
用ASP.NET Core 2.0 建立规范的 REST API -- 预备知识
-
用ASP.NET Core 2.1 建立规范的 REST API -- 缓存和并发
-
用ASP.NET Core 2.0 建立规范的 REST API -- DELETE, UPDATE, PATCH 和 Log
-
用ASP.NET Core 2.0 建立规范的 REST API -- GET 和 POST
-
用ASP.NET Core 2.0 建立规范的 REST API -- DELETE, UPDATE, PATCH 和 Log
-
用ASP.NET Core 2.1 建立规范的 REST API -- 缓存和并发
-
用ASP.NET Core 2.0 建立规范的 REST API -- 预备知识
-
用ASP.NET Core 2.0 建立规范的 REST API -- DELETE, UPDATE, PATCH 和 Log
-
用ASP.NET Core 2.0 建立规范的 REST API -- DELETE, UPDATE, PATCH 和 Log
-
用ASP.NET Core 2.0 建立规范的 REST API -- GET 和 POST