從 Asp.Net MVC 到 Web Form

邊城發表於2016-12-02

從 Asp.Net MVC 到 Web Form 這看起來有點奇怪,大家都研究如何從 Web Form 到 MVC 的時候,為什麼會出現一個相反的聲音?從研究的角度來說,對反向過程進行研究有助於理解正向過程。通過對 MVC 轉 Web Form 的研究,可以推匯出:如果想把一個 Web Form 應用轉換為 MVC 應用,可能需要進行怎麼樣的準備,應該從哪些方面去考慮重構?

當然研究不是我們最真實的目的,專案需要才是非常有力的理由——在我們用 MVC 框架已經初步完成專案第一階段的時候準備試執行的時候,客戶要求必須使用 Web Form——這不是客戶的原因,只是我們前期調研得不夠仔細。

產生這樣的需求有很多歷史原因,這不是今天要討論的範圍。我們要討論的是如何快速的把 MVC 框架改回 Web Form 框架。要完成這個任務,需要做哪些事情?

  • 在 Web Form 中 渲染 Razor 模板……如果不行,就得按 Razor 重寫 Aspx
  • 所有 Ajax 呼叫的 Controller 都必須改用 Ashx 來實現
  • MVC 的路由配置得取消,URL 與原始的目錄路徑結構強相關
  • 前端變化不大,但是要小心 Web Form 對元素 ID 和控制元件名稱(name)的強制處理

Razor 框架 → Aspx 框架

很不幸,沒找到現成的工具在 Web Form 框架中渲染 Razor 模板。所以這部分工作只是能手工完成了。幸好 Aspx 框架可以定義 Master 頁面,而且 Master 可以巢狀,其它一些框架元素也可以在 aspx 框架中找到對應的元素來解決:

  • layout 佈局頁 → Master 母板頁
  • cshtml 模板頁 → aspx 頁面
  • @section → asp:ContentPlaceHolder
  • @helper → ascx 控制元件

基於前後端分享的 MVC 框架沒有用到 aspx 的事件機制,可以直接在 web.config 裡禁用 ViewState,順便設定 clientIDModeStatic,免得 Web Form 亂改 ID 名稱。

<system.web>
    <pages clientIDMode="Static"
           enableSessionState="true"
           enableViewState="false"
           enableViewStateMac="false">
    </pages>
</system.web>

說起來輕鬆,但這部分工作需要大量的人工操作,所以其實是最累也最容易出錯的。

移植 Controller

Controller 是 MVC 中的概念,但實際上可以把 Controller 看作是一個 Action 的集合,而 Action 在 RPC 的概念中對應於過程(Procedure)名稱以及對應的引數定義。

由於前面對 Razor 的移植,所有返回 View() 的 Action 都被換成了 .aspx 頁面訪問。所以先把這部分 Action 從 Controller 中剔除掉。剩下的大部分是返回 JsonNetResult 的 Action,用於 Ajax 呼叫。現在不得不慶幸沒有使用 RESTful 風格,完全不用擔心 HTTP Method 的處理。

RESTful 很好,但不要迷信它,這種風格並不適應所有場景,有興趣可以看看 oschina 上的一篇協同翻譯文章 理解面向 HTTP API 的 REST 和 RPC

可能有些人能猜測到 JsonNetResult 是個什麼東西,不過我覺得還是有必要說一下

介紹 JsonNetResult

MVC API Controller 使用了 Newtonsoft Json.Net 來實現 JsonResultSystem.Web.Http.Results.JsonResult<T>,在 System.Web.Http.dll 中)。而普通 Controller 是用微軟自己的 JavaScriptSerializer 來實現的的 JsonResultSystem.Web.Mvc.JsonResult,在 System.Web.Mvc.dll 中)。因為 JavaScriptSerializer 不如 Json.Net 好用,所以在寫普通的 MVC Controller 的時候,會用 Json.Net 自己實現一個 JsonNetResult,在網上有很多實現,下面也會有一段類似的程式碼,所以就不貼了。

入口

在 MVC 中,路由系統可以找到指定的 Controller 和 Action,但在 Web Form 中沒有路由系統,自己寫個 HttpModule 是可以實現,不過工作量不小。既然剩下的幾乎都是請求資料的 HTTP API,比較合適的選擇是 IHttpHandler,即 ashx 頁面。

只需要定義一個 Do.ashx,通過引數指定 Controller 和 Action,把 Do.ashx 作為所有 Ajax 及類似請求的入口。

