目錄
- 概述
- 功能介紹
- 程式結構
- 伺服器端介紹
- 用戶端介紹
- “契約”
- Web API設計規則
- 並行寫入衝突與時間戳記
- 身分識別驗證詳解
- Web API驗證規則
- 用戶端MVVM簡介
- Web.Config
- 本DEMO的一些問題
- 相關下載
概述
我之前寫的一些關於ASP.net Web API的部落格中,得到了一些朋友的反響,我一直也想整理下代碼貼出來供大家參考,但後來發覺從整個項目工程中單獨把一部分代碼剝離出來還真是不容易,一轉眼就把這個事情忘記了,最近終於下定決心弄一弄,於是才有了此文,本DEMO雖然不完美,但已經包括了我目前所掌握的全部的關於WEB API的相關技術,至於有哪些地方還需要改進的,我會在文章末尾一一指出,由於Web API的伺服器端是沒有介面的,這樣不容易示範,所以我還提供了一個用WPF寫的用戶端,先一睹為快:
功能介紹
下面是這套程式所要示範的相關的技術或功能的介紹:
伺服器端:
- 完整的,代碼結構精良的(至少我這麼認為)ASP.net Web API的伺服器端
- 使用DataAnnotations進行Model驗證
- 自訂Model驗證
- 分層(分開UI層和商務邏輯層)
- 優異的日誌記錄方式
- 安全係數很高的身分識別驗證(好吧,我這麼寫是為了用激將法來引出高手給我挑挑毛病)
- 敏感資訊加密
- 純Web API代碼(去除了css/js及不需要的視圖引擎)
關於身分識別驗證的思路,可以參考《如何?RESTful Web API的身分識別驗證》
用戶端:
- 自行設計的MVVM簡易架構
- 大量的WPF實用技巧
- 使用DataAnnotations進行用戶端Model驗證
- HttpClient的完整樣本
其它:
- 自動對象映射(使用AutoMapper)
- 獨立運行,零配置,用Visual Studio 2010開啟即用
- 我已經盡量減少重複代碼……(其實做得還是不太夠)
- 二進位檔案的上傳和下載
程式結構
本程式主要目的是做一個文檔齊全,功能比較全面和零配置的demo,所以不涉及到DBMS的使用,儘管真正使用的時候DBMS幾乎是必須的,但這次我就用一個XML來代替DBMS了。
程式分為兩個部分,一是伺服器端,另一是用戶端,而且是分開成兩個不同的solution,這樣做完全是為了方便調試。但這樣帶來的問題是會產生一些重複的東西,比如這三個庫:CommLib,WebApiKit,WebApiContract,它們是公用庫,但又分別存在於不同的solution中,我在實際工作中是用了SVN這種工具來避免它們“改了這個忘了那個”的,而這次我用了一個自己很久以前寫的工具來讓它們“同步”:
這工具也會在本文後面提供下載。
伺服器端介紹
伺服器端的檔案結構
BLL - 商務邏輯層
UserInfo_BLL.cs - 就是使用者資訊類,尾碼“BLL”表示它屬於商務邏輯層,我習慣這樣區分各個層面不同的Model
UserManager.cs - 商務邏輯層的主類,提供各種“增刪查改”的方法
CommLib - 公用庫,包括DES加密類,MD5類,日誌類,一些Regex,全域常量等等……
Server - ASP.net Web API的主工程
AutoMapperConfig.cs - 自動對象映射的配置類,比如將UserInfo_BLL直接轉為UserInfo_API_Get,而不需要一個一個屬性地賦值
WebApiConfig.cs - ASP.net Web API的路由配置類
AvatarsController.cs - 頭像的擷取、修改和刪除
EntranceController.cs - 登入並擷取自己的資訊
PasswordController.cs - 修改自己的密碼
UsersInfoController.cs - 擷取單個使用者資訊、擷取使用者列表、修改使用者資訊、增加使用者(未實現)和刪除使用者(未實現)
ModelValidationFilter.cs - 針對所有請求的全域Model驗證過濾器
WebApiAuthFilter.cs - 針對絕大部分(不排除有些地方不需要身分識別驗證)的Controller的身分識別驗證器
WebApiExceptionFilter.cs - 全域異常處理器
WebApiRoleFilter.cs - 針對某些Action的角色許可權過濾器,比如某些動作只能管理員來做
GuidSet.cs - 用於防止重發攻擊的Guid集合協助類
WebApiPrincipal.cs - 登入使用者的身份類
GlobalServerData.cs - 裡麵包括一個靜態GuidSet
Managers.cs - 裡麵包括一個靜態UserManager
WebApiContract - 就是用這個庫來跟用戶端“磋商”的
WebApiKit - 用戶端/伺服器端都能用到的一些工具
用戶端介紹
用戶端的項目結構圖:
Client - 用戶端的主工程
PasswordHelper.cs - 密碼控制項的協助類,用於將密碼控制項的密碼文本綁定到View Model,WPF出自於安全的需要預設不提供這種綁定支援
UIVisibleConverter.cs - 一些WPF介面用的轉換器,用於根據View Model的一些屬性來控制介面元素的顯示與隱藏
ChangePassword_VM.cs - 修改密碼介面用的View Model,尾碼“VM”就是View Model的意思。
Login_VM.cs - 登入介面用的View Model。
UserInfo_VM.cs - 主介面上顯示/修改使用者資訊用的View Model。
ViewModelBase.cs - 所有的View Model的基類,實現了INotifyPropertyChanged介面、IDataErrorInfo介面和一些協助方法
“契約”
契約(Contract)這個詞其實來自於Web Service,但Web Service是一套很重量級的技術,我個人並不不喜歡它。其實契約簡單地說,就是:Web API如何用?契約中應該包括:調用地址是什麼,方法是什麼,有那些內容,有什麼驗證。以UserInfo_API_Put為例:
public class UserInfo_API_Base { [Required(ErrorMessage = Verifier.ERRMSG_CANNOT_BE_NULL)] [RegularExpression(Verifier.REG_EXP_CHINESE_NAME, ErrorMessage = Verifier.ERRMSG_REG_EXP_CHINESE_NAME)] public string RealName { get; set; } //真實姓名 public float Height { get; set; } //身高 public DateTime Birthday { get; set; } //生日 } //修改使用者資訊(普通使用者只能修改自己的資訊) //PUT api/usersinfo/{username} public class UserInfo_API_Put : UserInfo_API_Base { [EnuValueValidator(RoleType.ADMINISTARTOR, RoleType.NORMAL)] public string Role { get; set; } //角色Administrator, Normal, 普通使用者無法修改此欄位 }
如要修改“guogangj”這個使用者的資訊,那就往“api/usersinfo/guogangj”這個uri地址put這麼一個對象,其中RealName這個屬性不得為空白,還必須是2-10個中文字元,當然了,Height和Birthday也都不可為空白,因為float型和DateTime型都是不可空的類型,Role屬性則要執行一個自訂的驗證,確保其值必須為“Administrator”或“Normal”。
這樣的契約必須同時被伺服器端和用戶端所理解,所以做成了一個類庫的形式,伺服器端和用戶端都引用這個類庫,這樣做的最大的問題就在於這個類庫發生了變動的情況下,更新了一邊卻忘了另一邊,我目前是用一些工具來盡量避免這種情況的發生的,比如SVN的Externals參數設定。對此,各位高人有什麼更好的方法?希望能分享一下。
Web API設計規則
儘管在《對RESTful Web API的理解與設計思路》中,我已經提了一下Web API的“法則”,這裡再老調重彈外加幾句補充吧。
RESTFul的核心內容是“R”,也就是資源,我們把對資源的增刪查改具體化為HTTP的四個動作:POST、DELETE、GET和PUT。現在有這麼個問題:假如我的使用者名稱是guogangj,我要擷取我的資訊,是“GET /api/myinfo”呢,還是“GET /api/usersinfo/guogangj”呢?從技術上來說都沒問題,現在關鍵是要從“資源”的角度考慮,如果你認為“/api/myinfo”是一個資源,那就意味著每個使用者對這個資源的GET會得到不同的結果,而對於“/api/usersinfo/guogangj”這樣的資源,不管是誰,擷取到的內容應該是一致的(如果有許可權擷取的話),從這個角度看,“/api/usersinfo/guogangj”這種方式更加RESTFul,這是我的理解,不一定正確,還有請高手的分析。
並行寫入衝突與時間戳記
在對資源進行PUT和DELETE動作的時候,需要對其進行並行寫入衝突檢查,因為寫入的時候,資源可能已經被別人動過,這個檢查通常是用一個“時間戳記”來實現的,我使用的是DateTime類型的Ticks,這是一個long類型,足夠反映出資源發生變動的時間了。例如我現在要對使用者guogangj的資訊進行修改:
PUT http://localhost.:57955/api/usersinfo/guogangj?UpdateTicks=635054404507843749
{"Role":"Administrator","RealName":"蔣國綱","Height":1.67,"Birthday":"1981-11-12T00:00:00"}
也許仔細的你注意到了,localhost後面貌似多了個“.”,這是為了讓Fiddler能夠捕捉到這個http包而加的。
我會在URI中帶上UpdateTicks參數,伺服器端的商務邏輯層在執行Update的時候,會判斷這個時間戳記和現在資料庫裡的時間戳記是否一致,如果不一致,則拋出並行寫入衝突的異常。
我把UpdateTicks放在URI中的理由是:這個UpdateTicks也可以算是資源的一部分。例如對於上面這個PUT動作,我的意圖是:我要更新時間戳記為“635054404507843749”的“/api/usersinfo/guogangj”這個資源,如果它的時間戳記不是“635054404507843749”,那就不是我要更新的資源。
這是我的方法,另一種我能想出的辦法是把時間戳記放在HTTP頭中,如:
PUT http://localhost.:57955/api/usersinfo/guogangj
UpdateTicks:635054404507843749
{"Role":"Administrator","RealName":"蔣國綱","Height":1.67,"Birthday":"1981-11-12T00:00:00"}
這樣伺服器端在處理的時候一樣可以取出時間戳記,只不過方法稍有些不同,那種更好呢?就我個人而言,是偏向於前者,這裡也請高手指教一下。
身分識別驗證詳解
好吧,終於到重頭戲了,那就是Web API的身分識別驗證,為了使大家馬上有個直接的瞭解,我用Fiddler截取一個包,看看我每次請求到底發了些什嗎?
PUT http://localhost.:57955/api/usersinfo/guogangj?UpdateTicks=635054404507843749 HTTP/1.1
Custom-Auth-Name: guogangj
Custom-Auth-Key: 58E595EC40A74FF4EEF0856D7E59018F6141E12EA3DB965F74B416A4DFDB5746E6DCFDEDBDF5DA0C524254763FEE207B1FA8EF6D948132DF45C9C89AA7BF3A7373C509687C03BDE5
Accept: application/json
Content-Type: application/json; charset=utf-8
Host: localhost.:57955
Content-Length: 94
Expect: 100-continue
{"Role":"Administrator","RealName":"蔣國綱","Height":1.67,"Birthday":"1981-11-12T00:00:00"}
這是一個完整的HTTP請求,在HTTP頭中多了這麼兩個東西:“Custom-Auth-Name”和“Custom-Auth-Key”,Custom-Auth-Name不用說,一看就知道是User ID,表示發起人是誰,但如果他說自己是誰伺服器就認為他是誰的話,那就沒有任何安全可言了,所以還要Custom-Auth-Key(下面簡稱Key)這個東西來驗證一番,這個Key是長長的一串東西,這是經過加密和轉碼後的文本,下面說說這個Key是怎麼來的。
在WebApiKit這個庫中有這麼一個方法:WebApiClientHelper.MakePrincipleHeader,代碼全在裡面,不多,我一一解釋:
private static void MakePrincipleHeader(HttpRequestMessage reqMsg, string strUri){ //即便是一模一樣的請求內容,我也希望產生不同的key,所以每次都需要產生一個新的GUID,防止“重發”用的也是這個GUID,用這個GUID使得每次請求(不管URI和內容是否一樣)都是唯一的,不可複製和重複的 Guid guid = Guid.NewGuid(); //擷取有效URI,如這個請求的這一長串的URI擷取到的內容是“/api/usersinfo/guogangj” strUri = InternalHelper.GetEffectiveUri(strUri); //有效URI連上GUID,進行一次MD5加密,(用這種方法來獲得長度一致但每次都截然不同的內容)再連上GUID,這個結果作為對稱式加密的明文 string strToEncrypt = Md5.MD5Encode(strUri + guid) + " " + guid; //純文字密碼執行兩次MD5之後作為對稱式加密的密鑰,加密前面產生的那一串“明文”,好吧,Key就這樣產生了 string strTheAuthKey = Des.Encode(strToEncrypt, Md5.MD5TwiceEncode(Password)); //將結果加入到HTTP請求的Header中去 reqMsg.Headers.Add(Consts.HTTP_HEADER_AUTH_USER, UserName); reqMsg.Headers.Add(Consts.HTTP_HEADER_AUTH_KEY, strTheAuthKey);}
對稱式加密,沒有密鑰就無法還原,而密鑰並沒有在網路上傳輸,不可能被第三者通過截包等方式跟蹤到,所以這個密文應該來說是無法破解的。伺服器端拿到這個請求包之後,執行一個逆向操作:
public static bool VerifyAuthKey(string strAuthUser, string strAuthKey, string strRequestUri, string strPwdMd5TwiceSvr, ref Guid guidRequest){ try { //對稱式加密的解密,密鑰為使用者密碼的二次MD5,伺服器端知道的 string strUrlAndGuid = Des.Decode(strAuthKey, strPwdMd5TwiceSvr); //如果解密成功,用空格劈開成兩段,一段是“有效URI連上GUID,進行一次MD5加密”,另一段就是GUID了 string[] arrUrlAndGuid = strUrlAndGuid.Split(new[] { ' ' }); if (arrUrlAndGuid.Count() != 2) return false; string strUrl = arrUrlAndGuid[0]; string strGuid = arrUrlAndGuid[1]; //將解密出來的這個GUID作為返回參數,以便將其加入一個全域的集合中來防止“重發”(“重發”會在另一處地方檢查) guidRequest = Guid.Parse(strGuid); //再按照與用戶端一致的辦法產生“有效URI連上GUID,進行一次MD5加密”的結果,把這個結果與剛解密出來的結果比對,如果一致,身分識別驗證通過 strRequestUri = InternalHelper.GetEffectiveUri(strRequestUri); if (string.Compare(Md5.MD5Encode(strRequestUri + guidRequest), strUrl, true) == 0) { return true; } } catch (Exception) //忽略這其中產生的任何異常,將它認為是驗證不通過 { //Ignore any exception } return false;}
這種驗證方法可以杜絕了“身份冒充””和“重發”,而且完全不依賴於第三方的庫,方法十分簡單,開發人員能很輕易地對它進行進一步的強化,我認為對於大多數場合,夠了。好吧,等待高人來指正。
Web API驗證規則
驗證始終是應用程式的一個關鍵的功能,如前面提到的身分識別驗證其實也是一種驗證,驗證的目的是:確保正確的人做正確的事。
有些驗證僅僅是一個簡單的規則,比如中文名驗證:不可為空白,必須是2-10中文字元;有些驗證則需要訪問資料庫才知道,比如:添加一個使用者,不能和已有使用者的ID重複;還有些綜合型的驗證,在本例子中也有體現:使用者可以修改自己的資訊,但只有管理員才能修改別人的資訊。
驗證究竟是放在UI層還是放在商務邏輯層呢?其實這不只是Web API才有的問題,所有的系統,在設計的時候都要考慮這樣的問題。以前我在做系統的時候,認為層與層之間是互相不信任的,因此商務邏輯層要進行一套完整的驗證,而UI層當然也要進行一套完整的驗證,這樣帶來的後果是重複代碼增加,看起來有些淩亂,後來我這麼考慮:如果網站的UI層對使用者提供的資訊執行過了驗證,為什麼商務邏輯層還需要再執行一次?應該不需要了,因為UI層和商務邏輯層都放在伺服器端,這是我們自己能夠控制的,我們只需要針對用戶端過來的資料做驗證即可,於是我大刀闊斧地把商務邏輯層的驗證代碼削除掉了,程式果然看起來整潔了許多。
*註:在這個DEMO中,Server這個網站屬於UI層,而BLL這個類庫屬於商務邏輯層
但有些跟資料相關的驗證就不是那麼容易放在UI層做,比如前面說的“添加一個使用者,不能和已有的使用者的ID重複”,這個就需要到資料庫裡面查查到底有沒有這個使用者ID先。
所以,一般來說,我的規則是這樣:身分識別驗證、輸入驗證和許可權判斷能放在UI層就放在UI層,UI層做不到(比如涉及到具體資料的驗證),才放在商務邏輯層,UI層驗證和商務邏輯層的驗證最好不要重複。
用戶端MVVM簡介
本文的重點是Web API,但也順便簡單說說用戶端的MVVM模型,MVVM即“Model - View - ViewModel”,ViewModel與View綁定,綁定在這裡的意思就是:當View發生變化時,ViewModel要體現出來,反之,當ViewModel發生變化時,View也要體現出來。大概就是這樣,具體開來還要分什麼雙向繫結和單向綁定。
View發生變化,ViewModel也要跟著變,這個看起來並不難,比如你在UserName的文字框裡輸入“zhangsan”,當你的輸入焦點離開這個文字框時,程式會產生一個事件,它會去處理這個事件並把文字框的值賦到ViewModel去,這個“事件”不一定是失去焦點,還有可能是鍵入,也可能是手動觸發。
而ViewModel變化,View也要跟著變化,這個如何?呢?WPF提供了一個介面INotifyPropertyChanged,這個介面裡只有一個叫“PropertyChanged”的event,ViewModel發生變化的時候,就通過觸發這個event來通知View改變。我在用戶端代碼中提供了一個叫“ViewModelBase”的基類,就實現了這個介面,我的其它的ViewModel都從這個基類派生下來,在給它們的屬性SetValue的時候,就會觸發這個介面中的那個event,實現對View的通知。
網上關於MVVM的文章還是很多的,還有些相當重量級的架構,如Prism,要掌握這些東西就絕非一朝一夕之力了,但我相信萬變不離其宗,原理就如我所說的那樣。
另外關於WPF的一些技術,我就不在這裡提了,畢竟這不是本文重點,大家可以參考一些別的資料。
Web.Config
這也許是你見過的最簡單的Web.Config,因為我把不用的都去除了。
<?xml version="1.0" encoding="utf-8"?><!-- For more information on how to configure your ASP.NET application, please visithttp://go.microsoft.com/fwlink/?LinkId=169433 --><configuration> <system.web> <compilation debug="true" targetFramework="4.0" /> <authentication mode="None" /> </system.web></configuration>
沒錯,上面的內容就是全部的,唯一值得一提的是authentication的mode屬性,我們由於使用的是自訂的身分識別驗證方式,所以得把這個設為None,否則伺服器端很可能會使用Windows身分識別驗證機制。並且此程式已經在IIS下驗證過,正常使用沒什麼問題。
本DEMO的一些問題
DEMO畢竟是DEMO,我在寫的過程中也發現了一些問題,有些是因為條件的限制,有些則真是問題,所以必須列一下,以便大家在正式開發的時候注意避免:
- 時間戳記使用UTC時間而不是本地時間是否更佳?(考慮到如果使用UTC時間的話得多一點轉換,所以我在此DEMO中就不用了)
- 沒有使用事務。事務功能通常是DBMS的功能,本DEMO沒有使用DBMS,另外,一個好的系統還能做到檔案的復原,不只是DBMS,但這遠超本DEMO的範疇了。
- usersinfo的POST和DELETE功能沒做(偷懶)
- 用戶端的網路通訊均會阻塞UI線程,使用者體驗不佳。改進參考
- 用戶端ViewModel的驗證與API Contract的驗證存在重複,請問高手這個重複如何消除?
- 身分識別驗證需要調用商務邏輯層,沒有在UI層做緩衝,在正式的大型應用場合,沒有緩衝的話效率會很低的,但緩衝的更新也是個很大的問題,我相信大型的網路應用在這方面都有一套嚴謹而複雜的規則。
- 密碼在伺服器端的儲存格式固定為二次MD5,這樣不利於將來對密碼編譯演算法的改進。
- 用戶端的輸入驗證做得不夠好,例如在年齡裡輸入“abc”,雖然有出錯提示(轉換成數字失敗),但居然也可以提交(提交的內容是之前的數字)
- 由於伺服器端的一些全域資料是static的,因而可能存線上程安全的問題
相關下載
- 伺服器端代碼
- 用戶端代碼
- GornixSync同步工具