上集我們搞定了ASP.NET 3.5 Routing,能將api/{model}的Request正確導向指定的ashx。而在系列文一開始提過RESTful的另一個重點是依不同的HttpMethod進行不同作業,在jQuery.ajax()呼叫RESTful Web Service的文章中,其實已偷偷示範過如何用ASP.NET Web Form滿足RESTful Server端的要求,把類似的程式碼搬進ashx,就能打造出RESTful Web Service。

不過實務api中通常會包含多個Model的API程式,若只是"把類似程式碼搬進多個Model ashx再改一改"戲碼反覆上演,意味著得不斷"Copy & Paste",有違我努力奉行的DRY原則,萬萬不可! 在這個議題上,我決定採行的策略是--將每個Model API Handler都會用到的判斷HttpMethod、傳回JSON、適時回傳HttpStatus 201/404... 等程式邏輯,統一收編放在抽象類別中,而類別中宣告GetList(傳回集合), GetItem(傳回單一資料物件), CreateItem(新增資料), UpdateItem(更新資料), DeleteItem(刪除資料)五個抽象方法。如此,要開發不同Model的API Handler只需繼承這個父類別,實做GetList, GetItem, CreateItem, UpdateItem, DeleteItem五個方法,程式就寫完了。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Script.Serialization;
using System.IO;
 
public abstract class CompactRESTHandler<T> : IHttpHandler where T : new()
{
    public CompactRESTHandler()
    {
    }
 
    public bool IsReusable
    {
        get { return false; }
    }
    //REF: http://goo.gl/Uk1go
    protected static bool IsNullOrEmpty(T item)
    {
        return EqualityComparer<T>.Default.Equals(item, default(T));
    }
 
    public void ProcessRequest(HttpContext context)
    {
        var args = new Dictionary<string, string>();
        JavaScriptSerializer jss = new JavaScriptSerializer();
 
        foreach (var key in context.Items.Keys)
            args.Add(key.ToString(), Convert.ToString(context.Items[key]));
 
        //傳回JSON內容的共用方法
        Action<object, int> dumpDataJsonWithStatusCode = (data, statusCode) =>
        {
            if (statusCode > 0)
                context.Response.StatusCode = statusCode;
            context.Response.ContentType = "application/json; charset=utf-8";
            context.Response.Write(jss.Serialize(data));
        };
        Action<object> dumpDataJson = (data) => { dumpDataJsonWithStatusCode(data, 0); };
 
        //傳回特定Status Code的共用方法
        Action<int, string> returnHttpStatus = (statusCode, content) =>
        {
            context.Response.StatusCode = statusCode;
            if (!string.IsNullOrEmpty(content))
                context.Response.Write(content);
        };
 
        //由HttpMethod及參數決定呼叫何種方法
        string httpMethod = context.Request.HttpMethod;
        //除了GET以外,POST/PUT/DELETE時,Request的本文內容應為資料物件JSON
        //在此將其反序列化回.NET端的物件
        T item = default(T);
        if (httpMethod != "GET")
        {
            //檢查ContentType必須為application/json,確保Client端明確知道要傳JSON
            if (!context.Request.ContentType.StartsWith("application/json"))
                throw new ArgumentException("ConentType is not application/json!");
            try 
            {
                //理論上還要依ContentType的charset決定Encoding,在此偷懶省略
                using (StreamReader sr = new StreamReader(context.Request.InputStream))
                {
                    string jsonString = sr.ReadToEnd();
                    item = (T)jss.Deserialize<T>(jsonString);
                }
            }
            catch (Exception ex) 
            {
                throw new ArgumentException("Failed to parse JSON string");
            }
        }
        try
        {
            switch (httpMethod)
            {
                case "GET": //GetList及GetItem由是否提供Id來區分
                    if (args.ContainsKey("id"))
                    {
                        item = GetItem(args);
                        
                        if (IsNullOrEmpty(item))
                            returnHttpStatus(404, "Not Found!");
                        else
                            dumpDataJson(item);
                    }
                    else
                        dumpDataJson(GetList(args));
                    break;
                case "POST":
                    //傳回HTTP 201 Created
                    dumpDataJsonWithStatusCode(CreateItem(item), 201);
                    //加上Response Header - Location
                    context.Response.AddHeader("Location",
                        GetCreateLocationHeader(item));
                    break;
                case "PUT":
                    UpdateItem(item);
                    break;
                case "DELETE":
                    DeleteItem(item);
                    break;
            }
        }
        catch (Exception ex)
        {
            dumpDataJsonWithStatusCode(ex.Message, 500);
        }
        context.Response.End();
    }
 
