上週的 Coding4Fun 專案,我寫了一個 ASP.NET MVC (對,還不是 .NET Core,請體諒老人動作慢,勿心急敲碗) 小功能可以顯示 CPU 及記憶體使用狀況,後面又做了一些改良,將 Model、View、Controller 都搬進獨立類別程式庫,還把 js/css/ttf 等靜態檔一併包進去。這樣子任何 MVC 網站只要參照一個 .dll,在網頁掛一個 IFrame,就能擁有新功能,非常方便。

但有個美中不足之處 - 為了順利內嵌成組件資源,我將 View 的 .cshtml 轉成靜態 .html。若原本 View 內含較複雜的 Razor 語法、HtmlHelper 功能,硬要改寫成 HTML 有點不切實際,所以這裡有個好問題 - 我能不能將 cshtml 也存成 Embedded Resource 讓 ASP.NET MVC 載入?

答案是可行,關鍵是 VirtualPathProvider。

ASP.NET MVC 允許我們自訂 VirtualPathProvider,解析 ~/folder/filename,用 VirtualFile 類別傳回 Stream。換言之,我們可以實作一個類別,從 Embedded Resource、資料庫、WebAPI 或任何你想得到的來源取出對映檔案內容。(將 cshtml 存在資料庫這點很酷,我腦中有浮現一些有趣應用)

我先調整專案檔案配置,新開 Views 資料夾,把 View.html 轉成 Index.cshtml 跟 _Script.cshtml 拆成主 View 跟 Partial View (嚴格來說無此必要,純粹是想測試較複雜的情境),_Script.cshtml 還刻意放到 Partials 子資料夾,以模擬多層資料夾結構。而最大重點莫過於加入 EmbResVirtualPathProvider.cs 及 EmbResVirtualFile.cs 兩個類別:

EmbResVirtualPathProvider.cs 如下,重點是要繼承 VirtualPathProvider 類別,並實作 FileExists() 以測試路徑是否存在,GetFile() 則要傳回一個 VirtualFile 類別。為了加快處理效率,我事先查出組件所有內嵌資源名稱,建立一張虛擬路徑與含命名空間資源名稱的對應表(如:~/EmbViews/Partial/_Script.cshtml 對映成 WebStatsMonitor.Views.Partials._Script.cshtml)。虛擬路徑則要用 VirtualPathUtility.ToAppRelative() 方法轉成 ~ 起首路徑以求一致。

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Web;
using System.Web.Caching;
using System.Web.Hosting;

namespace WebStatsMonitor
{
    public class EmbResVirtualPathProvider : VirtualPathProvider
    {
        static Dictionary<string, string> virPathToEmbRes =
            typeof(EmbResVirtualPathProvider).Assembly.GetManifestResourceNames()
            .ToDictionary(
                o => o.Replace("WebStatsMonitor.Views", "~.EmbViews").ToLower(),
                o => o
                );

        public static bool IsEmbResPath(string virtualPath) =>
            VirtualPathUtility.ToAppRelative(virtualPath).StartsWith("~/EmbViews/");
        public static string FindEmbResFullName(string virtualPath)
        {
            if (!IsEmbResPath(virtualPath)) return null;
            virtualPath = VirtualPathUtility.ToAppRelative(virtualPath);
            var mappedName = virtualPath.Replace("/", ".").ToLower();
            if (virPathToEmbRes.ContainsKey(mappedName))
                return virPathToEmbRes[mappedName];
            return null;
        }

        public override bool FileExists(string virtualPath)
        {
            return 
                (IsEmbResPath(virtualPath) && FindEmbResFullName(virtualPath) != null) ||
                base.FileExists(virtualPath);
        }

        public override VirtualFile GetFile(string virtualPath)
        {
            if (!IsEmbResPath(virtualPath))
                return base.GetFile(virtualPath);
            var embResFullName = FindEmbResFullName(virtualPath);
            if (embResFullName == null)
                throw new HttpException((int)HttpStatusCode.NotFound,
                    "Rebedded Resource not found");
            return new EmbResVirtualFile(virtualPath, FindEmbResFullName(virtualPath));
            
        }

        public override CacheDependency GetCacheDependency(string virtualPath, IEnumerable virtualPathDependencies, DateTime utcStart)
        {
            if (IsEmbResPath(virtualPath)) return null;
            return base.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
        }

    }
}

EmbResVirtualFile.cs 的內容很簡單,接收資源全名,呼叫 Assembly.GetManifestResourceStream() 傳回 Stream:

using System.IO;
using System.Web.Hosting;

namespace WebStatsMonitor
{
    public class EmbResVirtualFile : VirtualFile
    {
        private readonly string embResFullName;

        public EmbResVirtualFile(string virtualPath, string embResFullName) : base(virtualPath)
        {
            this.embResFullName = embResFullName;
        }

        public override Stream Open()
        {
            return this.GetType().Assembly.GetManifestResourceStream(embResFullName);
        }
    }
}

Index.cshtml 有個地方要說一下,跟一般 MVC 專案不同,最上方要加入 @inherits System.Web.Mvc.WebViewPage 以及必要的 @using,其餘 Razor 語法跟 HtmlHelper 依與一般寫法相同:

@inherits System.Web.Mvc.WebViewPage
@using System.Web.Mvc
@using System.Web.Mvc.Html
@{
    Layout = null;
}
<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Web CPU & Memory</title>
    <link href="~/WebStatsMonitor/Resource?resName=webstats.css" rel="stylesheet" />
</head>
<body>
    <div class="frame">
        <div class="row">
            <span class="hdr">CPU</span> <span class="field" id="cpuLoad"></span>
        </div>
        <div class="row">
            <span class="hdr">RAM</span> <span class="field" id="memUsage"></span>
        </div>
    </div>
    <script src="~/WebStatsMonitor/Resource?resName=jquery.min.js"></script>
    @*沒什麼意義的 PartialView 拆分,只是為了證明存成內嵌資源也辦得到*@
    @Html.Partial("~/EmbViews/Partials/_Script.cshtml")
</body>
</html>

要加 inherits 跟 using 的理由是標準 MVC 有宣告在 Views/web.config 裡才不用加,放 dll 時得自己來。(若不想每個 .cshtml 手動加,在 VirutalFile.Open() 傳回內容自動補上也是解法)

WebStatsMonitorController 的 Index() 則要小改,原本 RedirectToAction 改為 return View("~/EmbViews/Index.cshtml"):

最後,引用元件的 MVC 網站需要註冊我們自訂的 EmbResVirtualPathProvider,最簡單的做法是加在 Global.asax.cs Application_Start():

public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        AreaRegistration.RegisterAllAreas();
        RouteConfig.RegisterRoutes(RouteTable.Routes);
        HostingEnvironment.RegisterVirtualPathProvider(
            new WebStatsMonitor.EmbResVirtualPathProvider());
    }
}

這樣子就能把 cshtml 內嵌成 Embedded Resource 跑囉~

有個我沒能克服的困擾是 Visual Studio 2019 編譯環境會出現大量內嵌 cshtml 編譯錯誤,有點惱人,但並不影響編譯跟執行就是了。

老樣子,完整程式範例我送上 Github 了,放在 emb-cshtml 分支,有興趣的同學請自取。

Example of how to embed .cshtml as embedded resource via customized VirtualPathProvider and VirtualFile.


Comments

Be the first to post a comment

Post a comment