Kamil Kliczbor @ asptip.net

5Dec/130

Castle Windsor – tryb życia komponentów w przykładzie

Castle Windsor

Wprowadzenie

Wpis nawiązuje do poprzedniego, teoretycznego wprowadzenia do trybów życia w Windsor Castle. W tym przykładzie spróbujemy posłużyć się semi-biznesowym przypadkiem. Powiedzmy, że mamy system do składania zamówień, który na podstawie kodu produktu oraz pewnej konfiguracji, będzie potrafił udzielić zniżki na wybrany produkt. Zepniemy te bardzo "wybujałe" wymagania i dostarczymy projekt w technologii ASP.NET MVC.

Modele, kontrolery, widoki i walidatory

Poniżej przedstawiłem screen z widokiem na solution tego projektu. W dalszych akapitach krótko scharakteryzuje standardowe byty jak dla aplikacji ASP.NET MVC , a później przedstawię koncepcję podpięcia Windsora do tego projektu.

WindsorLifeStyles

Poniżej znajduje się listing do Product.cs. Niczego specjalnego tutaj nie mamy. Ot taka zwykła encja, mamy kod produktu, liczbę zamówionych egzemplarzy oraz informację o tym, czy zostanie udzielona zniżka.

namespace WindsorLifeStyles.Models
{
    public class Product
    {
        public int Id { get; set; }

        public string Code { get; set; }

        public int Quantity { get; set; }

        public bool Discount { get; set; }
    }
}

Kontroler ProductsController.cs steruje logiką zarządzania przydzielania zniżki użytkownikowi, który zamierza kupić dany produkt. Jako zależności do kontrolera produktów, wstrzykiwane są dwa komponenty - ProductDiscountCache oraz CurrentUserProvider. Do ViewBaga widoku doklejamy informacje o liczbie wywołań poszczególnych komponentów - kontrolera, providera id użytkownika oraz cache zniżek produktów.

using System.Web.Mvc;
using WindsorLifeStyles.Models;
using WindsorLifeStyles.Services;

namespace WindsorLifeStyles.Controllers
{
    public class ProductsController : Controller
    {
        private readonly ProductDiscountCache _productDiscountCache;
        private readonly CurrentUserProvider _currentUserProvider;
        private int _calls;
        public ProductsController(
            ProductDiscountCache productDiscountCache,
            CurrentUserProvider currentUserProvider)
        {
            _productDiscountCache = productDiscountCache;
            _currentUserProvider = currentUserProvider;
        }

        public ActionResult Index()
        {
            _calls++;
            SetCallsData();
            return View(new Product());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Index(Product product)
        {
            _calls++;

            if (ModelState.IsValid)
            {
                var currentUserId = _currentUserProvider.GetCurrentUserId();
                product.Discount = _productDiscountCache.HasDiscount(product.Code, currentUserId);

                SetCallsData();
                return View("Details", product);
            }

            SetCallsData();
            return View(product);
        }

        private void SetCallsData()
        {
            ViewBag.GetCurrentUserCalls = _currentUserProvider.GetCalls;
            ViewBag.HasDiscountCalls = _productDiscountCache.GetCalls;
            ViewBag.ControllerCalls = _calls;
        }
    }
}

Widok Details.cshtml wyświetla podsumowanie zamówienia produktu oraz liczbę wywołań poszczególnych komponentów.

@model WindsorLifeStyles.Models.Product

@{
    ViewBag.Title = "Details";
}

<h2>Details</h2>

<fieldset>
    <legend>Product</legend>

    <div class="display-label">
        @Html.DisplayNameFor(model => model.Code)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Code)
    </div>

    <div class="display-label">
        @Html.DisplayNameFor(model => model.Quantity)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Quantity)
    </div>

    <div class="display-label">
        @Html.DisplayNameFor(model => model.Discount)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Discount)
    </div>
    <div class="display-field">
        ControllerCalls:  @ViewBag.ControllerCalls
    </div>
    <div class="display-field">
        GetCurrentUserCalls:  @ViewBag.GetCurrentUserCalls
    </div>
    <div class="display-field">
        HasDiscountCalls:  @ViewBag.HasDiscountCalls
    </div>       
