ASP.NET MVC - 客製化MVC專案目錄架構

在建立ASP.NET MVC專案時,預設會建立一些專案目錄與檔案,而這些是MVC基本的目錄架構。MVC的專案意即Model, View, Controller各自分離,當然在目錄分類上也不例外,另外在JS Library, Image, Font, CSS都各自獨立目錄,較方便以檔案類型分類。但是當我們改某個頁面的功能時,就要跑去JS, CSS目錄下找與此頁面相關的檔案真的是超麻煩的。像這種時候我們就會想要把同一頁面的相關JS, CSS, HTML放在一起,這樣就違反了MVC的基本目錄,所以今天這篇限量就是要來重整MVC的目錄架構。


原本建立MVC專案後預設的目錄架構會長成像下圖那樣:


因為開發時東找西找實在是太麻煩了,所以限量目標是要將目錄以頁面為主來整理,整理完會像下圖那樣:


首先,我們先把目標目錄按照規則(HTML, CSS, JS以Action的名稱命名放置在同名目錄下)建好,然後執行後就會出現這個錯誤頁面。


這個錯誤意思是系統依預設規則找了這些地方,然後還是沒有找到目標的View,再來就來看看限量如何解決這個問題。

解法一. 指定相對相對位置路徑 


在原本的Action裡 return View() 中加入指定的 View 路徑,修改如下:

public ActionResult ShoppingCart()
{
    return View("~/Views/Market/ShoppingCart/ShoppingCart.cshtml");
}

這種解法是最不用想太多的方法,只是每次都要自己去指定路徑是有點麻煩。





解法二. 使用ASP.NET MVC Dynamic Bundles


ASP.NET MVC Dynamic Bundles是別人做的NuGet套件,是一套可改變目錄架構的Plugin,可以解決目前的問題,詳細的設定流程可至 Dynamic Bundles for ASP.NET MVC 研究。


解法三. 客製化ViewEngine


MVC執行時其實是有一個ViewEngine來處理View相關的作業,而預設的ViewEngine為RazorViewEngine。限量用.Net Reflector深入去看看發現在RazorViewEngine定義了去那些位置去搜尋View,程式碼如下:


public class RazorViewEngine : BuildManagerViewEngine
{
 internal static readonly string ViewStartFileName = "_ViewStart";
 
 public RazorViewEngine() : this(null)
 {
 }
 
 public RazorViewEngine(IViewPageActivator viewPageActivator) : base(viewPageActivator)
 {
  base.AreaViewLocationFormats = new string[] { "~/Areas/{2}/Views/{1}/{0}.cshtml", "~/Areas/{2}/Views/{1}/{0}.vbhtml", "~/Areas/{2}/Views/Shared/{0}.cshtml", "~/Areas/{2}/Views/Shared/{0}.vbhtml" };
  base.AreaMasterLocationFormats = new string[] { "~/Areas/{2}/Views/{1}/{0}.cshtml", "~/Areas/{2}/Views/{1}/{0}.vbhtml", "~/Areas/{2}/Views/Shared/{0}.cshtml", "~/Areas/{2}/Views/Shared/{0}.vbhtml" };
  base.AreaPartialViewLocationFormats = new string[] { "~/Areas/{2}/Views/{1}/{0}.cshtml", "~/Areas/{2}/Views/{1}/{0}.vbhtml", "~/Areas/{2}/Views/Shared/{0}.cshtml", "~/Areas/{2}/Views/Shared/{0}.vbhtml" };
  base.ViewLocationFormats = new string[] { "~/Views/{1}/{0}.cshtml", "~/Views/{1}/{0}.vbhtml", "~/Views/Shared/{0}.cshtml", "~/Views/Shared/{0}.vbhtml" };
  base.MasterLocationFormats = new string[] { "~/Views/{1}/{0}.cshtml", "~/Views/{1}/{0}.vbhtml", "~/Views/Shared/{0}.cshtml", "~/Views/Shared/{0}.vbhtml" };
  base.PartialViewLocationFormats = new string[] { "~/Views/{1}/{0}.cshtml", "~/Views/{1}/{0}.vbhtml", "~/Views/Shared/{0}.cshtml", "~/Views/Shared/{0}.vbhtml" };
  base.FileExtensions = new string[] { "cshtml", "vbhtml" };
 }
 
 ...
}

在MSDN看過RazorViewEngine的資料後知道原來ViewLocationFormats是View的搜尋位置,而且為public代表是可以修改的。考慮到擴充性限量決定用繼承的方式來修改ViewLocationFormats,程式碼如下:

LimitedViewEngine.cs
public class LimitedViewEngine : RazorViewEngine
{
 public LimitedViewEngine() : base()
 {
  var viewLocationFormat = new List<string>()
  {
   "~/Views/{1}/{0}/{0}.cshtml"
  };
  viewLocationFormat.AddRange(this.ViewLocationFormats);

  this.ViewLocationFormats = viewLocationFormat.ToArray();
 }
}

接著要來找切入點載入LimitedViewEngine。MVC在啟動時,背景程序會載入ViewEngine,因此我們要在Application_Start的切入點載入,因為LimitedViewEngine繼承了RazorViewEngine,我們就不需要有重複的ViewEngine功能,所以要先清空預設載入的ViewEngine然後再加入LimitedViewEngine,加入程式碼如下:


Global.asax.cs
protected void Application_Start()
{
 // 清空預設載入的ViewEngines後再加入客製化的ViewEngine
 ViewEngines.Engines.Clear();
 ViewEngines.Engines.Add(new LimitedViewEngine());

 AreaRegistration.RegisterAllAreas();
 GlobalConfiguration.Configure(WebApiConfig.Register);
 FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
 RouteConfig.RegisterRoutes(RouteTable.Routes);
 BundleConfig.RegisterBundles(BundleTable.Bundles);
}

這樣就完成了,先來執行看看會不會再出現錯誤。


接下來同場加映,限量要來做一下進一步的修改。

MVC Bundle功能提供了一個小撇步可以讓各組Bundle Minify並合併成一個檔案,如下圖:



這個功能只要在Application啟動時加入一段程式碼就可以了:
BundleTable.EnableOptimizations = true;

如果照原本利用section方式在頁面上加入javascript,就算啟用Minify功能也無法讓HTML中的script區段Minify,因為Minify功能是針對.js與.css檔案才可以,如下圖:



在我們修改後的目錄架構中,雖然已經將JS, CSS, HTML都各自抽離,但要使用Minify功能必須要在BundleConfig中綁定後才會有效果,問題來了,我一個頁面綁一個Bundle,那十個頁面就要綁十個,這樣實在沒有好到哪去,所以限量就要來小改造一下。

限量的想法是在BundleConfig載入Bundle時動態遞迴去View目錄底下找JS與CSS並加入Bundle中。然後在頁面載入Layout時去找有沒有該頁面的Bundle,如果有就載入,想法就這麼簡單。實作方法如下:

先在BundleConfig中加入一個DynamicAddBundles方法並呼叫:
/// 
/// 動態至指定目錄下搜尋JS與CSS並加入Bundle
/// 
/// 指定目錄
/// Bundle
public static void DynamicAddBundles(string virtualPath, BundleCollection bundles)
{
 var vss = BundleTable.VirtualPathProvider;

 // 針對第一層目錄的所有Entity
 foreach (VirtualFileBase entity in vss.GetDirectory(virtualPath).Children)
 {
  // 如果是資料夾就往裡面搜尋
  if (entity.IsDirectory)
  {
   
   DynamicAddBundles(entity.VirtualPath, bundles);
  }
  else    // 如果是檔案就判斷是否要加入
  {
   // 取得副檔名
   var extension = VirtualPathUtility.GetExtension(entity.VirtualPath);

   // 取得檔案名稱
   var fileName = entity.Name.Replace(extension, string.Empty);

   // 取得檔案的虛擬路徑
   var vPath = VirtualPathUtility.ToAppRelative(entity.VirtualPath);

   if (extension == ".js")    // 如果是JS就加入至~/View/JS/XXX的Bundle
   {
    bundles.Add(new ScriptBundle(string.Format("~/{0}/Js/{1}", "Views", fileName.ToUpper()))
     .Include(vPath));
   }
   else if (extension == ".css")    // 如果是JS就加入至~/View/Css/XXX的Bundle
   {
    bundles.Add(new StyleBundle(string.Format("~/{0}/Css/{1}", "Views", fileName.ToUpper()))
     .Include(vPath));
   }
  }
 }
}

public static void RegisterBundles(BundleCollection bundles)
{
 bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
    "~/Scripts/jquery-{version}.js"));
 ...
 
 // 呼叫動態加入Bundle
 DynamicAddBundles("~/Views", bundles);
}

接著再_Layout.cshtml修改如下:
<!DOCTYPE html>
<html>
<head>
    ...
 
    @Styles.Render("~/Content/css")

    @{
  // 如果找到該頁面的Css Bundle就加入
        if (BundleTable.Bundles.GetBundleFor(
            string.Concat("~/Views/Css/", ViewBag.Page.ToUpper())) != null)
        {
            @Styles.Render(string.Concat("~/Views/Css/", ViewBag.Page.ToUpper()))
        }
    }
</head>
<body>
 ...

    @Scripts.Render("~/bundles/jquery")
    @Scripts.Render("~/bundles/bootstrap")

    @{
  // 如果找到該頁面的JS Bundle就加入
        if (BundleTable.Bundles.GetBundleFor(
            string.Concat("~/Views/Js/", ViewBag.Page.ToUpper())) != null)
        {
            @Scripts.Render(string.Concat("~/Views/Js/", ViewBag.Page.ToUpper()))
        }
    }

    @RenderSection("scripts", required: false)
</body>
</html>

注意:在這邊因為要知道Bundle的名稱才能讀取,所以限量會在View裡設定ViewBag.Page的值,讓程式去找這個值的Bundle,因此要特別注意。


完成了! 接下來就來看看執行結果:



執行結果可以看到我們頁面的JS與CSS有載進來且Minify,有沒有非常感動。這樣之後就可以加速開發與Debug速度了。
 







參考來源:

Matthew Renze - Clean Architecture in ASP.NET MVC 5
CodeProject - Introducing Dynamic Bundles for ASP.NET MVC
MSDN - RazorViewEngine 類別






留言