有了入口,還得模擬 MVC 對 Controller 和 Action 的處理。這裡有幾個關鍵點需要注意:

  • 所有 Action 返回的是一個 ActionResult,由框架處理 ActionResult 物件來向 Response 進行輸出。
  • Action 的引數會由 MVC 框架根據名稱來解析

如果這些要點沒處理好,Controller 就得進行結構上的變更。下面會根據這兩個要點來介紹 ActionResult 、Controller 和 Do.ashx 的實現,它們也是本文的重點。

Controller 基類

所有的 Controller 都從基類 Controller 繼承,看起來它很重要。但實際上 Controller 基類只是提供了一些工作方法,為所有 Controller 提供了統一擴充套件的基礎。而所有重要的事情,都不是在這裡面完成的。

引數的解析和自動賦值是在 Do.ashx 中完成的,當然,這個功能很重要,所以寫了一些類來實現;業務過程是在它的子類中完成的;結果處理則是在 ActionResult 中完成的。把它們組合在一起,這才是 Controller 乾的事情,而它必須要做的,就是提供一個基類,僅此而已。

IActionResult 和 ActionResult

從網上找到的 JsonNetResult 實現程式碼,基本上可以瞭解到,ActionResult 最終會通過 ExecuteResult(HttpContext) 方法將自身儲存的引數或者資料,進行一定的處理之後,輸出到 HttpContext.Response 物件。所以 IActionResult 介面比如簡單,而 ActionResult 就是一個預設實現。

public interface IActionResult
{
    void ExecuteResult(HttpContext context);
}

不過重要的不是 IActionResultActionResult,而是具體的實現。從原有的程式功能來看,至少需要實現:

  • JsonNetResult,用於輸出 JSON 結果
  • HttpStatsResult,用於輸出指定的 Http 狀態,比如 403
  • HttpNotFoundResult,用於輸出 404 狀態
  • FileResult,這是下載檔案要用到的

JsonNetResult

這是最主要使用的一個 Result。它主要是設定 ContentType 為 "application/json",預設編碼 UTF-8,然後就是用 Json.Net 將資料物件處理成 JSON 輸出到 Response。

public class JsonNetResult : IActionResult
{
    private const string DEFAULT_CONTENT_TYPE = "application/json";

    // 指定 Response 的編碼,未指定則使用全域性指定的那個(UTF-8)
    public Encoding ContentEncoding { get; set; }
    
    // ContentType,未設定則使用 DEFAULT_CONTENT_TYPE
    public string ContentType { get; set; }
    
    // 儲存要序列化成 JSON 的資料物件
    public object Data { get; set; }

    public JsonNetResult()
    {
        Settings = JsonConvert.DefaultSettings();
    }

    // 為當前的 Json 序列化準備一個配置物件,
    // 如果有特殊需要,可以修改其配置項,不會影響全域性配置
    public JsonSerializerSettings Settings { get; private set; }

    public void ExecuteResult(HttpContext context)
    {
        HttpResponse response = context.Response;

        if (ContentEncoding != null)
        {
            response.ContentEncoding = ContentEncoding;
        }

        if (Data == null)
        {
            return;
        }

        response.ContentType = string.IsNullOrEmpty(ContentType)
            ? DEFAULT_CONTENT_TYPE
            : ContentType;

        var scriptSerializer = JsonSerializer.Create(Settings);
        // Serialize the data to the Output stream of the response
        scriptSerializer.Serialize(response.Output, Data);
        response.Flush();
        // response.End() 加了會在後臺拋一個異常,所以把它註釋掉了
        // response.End();
    }
}

HttpStatusResult 和 HttpNotFoundResult

HttpNotFoundResult 其實就是 HttpStatusResult 的一個特例,所以只需要實現 HttpStatusResult 再繼承一個 HttpNotFoundResult 出來就好

HttpStatusResult 最主要的是需要一個程式碼,StatusCode,像 404 啊,403 啊,505 啊之類的。另外 IIS 實現了子狀態,所以還有一個子狀態碼 SubStatusCode。剩下的就是一個訊息了,都不是必須的屬性。實現起來非常簡單

public class HttpStatusResult : IActionResult
{
    public int StatusCode;
    public int SubStatusCode;
    public string Status;
    public string StatusDescription { get; set; }

    public HttpStatusResult(int statusCode, string status = null)
    {
        StatusCode = statusCode;
        Status = status;
    }

    public void ExecuteResult(HttpContext context)
    {
        var response = context.Response;
        response.StatusCode = StatusCode;
        response.SubStatusCode = SubStatusCode;
        response.Status = Status ?? response.Status;
        response.StatusDescription = StatusDescription ?? response.StatusDescription;
        response.End();
    }
}

