從 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,順便設定 clientIDMode
為 Static
,免得 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 來實現 JsonResult
(System.Web.Http.Results.JsonResult<T>
,在 System.Web.Http.dll 中)。而普通 Controller 是用微軟自己的 JavaScriptSerializer 來實現的的 JsonResult
(System.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);
}
不過重要的不是 IActionResult
和 ActionResult
,而是具體的實現。從原有的程式功能來看,至少需要實現:
-
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)
幸好我們原來的路由定義得並不複雜,所以這裡的解析函式也可以寫得很簡單,只是按分隔符 /
拆成幾段分別賦值給新物件的 Controller
、Action
和 Id
屬性就好。
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 反射工具類
雖然沒有路由系統,但是上面得到了 Controller
和 Action
這兩個名稱之後,還需要找到對應的 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
中進行處理。但如果你想做這件事情,一定要謹慎,因為這涉及到一個相對複雜的生命週期,搞不好就可能刨個坑把自個兒埋了。