本文所需的一些預備知識可以看這里: http://www.cnblogs.com/cgzl/p/9010978.html 和 http://www.cnblogs.com/cgzl/p/9019314.html
建立Richardson成熟度2級的POST和 GET的RESTful API請看這里:https://www.cnblogs.com/cgzl/p/9047626.html
之前一篇文章介紹了POST和GET,這篇要介紹建立Richardson成熟度2級的DELETE, PUT, PATCH.
本文需要用到的代碼(右鍵另存,后綴改為zip): https://images2018.cnblogs.com/blog/986268/201805/986268-20180524161857994-217513181.jpg
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定義驗證規則:
所有的驗證注解可以查看官方文檔:https://msdn.microsoft.com/en-us/library/system.componentmodel.dataannotations(v=vs.110).aspx
(這種方式比較簡單,但是把驗證和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:https://github.com/JeremySkinner/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相關的內容,所以這里就不再重復敘述了(https://www.cnblogs.com/cgzl/p/9019314.html)。
看我們之前寫的捕獲異常的代碼,在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(https://github.com/serilog/serilog)。
Nuget安裝:
提示安裝的依賴:
然后在Program.cs里使用擴展方法UseSerilog()使用Serilog即可,我就不做其它配置了:
Serilog支持把日志寫入到各種的Sinks里,可以把sink看做媒介(文件,數據庫等)。
我需要寫入到文件,那么就安裝:
Serilog的配置信息是這樣寫的,可以把它放到程序比較靠前執行的地方:
這里配置的意思是:全局最低記錄日志級別是Debug,但是針對以Microsoft開頭的命名空間的最低級別是Information。
使用Enruch.FromLogContext()可以讓程序在執行上下文時動態添加或移除屬性(這個需要看文檔)。
按日生成記錄文件,日志文件名后會帶着日期,並放到./logs目錄下。
這就是生成的日志文件:
注意使用了其它Log提供商之后,在它之前配置的Log提供商就不起作用了,所以控制台不輸出Log的異常信息了:
所以還是為Serilog添加一個控制台的Sink吧:
這樣控制台和文件的Log都可以輸出了:(注意windows下的命令行有時候會卡住,需要按一下回車才能繼續)
這次就寫到這里,下次寫一些翻頁和過濾的東西。
完成后的源碼:https://github.com/solenovex/ASP.NET-Core-2.0-RESTful-API-Tutorial