前言

閒聊 - Web API 是否一定要 RESTful? 一文提到我個人偏好用 ASP.NET MVC 寫 WebAPI,讀者 Mark 留言希望能有簡單範例參考。這篇文章將示範用 ASP.NET MVC 從無到有打造一個簡單 WebAPI 服務,提供給初學 ASP.NET MVC 不知如何下手的新手參考。註: 範例在於展示概念,省略了一些實務應有環節以免複雜失焦,如要應用於正式對外商轉,需將必要的安全與管理功能補齊。

希望文章裡的說明夠詳細,足以讓新同學們依步驟徒手寫出自己的 WebApi,但為防有人不幸卡關,範例專案我已放上 Github 當作急救包,希望用不到。XD

本文開始

來到正題。假設我有個加密演算法想寫成 WebAPI ,介面如下:

  1. EncryptString,傳入加密金鑰與待加密文字: encKey (字串), rawText (字串),API 產生加密資料以 byte[] 回傳
  2. BatchDecryptString,傳入解密金鑰 encKey 與多個加密資料 (List<byte[]>) API 批次解密,結果以 List 傳回

第一個 WebAPI 方法用一般 POST Form 傳參數即可,第二個 WebAPI 傳送的參數較複雜,我選擇另外定義參數物件,JSON 後當做 POST 內文送出,這也是實務常見做法。故意安排不同形式,藉此展示兩種不同呼叫方式的實作。

以下示範如何用 ASP.NET MVC 建立上述 WebAPI:

  1. 使用 VS2015/VS2017 建立一個 ASP.NET Web Application (別問我如果用 VS2013/VS2012/VS2010 該怎麼辦,我不想逆天啊啊啊啊)

  2. ASP.NET 5 起採用 One ASP.NET 概念,建立專案選 Empty 模版省去移除多餘項目的功夫,啟用項目則勾選 MVC 就好:
    (我不愛用 ASP.NET MVC 內建 WebAPI 功能的原因可參考:閒聊 - Web API 是否一定要 RESTful?)

  3. 下一步是新增一個 Controller (MVC 的 C),在 Controllers 目錄按右鍵選 Add / Controller

  4. 新增 Controller 時選 MVC 5 Controller – Empty 即可。

    Controller 名字很重要,如果希望 WebAPI 的 URL 是 httq://myserver/CodecApi/EncryptString,Controller 就要取名為 CodecApiController

    建好的 Controller 會預建一個空白 Index() 方法以及 Views/CodecApi/Index.cshtml,我們都用不到,請直接刪掉。

  5. 加解密部分非示範重點就不多解釋,我做了一個 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();
                }
            }
    
        }
    }
    
  6. 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"/>
    
  7. 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();
                }
            }
        }
    
    
    
    }
    
  8. 另外,先前講過我習慣用統一的 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;
            }
        }
    }
    
  9. 做到這裡,專案的主要檔案架構就差不多了:

  10. 接著來寫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 本體傳入,細節作法後面也會示範。

  11. 講到測試 WebAPI,強力推薦 Postman,https://www.getpostman.com/,以下也都以 Postman 示範。
    EncryptString 測試方式如下,方法選 POST,Body 取 x-www-form-urlencode,分別輸入 encKey 及 rawText 字串參數,按下 Send 後可看到結果,成功!

  12. 測試 BatchDecryptData 要先準備一段 JSON 當作 Body:

    {
    	encKey: "9527",
    	encData: [
    		"aNcjoi5QprU=",
    		"Ql9bsv6d1BVeq/9icNTcUQ=="
    	]
    }
    

    Body 型別選 raw,型別取 JSON(application/json),按下 Send 也測試成功。

  13. 這樣我們就完成了基本雛型,以下再補充一些眉角。如果出錯了怎麼辦? 例如故意不給 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 功能,但要應用在實務上還有不少改進空間,以下是一些努力方向:

  1. 每個 Action 都加 SecurityManager.Authorize(Request) 跟 try ... catch ... 太醜,可考量用 ActionFilter 讓程式優雅一點~
    參考:ASP.NET MVC Filter練習-限定本機存取ASP.NET MVC实现IExceptionFilter接口编写自定义异常处理过滤器
  2. 出錯時回傳 Exception.Message 讓呼叫端知道原因,但想抓出問題需要更多 Exception 的細節,因此在 try ... catch 拋回結果前,要設法將完整的錯誤資訊(包含 InnerException)及環境細節(Request.Url、User-Agent、IP... 等)保存下來, 參考:
  3. 關於 WebAPI 存取權限控管,除了自幹也可引用業界標準及第三方程式庫,例如:JWT, JSON Web Token
    參考:ASP.NET 使用 JWT 進行 WebAPI 驗證 by 全端開發人員天梯
  4. 為求簡單範例所有邏輯都大鍋炒放在同一個專案裡,實務上則常依性質功能拆成多顆 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 未授權,若含糊回覆系統有錯誤不告知原因,減少細節資訊洩漏給攻擊者,但相對的,善意呼叫端無法直接由回應獲得失敗原因,雙面刃。

Post a comment