用ASP.NET Core 2.0 建立規範的 REST API -- DELETE, UPDATE, PATCH 和 Log

solenovex發表於2018-05-29

本文所需的一些預備知識可以看這裡: 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測試,儘管這樣會造成看起來多寫了一些程式碼,但是考慮到軟體的質量以及更重要的後期維護,實際上這樣做是大大的節省了成本。綜上原因,我推薦使用第三方庫,FluentValidationhttps://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

 

相關文章