</fieldset>

Z kolei widok Index.cshtml prezentuje formularz dodawania produktu.

@model WindsorLifeStyles.Models.Product

@{
    ViewBag.Title = "Create";
}

<h2>Create</h2>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    @Html.ValidationSummary(true)

    <fieldset>
        <legend>Product</legend>

        <div class="editor-label">
            @Html.LabelFor(model => model.Code)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.Code)
            @Html.ValidationMessageFor(model => model.Code)
        </div>

        <div class="editor-label">
            @Html.LabelFor(model => model.Quantity)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.Quantity)
            @Html.ValidationMessageFor(model => model.Quantity)
        </div>

        <p>
            <input type="submit" value="Create" />
        </p>
        <div class="display-field">
            ControllerCalls:  @ViewBag.ControllerCalls
        </div>
        <div class="display-field">
            GetCurrentUserCalls:  @ViewBag.GetCurrentUserCalls
        </div>
        <div class="display-field">
            HasDiscountCalls:  @ViewBag.HasDiscountCalls
        </div>
    </fieldset>
}

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

Komponent CurrentUserProvider dostarcza informacji o identyfikatorze zalogowanego użytkownika. W tym przypadku zawsze tym identyfikatorem jest liczba 1, ale w prawdziwej implementacji provider ten powinien odwoływać się do mechanizmu uwierzytelniającego. W odniesieniu do stylu życia, komponent ten skonfigurujemy jako PerWebRequest, gdyż identyfikator zalogowanego użytkownika w ramach jednego requesta się nie zmienia. Co więcej jest to pewnego rodzaju optymalizacja, gdyż wywołanie tego providera może odbyć się wielokrotnie przy jednym requeście.

namespace WindsorLifeStyles.Services
{
    public class CurrentUserProvider
    {
        private int _calls;

        public int GetCalls
        {
            get { return _calls; }
        }

        public long GetCurrentUserId()
        {
            _calls++;
            return 1;
        }
    }
}

ProductDiscountCache jest komponentem, który dostarcza cache zmieniający się niezwykle rzadko lub wcale. Dobrym stylem życia dla tego komponentu jest Singleton. Przeładowanie cache odbędzie się w momencie, gdy kontener zostanie podniesiony od nowa, co stanie się przy restarcie puli. Natomiast sama implementacja leniwie odczytuje dane (wzorzec Lazy Loading), które zapisuje do słownika.

using System;
using System.Collections.Generic;

namespace WindsorLifeStyles.Services
{
    public class ProductDiscountCache
    {
        private readonly Lazy<Dictionary<string, IList>> _cache;

        private int _calls;

        public int GetCalls
        {
            get { return _calls; }
        }

        public ProductDiscountCache()
        {
            _cache = new Lazy<Dictionary<string, IList>>(GetCache);
        }

        private Dictionary<string, IList> GetCache()
        {
            return new Dictionary<string, IList>
                {
                    {"A", new List {1, 2, 3}},
                    {"B", new List {1}},
                    {"C", new List {2, 3}},
                };
        }

        public bool HasDiscount(string code, long currentUserId)
        {
            _calls++;

            var cache = _cache.Value;
            if (cache.ContainsKey(code))
            {
                var userIds = cache[code];
                return userIds.Contains(currentUserId);
            }

            return false;
        }
    }
}

Walidacja

Istotną składową tego projektu pod kątem zrozumienia trybu życia komponentów jest walidacja produktu, która również na podstawie CurrentUserProvider oraz ProductDiscountCache jest w stanie ustalić, czy użytkownik może dostać zniżkę na określoną liczbę produktów. W następnych akapitach napiszę jak sprytnie zintegrować walidację ASP.NET MVC z WindsorCastle.

using System.Collections.Generic;
using System.Web.Mvc;
using WindsorLifeStyles.Models;
using WindsorLifeStyles.Services;

namespace WindsorLifeStyles.Validators
{
    public class ValidadatorProvider : ModelValidatorProvider
    {
        private readonly CurrentUserProvider _currentUserProvider;
        private readonly ProductDiscountCache _productDiscountCache;

