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:
~/Areas/Admin/Views/Customers/Index.cshtml
~/Areas/Admin/Views/Shared/Index.cshtml
~/Views/Customers/Index.cshtml
~/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.