本文主要介紹一些常見情況的實現,包括:集合更新、翻頁、排序、過濾等等。但是仍然是Richardson成熟度為2級的Web API,未達到RESTful API的標準。 本代碼已經更新至ASP.NET Core 2.1. ...
本文所需的一些預備知識可以看這裡: 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
項目有一些文件的拜訪目錄可能不對,暫時先不處理。