        public ValidadatorProvider(CurrentUserProvider currentUserProvider, ProductDiscountCache productDiscountCache)
        {
            _currentUserProvider = currentUserProvider;
            _productDiscountCache = productDiscountCache;
        }

        public override IEnumerable GetValidators(ModelMetadata metadata, ControllerContext context)
        {
            if (metadata.Model != null)
            {
                if (typeof (Product).IsAssignableFrom(metadata.ModelType))
                {
                    yield return new ProductValidator(metadata, context, _currentUserProvider, _productDiscountCache);
                }
            }
        }
    }
}

W ramach tego projektu mamy tylko jeden walidator - ProductValidator, który sprawdza czy użytkownik przypadkiem nie zamierza kupić więcej niż 3 produktów ze zniżką. Jeżeli ten warunek nie będzie spełniony, walidacja się nie powiedzie, a kupujący otrzyma komunikat o błędzie.

using System.Collections.Generic;
using System.Web.Mvc;
using WindsorLifeStyles.Models;
using WindsorLifeStyles.Services;

namespace WindsorLifeStyles.Validators
{
    public class ProductValidator : ModelValidator
    {
        private readonly CurrentUserProvider _currentUserProvider;
        private readonly ProductDiscountCache _productDiscountCache;

        public ProductValidator(
            ModelMetadata metadata,
            ControllerContext controllerContext,
            CurrentUserProvider currentUserProvider,
            ProductDiscountCache productDiscountCache)
            : base(metadata, controllerContext)
        {
            _currentUserProvider = currentUserProvider;
            _productDiscountCache = productDiscountCache;
        }

        public override IEnumerable Validate(object container)
        {
            var product = (Product) Metadata.Model;
            var currentUserId = _currentUserProvider.GetCurrentUserId();
            var hasDiscount = _productDiscountCache.HasDiscount(product.Code, currentUserId);
            if (hasDiscount && product.Quantity > 3)
            {
                yield return new ModelValidationResult
                    {
                        MemberName = "Quantity",
                        Message = "Użytownik, który otrzyma zniżkę, nie może zakupić więcej niż 3 produkty."
                    };
            }
        }
    }
}

I teraz chyba najważniejsza rzecz: utworzenie dedykowanego providera (RootModelValidatorProvider.cs), który potrafi każdorazowo wyciągnąć z kontenera listę walidatorów. Dlaczego tak ? Ano, chcemy, aby każdorazowo walidatory miały możliwość wstrzyknięcia zależności od kontenera.

using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using Castle.MicroKernel;

namespace WindsorLifeStyles
{
    public class RootModelValidatorProvider : ModelValidatorProvider
    {
        private readonly IKernel _kernel;

        public RootModelValidatorProvider(IKernel kernel)
        {
            _kernel = kernel;
        }

        public override IEnumerable GetValidators(ModelMetadata metadata, ControllerContext context)
        {
            var modelValidators =
                _kernel.ResolveAll()
                       .SelectMany(n => n.GetValidators(metadata, context))
                       .ToList();
            return modelValidators;
        }
    }
}

Wpięcie Windsor Castla w ASP.NET MVC

Przyjrzyjmy się najpierw instalatorowi zależności(WindsorInstaller.cs). Wskazujemy do rejestracji:

  • wszystkie implementację IController jako transient, czyli po prostu wszystkie kontrolery,
  • wszystkie implementacje ModelValidatorProvidera jako transient, ale bez RootModelValidatorProvidera, gdyż wkroczylibyśmy potem w nieskończoną pętlę rozwiązywania zależności
  • ProductDiscountCache jako Singleton
  • CurrentUserProvider jako PerWebRequest
using System.Web.Mvc;
using Castle.MicroKernel.Registration;
using Castle.MicroKernel.SubSystems.Configuration;
using Castle.Windsor;
using WindsorLifeStyles.Services;

