目錄
- 概述
- 功能介紹
- 程序結構
- 服務器端介紹
- 客戶端介紹
- “契約”
- 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類,日志類,一些正則表達式,全局常量等等…… 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 visit http://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的,因而可能存在線程安全的問題