ASP.NET MVC Bundles

Każda strona zawiera wiele plików CSS oraz skryptów JS. Zwykle w celu przejrzystości i łatwiejszego utrzymania aplikacji, skrypty są rozdzielane na różne części logiczne. Podobnie jak w klasycznym programowaniu C#, w JS również korzystamy z komentarzy i formatowania kodu..

Ma to jednak pewien efekt uboczny dla wydajności. Każda spacja czy komentarz to dodatkowy tekst, który trzeba przesłać klientowi (przeglądarce).  Najbardziej optymalną sytuacją byłoby usunięcie wszelkich spacji, komentarzy, które nie mają znaczenia dla funkcjonalności oraz połączenie wszystkich skryptów w jeden plik tak, że nie trzeba wysyłać kilku zapytań. Oczywiście w praktyce taki kod byłoby niemożliwy w utrzymaniu. ASP. NET MVC zrobi to jednak za nas. Wystarczy wskazać jakie pliki chcemy połączyć w “bundle” i reszta zostanie wygenerowana za nas w tle.

Stworzenie własnego zestawu wygląda następująco:

bundles.Add(new StyleBundle("~/Content/themes/base/css").Include(
         "~/Content/themes/base/jquery.ui.core.css",
         "~/Content/themes/base/jquery.ui.resizable.css",
         "~/Content/themes/base/jquery.ui.selectable.css",
         "~/Content/themes/base/jquery.ui.accordion.css",
         "~/Content/themes/base/jquery.ui.autocomplete.css",
         "~/Content/themes/base/jquery.ui.button.css",
         "~/Content/themes/base/jquery.ui.dialog.css",
         "~/Content/themes/base/jquery.ui.slider.css",
         "~/Content/themes/base/jquery.ui.tabs.css",
         "~/Content/themes/base/jquery.ui.datepicker.css",
         "~/Content/themes/base/jquery.ui.progressbar.css",
         "~/Content/themes/base/jquery.ui.theme.css"));

Analogicznie tworzy się pakiety dla skryptów:

bundles.Add(new ScriptBundle("~/bundles/jqueryval").Include(
              "~/Scripts/jquery.unobtrusive*",
              "~/Scripts/jquery.validate*"));

Pierwszy parametr to nazwa zestawu, a następnie przekazujemy skrypty czy style, które chcemy połączyć w całość. Następnie w widoku zamiast bezpośrednio podłączać poszczególne pliki korzystamy z następujących funkcji:

@Styles.Render("~/Content/css")
@Scripts.Render("~/bundles/jquery")

Jeśli uruchomilibyśmy teraz aplikację, zobaczylibyśmy zwykle skrypty. Musimy uaktywnić powyższy mechanizm np. ustawiając debug na false w pliku web.config:

<system.web>
    <compilation debug="true" />
</system.web>

Innym sposobem jest umieszczenie następującego wywołania  w pliku Global:

BundleTable.EnableOptimizations = true;

Teraz po wygenerowaniu strony, zobaczymy odniesienia do bundle:

<link href="/Content/css?v=WMr-pvK-ldSbNXHT-cT0d9QF2pqi7sqz_4MtKl04wlw1" rel="stylesheet"/>

<script src="/bundles/modernizr?v=qVODBytEBVVePTNtSFXgRX0NCEjh9U_Oj8ePaSiRcGg1"></script>

Wyraźnie widać, że oryginalne pliki zostały zastąpione pojedynczymi, zoptymalizowanymi plikami (można je otworzyć w jakimś edytorze ale oczywiście nie są one czytelne).

ASP.NET MVC: Caching

Zarówno ASP.NET jak i ASP.NET MVC dostarczają bardzo prosty w użyciu mechanizm buforowania. Nie będę zajmował się tutaj Web Forms, a pokażę wyłącznie jak z tego korzystać w MVC.

Buforowanie oczywiście służy do szybszego wyświetlania stron. Zamiast za każdym razem, wywoływać akcję w kontrolerze, wykonuje się ją raz a potem wynik przechowuje się w pamięci.

Wyobraźmy sobie, że dana akcja wykonuje skomplikowane operacje i ponadto łączy się z różnymi bazami danych. Załóżmy, że wiele użytkowników w tym samy czasie łączy się z serwerem. Spowoduje to ogromne obciążenie. W takiej sytuacji zbuforowanie wyniku i przesyłanie go do klientów może przynieść ogromne korzyści.

Sama konfiguracja caching’u jest bardzo prosta – wystarczy użyć atrybutu OutputCache. Najpierw zdefiniujmy jednak widok:

@using System.Globalization
@model dynamic

@{
    ViewBag.Title = "title";

}

<h2>title</h2>


@DateTime.Now.ToString(CultureInfo.InvariantCulture)

Nie ma tutaj nic nadzwyczajnego. Wyświetlamy po prostu datę. Bez buforowania, za każdym odświeżeniem strony, czas byłby różny.  Następnie przejdźmy do implementacji akcji:

public class HomeController : Controller
{
   //
   // GET: /Admin/Home/
   [OutputCache(Duration = 20)]
   public ActionResult Index()
   {
       return View();
   }
}

Duration to czas ważności danego bufora. Jeśli w ciągu 20 sekund, kilkaset użytkowników wywoła /Home/Index, dostaną dokładnie tą samą, zbuforowaną stronę.

Pozostaje pytanie, gdzie kopia jest przechowywana? Dwie najważniejsze lokalizacje to serwer i klient. Jeśli wynik jest specyficzny dla danego użytkownika wtedy naturalne wydaje się, że chcemy przechowywać kopie u klienta, w przeglądarce. Załóżmy, że z jednym z elementów buforowanych to nazwa użytkownika albo inne specyficzne dla niego dane – nie chcemy w takiej sytuacji generować jednej, wspólnej dla wszystkich kopii po stronie serwera. Czasami jednak, lepiej mieć jedną kopie prezentowaną wszystkich, którą trzyma się na serwerze. Warto podkreślić, że przechowywanie po stronie klienta jest szybsze, ponieważ użytkownik nie musi wysyłać żadnego zapytania do serwera – kopia w końcu jest u niego już lokalnie na dysku. Powyższe zachowania można skonfigurować za pomocą parametru Location:

[OutputCache(Duration = 20,Location = OutputCacheLocation.Client)]
public ActionResult Index()
{
  return View();
}

Bardzo często, np. w celach testowych, chcemy zmniejszyć czas ważności kopii za pomocą pliku konfiguracyjnego. W ASP.NET możemy w łatwy sposób zrobić to za pomocą następującego kodu (Web.config):

<system.web>
    <caching>
        <outputCacheSettings>
            <outputCacheProfiles>
            <add name="profile" duration="30" enabled="true" varyByParam="pageNumber"/>
            </outputCacheProfiles>
        </outputCacheSettings>
    </caching>
</system.web>

Powyższa konfiguracja, definiuje profil buforowania o nazwie “profile”. Następnie w celu jego użycia należy  skorzystać z właściwości CacheProfile:

[OutputCache(CacheProfile = "profile")]
public ActionResult Index()
{
  return View();
}

Bardzo często, chcemy generować różne kopie w zależności od pewnych parametrów. Prawdopodobnie inną kopę chcemy posiadać dla /Home/Index/?IdEmployee=1 a inną dla /Home/Index/?IdEmployee=2. Możemy skorzystać z VaryByParam, przekazując nazwę zmiennej po której chcemy generować osobne kopie:

[OutputCache(Duration = 200,VaryByParam = "id")]
public ActionResult Index()
{
  return View();
}

Wartości możemy przekazać po przecinku, jeśli mamy kilka różnych parametrów.

Inne sposoby generowania różnych kopii to właściwości VaryByHeader oraz VaryByCustom. Pierwsza z nich analizuje nagłówek HTTP a druga z kolei, pozwala nam na zdefiniowanie własnej logiki.  VaryByCustom wywoła metodę GetVaryByCustomString, którą możemy zdefiniować w pliku Global.

Istnieje również właściwość SqlDependency, która pozwala uzależnić caching od bazy danych.

ASP.NET MVC Areas

Im większy projekt tym więcej kontrolerów i widoków w solucji. Istnieje wiele sposobów na poddział projektu na kilka części. Nie zawsze jednak jest sens tworzenia nowych bibliotek i zwykle lepiej zastosować po prostu podział za pomocą przestrzeni nazw. “Areas” to po prostu wydzielenie kilku kontrolerów i widoków do osobnej przestrzeni nazw. Domyślnie wszystkie kontrolery znajdują się w folderze Controllers a widoki w Views. Przy dużych projektach jest to nieczytelne i trudne w utrzymaniu. Warto wtedy rozdzielić to na kilka obszarów – wystarczy dodać (z menu kontekstowego) “Area”:

image

Zaglądając do Solution Explorer, przekonamy się, że nowy folder został dodany:

image

