Razor Generator i area w ASP.NET MVC

Jeśli ktoś jeszcze tego nie wie, to Razor Generator jest pomocnym narzędziem, które pozwala na umieszczanie widoków MVC w bibliotekach dll. W przypadku frameworka takiego jak PizzaMVC, jest to niezbędne - wszak z założenia wszystkie podstawowe widoki CRUDowe mają być częścią biblioteki. Brzmi prosto, ale w zeszłym tygodniu trochę czasu spędziłem zmagając się z prostym problemem, który wynikał z tego, że rzeczywistość jest trudniejsza niż tutoriale. ;)

Objawy

W zeszłym tygodniu realizowałem kilka frontendowych zadań związanych głównie z przeniesieniem plików JS i CSS do frameworka (do tej pory znajdowały się w testowej aplikacji klienckiej). Następnie aktualizowałem Pizzę w aplikacji, która na niej bazuje. Tak - PizzaMVC nie powstaje sama, równolegle powstaje korzystająca z niej aplikacja, która nie jest tylko pokazowym projektem, lecz ma prawdziwe wymagania - takie jakie tylko klient potrafi wymyślić. ;) Aplikacja ta składa się z publicznej strony - powiedzmy “wizytówki” firmy oraz panelu administracyjnego. Tenże panel jest oczywiście wydzielony i siedzi sobie w Areas/Admin. Praca była dość żmudna - aplikacja nie korzystała wcześniej z Bootstrapa, Pizza zaś już go ma, więc musiałem przerobić wszystkie layouty, użyć odpowiednich klas CSS, itp. - same nudy. Niestety nie wszystko było proste… Jakaś magia (naprawdę na początku tak myślałem!) sprawiała, że gdy wchodziłem na strony w panelu administracyjnym, wyświetlał mi się layout głównej strony. Zamiast menu z funkcjami obsługi produktów czy klientów, miałem publiczne menu: “o nas”, “kontakt”, itd. No nie tak miało być…

Diagnoza

Niby nie jest to trudna sprawa, ale trochę czasu minęło, zanim załapałem o co chodzi. Ale od początku…

Gdy wysyłamy żądanie pod adres /Admin/Customers/, domyślnie skonfigurowane ASP.NET MVC wykonuje metodę Index z kontrolera CustomersController. Ponieważ korzystam z PizzaMVC, to ta metoda jest zdefiniowana w GridControllerBase i wygląda następująco:

1 public ActionResult Index()
2 {
3     this.ViewBag.PageTitle = this.ViewNames[ViewType.Index];
4     return this.View(gridMetamodel);
5 }

Czyli ma po prostu zwrócić widok o nazwie Index. A zatem ASP.NET MVC zaczyna poszukiwania widoku o tej nazwie, w następujących lokalizacjach i kolejności:

  1. ~/Areas/Admin/Views/Customers/Index.cshtml
  2. ~/Areas/Admin/Views/Shared/Index.cshtml
  3. ~/Views/Customers/Index.cshtml
  4. ~/Views/Shared/Index.cshtml

Oprócz widoków z rozszerzeniem cshtml, których używa smutna większość programistów ASP.NET MVC, poszukiwane są najpierw te z rozszerzeniami aspx i ascx (jak widać pełna kultura - starsi ludzie, którzy ciągle piszą w webformsowej składni widoków mają pierwszeństwo), zaś na końcu poszukiwane są widoki z rozszerzeniem vbhtml - czyli coś dla totalnych perwersów prawdziwych hardkorów.

Wracając do tematu - żaden z tych plików fizycznie nie istnieje. Co oznacza, że jakiś mechanizm umieszcza je pod jedną z tych ścieżek w postaci wirtualnej. Fizyczny Index.cshtml istnieje tylko w Pizza.Mvc. w katalogu Views/Shared. Zajrzyjmy zatem do niego:

1 <h2>@ViewBag.PageTitle</h2>
2 @model Pizza.Mvc.Grid.Metamodel.GridMetamodel
3 
4 @Html.DisplayFor(x => Model)

Niezbyt imponująco, bo wszystko i tak odbywa się w DisplayTemplate dla klasy GridMetaModel, ale mniejsza z tym. Ponieważ korzystamy z Razor Generatora, to nasz widok jest kompilowany do takiej klasy:

 1 #pragma warning disable 1591
 2 //------------------------------------------------------------------------------
 3 // <auto-generated>
 4 //     This code was generated by a tool.
 5 //     Runtime Version:4.0.30319.42000
 6 //
 7 //     Changes to this file may cause incorrect behavior and will be lost if
 8 //     the code is regenerated.
 9 // </auto-generated>
