使用Asp.Net MVC 動態產生View Part.2 - 使用IViewEngine、IView

在上一篇文章中使用了VirtualPathProvider來完成產生動態頁面的功能,但是這樣的方法卻相當不容易進行測試。為甚麼說不容易進行測試呢?在使用自定義的CustomVirtualPathProvider時,需要在Global.asax中Application_Start方法將自定義的CustomVirtualPathProvider註冊到HostingEnvironment中,讓自定義的CustomVirtualPathProvider變成全域VirtualPathProvider。

HostingEnvironment.RegisterVirtualPathProvider(virtualPathProvider);

但是,在寫Controller測試時,並不會將站台實際創建起來,而只是將需要測試的Controller instance創建出來。如此一來,並不會執行到Global.asax裡的Application_Start方法,也就沒辦法將自定義的CustomVirtualPathProvider註冊到HostingEnvironment。

附帶一提,在System.Web.MVC原始碼中,可以找到VirtualPathProviderViewEngine類別的原始碼,這一個類別是RazorViewEngine的父類別之一,在原始碼中有一段可以指定ViewEngine中VirtualPathProvider屬性,並不一定需要透過HostingEnvironment

// Likely exists for testing only
protected VirtualPathProvider VirtualPathProvider
{
    get { return _vppFunc(); }
    set
    {
        if (value == null)
        {
            throw Error.ArgumentNull("value");
        }

        _vppFunc = () => value;
    }
}

但是,實際測試結果這樣直接在ViewEngine中指定VirtualPathProvider並不會Work,測試方式為,在自定義的ViewEngine裡,指定底層VirtualPathProvider屬性,當執行完Action由系統呼叫ViewResult.ExecuteResult,將取得Html寫入HttpContext.Response.Output,在取得Html的過程並不會執行到自定義的CustomVirtualPathProvider,而是執行原本系統定義的VirtualPathProvider。有興趣研究原始碼的朋友可以試試看。VirtualPathProviderViewEngine類別可以在VirtualPathProviderViewEngine.cs找到原始碼。

講了這麼多,該是回到本篇文章的重點了,既然VirtualPathProvider無法容易進行單元測試,那該如何由其他方法完成動態頁面的功能呢?這個時候,可以透過自定義IView和IViewEngine實作類別來完成。一樣先來看看這兩個介面在MSDN上的解釋,首先是IView

就如同MSDN上所敘述的,定義View所需的方法,而IView裡只定義了一個方法就是Render。在這個方法裡,會透過TextWriter將Html寫入到HttpContext.Response.Output中。接著,來看看IViewEngine

在IViewEngine,要注意到FindView和FindPartialView,這兩個方法中,會決定回傳ViewEngineResult裡IView的實作類別。所以,只要將 Controller中的ViewEngineCollection中所包含的ViewEngine換成自定義的CustomViewEngine。當Action執行結束回傳ViewResult時,會執行CustomViewEngine中的FindView,這個時候再將自定義的IView物件傳入ViewEngineResult中,當系統呼叫IView.Render時,就可以在這個方法裡透過Helper取得每位使用者專屬的Html寫入到Response裡。有興趣的人可以看看對岸大神寫的這幾篇文章

照慣例,解釋完方法,該是來看看程式碼的時候了。先定義提供Html的HtmlGeneratorHelper

public class HtmlGeneratorHelper : IHtmlGeneratorHelper
{
    /// <summary>
    /// Create Html
    /// </summary>
    /// <param name="pageName">page name</param>
    /// <param name="id">id</param>
    /// <returns>html</returns>
    public string GetHtml(string pageName, string id)
    {
        StringBuilder sb = new StringBuilder();
        var content = string.Empty;

        //// 根據pagename給出不同的content
        if (pageName == "View1")
        {
            content = string.Format("<div>I am {0}</div><div>PageName : {1}</div>", id, pageName);

            sb.AppendLine(content);
        }
        else if (pageName == "View2")
        {
            content = string.Format("<div>I am {0}</div><div>PageName : {1}</div>", id, pageName);

            sb.AppendLine(content);
        }

        return sb.ToString();
    }
}

CustomViewEngine

public class CustomViewEngine : IViewEngine
{
    /// <summary>
    /// Fined view
    /// </summary>
    /// <param name="controllerContext">controller context</param>
    /// <param name="viewName">view name</param>
    /// <param name="masterName">layout view</param>
    /// <param name="useCache">use chace</param>
    /// <returns>ViewEngineResult</returns>
    public ViewEngineResult FindView(ControllerContext controllerContext,
            string viewName, string masterName, bool useCache)
    {
        var viewObj = SplitViewName(viewName);

        return new ViewEngineResult(new CustomView(viewObj.Item2, viewObj.Item1, new HtmlGeneratorHelper()), this);
    }

    /// <summary>
    /// Find partial view
    /// </summary>
    /// <param name="controllerContext">controller context</param>
    /// <param name="partialViewName">partial view name</param>
    /// <param name="useCache">use cache</param>
    /// <returns>ViewEngineResult</returns>
    public ViewEngineResult FindPartialView(ControllerContext controllerContext,
           string partialViewName, bool useCache)
    {
        var viewObj = SplitViewName(partialViewName);

        return new ViewEngineResult(new CustomView(viewObj.Item2, viewObj.Item1, new HtmlGeneratorHelper()), this);
    }

    /// <summary>
    /// Disposable view
    /// </summary>
    /// <param name="controllerContext">controller context</param>
    /// <param name="view">view</param>
    public void ReleaseView(ControllerContext controllerContext, IView view)
    {
        IDisposable disDisposable = view as IDisposable;
        if (null != disDisposable)
        {
            disDisposable.Dispose();
        }
    }

    /// <summary>
    /// Splite viewName 
    /// </summary>
    /// <param name="viewName">view name</param>
    /// <returns>pagename and id</returns>
    private Tuple<string, string> SplitViewName(string viewName)
    {
        var tmp = viewName.Split('-');
        var tuple = Tuple.Create(tmp[0], tmp[1]);

        return tuple;
    }
}

這邊因為IViewEngine的兩個方法傳入參數都已經被定義好了,為了不想透過第三方取得使用者的Id,所以將使用者的Id和瀏覽頁面的名稱綁在一起,在FindView和FindPartialView中再將其拆解。而HtmlGenetatorHelper會根據PageName和Id傳回動態頁面的Html。接著,定義CustomView

public class CustomView : IView
{
    /// <summary>
    /// Html generator
    /// </summary>
    private IHtmlGeneratorHelper _htmlGenerator;

    /// <summary>
    /// 瀏覽的頁面名稱
    /// </summary>
    private string _pageName;

    /// <summary>
    /// 使用者Id
    /// </summary>
    private string _id;

    /// <summary>
    /// CustomView
    /// </summary>
    /// <param name="pageName">瀏覽的頁面名稱</param>
    /// <param name="id">使用者Id</param>
    /// <param name="htmlGenerator">Html generator</param>
    public CustomView(string pageName, string id, IHtmlGeneratorHelper htmlGenerator)
    {
        this._pageName = pageName;
        this._id = id;
        this._htmlGenerator = htmlGenerator;
    }

    public void Render(ViewContext viewContext, TextWriter writer)
    {
        var htmlContent = this._htmlGenerator.GetHtml(this._pageName, this._id);
        WriteHtml(writer, htmlContent);
    }

    /// <summary>
    /// 將Html寫入TextWrite
    /// </summary>
    /// <param name="textWriter">TextWrite</param>
    /// <param name="html">Html</param>
    private void WriteHtml(TextWriter textWriter, string html)
    {
        textWriter.Write(html);
    }
}

在Render中透過HtmlGenerator取得Html。定義好兩個View相關的類別後,就可以來寫Action的程式碼了

public ActionResult GetView(string pageName, string id)
{
    //// remove default view engine
    this.ViewEngineCollection.Clear();

    //// add custom view engine
    this.ViewEngineCollection.Add(new CustomViewEngine());

    var viewName = string.Format("{0}-{1}", id, pageName);

    return View(viewName);
}

這邊因為只有這一個Action會提供動態頁面,所以並沒有將註冊CustomViewEngine放到Global.asax中執行,而是放在Action中。在Action中,先清掉Controller的ViewEngineCollectin裡的ViewEngine,再將自定義的CustomViewEngine放到ViewEngine集合中。最後,在return View的時候將組合好的ViewName傳到View中。

最後來看一下執行結果

變更使用者Id以及瀏覽的頁面名稱

眼尖的人,應該會發現Layout page不見了。沒錯,這樣的做法完全捨棄了RazorEngine,自然而然也就不會取得Layout page的內容顯示在頁面上。除此之外,Razor語法的@Model也不能用了。不過,雖然內建的RazorEngine沒辦法使用,但是在HtmlGeneratorHelper中,可以使用第三方的Template Engine,像是RazorEngine, Handlebars.Net,這些Library一樣可以達到套用ViewModel的功能,只是在語法上有一些不同。

結論

雖然使用了與上一篇不太一樣的方法,但是一樣完成產生動態頁面的功能,比較可惜的是無法使用Asp.Net MVC內建RazorEngine語法。不過,筆者認為這樣動態頁面的功能,測試是絕對需要的,因為,原本Asp.net MVC產生頁面的方式,是當使用者瀏覽到已經Compile過的頁面時,會從Compile過的DLL檔中取出頁面資料。如果瀏覽的是沒有Compile過的頁面,則會將該頁面.cshtml檔案compile成DLL檔後取得頁面資料。所以,View被Compile後基本上就是固定的內容,不太容易有所變動。而一個View的框架或是固定的Html DOM元素在經過測試後,除非實體檔案有修改,否則也不會有所以改變。然後,本篇所敘述的方式,在每一次瀏覽動態頁面時,Html都是動態產生,與原本流程有極大不同,絕對需要在每一次系統佈署前執行自動化測試。更何況,頁面上的Bug絕對會在發生的第一時間被使用者發現,必須很小心處理這一個部分。在下一篇,將會介紹該如何測試Action所以產生的View是否與預期相符,並且實際測試本篇文章所寫的範例。

範例下載位置 : Github

參考資料

免責聲明:

"文章一定有好壞,文章內容有對有錯,使用前應詳閱公開說明書"