    //查詢取得物件集合
    public abstract List<T> GetList(Dictionary<string, string> args);
    //取得特定資料物件
    public abstract T GetItem(Dictionary<string, string> args);
    //新增資料(需回傳新增成功的資料物件)
    public abstract T CreateItem(T item);
    //傳回新增資料後的Location Response Header
    public abstract string GetCreateLocationHeader(T item);
    //更新資料
    public abstract void UpdateItem(T item);
    //刪除資料
    public abstract void DeleteItem(T item);
}

CompactRESTHandler裡用到泛型及不少Lambda技巧,略為複雜。但在一般應用中,可將其視為黑盒子,直接當元件用,以下透過範例展示如何繼承CompactRESTHandler寫出API Handler。

我做了一個極簡單的Model—BlahEntry,其實是模仿KeyValuePair<string, string>,只有兩個屬性Key及Value,因此BlahHandler就是用來處理BlahEntry新增、修改、刪除的API程式。為求簡化起見,先不把資料庫拉進來,只用一個List<BlahEntry>當成資料儲存區,新增用List.Add()、刪除用List.Remove()、更新則用Remove()+Add()模擬;查詢部分也很簡單,清單查詢就直接吐回整個List,查詢特定Key時則用LINQ查詢搞定。

<%@ WebHandler Language="C#" Class="BlahHandler" %>
 
using System;
using System.Web;
using System.Collections.Generic;
using System.Linq;
 
public class BlahEntry
{
    public string Key { get; set; }
    public string Value { get; set; }
    public BlahEntry() { }
    public BlahEntry(string key, string value)
    {
        Key = key; Value = value;
    }
}
 
public class BlahHandler : CompactRESTHandler<BlahEntry>
{
    static List<BlahEntry> dataStore = new List<BlahEntry>();
    static BlahHandler() //預設放入兩筆資料
    {
        dataStore.Add(new BlahEntry("Blog", "http://blog.darkthread.net"));
        dataStore.Add(new BlahEntry("Author", "Jeffrey Lee"));
    }
    //清單查詢API,直接傳回結果
    public override List<BlahEntry> GetList(Dictionary<string, string> args)
    {
        return dataStore; 
    }
    //使用LINQ查詢特定Key值內容
    private BlahEntry GetItem(string key)
    {
        return dataStore.SingleOrDefault(
            //比對時不分大小寫
            o => string.Compare(o.Key, key, true) == 0);
    }
    //查詢特定資料API    
    public override BlahEntry GetItem(Dictionary<string, string> args)
    {
        return GetItem(args["id"]);
    }
    //新增資料API
    public override BlahEntry CreateItem(BlahEntry item)
    {
        dataStore.Add(item);
        return GetItem(item.Key);
    }
    //更新資料API
    public override void UpdateItem(BlahEntry item)
    {
        var toUpdate = GetItem(item.Key);
        if (!IsNullOrEmpty(toUpdate))
        {
            //使用Remove + Add模擬Update
            dataStore.Remove(toUpdate);
            dataStore.Add(item);
        }
        else
            throw new ApplicationException("Cannot find the data to update!");
    }
    //刪除資料API
    public override void DeleteItem(BlahEntry item)
    {
        var toDelete= GetItem(item.Key);
        if (!IsNullOrEmpty(toDelete))
            dataStore.Remove(toDelete);
        else
            throw new ApplicationException("Cannot find the data to delete!");        
    }
    //新增資料時,傳回剛才新增資料之URI
    public override string GetCreateLocationHeader(BlahEntry item)
    {
        return "api/Blah/" + item.Key;
    }
}

複習一下先前介紹過的$.ajax() for REST,寫成以下的測試程式:

<!DOCTYPE>
 
