上篇博客【寫自己的ASP.NET MVC框架(上)】
我給大家介紹我的MVC框架對于Ajax的支持與實現原理。今天的博客將介紹我的MVC框架對UI部分的支持。
注意:由于這篇博客是基于前篇博客的,因此有些已說過的內容將會直接跳過,也不會給出提示。
所以,如果要想理解這篇博客,那么閱讀上篇博客【寫自己的ASP.NET MVC框架(上)】則是必要的。
MyMVC的特點
在開發MyMVC的過程中,我吸取了一些ASP.NET WebForm的使用經驗,也參考了ASP.NET MVC,也接受了Martin Fowler對于MVC思想的總結。
在設計過程中,我只實現了一些必要的功能,而且沒有引入其它的類庫與組件,因此,它非常簡單,且容易使用。
我們可以這樣理解MyMVC:它是一個簡單,容易使用,且符合MVC思想的框架。
在MyMVC框架中,View仍然采用了WebForm中的Page,畢竟Page已經使用了十年,能經得起時間的檢驗,它仍然是我們可信賴的技術。
另一方面,Page也是ASP.NET中默認的HTML輸出技術,使用它會比較方便。
MyMVC與微軟的ASP.NET MVC不同的是:
1. 不依賴于URL路由組件。
2. 不提供任何HtmlHelper
3. Controller只是一個Action的容器,沒有基類的要求。
4. Action處理的請求不區分POST, GET
5. URL可以直接對應一個網站目錄中的aspx頁面(View)。
6. View的使用是使用路徑來指定,與Controller,Action的名字無關。
說明:URL雖然可以與網站中的頁面對應,但這種對應并不是必須的,也可以不對應。
而且本質上與WebFrom中的頁面執行過程并不相同。
下圖反映了在MyMVC中,一個頁面請求的執行過程:

介紹示例項目
為了讓大家對MyMVC有興趣,也為了檢驗MyMVC的設計,我在開發MyMVC的過程,還專門開發一個基于MyMVC的ASP.NET網站示例項目。
網站提供了三種顯示風格(也就是三種View),下面以“客戶管理”頁面為例來展示三種View的不同:
風格1

View對應的代碼如下:
<%@ Page Title="客戶管理" Language="C#" MasterPageFile="MasterPage.master"
Inherits="MyPageView<CustomersPageModel>" %>
<asp:Content ID="Content1" ContentPlaceHolderID="head" Runat="Server">
<%= HtmlExtension.RefJsFileHtml("/js/MyPage/Customers.js")%>
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="ContentPlaceHolder1" Runat="Server">
<p><a id="btnCreateItem" href="#" class="easyui-linkbutton" iconCls="icon-add">創建客戶</a></p>
<table class="GridView" cellspacing="0" cellpadding="4" border="0" style="border-collapse:collapse;">
<tr align="left">
<th style="width:20px;"> </th>
<th style="width:260px;">客戶名稱</th>
<th style="width:80px;">聯系人</th>
<th>地址</th>
<th style="width:80px;">郵編</th>
<th style="width:160px;">電話</th>
</tr>
<% foreach( Customer customer in Model.List ) { %>
<tr>
<td><a href="/AjaxCustomer/Delete.cspx?id=<%= customer.CustomerID %>&returnUrl=<%= RequestUrlEncodeRawUrl %>"
title="刪除" class="easyui-linkbutton" plain="true">
<img src="/Images/delete.gif" alt="刪除" /></a>
</td>
<td><a href="#" class="easyui-linkbutton" rowId="<%= customer.CustomerID %>" plain="true" iconCls="icon-open">
<%= customer.CustomerName.HtmlEncode()%></a>
</td>
<td><span name="ContactName"><%= customer.ContactName.HtmlEncode() %></span>
</td>
<td><span name="Address"><%= customer.Address.HtmlEncode() %></span>
</td>
<td><span name="PostalCode"><%= customer.PostalCode.HtmlEncode() %></span>
</td>
<td><span name="Tel"><%= customer.Tel.HtmlEncode() %></span>
</td>
</tr>
<% } %>
<%= Model.PagingInfo.PaginationBar(6)%>
</table>
<div id="divCustomerInfo" title="客戶" style="padding: 8px; display: none">
<%= UcExecutor.Render("/Controls/Style1/CustomerInfo.ascx", Model.Customer)%>
</div>
</asp:Content>
風格2

View對應的代碼如下:
<%@ Page Title="客戶管理" Language="C#" MasterPageFile="MasterPage.master" %>
<asp:Content ID="Content1" ContentPlaceHolderID="head" Runat="Server">
<%= HtmlExtension.RefJsFileHtml("/js/MyPage2/Customers.js")%>
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="ContentPlaceHolder1" Runat="Server">
<table id="grid1"></table>
<div id="divCustomerInfo" title="客戶" style="padding: 8px; display: none">
<%= UcExecutor.Render("/Controls/Style2/CustomerInfo.ascx", null)%>
</div>
</asp:Content>
風格3

View對應的代碼如下:
<%@ Page Title="客戶管理" Language="C#" MasterPageFile="MasterPage.master"
Inherits="MyPageView<CustomersPageModel>" %>
<asp:Content ID="Content1" ContentPlaceHolderID="head" Runat="Server">
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="ContentPlaceHolder1" Runat="Server">
<ul class="itemList">
<% foreach( Customer customer in Model.List ) { %>
<li>
<table class="GridView" cellspacing="0" cellpadding="4" border="0" style="border-collapse:collapse;">
<tr><td><%= customer.CustomerName.HtmlEncode()%></td></tr>
<tr><td><%= customer.ContactName.HtmlEncode() %></td></tr>
<tr><td><%= customer.Address.HtmlEncode() %></td></tr>
<tr><td><%= customer.PostalCode.HtmlEncode() %></td></tr>
<tr><td><%= customer.Tel.HtmlEncode() %></td></tr>
</table>
</li>
<% } %>
</ul>
<%= Model.PagingInfo.PaginationBar()%>
</asp:Content>
這是三種截然不同的風格,在服務端的代碼也是完全不同的。
其中第二種風格,是采用了我上篇博客中總結的【純AJAX網站】的風格來開發,因此在服務端頁面的開發過程中,最為簡單,它需要輸出的HTML最少,UI部分由客戶端的JS來實現。
對于第一種和第三種風格,它們的HTML結構是不同的,頁面所能完成的功能也是不同的,
除此之外,它們應該是比較類似的,都是從下面這個泛型類型繼承而來:
Inherits="MyPageView<CustomersPageModel>"
從泛型類型繼承的好處是:我可以在設計頁面時,對于涉及Model的訪問,都會有智能提示。比如:

