範例教學:使用 ASP.NET MVC 打造 WebAPI 服務
22 |
前言
閒聊 - Web API 是否一定要 RESTful? 一文提到我個人偏好用 ASP.NET MVC 寫 WebAPI,讀者 Mark 留言希望能有簡單範例參考。這篇文章將示範用 ASP.NET MVC 從無到有打造一個簡單 WebAPI 服務,提供給初學 ASP.NET MVC 不知如何下手的新手參考。註: 範例在於展示概念,省略了一些實務應有環節以免複雜失焦,如要應用於正式對外商轉,需將必要的安全與管理功能補齊。
希望文章裡的說明夠詳細,足以讓新同學們依步驟徒手寫出自己的 WebApi,但為防有人不幸卡關,範例專案我已放上 Github 當作急救包,希望用不到。XD
本文開始
來到正題。假設我有個加密演算法想寫成 WebAPI ,介面如下:
- EncryptString,傳入加密金鑰與待加密文字: encKey (字串), rawText (字串),API 產生加密資料以 byte[] 回傳
- BatchDecryptString,傳入解密金鑰 encKey 與多個加密資料 (List<byte[]>) API 批次解密,結果以 List 傳回
第一個 WebAPI 方法用一般 POST Form 傳參數即可,第二個 WebAPI 傳送的參數較複雜,我選擇另外定義參數物件,JSON 後當做 POST 內文送出,這也是實務常見做法。故意安排不同形式,藉此展示兩種不同呼叫方式的實作。
以下示範如何用 ASP.NET MVC 建立上述 WebAPI:
-
使用 VS2015/VS2017 建立一個 ASP.NET Web Application (別問我如果用 VS2013/VS2012/VS2010 該怎麼辦,我不想逆天啊啊啊啊)
-
ASP.NET 5 起採用 One ASP.NET 概念,建立專案選 Empty 模版省去移除多餘項目的功夫,啟用項目則勾選 MVC 就好:
(我不愛用 ASP.NET MVC 內建 WebAPI 功能的原因可參考:閒聊 - Web API 是否一定要 RESTful?) -
下一步是新增一個 Controller (MVC 的 C),在 Controllers 目錄按右鍵選 Add / Controller
-
新增 Controller 時選 MVC 5 Controller – Empty 即可。
Controller 名字很重要,如果希望 WebAPI 的 URL 是 httq://myserver/CodecApi/EncryptString,Controller 就要取名為 CodecApiController
建好的 Controller 會預建一個空白 Index() 方法以及 Views/CodecApi/Index.cshtml,我們都用不到,請直接刪掉。 -
加解密部分非示範重點就不多解釋,我做了一個 Models/CodecModule.cs 實做加解密。(商業邏輯類別統一放在 Models 目錄下)
using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Web; namespace DemoWeb.Models { public class CodecModule { public static byte[] EncrytString(string encKey, string rawText) { return DESEncrypt(encKey, Encoding.UTF8.GetBytes(rawText)); } public static List<string> DecryptData(string encKey, List<byte[]> data) { return data.Select(o => { return Encoding.UTF8.GetString(DESDecrypt(o, encKey)); }).ToList(); } //REF: https://dotblogs.com.tw/supershowwei/2016/01/11/135230 static byte[] HashByMD5(string source) { MD5CryptoServiceProvider md5 = new MD5CryptoServiceProvider(); return md5.ComputeHash(Encoding.UTF8.GetBytes(source)); } static byte[] DESEncrypt(string key, byte[] data) { var des = new DESCryptoServiceProvider(); Rfc2898DeriveBytes rfc2898 = new Rfc2898DeriveBytes(key, HashByMD5(key)); des.Key = rfc2898.GetBytes(des.KeySize / 8); des.IV = rfc2898.GetBytes(des.BlockSize / 8); using (MemoryStream ms = new MemoryStream()) using (CryptoStream cs = new CryptoStream(ms, des.CreateEncryptor(), CryptoStreamMode.Write)) { cs.Write(data, 0, data.Length); cs.FlushFinalBlock(); return ms.ToArray(); } } static byte[] DESDecrypt(byte[] encData, string encKey) { DESCryptoServiceProvider des = new DESCryptoServiceProvider(); Rfc2898DeriveBytes rfc2898 = new Rfc2898DeriveBytes(encKey, HashByMD5(encKey)); des.Key = rfc2898.GetBytes(des.KeySize / 8); des.IV = rfc2898.GetBytes(des.BlockSize / 8); using (MemoryStream ms = new MemoryStream()) using (CryptoStream cs = new CryptoStream(ms, des.CreateDecryptor(), CryptoStreamMode.Write)) { cs.Write(encData, 0, encData.Length); cs.FlushFinalBlock(); return ms.ToArray(); } } } }
-
WebAPI 通常會開匿名存取,安全管控要自己來,最基本的防禦是鎖定呼叫來源 IP,更嚴謹一點可要求呼叫端附上專屬 API Key/Secret,更機車一點還可以限定某支 API Key 只能用於哪些 IP。
以下是最簡單限定 IP 的範例:(ScurityManager.cs 屬商業邏輯類別,也請放在 Models 目錄下)using System; using System.Collections.Generic; using System.Configuration; using System.Linq; using System.Web; namespace DemoWeb.Models { public class SecurityManager { //此處使用web.config設定允許存取來源IP,實務上可改用DB存放並加上管理介面 private static string[] allowedClientIps = (ConfigurationManager.AppSettings["api:AllowedClientIps"]??string.Empty) .Split(',', ';'); public static void Authorize(HttpRequestBase request) { if (!allowedClientIps.Contains(request.UserHostAddress)) throw new ApplicationException("Client IP Denied"); //如要求更嚴謹管控時可發放API Key,並要求附於Request Header //在此可檢查API Key是否合法,甚至API Key再綁定特定IP使用 //...request.Cookies["X-Api-Key"]... } } }
配合 IP 管控需在 web.config 加入可呼叫的來源 IP,例如測試期限定本機可以這樣寫:
<add key="api:AllowedClientIps" value="::1;127.0.0.1"/>
-
WebAPI 傳回結果的標準格式為 JSON,ASP.NET MVC Controller 雖然內建 JSON 序列化函式 Json(),但用的是微軟自家的 JavaScriptSerializer,而非業界主流 - Json.NET,建議費點手腳換掉,這點是用 ASP.NET MVC 寫 WebAPI 少數較不便的地方。(ASP.NET MVC WebAI 預設用 Json.NET,勝出) 置換 Controller.Json() 的細節可參考舊文:ASP.NET MVC小改裝 - 以Json.NET取代JavaScriptSerializer 先用 NuGet 安裝 Json.NET 再將文中的範例程式新增為 Models/JsonNetController.cs,CodecApiController 由繼承 Controller 改為繼承 JsonNetController 後,之後呼叫 Json() 便會改使用 Json.NET 執行序列化。
using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Web; using System.Web.Mvc; namespace DemoWeb.Models { public class JsonNetController : Controller { protected override JsonResult Json(object data, string contentType, Encoding contentEncoding, JsonRequestBehavior behavior) { if (behavior == JsonRequestBehavior.DenyGet && string.Equals(this.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)) //Call JsonResult to throw the same exception as JsonResult return new JsonResult(); return new JsonNetResult() { Data = data, ContentType = contentType, ContentEncoding = contentEncoding }; } } public class JsonNetResult : JsonResult { public JsonSerializerSettings SerializerSettings { get; set; } public Formatting Formatting { get; set; } public JsonNetResult() { SerializerSettings = new JsonSerializerSettings(); } public override void ExecuteResult(ControllerContext context) { if (context == null) throw new ArgumentNullException("context"); HttpResponseBase response = context.HttpContext.Response; response.ContentType = !string.IsNullOrEmpty(ContentType) ? ContentType : "application/json"; if (ContentEncoding != null) response.ContentEncoding = ContentEncoding; if (Data != null) { JsonTextWriter writer = new JsonTextWriter(response.Output) { Formatting = Formatting }; JsonSerializer serializer = JsonSerializer.Create(SerializerSettings); serializer.Serialize(writer, Data); writer.Flush(); } } } }
-
另外,先前講過我習慣用統一的 ApiResult 物件回傳 WebAPI 結果,故要在 Models/ApiResult.cs 定義 ApiResult 物件,這裡展示泛型強化版(ApiResult),可指定 Data 型別加上強型別保護。
using System; using System.Collections.Generic; using System.Linq; using System.Web; namespace DemoWeb.Models { // <summary> /// API呼叫時,傳回的統一物件 /// </summary> public class ApiResult<T> { /// <summary> /// 執行成功與否 /// </summary> public bool Succ { get; set; } /// <summary> /// 結果代碼(0000=成功,其餘為錯誤代號) /// </summary> public string Code { get; set; } /// <summary> /// 錯誤訊息 /// </summary> public string Message { get; set; } /// <summary> /// 資料時間 /// </summary> public DateTime DataTime { get; set; } /// <summary> /// 資料本體 /// </summary> public T Data { get; set; } public ApiResult() { } /// <summary> /// 建立成功結果 /// </summary> /// <param name="data"></param> public ApiResult(T data) { Code = "0000"; Succ = true; DataTime = DateTime.Now; Data = data; } } public class ApiError : ApiResult<object> { /// <summary> /// 建立失敗結果 /// </summary> /// <param name="code"></param> /// <param name="message"></param> public ApiError(string code, string message) { Code = code; Succ = false; this.DataTime = DateTime.Now; Message = message; } } }
-
做到這裡,專案的主要檔案架構就差不多了:
-
接著來寫Controller
using DemoWeb.Models; using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.Mvc; namespace DemoWeb.Controllers { public class CodecApiController : JsonNetController { [HttpPost] public ActionResult EncryptString(string encKey, string rawText) { SecurityManager.Authorize(Request); return Json(new ApiResult<byte[]>( CodecModule.EncrytString(encKey, rawText))); } public class DecryptParameter { public string EncKey { get; set; } public List<byte[]> EncData { get; set; } } [HttpPost] public ActionResult BatchDecryptData(DecryptParameter decData) { SecurityManager.Authorize(Request); return Json(new ApiResult<List<string>>( CodecModule.DecryptData(decData.EncKey, decData.EncData))); } } }
每個 ActionResult 方法對應一個 WebAPI 方法,加解密前要呼叫 SecurityManager.Authorize() 檢查客戶端 IP 確認是否為合法呼叫端。 接著執行主要邏輯進行加解密,最後用 this.Json() 傳回 ApiResult。
加註 [HttpPost] 限定 POST 呼叫是好習慣。參考:隱含殺機的GET式AJAX資料更新
EncryptString() 宣告了 encKey, rawText 兩個字串參數,等下會示範如何傳進來。
BatchDecryptData() 的輸入參數我另外定義了 DecryptParamter 類別,這類複雜參數通常會以 JSON 格式作為 POST 本體傳入,細節作法後面也會示範。 -
講到測試 WebAPI,強力推薦 Postman,https://www.getpostman.com/,以下也都以 Postman 示範。
EncryptString 測試方式如下,方法選 POST,Body 取 x-www-form-urlencode,分別輸入 encKey 及 rawText 字串參數,按下 Send 後可看到結果,成功! -
測試 BatchDecryptData 要先準備一段 JSON 當作 Body:
{ encKey: "9527", encData: [ "aNcjoi5QprU=", "Ql9bsv6d1BVeq/9icNTcUQ==" ] }
Body 型別選 raw,型別取 JSON(application/json),按下 Send 也測試成功。
-
這樣我們就完成了基本雛型,以下再補充一些眉角。如果出錯了怎麼辦? 例如故意不給 encKey,MVC 會噴出 HTTP 500,呼叫端只知出錯很難從傳回結果抓出錯誤原因。
最簡單的做法是用 try {… } catch {…} 包住程式,catch 錯誤傳回 ApiError,像這個樣子:[HttpPost] public ActionResult EncryptString(string encKey, string rawText) { try { SecurityManager.Authorize(Request); return Json(new ApiResult<byte[]>( CodecModule.EncrytString(encKey, rawText))); } catch (Exception ex) { return Json(new ApiError("500", ex.Message)); } }
如此,出錯時呼叫端一樣會拿到 ApiResult,並可由 Code 及 Message 取得錯誤資訊。
至此,這個雛型已符合規格具備最基本的 WebAPI 功能,但要應用在實務上還有不少改進空間,以下是一些努力方向:
- 每個 Action 都加 SecurityManager.Authorize(Request) 跟 try ... catch ... 太醜,可考量用 ActionFilter 讓程式優雅一點~
參考:ASP.NET MVC Filter練習-限定本機存取、ASP.NET MVC实现IExceptionFilter接口编写自定义异常处理过滤器 - 出錯時回傳 Exception.Message 讓呼叫端知道原因,但想抓出問題需要更多 Exception 的細節,因此在 try ... catch 拋回結果前,要設法將完整的錯誤資訊(包含 InnerException)及環境細節(Request.Url、User-Agent、IP... 等)保存下來, 參考:
- 關於 WebAPI 存取權限控管,除了自幹也可引用業界標準及第三方程式庫,例如:JWT, JSON Web Token
參考:ASP.NET 使用 JWT 進行 WebAPI 驗證 by 全端開發人員天梯 - 為求簡單範例所有邏輯都大鍋炒放在同一個專案裡,實務上則常依性質功能拆成多顆 DLL,有助於分工開發、重複使用、單元測試、部署換版... 等,好處很多。
That's all folks.
A step by step tutorial for ASP.NET MVC beginner to build a simple WebAPI service with ASP.NET MVC project.
Comments
# by 小小工程師
報告黑大: 閒聊 - Web API 是否一定要 RESTful? 的連結好像多了 .aspx
# by Jeffrey
to 小小工程師, 謝謝提醒,已更正。
# by a
test
# by GOTO
報告黑大: 發現T Data如果是DataSet型態時, 用GET呼叫時加JsonRequestBehavior.AllowGet, 然後會出現"序列化 'System.Globalization.CultureInfo' 型別的物件時偵測到循環參考。"錯誤訊息, 不知道要怎麼解決QQ
# by Jeffrey
to GOTO,資料型別如果有A 指向 B,B 又指向 A 的狀況時,在 JSON 化時就會產生循環參考錯誤,我最常遇到的狀況是樹狀結構,節點有 Children 集合指向一群子節點,而子節點有個 Parent 屬性再指向自己,此時JSON 序列化就會有問題,換言之,不是所有物件都可以直接 JSON 序列化。如果是自訂物件,我會加上 [JsonIgnore] 避開(接收端再視需要還原)。 我印象中 Json.NET 是可以序列化 DataSet 的 https://www.newtonsoft.com/json/help/html/SerializeDataSet.htm ,建議你可以自己寫一小段程式用 JsonConvert.Serialize(yourDataSet) 找出循環參考的根源。
# by GOTO
Jeffrey大: 我稍微有去了解JSON序列化的問題了, 雖用了 [JsonIgnore]還是出現了錯誤訊息, 但我用JsonConvert.SerializeObject(DataSet)後, 透過Web Api接收到的responseBody(JSON字串)會帶有好多"\"(斜線), 但經過以下轉換後: JObject obj = JObject.Parse(responseBody); DataSet ds = JsonConvert.DeserializeObject<DataSet>(obj["Data"].ToString()); 成功轉為DataSet了, 感謝Jeffrey大的指點, 謝謝。
# by Lauyea
黑大你好: 我按照步驟跟著做下來以後,在測試API的地方卡住了。 https://i.imgur.com/wOLvZKh.png 下面是方案總管 https://i.imgur.com/CvEhs6N.png 唯一比較不同的步驟是我直接用Nuget安裝json了,總覺得這步錯誤的可能性最大... https://i.imgur.com/BEhWLjh.png 然後我沒有用IIS,所以打算用Visual內建的IIS express暫時充當伺服器,不過好像也有問題。 https://i.imgur.com/ClSfOXN.png 我自己在寫網頁的方面還學得非常淺,如果有什麼膚淺的問題也請見諒,感謝。
# by Jeffrey
to Lauyea, 由圖例看不出什麼問題。第一張感覺 IIS Express 沒執行,最後一張出現 404 表示 IIS Express 有執行,但 / 不是有效網址,得到 404 是合理的。為了對照驗證,建議新增一個 HomeController.cs,將裡面的 Index() 由 return View(); 改為 return Content(DateTime.Now.ToString()); 然後在 VS 按 F5 看瀏覽器出現目前時間確認 IIS Express 執行正常,此時再用 PostMan 測試,看結果是否不同?
# by Lauyea
黑大你好: 我照你說的新增HomeController以後確認IIS express正常執行。 https://i.imgur.com/lihB5W8.png https://i.imgur.com/52niStD.png 不過Postman的情況還是與昨天相同,還是我先學怎麼上傳專案到GitHub,這樣可能會比較清楚一些。 感謝百忙之中還抽空回應。
# by Lauyea
黑大你好: 我把專案上傳到GitHub了,希望可以指點一下。 https://github.com/Lauyea/WebAPI-test 萬分感謝。
# by Jeffrey
to Lauyea,破案了,請參考新文 https://blog.darkthread.net/blog/postman-cant-test-iisexress/。
# by Jimmy
黑大您好: 小弟下載了您的範例包程式當參考。遇到了以下問題: 於 IIS Express 下執行是正常的,但是當我發行到 IIS 底下,再度 POST 時就出現 404 的錯誤,不管放在 Default Web Site 或使用新增站台都一樣。 不曉得是哪裡有問題? 對於MVC API 這塊是剛起手,還不是很熟,請見諒問題內容,感謝~ 註:我是將您的範例程式發行到 IIS 底下做測試。
# by Jeffrey
to Jimmy, 依據 IIS Express 測試正常這條線索,推測是部署 IIS 的做法可能有瑕疵,但只靠文字描述很難判斷錯在哪個環節。建議先試著部署一個 Visual Studio 標準範例 MVC 專案網站到 IIS,若能成功瀏覽/YouAppName/Home/Index,再比較該專案與 WebAPI 專案的差異;若失敗則代表你認知的 IIS 發行做法可能不正確。(一個更快的做法是找周遭有 ASP.NET MVC 經驗的朋友實際幫忙檢查你的 IIS 設定。)
# by Jimmy
黑大您好: 試了一天,終於找到問題點了。 原因是我用 VS 2017 進行網站發行時,多勾了設定裡面的 "在發行期間先行編譯" 的選項,導致問題產生;取消勾選並重新發行至 IIS 下,就可以正常使用。 有點犯蠢了,感謝您的回覆,果然是發行的做法有問題。 ^^
# by Akira
請問一下我如果是Windows Form,要傳MyData給Post,語法要如何寫? public class MyDatat { public int id; public string name; } // POST api/values public HttpResponseMessage Post(MyDatat value) { string yourJson = Newtonsoft.Json.JsonConvert.SerializeObject(value); var response = this.Request.CreateResponse(HttpStatusCode.OK); response.Content = new StringContent(yourJson, Encoding.UTF8, "application/json"); return response; }
# by Jeffrey
to Akira,不是很懂,Post Action 的用途是接收以 JSON 格式上傳的 MyData 物件,再傳回該物件 JSON 序列化內容?
# by Akira
這段程式是Web API // POST api/values public HttpResponseMessage Post(MyDatat value) { string yourJson = Newtonsoft.Json.JsonConvert.SerializeObject(value); var response = this.Request.CreateResponse(HttpStatusCode.OK); response.Content = new StringContent(yourJson, Encoding.UTF8, "application/json"); return response; } 如果我有一個Windows Form的程式(Client),想要呼叫這個Web API的Post,語法要如何下?
# by Jeffrey
to Akira, 可以用 WebClient 簡單搞定: var wc = new System.Net.WebClient(); wc.Headers.Add("Content-Type", "application/json"); var res = wc.UploadString("http://localhost:60078/api/test", @"{ ""id"":123,""name"":""Jeffrey"" }"); Console.WriteLine(res);
# by Steven
為什麼API明明沒辦法處理request卻還回應200status code呢?然後在回應的內容才補上500 error,這設計很奇怪,很像這張迷因圖。 https://www.facebook.com/100064374684162/posts/pfbid02sr3ssSvNmhBvtVuhHAnNBM7XRw339v7B7T6wx5fwgX3sgfhKaAmJs2iZE3qzzxRkl/?d=n
# by Jeffrey
to Steven,HTTP Status 必須反映處理狀態是 REST 的主張。即使 REST 是當今主流,也不代表全天下的 Web API 都遵循 REST。就像程式語言百百種,用主流語言角度去看小眾語言,大概也是一整個怪,但它們一直都在。
# by 小黑
請教黑大 ~ SecurityManager.Authorize() 的作用為何不用 ActionFilter(AuthorizeAttribute) 實作? 這樣可以掛在 Controller 類別上而不用每個 Action 都寫?這部分是否有其他考量?另外,若為不合法IP 來源,後續對應的動作會是哪些?寫Log?回覆無請求權限給使用端?另外若是 mvc view 頁面,可以如何回應?
# by Jeffrey
to 小黑, 文章主要想說明原理,故範例採平鋪直敘的寫法,文本有提到實務上建議用 ActionFilter。 不合法 IP 一般阻擋留 Log 即可,若想積極偵測非法存取再加其他機制。回應策略有兩種,明確告知 IP 未授權,若含糊回覆系統有錯誤不告知原因,減少細節資訊洩漏給攻擊者,但相對的,善意呼叫端無法直接由回應獲得失敗原因,雙面刃。