用ASP.NET Core 2.1 建立规范的 REST API -- 翻页/排序/过滤等
本文所需的一些預備知識可以看這里:??用ASP.NET Core 2.0 建立規范的 REST API -- 預備知識?和??用ASP.NET Core 2.0 建立規范的 REST API -- 預備知識 (2) + 準備項目
建立Richardson成熟度2級的POST、GET、PUT、PATCH、DELETE的RESTful API請看這里:?用ASP.NET Core 2.0 建立規范的 REST API -- DELETE, UPDATE, PATCH 和 Log
本文需要的代碼 (右鍵另存,把后綴改為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個城市(北平,上海,盛京,海參崴);而幾個世紀后北平改名叫北京了,盛京改名為沈陽了,海參崴不屬于中國了就刪除了,威海從縣成為市就算是新增,而上海保持不變?,F在就是要對中國的城市進行整體性的更新操作,里面會包含:添加、刪除、更新操作??创a:
集合更新,我一共分了三步進行的操作:
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
項目有一些文件的拜訪目錄可能不對,暫時先不處理。
相關文章:
用ASP.NET Core 2.0 建立規范的 REST API -- 預備知識
用ASP.NET Core 2.0 建立規范的 REST API -- 預備知識 (2) + 準備項目
用ASP.NET Core 2.0 建立規范的 REST API -- GET 和 POST
用ASP.NET Core 2.0 建立規范的 REST API -- DELETE, UPDATE, PATCH 和 Log
原文地址: http://www.cnblogs.com/cgzl/p/9117448.html
.NET社區新聞,深度好文,歡迎訪問公眾號文章匯總 http://www.csharpkit.com
總結
以上是生活随笔為你收集整理的用ASP.NET Core 2.1 建立规范的 REST API -- 翻页/排序/过滤等的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 在ASP.NET Core中使用brot
- 下一篇: 用ASP.NET Core 2.1 建立