Oprócz folderów Controllers i Views, mamy Areas, który zawiera właśnie dodany obszar o nazwie Admin. W Admin znajdziemy z kolei Controllers i Views. Innymi słowy, możemy tworzyć nowe obszary z własnymi kontrolerami i widokami. Zamiast wszystko umieszczać w głównych folderach, warto rozdzielić to na logiczne części (takie “podprojekty”).

Oprócz folderów, została dodana również klasa AdminAreaRegistration.cs, która znajduje się w Areas\Admin:

public class AdminAreaRegistration : AreaRegistration
{
   public override string AreaName
   {
       get
       {
           return "Admin";
       }
   }

   public override void RegisterArea(AreaRegistrationContext context)
   {
       context.MapRoute(
           "Admin_default",
           "Admin/{controller}/{action}/{id}",
           new { action = "Index", id = UrlParameter.Optional }
       );
   }
}

Klasa zawiera konfigurację danego obszaru (area). Najważniejszą częścią jest chyba zdefiniowany routing. Jak widać z metody RegisterArea, wszystkie kontrolery w danym obszarze można wywołać za pomocą URL w postaci “Admin/{controller}/{action}. Jeśli stworzyliśmy np. kontroler UserController to akcja Index będzie osiągalna za pomocą: http://localhost:54256/Admin/User/Index.

Z kolei w pliku global.asax.cs znajduje się rejestracja obszaru:

public class MvcApplication : System.Web.HttpApplication
{
   protected void Application_Start()
   {
       AreaRegistration.RegisterAllAreas();

       WebApiConfig.Register(GlobalConfiguration.Configuration);
       FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
       RouteConfig.RegisterRoutes(RouteTable.Routes);
       BundleConfig.RegisterBundles(BundleTable.Bundles);
       AuthConfig.RegisterAuth();
   }
}

Spróbujmy dodać kolejny kontroler o nazwie Home. Powstała struktura powinna wyglądać następująco:

image

Mamy zatem dwa kontrolery o nazwie HomeController. Jeden znajduje się w Admin/Home/Controllers a drugi w głównym Controllers. Wywołując http://localhost:54256/Admin/Home/Index sprawa jest prosta i zostanie wykonany prawidłowy kontroler. Co z kolei jednak gdy chcemy wykonać główny kontroler? Próbując wykonać /Home/Index dostaniemy wyjątek:

Multiple types were found that match the controller named 'Home'. This can happen if the route that services this request ('{controller}/{action}/{id}') does not specify namespaces to search for a controller that matches the request. If this is the case, register this route by calling an overload of the 'MapRoute' method that takes a 'namespaces' parameter.

The request for 'Home' has found the following matching controllers:
MvcApplication3.Areas.Admin.Controllers.HomeController
MvcApplication3.Controllers.HomeController 

Zaglądając do pliku App_Start/RouteConfig.cs przekonamy się, że zdefiniowany routing jest zbyt ogólny:

routes.MapRoute(
           name: "Default",
           url: "{controller}/{action}/{id}",
           defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
       );

Domyślnie wszystkie przestrzenie nazw są przeszukiwane. Z tego względu /Home/Index zostanie dopasowany do kilku kontrolerów.  Rozwiązaniem jest jawne zdefiniowanie namesapce:

 routes.MapRoute(
                name: "Default",
                url: "{controller}/{action}/{id}",
                defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }, 
                namespaces:new[] { "MvcApplication3.Controllers" }
            );

Teraz główny routing najpierw przeszuka klasy znajdujące się w głównym folderze Controllers. W poprzednim przykładzie wszystkie przestrzenie nazw były brane pod uwagę i jeśli znaleziono kilka dopasowań to wyjątek był wyrzucany.

Generowanie linków do konkretnej akcji odbywa się tak samo za pomocą @Html.ActionLink. W większości sytuacji nie trzeba przekazywać dodatkowych parametrów – dlatego nigdy nie polecam używania bezpośrednio <a href…/>.

Z kolei jeśli chcemy z obszaru A, stworzyć link do obszaru B, wtedy musimy jakoś to oznaczyć. Domyślnie wszystkie linki generowane są dla tego samego obszaru, w  którym znajduję się dany widok. Na szczęście wystarczy przekazać po prostu dodatkową zmienną @area:

@Html.ActionLink("DisplayText", "Index", new { area = "CustomArea" })

Powyższa metoda wygeneruje link do /CustomArea/Home/Index.

Inny scenariusz to link z jakiegoś obszaru do akcji w głównym folderze:

@Html.ActionLink("Jakis tekst", "Index", new { area = "" })

Przekazując pustą nazwę, zostanie wygenerowany odnośnik do głównego kontrolera tzn. /Home/Index.