public sealed class HttpNotFoundResult : HttpStatusResult, IActionResult
{
    public HttpNotFoundResult()
        : base(404, "404 Resource not found")
    {
    }
}

FileResult

對於檔案來說,有三個主要的屬性:MIME、檔案流和檔名。配置好 Response 的頭之後,簡單的把檔案流拷貝到 Response 的輸出流就解決問題

public class FileResult : IActionResult
{
    const string DEFAULT_CONTENT_TYPE = "application/octet-stream";
    public string ContentType { get; set; }

    readonly string filename;
    readonly Stream stream;

    public FileResult(Stream stream, string filename = null)
    {
        this.filename = filename;
        this.stream = stream;
    }

    public void ExecuteResult(HttpContext context)
    {
        var response = context.Response;
        response.ContentType = string.IsNullOrEmpty(ContentType)
            ? DEFAULT_CONTENT_TYPE
            : ContentType;

        if (!string.IsNullOrEmpty(filename))
        {
            response.AddHeader("Content-Disposition",
               string.Format("attachment; filename="{0}"", filename));
        }

        response.AddHeader("Content-Length", stream.Length.ToString());

        stream.CopyTo(response.OutputStream);
        stream.Dispose();
        response.End();
    }
}

Do.ashx

上面已經提到了 Do.ashx 是一個入口,它的首要工作是選擇正確的 Controller 和 Action。Action 的指定是通過引數實現的,我們得定義一個特別的引數,思考再三,將引數名定義為 $,因為它夠特殊,而且比 action 或者 _action 短。而這個引數的值,就延用 MVC 中路由的結構 /controller/action/id

幸好原來路由結構就不復雜,不然解析函式就難寫了。

MVC 框架中有一個 ActionDescriptor 類儲存了 Controller 和 Action 的資訊。所以我們模擬一個 ActoinDescriptor,然後 Do.ashx 就只需要對每次請求生成一個 ActionDescriptor 物件,讓它來解析引數,選擇 Controller 和 Action,再呼叫找到的 Action,處理結果……明白了吧,它才是真正的排程中心!

ActionDescriptor 要乾的第一件事就是解析 $ 引數。因為在 Controller 和 Action 不明確之後,ActionDescriptor 物件就沒必要存在,所以我們定義了一個靜態方法:

static ActionDescriptor Parse(string action)

幸好我們原來的路由定義得並不複雜,所以這裡的解析函式也可以寫得很簡單,只是按分隔符 / 拆成幾段分別賦值給新物件的 ControllerActionId 屬性就好。

internal static ActionDescriptor Parse(string action)
{
    if (string.IsNullOrWhiteSpace(action))
    {
        return null;
    }

    var parts = action
        .Trim(`/`, ` `)
        .Split(SPLITERS, StringSplitOptions.RemoveEmptyEntries);

    return new ActionDescriptor {
        Controller = parts[0],
        Action = parts.Length > 1 ? parts[1] : "index",
        Id = parts.Length > 2 ? parts[2] : null
    };
}

Router 反射工具類

雖然沒有路由系統,但是上面得到了 ControllerAction 這兩個名稱之後,還需要找到對應的 Controller 類,以及對應於 Action 的方法——這一些都需要用反射來完成。

Router 就是定義來幹這個事情,所以它是一個反射工具類。它所做的事情,只是把類和方法找出來,即一個 Type 物件,一個 MethodInfo 物件。

Router 類有 60 多行程式碼,不算大也不算小。限於篇幅,程式碼我就不準備貼了,因為它乾的事情實在很簡單,只要有反射的基礎知識,寫出來也就是分分鐘的事情。

ActionDescriptor.Do(HttpContext)

Router 把 Controller 的類,一個 Type 物件,以及 Action 對應的方法,一個 MethodInfo 物件找出來之後,還需要例項化並對例項呼叫方法,得到一個 IActionResult,再呼叫它的 ExecuteResult(HttpContext) 方法將結果輸出到 Response。

這一整個過程就是 ActionDescriptor.Do() 乾的事情,非常清晰也非常簡單。用虛擬碼描述出來就是

var tuple = Router.Get(controllerName, actionName);
// tuple.Item1 是 Type 物件
// tuple.Item2 是 MethodInfo 物件

var instance = Activator.CreateInstance(tuple.Item1);
var result = method.Invoke(c, GetArguments(method, context.Request));