namespace WindsorLifeStyles
{
    public class WindsorInstaller : IWindsorInstaller
    {
        public void Install(IWindsorContainer container, IConfigurationStore store)
        {
            container.Register(
                Classes.FromThisAssembly()
                       .BasedOn<IController>()
                       .LifestyleTransient(),
                Classes.FromThisAssembly()
                       .Pick()
                       .If(typeof (ModelValidatorProvider).IsAssignableFrom)
                       .If(t => t != typeof (RootModelValidatorProvider))
                       .WithServices(typeof (ModelValidatorProvider))
                       .LifestyleTransient(),
                Component.For<ProductDiscountCache>()
                         .LifestyleSingleton(),
                Component.For<CurrentUserProvider>()
                         .LifestylePerWebRequest()
                );
        }
    }
}

Tworzymy własną implementację fabryki kontrolerów WindsorControllerFactory.cs opartą o Windsor Castla. Przedstawiam tutaj dosyć standardowe podejście do tematu:

using System;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
using Castle.MicroKernel;

namespace WindsorLifeStyles
{
    public class WindsorControllerFactory : DefaultControllerFactory
    {
        private readonly IKernel _kernel;

        public WindsorControllerFactory(IKernel kernel)
        {
            _kernel = kernel;
        }

        public override void ReleaseController(IController controller)
        {
            _kernel.ReleaseComponent(controller);
        }

        protected override IController GetControllerInstance(RequestContext requestContext, Type controllerType)
        {
            if (controllerType == null)
            {
                throw new HttpException(404, string.Format("The controller for path '{0}' could not be found.", requestContext.HttpContext.Request.Path));
            }

            return (IController) _kernel.Resolve(controllerType);
        }
    }
}

No i wreszcie spięcie wszystkiego w  Global.asax.

using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using Castle.Windsor;
using Castle.Windsor.Installer;

namespace WindsorLifeStyles
{
    public class MvcApplication : System.Web.HttpApplication
    {
        private static IWindsorContainer _container;

        private static void BootstrapContainer()
        {
            _container = new WindsorContainer().Install(FromAssembly.This());
            ControllerBuilder.Current.SetControllerFactory(new WindsorControllerFactory(_container.Kernel));
        }

        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();

            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);

            BootstrapContainer();

            ModelValidatorProviders.Providers.Clear();
            ModelValidatorProviders.Providers.Add(new RootModelValidatorProvider(_container.Kernel));
        }

        protected void Application_End()
        {
            _container.Dispose();
        }
    }
}

Uruchomienie aplikacji

Przy pierwszym wejściu na ekran pokazuje się formularz wprowadzania danych. Zauważmy, że w tym miejscu nie jest wołana walidacja, ani też odwołania do serwisów pobierających id użytkownika i dane z cache. Dlatego otrzymujemy wartości 1, 0, 0.

IndexCreate

Dla danych czterech produktów o kodzie "AA" nie została udzielona zniżka. Przyjrzyjmy się liczbom wywołań poszczególnych komponentów: kontroler produktów został wywołany tylko raz, gdyż jego tryb życia to Transient. Z kolei ta sama instancja CurrentUserIdProvider została wywołana dwa razy, zgodnie z oczekiwaniami.

DetailsValidated

W ostatnim wywołaniu wprowadzamy celowo produkt, który może mieć zniżkę, ale niestety została przekroczona maksymalna liczba produktów, które można zakupić. Pojawił się komunikat walidacyjny. Zestawienie wywołań również jest zgodne z oczekiwanymi. Jedno wywołanie kontrolera (Transient), jedno wywołanie serwisu pobierającego id użytkownika oraz 3 wywołania odwołania do cache. Liczba odwołań do tego ostatniego serwisu będzie się zwiększać, co też jest potwierdzeniem stylu życia Singleton.

DetailsInvalid

Podsumowanie

W tym wpisie przedstawiłem sposób w jaki można skonfigurować kontener Windsor Castle w prostej aplikacji MVC. Pokazałem na przykładzie jak ustawić style życia komponentów rejestrowanych w Castle dla Transient, PerWebRequest i Singleton. Ponad to wskazałem jak powiązać kontener z mechanizmem walidacyjnym ASP.NET MVC.  Najciekawszym jednak doświadczeniem było, to , że nie musiałem rejestrować handlera do obsługi lifestyla  PerWebRequest przez wpis konfiguracyjny w web.configu (tak jak sugeruje informacja na stronie Castla).

Comments (0) Trackbacks (0)

No comments yet.


Leave a comment

No trackbacks yet.