<html>
<head>
    <title>Blah Test</title>
    <style>
        body,input { font-size: 9pt; }
        #dvEditor input { width: 100px; }
    </style>
    <script src='http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.7.1.js'></script>   
    <script>
        $(function () {
            $.ajaxSetup({
                error: function (xhr, statusText, err) {
                    alert("Error: " + err + "\n" + xhr.responseText);
                }
            });
            $("#btnList").click(function () {
                $.ajax({
                    url: "api/Blah", //用GET, 不帶{id}參數
                    dataType: "json", //自動將結果解成物件
                    cache: false, //GET時要防止Cache
                    success: function (list) {
                        alert("List: " + JSON.stringify(list));
                    }
                });
            });
            //將使用者輸入內容變成{ Key:*, Value:* }物件
            function getKeyValueObject() {
                return {
                    Key: $("#txtKey").val(),
                    Value: $("#txtValue").val()
                };
            }
            $("#btnCreate").click(function() {
                $.ajax({
                    url: "api/Blah", 
                    type: "POST", //用POST代表新增資料
                    dataType: "json",
                    contentType: "application/json",
                    data: JSON.stringify(getKeyValueObject()),
                    statusCode: {
                        201: function (data, status, xhr) {
                            //預設會直接在Response Body傳回新增完的資料物件
                            alert("Create-" + JSON.stringify(data));
                            //由Response Header可取得資料URI
                            alert("Location-" + xhr.getResponseHeader("Location"));
                        }
                    }
                });
            });
            $("#btnGet").click(function() {
                $.ajax({
                    url: "api/Blah/Remark", //指定{id},讀取特定資料
                    cache: false, //GET時要防止Cache
                    dataType: "json",
                    success: function(data) {
                        alert("Get-" + JSON.stringify(data));
                    }
                });
            });
            $("#btnUpdate").click(function() {
                var kv = getKeyValueObject();
                $.ajax({
                    url: "api/Blah/" + kv.Key,
                    type: "PUT", //使用PUT更新資料
                    contentType: "application/json",
                    //傳入要更新的資料JSON
                    data: JSON.stringify(kv)
                });
            });
            $("#btnDelete").click(function() {
                var kv = getKeyValueObject();
                $.ajax({
                    url: "api/Blah/" + kv.Key,
                    type: "DELETE", //使用DELETE刪除資料
                    contentType: "application/json",
                    //傳入要刪除的資料JSON
                    data: JSON.stringify(kv) 
                });
            });
        });
    </script>
</head>
<body>
<div id="dvEditor">
Key: <input id="txtKey" value="Remark" />
Value: <input id="txtValue" value="Test Text" />
</div>
<input type="button" id="btnList" value="List" />
<input type="button" id="btnCreate" value="Create" />
<input type="button" id="btnGet" value="Get [Remark]" />
<input type="button" id="btnUpdate" value="Update" />
<input type="button" id="btnDelete" value="Delete" />
</body>
</html>

畫面如下

測試順序與結果:

  1. 按下【List】
    List: [{"Key":"Blog","Value":"httq://blog.darkthread.net"},{"Key":"Author","Value":"Jeffrey Lee"}]
  2. 按下【Get[Remark]】(此時還沒有資料)
    Error: Not Found
    Not Found!
  3. 按下【Create】
    Create-{"Key":"Remark","Value":"Test Text"}
    以及
    Location-api/Blah/Remark
  4. 再按一次【List】(可以看見Remark在其中囉!)
    List: [{"Key":"Blog","Value":"httq://blog.darkthread.net"},{"Key":"Author","Value":"Jeffrey Lee"},{"Key":"Remark","Value":"Test Text"}]
  5. 在Value欄位輸入"I am awesome",按下【Update】
  6. 再按一次【Get[Remark]】
    Get-{"Key":"Remark","Value":"I am awesome"}
  7. 按下【Delete】
  8. 按下【List】(Remark資料消失囉~)
    List: [{"Key":"Blog","Value":"httq://blog.darkthread.net"},{"Key":"Author","Value":"Jeffrey Lee"}]

表演完畢,下台一躹躬!


Comments

# by Tim

黑暗大請教 在Global.asax裡的 routes.Add("Item", new Route("api/{model}/{id}", new CompactRESTRouteHandler(T))); T應該為何? 感謝

Post a comment