if (typeof(IActionResult).IsAssignableFrom(result.GetType()))
{
    ((IActionResult)result).ExecuteResult(context);
}
else
{
    // 如果返回的不是 IActionResult,當作 JsonNetResult 的資料來處理
    // 這樣相當於擴充套件了 Action,可以直接返回需要序列化成 JSON 的資料物件
    new JsonNetResult
    {
        Data = result
    }.ExecuteResult(context);
}

等一等,發現身份不明的東東——GetArguments() 這是幹啥用的?

object[] GetArguments(MethodInfo, HttpRequest)

從簽名就可以猜測 GetArguments() 要分析 Action 對應方法的引數定義,然後從 Reqeust 中取值,返回一個與 Action 方法引數定義一一對應的引數值列表(陣列)……也就是 MethodInfo.Invoke() 方法的第二個引數。

GetArguments() 內部使用 ReqeustParser 來實現對每一個引數進行取值,它的主要過程只是對傳入的 MethodInfo 物件的引數列表進行遍歷

object[] GetArguments(MethodInfo method, HttpRequest request)
{
    var parser = new RequestParser(request);

    // 通過 Linq 的 Select 擴充套件來遍歷引數列表,並依次通過 RequestParser 來取值
    return method.GetParameters()
        .Select(p => parser.ParseValue(p.Name, p.ParameterType))
        .ToArray();
}

這麼一來,取值的重任就交給 RequestParser 了——你覺得任務不夠重嗎?如果只是對簡單的資料型別,比如 int、string 取值,當然不重,但如果是一個資料模型呢?

RequestParser

ReqeustParser 首要實現的就是對簡單型別取值,這是在 ParseValue() 方法中實現的,進行簡單的分析之後呼叫 Convert.ChangeType() 就能解決問題。

但如果遇到一個資料模型,就需要用 ParseObject() 來處理了,它會遍歷模型物件的所有屬性,並依次遞迴呼叫 ParseValue() 來進行處理——這裡偷懶了,只處理了屬性,沒有去處理欄位——如果你需要,自己實現也不是難事

class RequestParser
{
    static bool IsConvertableType(Type type)
    {
        switch (type.FullName)
        {
            case "System.DateTime":
            case "System.Decimal":
                return true;
            default:
                return false;
        }
    }

    readonly HttpRequest request;

    internal RequestParser(HttpRequest request)
    {
        this.request = request;
    }

    internal object ParseValue(string name, Type type)
    {
        string value = request[name];
        if (type == typeof(string))
        {
            return value;
        }

        if (string.IsNullOrWhiteSpace(value))
        {
            value = null;
        }

        var vType = Nullable.GetUnderlyingType(type) ?? type;

        if (vType.IsEnum)
        {
            return value == null
                ? null
                : Enum.ToObject(
                    vType,
                    Convert.ChangeType(value, Enum.GetUnderlyingType(vType)));
        }

        if (vType.IsPrimitive || IsConvertableType(vType))
        {
            return value == null ? null : Convert.ChangeType(value, vType);
        }

        return ParseObject(vType);
    }

    internal object ParseObject(Type type)
    {
        const BindingFlags flags
            = BindingFlags.Instance
            | BindingFlags.SetProperty
            | BindingFlags.Public;

        object obj;
        try
        {
            obj = Activator.CreateInstance(type);
        }
        catch
        {
            return null;
        }

        foreach (var p in type.GetProperties(flags)
            .Where(p => p.GetIndexParameters().Length == 0))
        {
            var value = ParseValue(p.Name, p.PropertyType);
            if (value != null)
            {
                p.SetValue(obj, value, null);
            }
        }
        return obj;
    }
}

雖然一句註釋都沒有,但我相信你看得懂。如果實在不明白,請留言。

結束語

到此,從 MVC 轉為 Web Form 的主要技術問題都已經解決了。其中一些處理方式是借鑑了 MVC 框架的實現思路。因此這個專案在切換框架的時候還不是特別複雜,所以要處理的事情也相對較少。對於一個成熟的 MVC 框架實現的專案來說,轉換絕不是一件輕鬆的事情——相當於你得自己在 Web Form 中實現 MVC 框架,工作量大不說,穩定性也堪憂。

MVC 框架還有很重要的一個部分就是 Filter,對於 Filter 的簡單實現,可以在 ActionDescriptor 中進行處理。但如果你想做這件事情,一定要謹慎,因為這涉及到一個相對複雜的生命週期,搞不好就可能刨個坑把自個兒埋了。

相關文章