由于有智能提示的支持,可以提高開發效率,并可以避免一些低級的拼寫錯誤。
雖然前面我們可以從圖片中看到訪問【同一個URL地址】出現【三個不同的頁面】,但它們背后的Controller卻是同一個:
public class CustomerController
{
[Action]
[PageUrl(Url = "/mvc/Customers")]
[PageUrl(Url = "/mvc/Customers.html")]
[PageUrl(Url = "/mvc/CustomerList.aspx")]
[PageUrl(Url = "/Pages/Customers.aspx")]
public static object LoadModel(int? page)
{
// 說明:參數page表示分頁數,方法名LoadModel其實可以【隨便取】。
// 根據用戶選擇的界面風格,計算實現要呈現的頁面路徑。
string papeUrl = StyleHelper.GetTargetPageUrl("Customers.aspx");
if( StyleHelper.PageStyle == StyleHelper.StyleArray[1] )
// Style2 風格下,頁面不需要綁定數據。數據由JS通過AJAX方式獲取
return new PageResult(papeUrl, null);
// 為Style1 風格獲取數據。
CustomerSearchInfo info = new CustomerSearchInfo();
info.SearchWord = string.Empty;
info.PageIndex = page.HasValue ? page.Value - 1 : 0;
info.PageSize = AppHelper.DefaultPageSize;
CustomersPageModel result = new CustomersPageModel();
result.PagingInfo = info;
result.List = BllFactory.GetCustomerBLL().GetList(info);
return new PageResult(papeUrl, result);
}
}
通過上面代碼可以看到我用了4個[PageUrl],這意味著其實我可以使用4種不同的URL都能訪問到這三個頁面,
而且每一個URL都會根據當前用戶所選擇的風格,呈現對應的頁面。
事實上,我還可以為這個Action指定更多的[PageUrl],讓它可以處理更多的URL。關于[PageUrl]的使用與設計目的,請繼續往下閱讀。
關于URL路由
隨著 .net framewrok 3.5 的問世,微軟發布了一個【ASP.NET 路由】組件,它的出現給當時的URL優化方法提供了另外一種選擇,
不僅如此,它還提供了一些URL重寫組件沒有的功能:生成URL 。
隨著AP.NET MVC的出現,【ASP.NET 路由】成為此框架的直接依賴組件,我們很難有其它的選擇,
而且,想不用都不行。
有趣的是:【ASP.NET 路由】這個后生小子的出現,并沒有很好地遵守ASP.NET制定的一些規則,
其中最為明顯的是:它跳過了【處理器的映射】階段,導致ASP.NET MVC在支持Session時,很為難。
直到最后ASP.NET 4.0,微軟修改了Session的部分實現方式,這樣ASP.NET MVC才能最終借此機會解決Session的完整支持問題。
ASP.NET 路由雖然可以生成URL,但它引入了RouteData的概念,要想支持它,需要在框架層面上做許多基礎工作。
而且,我認為:
1. 并不是每個網站都需要這種技術,對于不需要URL優化的網站來說,URL路由的使用只是白白地浪費性能。
2. 另一方面,即使需要URL優化,我們還有眾多的URL重寫組件可供選擇,這樣可以不用改變現在構架。
因此,MyMVC雖然不支持URL路由,但并不表示不能實現URL優化。
在MVC思想中,Controller應該是處理請求的地方,也是最先運行的部分。
然而在傳統的WebForm編程模型中,aspx頁面負責處理請求。
因此,必須采取一種方式讓最先處理請求的地方從aspx頁面中轉移,并能提前執行。
而且,將代碼從頁面移出還有另外二個好處:
1. 被移出的代碼肯定是與UI部分無關的,因此,會比較容易測試。
2. 代碼與UI的分享也意味著:可以根據運行條件,有選擇地將結果交給不同的View來呈現。
考慮到Action可以選擇將結果交給不同的View來呈現,而Session也需要支持的問題,
最終我決定,在框架內部使用一個專門的HttpHandler來執行用戶的Action,根據Action所要求的Session支持模式,
HttpHandlerFactory創建不同的HttpHandler來支持。由于需要使用HttpHandlerFactory,所以必須在web.config中注冊。
配置MyMVC框架
MyMVC在使用時,需要在web.config中簡單的配置:
<httpHandlers>
<add path="*.aspx" verb="*" type="MyMVC.MvcPageHandlerFactory, MyMVC" validate="true"/>
</httpHandlers>
如果使用IIS7,則參考以下配置:
<system.webServer>
<handlers>
<add name="MvcPageHandlerFactory" verb="*" path="*.aspx"
type="MyMVC.MvcPageHandlerFactory, MyMVC" preCondition="integratedMode"/>
</handlers>
</system.webServer>
我們可以把MvcPageHandlerFactory理解成MyMVC在ASP.NET管線的入口。
注意:
1. 上面的配置代碼中,選擇aspx這個擴展名并不是必須的,您也可以選擇喜歡的擴展名。
2. 如果不喜歡擴展名的映射,可以使用HttpModule,MyMVC中提供的方法也能替代這個過程。
映射處理器(入口)
在web.config中注冊MvcPageHandlerFactory后,所有符合條件的請求將會進入MvcPageHandlerFactory。
我們來看一下MvcPageHandlerFactory的實現代碼:
internal sealed class AspnetPageHandlerFactory : PageHandlerFactory { }
public sealed class MvcPageHandlerFactory : IHttpHandlerFactory
{
/// <summary>
/// 嘗試根據當前請求,獲取一個有效的Action,并返回ActionHandler
/// 此方法可以在HttpModule中使用,用于替代httpHandler的映射配置
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public static IHttpHandler TryGetHandler(HttpContext context)
{
InvokeInfo vkInfo = ReflectionHelper.GetPageActionInvokeInfo(context.Request.FilePath);
if( vkInfo == null )
return null;
return ActionHandler.CreateHandler(vkInfo);
}
private AspnetPageHandlerFactory _msPageHandlerFactory;
IHttpHandler IHttpHandlerFactory.GetHandler(HttpContext context,
string requestType, string virtualPath, string physicalPath)
{
// 嘗試根據請求路徑獲取Action
InvokeInfo vkInfo = ReflectionHelper.GetPageActionInvokeInfo(virtualPath);
// 如果沒有找到合適的Action,并且請求的是一個ASPX頁面,則按ASP.NET默認的方式來繼續處理
if( vkInfo == null && virtualPath.EndsWith(".aspx", StringComparison.OrdinalIgnoreCase) ) {
if( _msPageHandlerFactory == null )
_msPageHandlerFactory = new AspnetPageHandlerFactory();
// 調用ASP.NET默認的Page處理器工廠來處理
return _msPageHandlerFactory.GetHandler(context, requestType, virtualPath, physicalPath);
}
return ActionHandler.CreateHandler(vkInfo);
}
void IHttpHandlerFactory.ReleaseHandler(IHttpHandler handler)
{
}
}
從代碼中可以看到,MyMVC首先會根據當前的請求地址查找有沒有一個Action可以處理它,如果沒有,則采用ASP.NET默認的方式來處理。
因此,把【*.aspx】交給MvcPageHandlerFactory是不會有問題的。
說明:創建一個空殼類型AspnetPageHandlerFactory的原因是:不能直接調用PageHandlerFactory的構造函數。
內部初始化
MyMVC在第一次處理請求時,要做一個初始化的過程,這個過程是由MvcPageHandlerFactory中的一個調用引發的:
// 嘗試根據請求路徑獲取Action
InvokeInfo vkInfo = ReflectionHelper.GetPageActionInvokeInfo(virtualPath);
ReflectionHelper有個靜態構造函數,雖然上次我已貼出它的代碼,但那只是部分代碼,以下才是完整的初始化代碼:
internal static class ReflectionHelper
{
// 保存PageAction的字典
private static Dictionary<string, ActionDescription> s_PageActionDict;
// 保存AjaxController的列表
private static List<ControllerDescription> s_AjaxControllerList;
// 保存AjaxAction的字典
private static Hashtable s_AjaxActionTable = Hashtable.Synchronized(
new Hashtable(4096, StringComparer.OrdinalIgnoreCase));
// 用于從類型查找Action的反射標記
private static readonly BindingFlags ActionBindingFlags =
BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase;
static ReflectionHelper()
{
InitControllers();
}
/// <summary>
/// 加載所有的Controller
/// </summary>
private static void InitControllers()
{
s_AjaxControllerList = new List<ControllerDescription>(1024);
var pageControllerList = new List<ControllerDescription>(1024);
ICollection assemblies = BuildManager.GetReferencedAssemblies();
foreach( Assembly assembly in assemblies ) {
// 過濾以【System.】開頭的程序集,加快速度
if( assembly.FullName.StartsWith("System.", StringComparison.OrdinalIgnoreCase) )
continue;
try {
foreach( Type t in assembly.GetExportedTypes() ) {
if( t.Name.StartsWith("Ajax") )
s_AjaxControllerList.Add(new ControllerDescription(t));
else if( t.Name.EndsWith("Controller") )
pageControllerList.Add(new ControllerDescription(t));
}
}
catch { }
}
// 提前加載Page Controller中的所有Action方法
s_PageActionDict = new Dictionary<string, ActionDescription>(4096, StringComparer.OrdinalIgnoreCase);
foreach( ControllerDescription controller in pageControllerList ) {
foreach( MethodInfo m in controller.ControllerType.GetMethods(ActionBindingFlags) ) {
PageUrlAttribute[] pageUrlAttrs = m.GetMyAttributes<PageUrlAttribute>();
ActionAttribute actionAttr = m.GetMyAttribute<ActionAttribute>();
if( pageUrlAttrs.Length > 0 && actionAttr != null ) {
ActionDescription actionDescription =
new ActionDescription(m, actionAttr) { PageController = controller };
foreach( PageUrlAttribute attr in pageUrlAttrs ) {
if( string.IsNullOrEmpty(attr.Url) == false )
s_PageActionDict.Add(attr.Url, actionDescription);
}
}
}
}
// 用于Ajax調用的Action信息則采用延遲加載的方式。
}
從以上代碼可以看出,在初始化時,MyMVC加載了全部的PageAction ,而AjaxAction卻沒有采用這種方式來實現,為什么呢?
請繼續閱讀。
從URL到Action的映射過程
前面我們看到了MyMVC的初始化過程,其實是在ReflectionHelper的構造函數中完成的。
在這個初始化之后,MvcPageHandlerFactory調用ReflectionHelper.GetPageActionInvokeInfo(virtualPath)便可以得到要調用的Action的具體描述。
我稱這個過程為:從URL到Action的映射。
GetPageActionInvokeInfo方法的實現代碼如下:
/// <summary>
/// 根據一個頁面請求路徑,返回內部表示的調用信息。
/// </summary>
/// <param name="url"></param>
/// <returns></returns>
public static InvokeInfo GetPageActionInvokeInfo(string url)
{
if( string.IsNullOrEmpty(url) )
throw new ArgumentNullException("url");
ActionDescription action = null;
if( s_PageActionDict.TryGetValue(url, out action) == false )
return null;
InvokeInfo vkInfo = new InvokeInfo();
vkInfo.Controller = action.PageController;
vkInfo.Action = action;
if( vkInfo.Action.MethodInfo.IsStatic == false )
vkInfo.Instance = Activator.CreateInstance(vkInfo.Controller.ControllerType);
return vkInfo;
}
在介紹這個映射過程之前,讓我們再來回顧一下Action的聲明代碼:
public class CustomerController
{
[Action]
[PageUrl(Url = "/mvc/Customers")]
[PageUrl(Url = "/mvc/Customers.html")]
[PageUrl(Url = "/mvc/CustomerList.aspx")]
[PageUrl(Url = "/Pages/Customers.aspx")]
public static object LoadModel(int? page)
{
通過ReflectionHelper構造函數中所完成的初始化過程,每個Action的描述會根據[PageUrl]的數量而生成多個字典條目,
因此,在GetPageActionInvokeInfo的實現過程中,也只是簡單的查找了這個字典而已,就可以得到所需要的調用信息,從面完成映射的過程。
整個過程可以用以下圖形來表示:

在上面的示例中,我使用了"/mvc/Customers"這種URL,顯然它并不符合我在web.config中為MvcPageHandlerFactory注冊時所指定的URL模式要求。
那么,又該如何處理呢?
雖然這種URL雖然沒有擴展名,但我仍然可以通過配置httpHandler的方式來解決,下面的配置就是我們需要的:
<httpHandlers>
<add path="/mvc/*" verb="*" type="MyMVC.MvcPageHandlerFactory, MyMVC" validate="true" />
</httpHandlers>
在介紹MvcPageHandlerFactory時,MyMVC提供了另一個方法TryGetHandler供外部使用。
因此,在示例網站中,我還可以在Global.asax中調用這個方法來解決前面的那個問題:
protected void Application_PostResolveRequestCache(object sender, EventArgs e)
{
// 這里只是一個演示。
// 主要是將諸如:/mvc/Customers 這類請求映射到MyMVC框架的處理器
HttpApplication app = (HttpApplication)sender;
if( app.Request.FilePath.StartsWith("/mvc/") ) {
IHttpHandler myHandler = MyMVC.MvcPageHandlerFactory.TryGetHandler(app.Context);
if( myHandler != null )
app.Context.RemapHandler(myHandler);
}
}
對于切換HttpHandler的操作,我有以下建議:
1. 盡量放在HttpModule中去實現。因為可以通過修改配置來切換規則(啟用或者禁止),所以會比較靈活。
2. 如果可以通過HttpHandler映射能實現的,盡量首選HttpHandler映射方式。原因:更快,更標準。
PageUrl的設計思想
在前面的示例代碼中,我為一個Action添加多個[PageUrl],來標記這個Action可以處理多個URL,
因此,一個Action能處理哪些URL是通過指定[PageUrl]來實現的。
為什么要叫【PageUrl】?
我想或許有些人會有這個疑問。
下面我就來回答這個問題,也可以讓大家了解我設計PageUrl的原因:
1. 我們請求一個URL通常是為了得到一個頁面顯示,因此可以認為一個URL最終可以表示成一個頁面。
2. 我也想過使用[Url]這種名稱,但感覺太短了,而且Ajax請求也有URL,那么必須顯式地加以區分。
所以,我最終決定使用[PageUrl]這個名字。
在Ajax部分,我認為通常只需要完成獲取數據以及處理提交數據的功能就可以了。
因此,絕大多數情況下是不要需View的,而且,一個功能與一個URL對應,這樣還可以簡化問題。
所以,在Ajax部分,我提倡在URL中直接指出要調用哪個Controller中的哪個Action。
在Page部分,事實上也需要一個Action,本來也是可以繼續使用這種做法的,
不過,我并沒有這種做,理由如下:
1. 我們創建View其實也是創建Page,使用Page的路徑不是更好嗎?而且WebForm的粉絲或許會更喜歡。
2. 多URL的匹配功能。后面會有詳細說明。
由于以上種種原因,我將[PageUrl]設計成與[Action]是獨立關系,并且[PageUrl]可以多次指定的。
注意:
1. Url參數中指定的字符串,可以對應一個aspx頁面。也可以不對應aspx頁面。
2. Url參數中,不要包含QueryString,否則根本不能匹配。
3. 如果您使用URL重寫組件,那么此處應該是重寫后的路徑。
由于我在MvcPageHandlerFactory中使用ASP.NET框架傳入的virtualPath并不包含查詢參數,
因此,把它理解成頁面路徑也是非常合適的。
多URL的匹配功能
或許有些人認為多URL匹配一個Action是沒有意義的,比如下面的這個Action會更符合常理:
public class CategoryController
{
[Action]
[PageUrl(Url = "/Pages/Categories.aspx")]
public object LoadModel()
{
是的,通常情況下,一個Action處理一個URL也是較為常見。
但仍然有二種情況需要這個功能。首先來看下面的示例:
[Action]
[PageUrl(Url = "/Pages/AddOrder.aspx")]
[PageUrl(Url = "/Pages/CodeExplorer.aspx")]
[PageUrl(Url = "/Pages/Default.aspx")]
[PageUrl(Url = "/Pages/Orders.aspx")]
public object TransferRequest()
{
// 這個Action要做的事較為簡單,
// 將請求 "/Pages/Orders.aspx" 用實際的頁面 "/Pages/StyleX/Orders.aspx" 來響應。
// 因為用戶選擇的風格不同,但URL地址是一樣的,所以在這里切換。
// 當然這樣的處理也只適合頁面不需要Model的情況下。
string filePath = HttpContextHelper.RequestFilePath;
int p = filePath.LastIndexOf('/');
string pageName = filePath.Substring(p + 1);
return new PageResult(StyleHelper.GetTargetPageUrl(pageName), null /*model*/);
}
代碼所涉及的4個頁面在呈現時,由于并不需要數據,但為了能夠實現多樣式的支持,它們可以共用一個Action,因此這里只是切換一個View的路徑而已。
理解上面那句話,可能還需要知道StyleHelper的實現代碼:
public static class StyleHelper
{
public static readonly string STR_PageStyle = "PageStyle";
public static readonly string[] StyleArray = new string[] { "Style1", "Style2", "Style3" };
public static string PageStyle
{
get { return CookieHelper.GetCookieValue(STR_PageStyle) ?? StyleArray[1]; }
}
public static string GetTargetPageUrl(string pageName)
{
return string.Format("/Pages/{0}/" + pageName, PageStyle);
}
}
示例網站的目錄結構如下圖:

在示例網站中,由于三種風格的截然不同,尤其是在功能與HTML結構上就完全不同,因此根本不可能通過CSS或者SKIN的方式來解決,
所以我為三種風格創建了三個目錄,分別存放相應的頁面文件。
最終根據用戶的選擇(Cookie)來決定使用哪個目錄下的頁面來呈現。
用戶設置風格的JS代碼如下,
$(function(){
$("a.btnSetStyle").click(function(){
var style = $(this).attr("ps");
// 其實寫cookie也可以直接使用JS去寫的。
$.ajax({
url: "/AjaxStyle/SetStyle.cspx",
data: {style: style},
success: function(){
window.location = window.location;
}
});
return false;
});
});
服務端的C#代碼如下:
public class AjaxStyle
{
[Action]
public void SetStyle(string style)
{
if( Array.IndexOf(StyleHelper.StyleArray, style) >= 0 ) {
HttpCookie cookie = new HttpCookie(StyleHelper.STR_PageStyle, style);
cookie.Expires = DateTime.Now.AddYears(1);
CookieHelper.AddCookie(cookie);
}
}
}
說明:CookieHelper是設計成支持單元測試的,所以不要懷疑這里的代碼不符合MVC,后面會專門談它。
所以,在這種情況下,多個URL映射到一個Action是有意義的。這是【多URL的匹配功能】的第一個用途。
解決老的URL兼容問題
在一個網站的成長過程中,一般會有重構的過程。在重構過程中,或許會刪除以前的某些頁面,或許調整URL格式。
然而,用戶也可能會收藏這個網站的鏈接,但由于頁面重構了,老的鏈接可能會因此而失效,造成404錯誤。
此時就要解決URL的兼容問題。
在ASP.NET中,我們可以在web.config配置urlMappings節點來做這樣的映射轉換。
還有另一種方法是,創建一個HttpModule專門判斷是否在請求一些老的URL,如果是,則重定向到新的頁面。
總之,不管使用哪種方法,都需要為每個傳入請求檢查URL是否是老格式的URL,
這個過程會根據一個列表來逐一檢查,不過,可惜的是:絕大部分請求可能都是新的URL格式,
而那些兼容方案無疑會浪費很多的CPU資源。
在MyMVC中,可以簡單地處理這個問題,就像下面的這個示例一樣:
public class CustomerController
{
[Action]
[PageUrl(Url = "/mvc/Customers")]
[PageUrl(Url = "/mvc/Customers.html")]
[PageUrl(Url = "/mvc/CustomerList.aspx")]
[PageUrl(Url = "/Pages/Customers.aspx")]
public static object LoadModel(int? page)
{
這個“客戶管理”頁面可能經過了多次重構,沒關系,只要把各個版本的地址用[PageUrl]標識出來就可以了,完全不用前面所說的兼容方案,
因此,在URL的兼容處理上沒有任何負擔,也不會影響性能。
說明:[PageUrl]的順序并不重要,可以隨意調整。
對身份認證的支持
MyMVC也支持一些基本的身份認證,可以通過在Action方法中添加[Authorize]修飾屬性來指示。
AuthorizeAttribute的實現代碼如下:
/// <summary>
/// 用于驗證用戶身份的修飾屬性
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class AuthorizeAttribute : Attribute
{
private string _user;
private string[] _users;
private string _role;
private string[] _roles;
private string[] SplitString(string value)
{
if( string.IsNullOrEmpty(value) )
return null;
else
return (from s in value.Split(',')
let u = s.Trim()
where u.Length > 0
select u).ToArray();
}
/// <summary>
/// 允許訪問的用戶列表,用逗號分隔。
/// </summary>
public string Users
{
get { return _user; }
set { _user = value; _users = SplitString(value); }
}
/// <summary>
/// 允許訪問的角色列表,用逗號分隔。
/// </summary>
public string Roles
{
get { return _role; }
set { _role = value; _roles = SplitString(value); }
}
internal bool AuthenticateRequest(HttpContext context)
{
if( context.Request.IsAuthenticated == false )
return false;
if( _users != null &&
_users.Contains(context.User.Identity.Name, StringComparer.OrdinalIgnoreCase) == false )
return false;
if( _roles != null && _roles.Any(context.User.IsInRole) == false )
return false;
return true;
}
}
認證檢查發生在調用Action之前,代碼如下:
internal static void ExecuteAction(HttpContext context, InvokeInfo vkInfo)
{
if( context == null )
throw new ArgumentNullException("context");
if( vkInfo == null )
throw new ArgumentNullException("vkInfo");
// 驗證請求是否允許訪問(身份驗證)
AuthorizeAttribute authorize = vkInfo.GetAuthorize();
if( authorize != null ) {
if( authorize.AuthenticateRequest(context) == false )
ExceptionHelper.Throw403Exception(context);
}
// 調用方法
object result = ExecuteActionInternal(context, vkInfo);
// 設置OutputCache
OutputCacheAttribute outputCache = vkInfo.GetOutputCacheSetting();
if( outputCache != null )
outputCache.SetResponseCache(context);
// 處理方法的返回結果
IActionResult executeResult = result as IActionResult;
if( executeResult != null ) {
executeResult.Ouput(context);
}
else {
if( result != null ) {
// 普通類型結果
context.Response.ContentType = "text/plain";
context.Response.Write(result.ToString());
}
}
}
下面的示例代碼演示了它的用法:
[Action]
[Authorize(Users="fish")]
[PageUrl(Url = "/Pages/Demo/TestAuthorize/Fish.aspx")]
public object ShowFishPage()
{
// 僅當當前用戶是 fish 時,才允許訪問這個PageAction
// 注意:第一參數為null,表示使用當前地址。
return new PageResult(null, null);
}
[Action]
[Authorize]
[PageUrl(Url = "/Pages/Demo/TestAuthorize/LoginUser.aspx")]
public object ShowLoginUserPage()
{
// 僅當當前用戶是已登錄用戶時,才允許訪問這個PageAction
// 注意:第一參數為null,表示使用當前地址。
return new PageResult(null, null);
}
注意:
1. 如果一個Action沒有使用[Authorize],則表示允許任意用戶訪問(包括未登錄用戶)。
2. [Authorize]對于AjaxAction仍然有效。
View的設計方式
在MyMVC中,View采用了ASP.NET Page,不過,我并不建議使用CodeFile文件。
不使用CodeFile文件,我想這是很多喜歡WebForm的人不能接受的。
他們更愿意在CodeFile文件中獲取數據,綁定數據,響應事件,處理用戶的提交數據。
也正是由于這個原因,才會讓其它人認為WebForm是一種對單元測試極差的編程模型。
這里我要表達一下我的觀點:代碼是否可支持單元測試,這其中最主要的原因還是開發人員自身造成的,
框架的選擇只是起到促進或是部分限制的作用。
就算讓一些人使用ASP.NET MVC,他們所編寫的代碼未必就能支持單元測試,
有些人實在太依賴于HttpContext.Current,甚至在ASP.NET MVC中還在寫這種代碼。
好吧,還是回到Page的設計這個話題上來。MyMVC所提倡的做法與ASP.NET MVC的做法類似,
那就是直接在Page中采用內聯的方式顯示數據,而不是在CodeFile中綁定數據。
許多人一看到ASP.NET MVC的這種內聯寫法,感覺又回到了ASP時代,認為是在倒退,其實這只是表面現象。
表面的背后是:代碼遠離了UI。,也可以理解成:邏輯遠離了UI。
這也是正是ASP.NET MVC一直所提倡的:分離關注點。
在新的開發理念中,原來的Page分解成View和Controller,在實現它們時,只關注自身那一部分就可以了,
因此,如果單看Page時,可能是會有前面所說的那種感覺。
另一方面,由于代碼遠離了UI,或許可以有更多的機會重構它們,使它們的重用性更高。
下面還是來回顧一下MyMVC中Page的代碼:
<%@ Page Title="客戶管理" Language="C#" MasterPageFile="MasterPage.master"
Inherits="MyPageView<CustomersPageModel>" %>
<asp:Content ID="Content1" ContentPlaceHolderID="head" Runat="Server">
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="ContentPlaceHolder1" Runat="Server">
<ul class="itemList">
<% foreach( Customer customer in Model.List ) { %>
<li>
<table class="GridView" cellspacing="0" cellpadding="4" border="0" style="border-collapse:collapse;">
<tr><td><%= customer.CustomerName.HtmlEncode()%></td></tr>
<tr><td><%= customer.ContactName.HtmlEncode() %></td></tr>
<tr><td><%= customer.Address.HtmlEncode() %></td></tr>
<tr><td><%= customer.PostalCode.HtmlEncode() %></td></tr>
<tr><td><%= customer.Tel.HtmlEncode() %></td></tr>
</table>
</li>
<% } %>
</ul>
<%= Model.PagingInfo.PaginationBar()%>
</asp:Content>
此時,對于呈現所需的數據可以直接從Model對象中獲取,但要求在Page指令中指出Model的類型,這樣還可以有智能提示的優點。
如果頁面需要顯示數據,請務必從MyPageView<>繼承,它的實現代碼如下:
/// <summary>
/// 頁面視圖的基類
/// </summary>
/// <typeparam name="TModel">傳遞給頁面呈現時所需的數據實體對象類型</typeparam>
public class MyPageView<TModel> : MyBasePage
{
/// <summary>
/// 用于頁面呈現時所需的數據實體對象
/// </summary>
public TModel Model { get; set; }
}
其實也就是一個簡單的類型,包含了Model這個屬性而已。
至于MyBasePage的實現代碼,我們可以忽略它,它是直接從System.Web.UI.Page繼承的。
再來一段用戶控件的代碼:
<%@ Control Language="C#" Inherits="MyUserControlView<Customer>" %>
<table cellpadding="4" border="0px">
<tr><td style="width: 80px">客戶名稱</td><td>
<input name="CustomerName" type="text" maxlength="50" id="txtCustomerName"
class="myTextbox w400" value="<%= Model.CustomerName.HtmlEncode() %>" />
</td></tr>
<tr><td>聯系人</td><td>
<input name="ContactName" type="text" maxlength="50" id="txtContactName"
class="myTextbox w400" value="<%= Model.ContactName.HtmlEncode() %>" />
</td></tr>
<tr><td>地址</td><td>
<input name="Address" type="text" maxlength="50" id="txtAddress"
class="myTextbox w400" value="<%= Model.Address.HtmlEncode() %>" />
</td></tr>
<tr><td>郵編</td><td>
<input name="PostalCode" type="text" maxlength="10" id="txtPostalCode"
class="myTextbox w400" value="<%= Model.PostalCode.HtmlEncode() %>" />
</td></tr>
<tr><td>電話</td><td>
<input name="Tel" type="text" maxlength="50" id="txtTel"
class="myTextbox w400" value="<%= Model.Tel.HtmlEncode() %>" />
</td></tr>
</table>
基本上,與Page的開發方式差不多,只是基類換成了MyUserControlView<>而已。
在這里我認為要補充一點的是:
與ASP.NET MVC不同,MyMVC不提供任何HtmlHelper。
我認為HtmlHelper與MVC思想完全沒有關系,因此不提供這些方法。
另一方面,很多人希望更好地控制HTML代碼,因此就更沒必要提供這些方法了。
如果您認為需要一些必要的HtmlHelper方法,那么可以實現自己喜歡的HtmlHelper類庫。
最后我想說的是:頁面繼承泛型類,還需要一些額外的處理。比如下面的代碼:
Inherits="MyPageView<CustomersPageModel>"
要讓這種設置能夠通過編譯,需要在web.config中做如下配置:
<pages pageParserFilterType="MyMVC.ViewTypeParserFilter, MyMVC" >
ViewTypeParserFilter的實現代碼較長,我就不在此貼出了,可以從本文結尾處下載。
Controller,Action的設計方式
在MyMVC中,Action分為二種:AjaxAction和PageAction。
PageAction與AjaxActioin在方法的定義上并沒有什么差異,只要是個public方法就可以了。
不過,PageAction與AjaxAction不同點在于:
1. Controller的容器名稱不同,PageAction要求Controller的名字必須以Controller結尾。
2. 必須有一個有效的[PageUrl]的修飾屬性指出可以處理的URL
3. Action的名字與URL無關,可以隨意取名。
在MyMVC中,2種Action還有另一特點是:不區分GET,POST 。
原因是:我喜歡用JQuery,用它實現客戶端的Ajax時,GET, POST,只是一個參數的差別而已。
另一方面,對于HTML表單來說,GET, POST也只是一個參數的差別,大部分表單也可以通過GET方式來提交,只要您愿意。
所以,我想,既然客戶端可以這樣靈活地切換,服務端也就沒有必要再去做那樣限制。
或許有些人認為區分二者會更安全,但我認為它們對安全性基本上不構成影響。
反而,如果服務端忽略它們,只會讓客戶端更容易調用。
還有一種情況下可能需要區分二者:請求與提交是同一個地址。
這應該可以算得上是我在上篇總結的【以服務端為中心的網站】的開發方式。
事實上,在使用MyMVC的項目中,<form>標簽應該需要手寫,可能更多的時候會提交到另一個地址,
因為,我更建議使用Ajax方式提交數據。
所以,最終我決定:MyMVC的Action不區分GET, POST.
在設計MyMVC時,我一直沒有忘記將View和Controller的分離,而且對于Controller,只有名字上的約束,
Action的約束也較少,因此,我們在實現Action時,完全可以把它們獨立到【類庫項目】中,
就像示例項目這樣:

這樣做的好處是:測試Actioin會更容易。
此時網站可能只是一堆aspx,js, css文件。我一直期待能將aspx也交給美工去維護,這樣設計但愿能讓可能性更大一些。
輸出HTML的方式
MyMVC提供二種方式在Action中返回HTML,分別是返回PageResult或者UcResult,表示需要呈現一個頁面或者一個用戶控件。
當在Action返回這二種結果時,Action的部分就執行完畢了。
剩下的處理是在MyMVC框架中進行的,MyMVC框架會對這二種結果,以IActionResult接口的方式調用Ouput方法輸出結果給客戶端。
PageResult和UcResult的實現代碼如下:
public interface IActionResult
{
void Ouput(HttpContext context);
}
/// <summary>
/// 表示一個用戶控件結果(用戶控件將由框架執行)
/// </summary>
public sealed class UcResult : IActionResult
{
public string VirtualPath { get; private set; }
public object Model { get; private set; }
public UcResult(string virtualPath, object model)
{
if( string.IsNullOrEmpty(virtualPath) )
throw new ArgumentNullException("virtualPath");
this.VirtualPath = virtualPath;
this.Model = model;
}
void IActionResult.Ouput(HttpContext context)
{
context.Response.ContentType = "text/html";
string html = UcExecutor.Render(VirtualPath, Model);
context.Response.Write(html);
}
}
/// <summary>
/// 表示一個頁面結果(頁面將由框架執行)
/// </summary>
public sealed class PageResult : IActionResult
{
public string VirtualPath { get; private set; }
public object Model { get; private set; }
public PageResult(string virtualPath, object model)
{
this.VirtualPath = virtualPath;
this.Model = model;
}
void IActionResult.Ouput(HttpContext context)
{
if( string.IsNullOrEmpty(this.VirtualPath) )
this.VirtualPath = context.Request.FilePath;
context.Response.ContentType = "text/html";
string html = PageExecutor.Render(context, VirtualPath, Model);
context.Response.Write(html);
}
}
這二個類型的使用方式是一樣的,都需要提供二個參數,第一個參數表示頁面或者用戶控件的存放路徑,第二個參數表示給頁面或者用戶控件所需的顯示數據。
比如下面這個示例:
[Action]
public object ShowCustomerPicker(string searchWord, int? page)
{
CustomerSearchInfo info = new CustomerSearchInfo();
info.SearchWord = searchWord ?? string.Empty;
info.PageIndex = page.HasValue ? page.Value - 1 : 0;
info.PageSize = AppHelper.DefaultPageSize;
CustomerPickerModel data = new CustomerPickerModel();
data.SearchInfo = info;
data.List = BllFactory.GetCustomerBLL().GetList(info);
return new UcResult("/Controls/Style1/CustomerPicker.ascx", data);
}
設計這二類結果,我的本意是:
1. UcResult給Ajax請求使用,因為有可能會要求服務端輸出一段HTML
2. PageResult用于整頁面的響應。
在MyMVC中,執行頁面或者用戶控件,需要指出頁面或者用戶控件的路徑,而不是采用什么約定關系。
我認為約定會造成名字耦合,約定也會影響限制靈活,因此,必須明確指定(允許為null)。
PageResult多用于PageAction,而PageAction又有[PageUrl]來指示可以處理哪些URL,雖然一個PageAction可以處理多個URL,
但通常情況下,還是以一個PageAction處理一個URL的情況居多。此時,MyMVC允許在返回PageResult時,
第一個參數可以設置為null,表示使用當前請求地址。
如果此時當前請求地址有一個aspx頁面與之對應,自然就會方便很多。
可以參考下面的示例:
[Action]
[Authorize(Users="fish")]
[PageUrl(Url = "/Pages/Demo/TestAuthorize/Fish.aspx")]
public object ShowFishPage()
{
// 僅當當前用戶是 fish 時,才允許訪問這個PageAction
// 注意:第一參數為null,表示使用當前地址。
return new PageResult(null, null);
}
在MyMVC框架中,PageResult最終會調用PageExecutor.Render()來獲取頁面的生成代碼,具體過程如下:
public static class PageExecutor
{
internal static readonly Type MyPageViewOpenType = typeof(MyPageView<>);
private static void SetPageModel(IHttpHandler handler, object model)
{
if( handler == null )
return;
if( model != null ) {
Type handlerType = handler.GetType().BaseType;
Type viewType = MyPageViewOpenType.MakeGenericType(model.GetType());
// 檢查類型是否匹配
if( handlerType == viewType || handlerType.IsSubclassOf(viewType) ) {
DataMember member = ReflectionHelper.GetMemberByName(viewType, "Model", true);
member.SetValue(handler, model);
}
else
throw new ArgumentException("參數model的類型與頁面的參數類型不一致。");
}
}
/// <summary>
/// 用指定的頁面路徑以及視圖數據呈現結果,最后返回生成的HTML代碼。
/// 頁面應從MyPageView<T>繼承
/// </summary>
/// <param name="context">HttpContext對象</param>
/// <param name="pageVirtualPath">Page的虛擬路徑</param>
/// <param name="model">視圖數據</param>
/// <returns>生成的HTML代碼</returns>
public static string Render(HttpContext context, string pageVirtualPath, object model)
{
if( context == null )
throw new ArgumentNullException("context");
if( string.IsNullOrEmpty(pageVirtualPath) )
throw new ArgumentNullException("pageVirtualPath");
Page handler = BuildManager.CreateInstanceFromVirtualPath(
pageVirtualPath, typeof(object)) as Page;
if( handler == null )
throw new InvalidOperationException(
string.Format("指定的路徑 {0} 不是一個有效的頁面。", pageVirtualPath));
SetPageModel(handler, model);
StringWriter output = new StringWriter();
context.Server.Execute(handler, output, false);
return output.ToString();
}
UcResult則會調用UcExecutor.Render()生成用戶控件的輸出代碼,具體過程如下:
public static class UcExecutor
{
internal static readonly Type MyUserControlViewOpenType = typeof(MyUserControlView<>);
/// <summary>
/// 用指定的用戶控件以及視圖數據呈現結果,最后返回生成的HTML代碼。
/// 用戶控件應從MyUserControlView<T>繼承
/// </summary>
/// <param name="ucVirtualPath">用戶控件的虛擬路徑</param>
/// <param name="model">視圖數據</param>
/// <returns>生成的HTML代碼</returns>
public static string Render(string ucVirtualPath, object model)
{
if( string.IsNullOrEmpty(ucVirtualPath) )
throw new ArgumentNullException("ucVirtualPath");
Page page = new Page();
Control ctl = page.LoadControl(ucVirtualPath);
if( ctl == null )
throw new InvalidOperationException(
string.Format("指定的用戶控件 {0} 沒有找到。", ucVirtualPath));
MyBaseUserControl myctl = ctl as MyBaseUserControl;
if( myctl != null ) {
if( model != null ) {
Type viewType = MyUserControlViewOpenType.MakeGenericType(model.GetType());
Type ctrlType = myctl.GetType();
// 檢查用戶控件的類型是否匹配
if( ctrlType == viewType || ctrlType.IsSubclassOf(viewType) ) {
// 給用戶控件的視圖數據賦值。
DataMember member = ReflectionHelper.GetMemberByName(viewType, "Model", true);
member.SetValue(myctl, model);
}
else
throw new ArgumentException("參數model的類型與用戶控件的參數類型不一致。");
}
}
// 將用戶控件放在Page容器中。
page.Controls.Add(ctl);
StringWriter output = new StringWriter();
HtmlTextWriter write = new HtmlTextWriter(output, string.Empty);
page.RenderControl(write);
// 用下面的方法也可以的。
//HttpContext.Current.Server.Execute(page, output, false);
return output.ToString();
}
HTML分塊輸出
注意哦,前面介紹的2個Render方法的可見性都是public,這樣設計的想法是讓框架提供對外生成HTML的能力,或許有些用戶有這樣的需求。
另一方面,或許還有些用戶打算在Action的執行過程中,將原來較大的HTML頁面分塊輸出給客戶端。
BigPipe就使用了這種想法:
整個請求不用等到全部數據獲取成功后一次性輸出,而是將頁面按業務邏輯拆分,并在獲取到相應的數據后,立即向客戶端輸出部分片段。
其實HTML分塊輸出在ASP.NET中并不是什么新的技術,而是在ASP.NET一出現時就已經存在了,
那就是在輸出的過程中不斷調用Response.Flush();
由于MyMVC將生成HTML做為一種基礎功能,因此在MyMVC中,只要您調用Response.Flush();便可以方便地實現分塊輸出。
不過,為了讓調用更簡單,我提供了二個輔助方法來簡化這個過程。
在PageExecutor類型中的ResponseWrite方法:
/// <summary>
/// 用指定的Page以及視圖數據呈現結果,
/// 然后將產生的HTML代碼寫入HttpContext.Current.Response
/// 用戶控件應從MyPageView<T>繼承
/// </summary>
/// <param name="pageVirtualPath">Page的虛擬路徑</param>
/// <param name="model">視圖數據</param>
/// <param name="flush">是否需要在輸出html后調用Response.Flush()</param>
public static void ResponseWrite(string pageVirtualPath, object model, bool flush)
{
HttpContext context = HttpContext.Current;
if( context == null )
return;
if( string.IsNullOrEmpty(context.Response.ContentType) )
context.Response.ContentType = "text/html";
string html = Render(context, pageVirtualPath, model);
context.Response.Write(html);
if( flush )
context.Response.Flush();
}
在UcExecutor類型中的ResponseWrite方法:
/// <summary>
/// 用指定的用戶控件以及視圖數據呈現結果,
/// 然后將產生的HTML代碼寫入HttpContext.Current.Response
/// 用戶控件應從MyUserControlView<T>繼承
/// </summary>
/// <param name="ucVirtualPath">用戶控件的虛擬路徑</param>
/// <param name="model">視圖數據</param>
/// <param name="flush">是否需要在輸出html后調用Response.Flush()</param>
public static void ResponseWrite(string ucVirtualPath, object model, bool flush)
{
HttpContext context = HttpContext.Current;
if( context == null )
return;
if( string.IsNullOrEmpty(context.Response.ContentType) )
context.Response.ContentType = "text/html";
string html = Render(ucVirtualPath, model);
context.Response.Write(html);
if( flush )
context.Response.Flush();
}
注意:由于這二個方法在內部使用了HttpContext.Current,因此如果在Action中調用它們,會造成Action不能支持單元測試。
關于單元測試的支持
提到MVC思想,我想就不得不談單元測試了。
因為MVC的主要思想還是想把這三個字目對應的事物分開,以方便開發與測試。
這里面,我認為尤其是View與Controller的分離最為重要,因為有UI的地方比較難測試,
反過來,如果沒有UI的東西就比較容易測試了。
不過,在ASP.NET中,影響單元測試的不僅僅只是UI元素,還有HttpContxt, HttpRequest, HttpResponse這之類的核心對象。
比如:即使我們將Controller放在類庫項目中實現,但在Action中還在訪問QueryString,Form,甚至發起重定向的請求,你說這樣的代碼如何測試。
我認為判斷一個方法是否可支持單元測試有一個簡單的辦法:寫個控制臺的程序去調用它,看它能否正常運行。
通常,用戶的輸入數據主要有三個來源:QueryString, Form, Cookie。而且前二者居多,Cookie則多用于保存用戶偏好設置。
因此,在MyMVC中,可以讓Action不再去直接訪問QueryString, Form,替代的方式是:將要讀取的名字做為C#方法的參數名明確指出。
這樣,Actioin中的代碼就遠離了QueryString, Form。至于Cookie的訪問,MyMVC則提供一個輔助類來支持訪問:
/// <summary>
/// 讀寫Cookie的輔助工具類。這個類對測試環境仍然有效。
/// </summary>
public static class CookieHelper
{
[ThreadStatic]
private static HttpCookieCollection s_cookies;
private static HttpCookieCollection TestCookies
{
get
{
if( TestEnvironment.IsTestEnvironment == false )
throw new InvalidOperationException();
if( s_cookies == null ) // 測試環境不考慮線程安全問題。
s_cookies = new HttpCookieCollection();
return s_cookies;
}
}
/// <summary>
/// 從Request中獲取一個Cookie對象
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public static HttpCookie GetCookie(string key)
{
if( TestEnvironment.IsTestEnvironment )
return TestCookies[key];
return HttpContext.Current.Request.Cookies[key];
}
/// <summary>
/// 從Request中獲取一個Cookie對象的值,如果不存在,則返回null
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public static string GetCookieValue(string key)
{
HttpCookie cookie = GetCookie(key);
if( cookie == null )
return null;
else
return cookie.Value;
}
/// <summary>
/// 將一個Cookie對應寫入到Response中
/// </summary>
/// <param name="cookie"></param>
public static void SetCookie(HttpCookie cookie)
{
if( TestEnvironment.IsTestEnvironment )
TestCookies.Set(cookie);
else
HttpContext.Current.Response.Cookies.Set(cookie);
}
/// <summary>
/// 將一個Cookie對應寫入到Response中
/// </summary>
/// <param name="cookie"></param>
public static void AddCookie(HttpCookie cookie)
{
if( TestEnvironment.IsTestEnvironment )
TestCookies.Add(cookie);
else
HttpContext.Current.Response.Cookies.Add(cookie);
}
/// <summary>
/// 清除所有寫入的Cookie
/// </summary>
public static void ClearCookie()
{
if( TestEnvironment.IsTestEnvironment )
TestCookies.Clear();
else
HttpContext.Current.Response.Cookies.Clear();
}
/// <summary>
/// 刪除指定名稱的Cookie
/// </summary>
/// <param name="key"></param>
public static void RemoveCookie(string key)
{
if( TestEnvironment.IsTestEnvironment )
TestCookies.Remove(key);
else
HttpContext.Current.Response.Cookies.Remove(key);
}
}
重定向也是常見的需求。MyMVC則是通過提供RedirectResult來支持的:
/// <summary>
/// 表示一個重定向的結果
/// </summary>
public sealed class RedirectResult : IActionResult
{
public string Url { get; private set; }
public RedirectResult(string url)
{
if( string.IsNullOrEmpty(url) )
throw new ArgumentNullException("url");
Url = url;
}
void IActionResult.Ouput(HttpContext context)
{
context.Response.Redirect(Url, true);
}
}
說明:Ouput方法由框架調用,不影響Action的單元測試。
示例代碼:
[Action]
public object Delete(int id)
{
BllFactory.GetCategoryBLL().Delete(id);
return new RedirectResult("/Pages/Categories.aspx");
}
在ASP.NET項目開發過程中,還有一類需求較為常見,那就是:訪問一些當前環境變量。
MyMVC則是通過以下二個類型來處理的。
public static class TestEnvironment
{
/// <summary>
/// 當前運行環境是否為測試環境(非ASP.NET環境)
/// </summary>
internal static readonly bool IsTestEnvironment = (HttpRuntime.AppDomainAppId == null);
[ThreadStatic]
private static Hashtable s_ContextInfo;
private static Hashtable ContextInfo
{
get
{
if( TestEnvironment.IsTestEnvironment == false )
throw new InvalidOperationException("只有測試代碼才允許執行這個調用。");
if( s_ContextInfo == null ) // 測試環境不考慮線程安全問題。
s_ContextInfo = new Hashtable(100, StringComparer.Ordinal);
return s_ContextInfo;
}
}
public static object GetValue(string key)
{
object val = ContextInfo[key];
if( val == null )
throw new InvalidOperationException("您忘記了給測試環境賦值了。參數名:" + key);
return val;
}
public static void SetValue(string key, object val)
{
ContextInfo[key] = val;
}
public static void ClearTestEnvironmentInfo()
{
ContextInfo.Clear();
CookieHelper.ClearCookie();
}
}
/// <summary>
/// 用于訪問當前請求上下文的工具類。這個類對測試環境仍然有效。
/// </summary>
public static class HttpContextHelper
{
// 為了能讓代碼支持測試環境
// 會判斷 TestEnvironment.IsTestEnvironment
// 如果在ASP.NET環境中運行,則直接返回HttpRuntime或者HttpContext.Current中的參數值
// 如果是在測試環境中運行,則使用另一個靜態變量來維護所有需要訪問的狀態。
// 在測試前,需要先給相應的參數賦值。
// 測試完成后可調用TestEnvironment.ClearTestEnvironmentInfo()清除臨時環境。
/// <summary>
/// return HttpRuntime.AppDomainAppPath;
/// </summary>
public static string AppRootPath
{
get
{
if( TestEnvironment.IsTestEnvironment )
return TestEnvironment.GetValue("AppDomainAppPath") as string;
return HttpRuntime.AppDomainAppPath;
}
set
{
TestEnvironment.SetValue("AppDomainAppPath", value);
}
}
/// <summary>
/// return HttpContext.Current.Request.FilePath;
/// </summary>
public static string RequestFilePath
{
get
{
if( TestEnvironment.IsTestEnvironment )
return TestEnvironment.GetValue("RequestFilePath") as string;
return HttpContext.Current.Request.FilePath;
}
set
{
TestEnvironment.SetValue("RequestFilePath", value);
}
}
/// <summary>
/// return HttpContext.Current.Request.RawUrl;
/// </summary>
public static string RequestRawUrl
{
get
{
if( TestEnvironment.IsTestEnvironment )
return TestEnvironment.GetValue("RequestRawUrl") as string;
return HttpContext.Current.Request.RawUrl;
}
set
{
TestEnvironment.SetValue("RequestRawUrl", value);
}
}
/// <summary>
/// return HttpContext.Current.User.Identity.Name;
/// </summary>
public static string UserIdentityName
{
get
{
if( TestEnvironment.IsTestEnvironment )
return TestEnvironment.GetValue("UserIdentityName") as string;
if( HttpContext.Current.Request.IsAuthenticated == false )
return null;
return HttpContext.Current.User.Identity.Name;
}
set
{
TestEnvironment.SetValue("UserIdentityName", value);
}
}
// 如果還需要訪問更多的HttpContext信息,也可以采用下面的方法。請自行完成。
//public static HttpContextBase Current
//{
// get { }
// set { }
//}
}
注意HttpContextHelper這個類,我將平時訪問的一些與請求或者與ASP.NET運行環境相關的屬性全部封裝在這里了。
如果不夠,還可以繼續添加。有了這些代碼,我就可以簡單在控制臺程序中調用它們(您也可以移到單元測試項目中):
class Program
{
static void Main(string[] args)
{
InitDB();
Test_AjaxStyle_SetStyle();
Test_AjaxCustomer_GetById();
Test_TestEnvironment();
Console.ReadLine();
}
static void InitDB()
{
string xmlPath = "..\\..\\DemoWebSite1\\App_Data\\MyNorthwindDataBase.xml";
WebSiteDB.LoadDbFromXml(xmlPath);
}
static void Test_AjaxStyle_SetStyle()
{
AjaxStyle ajax = new AjaxStyle();
ajax.SetStyle(StyleHelper.StyleArray[0]);
if( CookieHelper.GetCookieValue(StyleHelper.STR_PageStyle) == StyleHelper.StyleArray[0] )
Console.WriteLine("AjaxStyle.SetStyle(\"{0}\") OK", StyleHelper.StyleArray[0]);
else
Console.WriteLine("AjaxStyle.SetStyle(\"{0}\") faild.", StyleHelper.StyleArray[0]);
CookieHelper.ClearCookie();
ajax.SetStyle("abc"); // 一個無效的值。
if( CookieHelper.GetCookieValue(StyleHelper.STR_PageStyle) == null )
Console.WriteLine("AjaxStyle.SetStyle(\"abc\") OK");
else
Console.WriteLine("AjaxStyle.SetStyle(\"abc\") faild.");
}
static void Test_AjaxCustomer_GetById()
{
AjaxCustomer ajax = new AjaxCustomer();
object result = ajax.GetById(1);
if( result is JsonResult )
Console.WriteLine("AjaxCustomer.GetById(1) OK");
else
Console.WriteLine("AjaxCustomer.GetById(1) faild");
}
static void Test_TestEnvironment()
{
TestEnvironment.SetValue("key1", 123);
TestEnvironment.SetValue("key2", "abc");
if( (int)TestEnvironment.GetValue("key1") == 123 )
Console.WriteLine("TestEnvironment.SetValue(123) OK.");
if( (string)TestEnvironment.GetValue("key2") == "abc" )
Console.WriteLine("TestEnvironment.SetValue(\"abc\") OK.");
}
用過ASP.NET MVC的人可能會問我:
為什么不使用System.Web.Abstractions定義的那些類型,那樣不是更容易支持單元測試嗎?
是啊,我也知道那種做法的好處。但那樣做的工作量也會更大。
根據目前的MyMVC設計方式,如果要引入HttpContextBase, HttpRequestBase, HttpResponseBase這類對象,
就需要設計Controller基類,并且創建Controller的過程也會比目前復雜,
或者要把ASP.NET MVC那套創建Controller的過程搬過來,否則仍然會不完整。
然而,我還是打算自己再設計另一種簡單的方法 盡可能 地去解決這個問題。
上面的方法就是我的設計,雖然不夠完整,卻是簡單有效的,而且測試代碼也會簡單很多。
另一方面,我不提供Controller基類,但可以設計諸如HttpContextHelper.Current這樣的訪問方式,
最終的結果仍然是可以支持單元測試的,況且HttpContextHelper.Current這種用法也不會讓人難以適應。
不提供還有另外的好處:允許設計自己的基類。
還有一點要補充的是:MyMVC框架內部也沒有使用System.Web.Abstractions,是的,我知道。
這也只能說:框架的代碼不能進行單元測試而已。 但不影響用戶的Action代碼的單元測試。
再說框架中的代碼有些也很難做單元測試,畢竟太依賴于ASP.NET,而且我沒那么多的空閑時間以及驅動力。
MyMVC還有一個沒有支持的是文件的上傳與下載。
這里我來說說對于這塊功能訪如何去實現:
1. 可以直接訪問HttpContext.Current ,并忽略這些代碼的單元測試能力。
2. 自行實現我前面沒有實現的HttpContextHelper.Current 。
是的,我的確沒有完成這個功能,而把它留給了用戶,抱歉。
關于框架代碼與示例代碼
在本文的未尾,我提供了MyMVC框架的代碼,以及全部示例代碼。
以前我也提供過我的老版本框架的演示示例,
我認為我已經考慮地相當周到了:
1. 沒有IIS,沒有VS,一樣可以運行我的DEMO,因為我把FishAspnetLoader放進去了,調用的BAT文件也準備好了。
2. SQL SERVER如果支持【用戶實例】模式,部署會容易。
3. 在數據方面,我不但提供了mdf文件,還提供了sql腳本。
4. 還準備了一些說明文件。
然而,事實卻沒有我想像那么好,還是有很多人給我發郵件,問我示例為什么不能運行。
不能運行的環境也是讓我完全沒有想到的:
1. 有人把它部署到了IIS6,擴展名的映射遇到問題。
2. 有人把它部署到了IIS7,可我沒有提供對IIS7的配置!
3. 有人沒有安裝SQL SERVER。這個只能是沒有辦法了!
4. 有人不能完成示例程序所需的SQL SERVER配置。
5. 有人用VS2010打開項目并升級了.net版本,遇到一些說不清楚的問題。
吸取前面的教訓后,這次我的示例采用XML文件做為數據源,而且增加了IIS7的配置。
不過,有一點我不能替您設置的是XML文件的寫入權限。
如果數據不能保存,請檢查目錄的寫入權限,此時程序沒有任何提示。
再補充二點:
1. 如果您使用VS2010打開示例項目,請不要選擇升級.net版本,不要盲目點擊確定。
2. 如果在IIS中部署示例網站遇到問題,那么建議使用VS運行示例網站。
如果您還有配置ASP.NET應用程序的問題,那么請關注我的后續博客。
下篇博客我打算談一下在部署ASP.NET網站時,IIS6/7 以及SQL SERVER中必須知道的一些設置。
點擊此處下載示例代碼
如果,您認為閱讀這篇博客讓您有些收獲,不妨點擊一下右下角的【推薦】按鈕。
如果,您希望更容易地發現我的新博客,不妨點擊一下右下角的【關注 Fish Li】。
因為,我的寫作熱情也離不開您的肯定支持。
感謝您的閱讀,如果您對我的博客所講述的內容有興趣,請繼續關注我的后續博客,我是Fish Li 。