用ASP.NET Core 2.1 建立規範的 REST API -- 翻頁/排序/過濾等

solenovex發表於2018-06-07

本文所需的一些預備知識可以看這裡: http://www.cnblogs.com/cgzl/p/9010978.html 和 http://www.cnblogs.com/cgzl/p/9019314.html

建立Richardson成熟度2級的POST、GET、PUT、PATCH、DELETE的RESTful API請看這裡:https://www.cnblogs.com/cgzl/p/9047626.html 和 https://www.cnblogs.com/cgzl/p/9080960.html

本文需要的程式碼 (右鍵另存,把字尾改為zip):https://images2018.cnblogs.com/blog/986268/201806/986268-20180604151009219-514390264.jpg

本程式碼已經更新至ASP.NET Core 2.1. (從ASP.NET Core 2.0 遷移至 ASP.NET Core 2.1: https://docs.microsoft.com/en-us/aspnet/core/migration/20_21?view=aspnetcore-2.1)

 

本文主要介紹一些常見情況的實現,包括:集合更新、翻頁、排序、過濾等等。但是仍然是Richardson成熟度頂多為2級的Web API,未達到RESTful API的標準和約束。

 

集合的更新操作

 

看這種更新集合的情況,原來資料庫裡中國存了4個城市(北平,上海,盛京,海參崴);而幾個世紀後北平改名叫北京了,盛京名為瀋陽了,海參崴不屬於中國了就刪除了,威海從縣成為市就算是新增,而上海保持不變。現在就是要對中國的城市進行整體性的更新操作,裡面會包含:新增、刪除、更新操作。看程式碼:

集合更新,我一共分了三步進行的操作:

1. 把資料庫中存在的但是傳進來的資料裡沒有的城市刪掉

2. 把資料庫中沒有的而傳進來的資料裡有的資料進行新增操作,其實這裡只判斷id為0即可

3. 把資料庫中原有和傳進來的引數裡也存在的資料條目進行更新。

然後儲存即可。

先看一下原有的資料:

然後我們執行集合的更新:

執行之後,再次查詢:

集合按預期更新了。

我相信大家肯定會寫這段程式碼,或者有更簡單的實現方式(請貼出來)。但這不是重點,我看到有人這樣寫,把上面那三步程式碼寫在了AutoMapper的配置檔案裡:

首先,需要忽略Country的Cities屬性的對映操作,然後把那部分程式碼寫在AfterMap裡面即可,這樣在Action方法裡面就簡單了,可以使用Automapper了:

這只是一種可選的寫法而已,不一定就必須放在AutoMapper的配置檔案裡。

 

翻頁

翻頁可以避免一些效能問題,不必一次性載入所有資料。所以最好預設就採用分頁,而且每頁的條目數量必須有限制,不能太大。

分頁資訊應該使用查詢字串(query stringg)傳遞引數。格式應該這樣:

http://localhost:5000/api/country?pageIndex=12&pageSize=10

這裡我喜歡使用pageIndex這個詞,這也意味著頁數是從0開始的;當然很多人喜歡用pageNumber等詞,也就是說更喜歡頁數從1開始,這個其實隨意吧。

在ASP.NET Core裡,我要使用Linq來動態組建一個查詢的表示式(IQueryable<T>,可以建立表示式樹),它是延遲執行的,直到各種條件都判斷完了並組建出最終的查詢表示式之後才去執行(查詢資料庫)。這個查詢表示式只有在進行迭代的時候才會查詢資料庫。

觸發迭代動作可以使用下面的方法:

  • foreach 迴圈
  • ToList(), ToArray(), ToDictionary() 以及相應的非同步版本(ToXxxxAsync())
  • 單項查詢,例如 Average(), Count(), First(), FirstOrDefault(), SingleOrDefault()等等,以及相應的非同步版本。

需要確保的是要在迭代發生之前,使用Skip()和Take()以及Where()。

下面我一點一點來寫程式碼:

首先我們需要從引數(query string引數)傳進來pageIndex和pageSize,還要賦預設值,以防止API的消費者沒有設定pageIndex和pageSize;由於pageSize的值是由API的消費者來定的,所以應該在後端設定一個最大值,以免API的消費者設定一個很大的值。

由於所有的資源幾乎都要使用翻頁,所以我們最好使用一個公共類來封裝這些翻頁相關的資訊:

(我暫時把這個類放在了Core專案裡)

這個公共類很簡單,可以為pageIndex和pageSize設定預設值,也設定了一個每頁的最多條目數是100;這裡面還有一個OrderBy屬性,預設值是“Id”,因為翻頁必須要先排序,但目前這個OrderBy屬性還沒用上。

而針對具體的資源,我們可以再建立一個類繼承於PaginationBase,這個類就是Country的引數類:

由於暫時還沒有什麼特別的引數,所以裡面是空的。

下面我修改一下CountryRepository:

可以看到我組建了這個查詢的表示式,並且直接出發了迭代動作,返回查詢結果。

回到Action方法裡:

我使用了這個引數類代替了之前的pageIndex和pageSize引數,因為ASP.NET Core足夠智慧,可以把這兩個引數解析到這個類裡面。

下面測試一下:

我就不進行多次測試了,這個是好用的。

如果你是用的是關係型資料庫的話,應該可以在Log的輸出媒介上看到列印出的SQL語句(但我這裡使用的是記憶體資料庫,所以看不到),如果使用關係型資料庫還是看不到SQL語句的話,請配置一下:

返回翻頁的後設資料

很顯然只返回當前頁的資料是不滿足需求的,至少還需要返回總頁數,總數等資訊,還有可能需要返回前一頁或者後一頁的連結。但是如何把這些資訊連同當頁的資料一起返回給API消費者呢?

下面的做法是可以把這些資料都返回去的:

{
     “data”: [{country1}, {country2}...],
     “metadata”: {"prev": "/api/...", ....}    
}

但是這樣做的話就導致了響應的body不再符合Accept Header了(不是資源的JSON表述了),也就不是application/json了,而是一種新的media type。

所以如果返回這樣的資料就違反了REST的規則了(儘管本文程式碼的Richardson成熟度最多也就是2級),它違反了自我描述的約束(請參考本系列的預備知識文章),API消費者不知道如何通過application/json這個設定的contety-type來解釋響應資料了。

所以說翻頁的後設資料並不是資源表述的一部分。我們應該使用自定義的Header,例如“X-Pagination”來表述翻頁後設資料,這個名也是比較常用的。

首先,我建立一個類可以存放翻頁的資料:

可以向上面這樣做這個類:該類繼承於List<T>,同時還包含PaginationBase作為屬性,還可以判斷是否有前一頁和後一頁。使用靜態方法建立該類的例項。

這個靜態方法也許會有一點點問題,這裡沒有使用非同步方法,這樣做是OK的;但是如果使用非同步方法,例如source.CountAsync()和source.ToListAsync(),就會有一些問題,因為我需要修改CountryRepository的GetCountriesAsync方法的返回型別,改成上面這個型別,所以它的介面ICountryRepository也需要改;而它的介面是整個專案的核心並放在Core專案裡,而整個專案的核心(合約)我個人認為應該是和具體的ORM無關的,但是這裡依賴於EntityFrameworkCore了(ToListAsync())。所以我最後決定去掉這個靜態方法,這樣可能會導致多寫一些程式碼;此外還新增HasPrevious和HasNext屬性,判斷是否有前一頁和後一頁:

(暫時放在Core專案裡面了)

然後修改CountryRepository:

然後在Action方法裡,我們還需要生成前一頁和後一頁的URI,所以這裡可以使用UrlHelper,需要在Startup的ConfigureServices方法裡面註冊:

然後回到Controller裡面建立一個方法來生成URI:

在這裡我還建立了一個列舉,PaginationResourceUriType。我還為PaginationBase新增了一個Clone()方法,目的是建立出一個屬性值和它相同的另一個例項,因為這裡有修改pageIndex屬性這個操作;也許Clone不是最好的辦法,直接new可能更合適。

下面就是修改Action方法了:

通過之前的方法分別建立出兩個連結,然後把翻頁相關的資料組成一個匿名類,使用JSON.NET將其序列化,並放到響應的自定義Header:“X-Pagination”裡面。

而body部分還是資源的集合資料。

測試一下:

響應的body正常的返回來了,再看一下響應的Header:

可以看到自定義的X-Pagination Header了,然後我複製一下里面的NextPageLink連結,併傳送該請求:

都沒有問題。

這個Action目前的Richardson成熟度已經接近3級了(HATEOAS),但還不是。翻頁現在是到這,下面要進行過濾並翻頁。

 

過濾和搜尋

過濾的意思就是對集合資源附加一些條件然後篩選出結果,它的URI是下面的形式:

http://localhost:5000/api/countries?englishName=China

所以需要在查詢字串裡寫上屬性的名字和屬性的值來表示要按這個屬性的值來進行過濾,當然也可以寫多個過濾的條件。

過濾的條件是應用於ResourceModel(或叫做Dto,ViewModel),例如CountryResource,而不應用於其它級別的Model,因為API消費者只知道ResourceModel,它不知道內部實現的細節,也就是不知道EntityModel的樣子。

 

搜尋呢,是通過一個搜尋關鍵字來模糊的篩選集合資源,可能會有多個屬性針對這個關鍵字進行模糊篩選。

搜尋的URI大致是下面的形式:

http://localhost/api/countries?searchTerm=hin

 

上面這個URI可以理解為針對Countries資源,凡是字串型別的屬性,它的值包含hin的都符合條件,就返回符合這個條件的結果。

首先看一下過濾的實現。在Countries的GET Action方法裡,我使用CountryResourceParameters類作為引數,所以要增加針對某個屬性的過濾條件,只需擴充套件這個類即可,而增加的屬性名要和ResourceModel裡面的屬性名一致:

然後是修改CountryRepository裡面的方法:

首先要在執行分頁動作之前附加過濾條件,query的型別必須是IQueryable<Country>才可以動態組建查詢表示式,所以使用了AsQueryable()方法;然後分別判斷兩個條件並附加條件(注意大小寫問題和兩頭空格的問題),最後再執行分頁查詢。

由於新增了引數,所以CreateUri的方法也需要改:

這個方法引數變成了CountryResourceParameters,而且Clone方法克隆出來的也是CountryResourceParameters類:

下面測試:

沒有問題的,但是還要看看Header:

針對這個結果是OK的。

下面我做一些資料,使其擁有同樣的EnglishName,然後測試:

 

 OK,再看看Header:

使用NextLink再次傳送請求, 結果是OK的,我就不貼圖了。

但是你應該注意到,X-Pagination的屬性名不符合camelCase命名規範,所以需要在轉化成JSON的時候新增一些配置:

然後再測試一下:

屬性的命名符合camelcase規範了,但是previousLink和nextLink裡面的查詢字串的大小寫依然不正確,所以我乾脆去掉了Clone()方法,然後在CreateCountryUri的方法裡直接new出來新連結的引數:

測試:

現在命名終於符合規範了。

 

排序

之前做的翻頁都需要排序,暫時都是按照Id進行排序的。而實際上API消費者可能讓資源按照資源的某個屬性或多個屬性進行正向或反向的排序

我們先從最簡單的例子開始,只考慮只按照某一個屬性(針對的是資源的屬性,例如CountryResource的EnglishName)進行排序,針對這個例子,我先使用比較笨的方法。

首先我假定,引數類裡面的OrderBy屬性如果以" desc" 結尾,例如:“EnglishName desc”,那麼就是按照EnglishName倒序排列,而“EnglishName”就是正序排列。

只需在CountryRepository裡面修改程式碼即可:

 

嗯,很笨重的程式碼。

先測試一下:

至少功能是OK的,再看一下倒序:

也OK,所以雖然程式碼很笨重,但是針對這種簡單的情況是可以應付的。

下面我們對它進行第一次優化。像上面這樣挨個屬性的判斷實在是太費勁了,所以我們來分析一下,OrderBy的值是字串,而OrderBy()方法裡面的lambda表示式的型別是Expression具體的型別是Expression<Func<Country, object>>。這裡簡單講一下,萬一您不知道lambda表示式的話可以看一下。lambda表示式就是匿名的函式,它的型別是Func(可以賦值給Func型別的變數):

同時我們也可以把這個lambda表示式賦值給Expression:

而OrderBy()這個Linq方法接收的引數型別就是Expression<Func<Country, object>>。

使用Expression,我們可以構建Expression Tree;使用Expression Tree,可以表示一些邏輯。而在執行時,Linq的提供商將會解析這個Expression Tree,並把這些邏輯轉化為SQL語句:

再看上面的排序條件判斷,我們可以把OrderBy的字串和Expression對映起來,就像Key-Value 鍵值對那樣,這樣做也許就會是程式碼稍微好看一些。所以你肯定會想到Dictionary<K, V>。

所以修改後的程式碼如下:

我相信你能看懂,我就不解釋了,下面測試:

總之是好用的,我就不貼其他測試結果的圖片了。

應該把上面這段程式碼提取出來封裝成一個方法函式並泛型化,但是我暫時先不這樣做。

 

經過第一次優化,使用Dictionary,程式碼簡潔了許多,但是期間還是有手動把屬性名字串轉化為Expression的動作。之所以這麼寫是因為OrderBy僅支援Expression的引數型別,如果支援字串,那就完美了。

幸好有一個微軟的庫支援這種操作,它叫做System.Linq.Dynamic.Core(其作者是紅衣教主啊)

我把它安裝在了Infrastructure專案裡供Repository使用。

再次修改排序那部分的程式碼:

注意這裡OrderBy的名稱空間是:System.Linq.Dynamic.Core

經過第二次優化,程式碼已經很簡潔了,但是還有很多待完善的地方,例如:

  • Resource Model的一個屬性可能會對映到Entity Model的多個屬性上:Name 屬性通常會對映成EntityModel的 FirstName 和 LastName屬性
  • Resource Model上的正序可能在Entity Model上就是倒序的:Age 升序,而Entity Model的BirthDate就是降序
  • 需要支援多屬性的排序:EnglishName desc, Id, ChineseName。
  • 複用

 

第三次優化,要解決Model屬性對映引起的問題。

也就是說要從ResourceModel的一個屬性對映到Entity Model的一個或者多個屬性上,而且它們之間的排列順序可能是不同的,舉一個極端的例子:

假設ResourceModel 有個屬性叫做 Rank(排名) ,它所對映Entity Model的兩個屬性Result(成績)和Weight(體重);假設這是舉重比賽的Model,排名結果(Rank)是按照成績(Result)從高到低排序的,但是如果多名選手的成績相同,則體重輕的排名靠前。

也就是Rank asc -> Result desc, Weight asc。

用程式來說就是,一個字串“Rank asc”要對映成一個集合,而集合元素的型別有兩個屬性:Entity Model的屬性名和排序的方向。

所以先把集合裡這種元素的類建立出來:

這裡方向我是用的Revert這個單詞,表示其方向是否與Resource Model的屬性方向相反即可。

然後在做針對CountryResource的整套對映,不過首先我考慮建立一個抽象父類,裡面可能有些公用的東西:

由於Id這個屬性可能是每個相關的Model共有的,所以在這個父類裡,我新增了Id屬性的對映,Id是一對一的對映,排序方向相同。

然後我針對CountryResource,寫一個派生於PropertyMapping的子類:

注意紅框很重要,比較key的時候忽略大小寫。

到這裡,Resource和Entity Model之間對映的部分差不多做完了,接下來要考慮整個排序的問題,做這樣一個擴充套件方法:

它應用於IQueryable,並把orderBy字串和屬性對映表傳進來。

經過一些初步檢驗之後,把orderBy按“,”分解成欄位屬性的陣列。然後去掉兩邊可能存在的空格,判斷是否是倒序,提取出屬性的名稱。如果在對映表裡面找不到該名稱或者該名稱對應的值是空,那就丟擲異常。

然後先迴圈欄位陣列,然後內層迴圈該欄位對映的屬性集合。

最後通過DynamicLinq即可組建出所需的排序表示式。

使用DynamicLinq的OrderBy時要注意,排序條件必須反向附加,不信可以試試。

隨後我們修改一下Repository:

就剩下一句話了,很簡潔了。但是這裡需要new一個CountryPropertyMapping類,這樣做對單元測試就不友好了,也許把它放在一個容器裡取出來用更合適?

那麼就建立一個容器:

該容器的Register和Resolve分別用來註冊和提取對映表。

下面還有個檢查對映是否存在的方法,fields是一個或者多個欄位屬性組成的字串,其格式如“EnglishName,ChineseName”;它檢查是否能在對映配置表(MappingDictionary)找到相應的Key,如果找不到就驗證失敗。

這個容器在整個應用範圍內也是個容器,所以需要在Startup裡面註冊,由於它的程式碼可能比較多(因為本身它也是個容器,還有很多註冊內容用的程式碼),所以我單獨寫了個擴充套件方法:

該方法可以在Startup裡面呼叫,從而註冊到ASP.NET Core的服務容器裡:

然後再次修改CountryRepository:

先注入了該容器服務,然後從該容器中按照對映兩端的Model型別取出需要的對映表:

 測試:

看起來是OK的,那我們針對排序,暫時先優化到這裡。

 

排序的異常

還需要考慮到如果OrderBy裡面的欄位在對映表裡面不存在的情況,所以我使用這個方法來進行判斷:

我把這個方法放在了PropertyMappingContainer裡,因為PropertyMappingContainer本身實際上就是一個服務,放在裡還是比較合適的。

這裡需要注意的是fileds裡面的欄位可能是這種形式的“EnglishName desc”,所以需要把空格和desc部分去掉。

隨後在Action方法裡呼叫即可:

測試:

應該是沒問題的,我就不多測試了,以後要實行單元測試的。

 

資源塑形

如果某個資源的屬性比較多,那麼客戶端的API消費者可能只需要一部分屬性,這時就應該進行資料塑形,而且這樣做有可能會提升效能。

資料塑形要考慮兩種情況,集合資源單個資源

集合資源塑形

先考慮集合資源,首先我做一個擴充套件方法,把IEnumerable<T>可以轉化為IEnumerable<dynamic>,這裡要用到dynamic(ExpandoObject):

由於反射比較消耗資源,所以在這裡,我一次性把需要的屬性弄成PropertyInfo放到了一個集合裡。如果fields是空的,說明需要所有屬性,就把所有public和例項的property都放到集合裡,否則,就把需要的屬性放進去即可。

然後迴圈資料來源,使用反射通過PropertyInfo獲取該屬性的值,最後組成一個ExpandoObject,再把這個ExpandoObject放到結果集合裡面即可。

接下來修改引數類,因為這是個通用的東西,那就是為PaginationBase新增一個Fields屬性吧:

最後修改Action方法:

測試:

好用的。但是返回的資料並不是camelcase的,這是因為JSON.net序列化的ContractResolver並不適用於Dictionary。下面來處理這個問題。

開啟Startup,在services.AddMvc()後邊新增:

這句話就是配置了JSON轉化的ContractResolver。

在測試一下:

現在Ok了。

處理異常

但如果API消費者在Fields裡面提供了不存在的屬性,那麼就應該返回Bad Request。

原理上我也許可以使用ProperyMappingContainer裡面的驗證方法,但是資料塑形並不使用對映表。而且目的不同,一個是排序一個是資料塑形,所以因為關注分離吧(SoC)。

我們要做的就是給定一個Fields和一個型別,需要判斷Fields裡面包含的欄位屬性在這個型別裡面都存在,所以還是做一個Service比較好,可以注入使用。

看程式碼:

這個類比較簡單不多講了,別忘了在Startup裡面註冊。

然後在Controller裡面注入並使用,別忘了還需要修改CreateCountryUri方法:

測試:

OK.

對單個資源塑形

這個跟集合的原理差不多,先建立一個擴充套件方法:

再修改Action即可:

測試:

 

是好用的,我就不多測試了。

 

針對資料塑形需要注意的是,儘量把Id帶上,否則可能無法獲取相關的連結了。

 

今天先寫到這裡,還有很多更深入一點的功能沒有做,我就不做了。

到目前為止,這些Web API仍然稱不上是RESTful的API,成熟度不夠高,有些約束也沒達到。下一篇文章會把升級這些API以便支援HATEOAS。

程式碼在這:https://github.com/solenovex/ASP.NET-Core-2.0-RESTful-API-Tutorial

專案有一些檔案的拜訪目錄可能不對,暫時先不處理。

 

相關文章