10 //------------------------------------------------------------------------------
11 
12 namespace ASP
13 {
14     using System;
15     using System.Collections.Generic;
16     using System.IO;
17     using System.Linq;
18     using System.Net;
19     using System.Text;
20     using System.Web;
21     using System.Web.Helpers;
22     using System.Web.Mvc;
23     using System.Web.Mvc.Ajax;
24     using System.Web.Mvc.Html;
25     using System.Web.Optimization;
26     using System.Web.Routing;
27     using System.Web.Security;
28     using System.Web.UI;
29     using System.Web.WebPages;
30     using Pizza.Mvc;
31     using Pizza.Mvc.HtmlHelpers;
32     
33     [System.CodeDom.Compiler.GeneratedCodeAttribute("RazorGenerator", "2.0.0.0")]
34     [System.Web.WebPages.PageVirtualPathAttribute("~/Views/Shared/Index.cshtml")]
35     public partial class _Views_Shared_Index_cshtml : System.Web.Mvc.WebViewPage<Pizza.Mvc.Grid.Metamodel.GridMetamodel>
36     {
37         public _Views_Shared_Index_cshtml()
38         {
39         }
40         public override void Execute()
41         {
42 WriteLiteral("<h2>");
43 
44             
45             #line 1 "..\..\Views\Shared\Index.cshtml"
46 Write(ViewBag.PageTitle);
47 
48             
49             #line default
50             #line hidden
51 WriteLiteral("</h2>\r\n");
52 
53             
54             #line 4 "..\..\Views\Shared\Index.cshtml"
55 Write(Html.DisplayFor(x => Model));
56 
57             
58             #line default
59             #line hidden
60 WriteLiteral("\r\n");
61 
62         }
63     }
64 }
65 #pragma warning restore 1591

I tutaj, w 34 linijce widzimy istotny kod. Atrybut PageVirtualPathAttribute z ustawioną wartością "~/Views/Shared/Index.cshtml". Zagadka rozwiązana - wiemy już, gdzie MVC i co znajduje. No i ponieważ znajduje go pod takim adresem, to dopasowuje mu layout ze ścieżki ~Views/Shared/_Layout.cshtml. Taki plik istnieje fizycznie w docelowej aplikacji, więc jest używany. Layout z głównej strony. Wszystko zgodnie z zasadami i logiką. Wynika z tego, że wszystkie gridy w aplikacji, bez względu na to, w jakim są obszarze, będą korzystały z głównego layoutu. Smutne, bo nie tego przecież chcemy.

Terapia

Na początek, naprawa metodą prób i błędów. Zmieniłem wartość atrybutu na: [System.Web.WebPages.PageVirtualPathAttribute("~/Areas/Admin/Views/Customers/Index.cshtml")], uruchomiłem aplikację, i… zadziałało! No dobra, oczywiście to niczego nie naprawia. To ma działać dla wszystkich widoków, zarówno wewnątrz każdego area jak i poza nimi. Jak to osiągnąć? Na początku myślałem, żeby jakoś dynamicznie zmieniać wartość atrybutu. No, ale czegoś takiego nie da się przecież zrobić. Można generować jakieś klasy proxy i nadpisywać atrybut. Do zrobienia, tylko trochę potrwa… Czy nie da się prościej?

Ogólnie, Razor Generator działa dzięki temu, że jego PrecompiledMvcEngine jest rejestrowany jako jedna z VirtualPathFactory. Ten kod wyglądał tak:

1 var engine = new PrecompiledMvcEngine(typeof(PizzaMvcPostApplicationStart).Assembly)
2 {
3     UsePhysicalViewsIfNewer = HttpContext.Current.Request.IsLocal
4 };
5 
6 ViewEngines.Engines.Add(engine);
7 VirtualPathFactoryManager.RegisterVirtualPathFactory(engine);

I jak wiadomo działał świetnie dopóki nie chciałem skorzystać z area. Na szczęście odkryłem, że PrecompiledMvcEngine posiada przeciążony konstruktor przyjmujący drugi parametr - baseVirtualPath. W ten sposób naokoło, ale skutecznie, rozwiązałem problem. Wystarczyło nieco zmodyfikować powyższy kod i napisać go w ten spośób:

 1 var areaNames = RouteTableHelper.GetApplicationAreaNames();
 2 foreach (var areaName in areaNames)
 3 {
 4     var engine = new PrecompiledMvcEngine(typeof(PizzaMvcPostApplicationStart).Assembly, string.Format("~/Areas/{0}/", areaName))
 5     {
 6         UsePhysicalViewsIfNewer = HttpContext.Current.Request.IsLocal,
 7     };
 8 
 9     ViewEngines.Engines.Add(engine);
10     VirtualPathFactoryManager.RegisterVirtualPathFactory(engine);
11 }
12 
13 var mainEngine = new PrecompiledMvcEngine(typeof(PizzaMvcPostApplicationStart).Assembly)
14 {
15     UsePhysicalViewsIfNewer = HttpContext.Current.Request.IsLocal,
16 };
17 
18 ViewEngines.Engines.Add(mainEngine);
19 VirtualPathFactoryManager.RegisterVirtualPathFactory(mainEngine);

I tyle. Teraz wszystko działa. No dobra, nie wszystko, ale to akurat tak.

Opublikowano: Ostatnia aktualizacja: