用ASP.NET Core 2.0 建立規範的 RESTful API -- 建立Richardson成熟度2級的DELETE, UPDATE, PATCH 和 Log。 之前一篇文章講了POST和GET。 ...
本文所需的一些預備知識可以看這裡: 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