Model-View-Presenter z Autofac w aplikacji desktopowej

5

Zastrzeżenie: może poniższe wypociny to wcale nie Model-View-Presenter a Model-View-Controller, może MVC: Passive View a może MVC: Supervising Controller a może  MVP: Ostatnia Krew. Szczerze: I don’t give a damn. (no… nie do końca; polecam artykuły Fowlera w sekcji Presentation Patterns jako bardzo ciekawą lekturę, ale zbytnie rozwodzenie się nad włożeniem danej implementacji do prawidłowej szufladki uważam za lekki przerost formy nad treścią)

Niniejszy post zakłada, że Czytelnik jest zaznajomiony z tematyką omawianego wzorca, więc skupię się na tej konkretnej implementacji. Dzisiaj KOD jest najważniejszy.

Założenia

  • aplikacja desktopowa (u mnie WinForms, ale generalnie nie powinno to mieć wielkiego znaczenia)
  • poszczególne ekrany są identyfikowane wyłącznie za pomocą interfejsów widoku
  • interfejs widoku wystarczy do utworzenia instancji widoku oraz jego presentera w nowym scope kontenera DI (czyli: każdy presenter otrzymuje własne instancje swoich zależności, chyba że zostały jawnie zarejestrowane jako singleton)
  • warstwa dostępu do danych to po prostu NHibernate – sesja NH powinna być więc dostępna z poziomu presenterów

Prezentacja Widoków, czyli IView i IPresenter

Zaczniemy od rzeczy najbardziej podstawowych, czyli definicji interfejsów bazowych dla wszystkich widoków i wszystkich presenterów:

  1:  public interface IView : IDisposable
  2:  {
  3:  	event EventHandler Load;
  4:  	event EventHandler Disposed;
  5:  }

Jak można zauważyć, korzystam z tego, że pracuję z WinForms. Każda forma czy kontrolka deklaruje powyższe zdarzenia oraz implementuje wymagany interfejs IDisposable, zatem dodaję sobie trochę możliwości integracji z widokami na wyższym poziomie nie powodując żadnego nakładu pracy od strony implementacji widoku. Przyda się później.

Z presenterem na poziomie interfejsu sprawa jest banalna:

  1:  public interface IPresenter : IDisposable
  2:  {
  3:  
  4:  }

Zabawa zaczyna się troszkę niżej, w abstrakcyjnej klasie bazowej:

  1:  public abstract class PresenterBase<TView> : IPresenter
  2:  	where TView : IView
  3:  {
  4:  	private TView _view;
  5:  	public TView View
  6:  	{
  7:  		get { return _view; }
  8:  		set
  9:  		{
 10:  			_view = value;
 11:  			_view.Load += (s, e) => OnViewLoaded();
 12:  		}
 13:  	}
 14:  
 15:  	protected virtual void OnViewLoaded()
 16:  	{
 17:  	}
 18:  
 19:  	public Func<ISession> CreateSession { private get; set; }
 20:  	private ISession _session;
 21:  	protected ISession Session
 22:  	{
 23:  		get
 24:  		{
 25:  			if (_session == null)
 26:  				_session = CreateSession();
 27:  			return _session;
 28:  		}
 29:  	}
 30:  	public virtual void Dispose()
 31:  	{
 32:  		if (_session != null)
 33:  			_session.Dispose();
 34:  	}
 35:  }

Zatrzymajmy się tu na chwilę.

Dzięki dodaniu generycznego parametru TView w każdej klasie dziedziczącej będę miał dostęp do faktycznego typu widoku, bez potrzeby rzutowania z IView.

Tworząc property-dependency zamiast constructor-dependency ułatwiam sobie życie, ponieważ faktyczne implementacje presenterów będą musiały definiować konstruktor tylko wtedy, gdy będą potrzebować zależności innych niż “widok” (który jest oczywisty). Niby rzecz nieduża (i być może dyskusyjna), ale pozwala zaoszczędzić sporo zbędnych linijek kodu.

Zdarzenie Load zdefiniowane na poziomie widoku umożliwia podpięcie pod nie wirtualnej – domyślnie pustej – metody używanej np. do inicjalizacji widoku danymi. Ponownie: wyjęcie tego do klasy bazowej maksymalnie upraszcza końcowych presenterów. Jedyne co muszą zrobić to nadpisać metodę OnViewLoaded.

Publiczna właściwość CreateSession typu Func<ISession> zasługuje na szczególną uwagę. Zauważcie, że z zewnątrz jest ona write-only (publiczny setter, prywatny getter). Cóż to takiego? W założeniach napisałem, że presenter może potrzebować skorzystać z sesji NH. Każdy powinien mieć oczywiście swoją. Czy powinniśmy jednak zawsze tworzyć sesję, nawet, gdy nie jest potrzebna? W żadnym wypadku! Powyższa konstrukcja wykorzystuje świetny koncept Autofac zwany DelegateFactories. Polega to na bardzo prostym i naturalnym założeniu: jeżeli zależność definiujemy jako delegata zwracającego instancję danego typu, to de facto dokładnie to otrzymamy: metodę pobierającą z pojemnika instancję. JAK ta instancja zostanie utworzona, czy będzie to singleton czy nie – zależy od rejestracji komponentu. Wykorzystanie DelegateFactory po prostu odkłada w czasie moment tego wywołania. W przypadku ISession ma to sens: prosimy kontener o kolejną sesję dopiero wtedy, gdy ma być ona wykorzystana po raz pierwszy: w getterze właściwości Session. Presentery nie operujące na danych pochodzących z bazy nie będą powodowały utworzenia sesji.

Implementacja Dispose() jest raczej self-explanatory.

Powłoka – IShellView

Każda aplikacja ma jakieś “okno główne”. Tutaj nie jest inaczej. Okno to zawiera jakieś-tam podstawowe menu oraz jest odpowiedzialne za wyświetlanie poszczególnych widoków. Uproszony interfejs:

  1:  public interface IShellView : IView
  2:  {
  3:  	void Display(IView control);
  4:  }

Implementacja w postaci formy jest dość prosta:

  1:  public partial class Shell : Form, IShellView
  2:  {
  3:  	public void Display(IView view)
  4:  	{
  5:  		var currentView = contentPanel.Controls.Cast<Control>().SingleOrDefault();
  6:  		if (currentView != null)
  7:  			currentView.Dispose();
  8:  		contentPanel.Controls.Clear();
  9:  
 10:  		Control control = view as Control;
 11:  
 12:  		control.Dock = DockStyle.Fill;
 13:  		contentPanel.Controls.Add(control);
 14:  	}
 15:  
 16:  	//... more code

Gdzieś na formie znajduje się panel pokazujący aktualny widok.

Aktualny widok może być tylko jeden (czyli w tym przypadku: żadnego MDI, choć nietrudno byłoby to rozszerzyć).

Każdy widok jest kontrolką (w końcu piszę w WinForms).

Przed wyświetleniem nowego widoku – wywołuję Dispose() na poprzednim.

I… tyle. Żadna filozofia.

Widoku, pokaż się! IViewManager

Została nam rzecz, która zwykle sprawia chyba najwięcej kłopotu, czyli: zarządzanie widokami. Kto jest odpowiedzialny za wyświetlenie widoku? Jak umieścić widok w osobnym “scope” kontenera DI? W jaki sposób go wywołać i jak się do tej funkcjonalności dobrać?

Rozwiązanie może być proste (i tak jest w moim przypadku): klasa ViewManager jest odpowiedzialna za utworzenie oraz wyświetlenie wszelkich widoków. Jej zubożony do celów demonstracyjnych interfejs:

  1:  public interface IViewManager
  2:  {
  3:  	bool Dialog<T>() where T : IView;
  4:  	void Load<T>() where T : IView;
  5:  }

Oraz, wykastrowana do ładowania normalnego widoku (“dialogi” tylko dodają zbędnej na tym etapie komplikacji), implementacja:

  1:  public class ViewManager : IViewManager
  2:  {
  3:  	private static T ResolveView<T>() where T : IView
  4:  	{
  5:  		var scope = AppFacade.DI.Container.BeginLifetimeScope();
  6:  
  7:  		T view = scope.Resolve<T>();
  8:  		view.Disposed += (s, e) => scope.Dispose();
  9:  		return view;
 10:  	}
 11:  
 12:  	public void Load<T>() where T : IView
 13:  	{
 14:  		T view = ResolveView<T>();
 15:  
 16:  		AppEventsManager.Raise(new LoadingView(view));
 17:  
 18:  		AppFacade.DI.Container.Resolve<IShellView>().Display(view);
 19:  
 20:  		AppEventsManager.Raise(new ViewLoaded(view));
 21:  	}
 22:  
 23:  	// more code (showing dialogs)

Metoda pobierająca widok z kontenera, pomimo swojej prostoty, zapewnia nam bardzo ważną rzecz: każdy widok będzie rezydował w stworzonym specjalnie dla niego “zakresie” kontenera Dependency Injection. Zero współdzielenia usług domenowych pomiędzy presenterami i widokami. Każdy dostaje swoje instancje.

Zamknięcie widoku powoduje wywołanie zdarzenia Disposed, do którego się podpinamy. A w obsłudze… niszczymy ów scope. Spowoduje to wywołanie Dispose() na presenterze (co, jak pamiętamy, skutkuje posprzątaniem sesji NH) oraz wszystkich jego zależnościach. Über-cool!

Samo wyświetlenie widoku otoczone jest zdarzeniami – tą koncepcję omawiałem dwukrotnie, ostatnio właśnie w kontekście Autofac: “Autofac i open generic types: Application Events revisited” (i dzięki temu mechanizmowi zrealizowałem “Pseudo-style dla Windows Forms“).

Odpowiedzialność faktycznego pokazania widoku użytkownikowi spoczywa, jak wcześniej wspomniałem, na widoku Shell. ViewManager dobiera się do niego bezpośrednio za pomocą kontenera. Zrozumiałe jest, że taki główny widok powinien być zarejestrowany jako singleton, więc nie ma problemu.

Który to wątek prowadzi nas do kroku kolejnego…

Rejestracja w Autofac

Przyznam, że spędziłem sporo czasu zanim właściwie skonfigurowałem rejestrację wszystkich komponentów. Na szczęście API Autofac jest na tyle przejrzyste i prowadzące za rączkę, że w końcu się udało.

Cały proces rejestracji rozbiłem na moduły, ten temat z kolei opisałem w poście “Samobudująca się aplikacja z Autofac“.

Rejestracja widoków:

  1:  public class ViewsRegistrationModule : Module
  2:  {
  3:  	protected override void Load(ContainerBuilder builder)
  4:  	{
  5:  		builder
  6:  			.RegisterAssemblyTypes(AppDomain.CurrentDomain.GetAssemblies())
  7:  			.AssignableTo<IView>()
  8:  			.InstancePerLifetimeScope()
  9:  			.AsImplementedInterfaces();
 10:  
 11:  		builder
 12:  			.RegisterAssemblyTypes(AppDomain.CurrentDomain.GetAssemblies())
 13:  			.AssignableTo<IShellView>()
 14:  			.SingleInstance()
 15:  			.As<IShellView>();
 16:  	}
 17:  }

Najpierw znajdujemy wszystkie klasy implementujące interfejs IView. Ich instancje mają być współdzielone w jednym scope (InstancePerLifetimeScope())… co jest OK, ponieważ mamy jeden scope per widok. Taka konfiguracja pozwala wykorzystać już utworzoną instancję widoku podczas instancjonowania odpowiadającego mu presentera, zamiast utworzenia nowej. Jeden ekran może implementować kilka interfejsów widoków, więc właśnie w ten sposób zarejestrujemy go w kontenerze: AsImplementedInterfaces().

Po tym procesie wyszukamy klasę implementującą IShellView i zarejestrujemy ją jako singleton (SingleInstance()). Kontener dla każdego żądania implementacji interfejsu IShellView zwróci nam tą samą instancję.

Rejestracja presenterów:

  1:  public class PresentersRegistrationModule : Module
  2:  {
  3:  	protected override void Load(ContainerBuilder builder)
  4:  	{
  5:  		builder
  6:  			.RegisterAssemblyTypes(AppDomain.CurrentDomain.GetAssemblies())
  7:  			.AssignableTo<IPresenter>()
  8:  			.InstancePerLifetimeScope()
  9:  			.AsSelf()
 10:  			.PropertiesAutowired(true);
 11:  	}
 12:  }

Dwie linijki są tutaj warte uwagi.

Po pierwsze: każdy presenter będzie dostępny z kontenera jedynie poprzez bezpośrednie żądanie jego konkretnej klasy (AsSelf()). Jak dla mnie jest to jak najbardziej OK – jakkolwiek byśmy nie zrealizowali widoków, w omawianej architekturze widok tak czy siak musi wiedzieć jaki presenter go obsługuje (a wprowadzanie dodatkowej warstwy abstrakcji, czyli interfejsów nad presenterami, byłoby bez sensu).

Po drugie: instruujemy kontener, że oprócz constructor-dependency ma wykonać również wstrzyknięcie zależności poprzez właściwości. Mało tego: wartość parametru true zezwala na “circular dependencies“, czyli… zależności wzajemne? Chodzi o to, że presenter potrzebuje widoku. A widok potrzebuje presentera. Dzięki temu wywołaniu widok zostanie dostarczony presenterowi pomimo wzajemnych zależności.

Rejestracja ViewManager:

  1:  public class ViewManagerRegistrationModule : Module
  2:  {
  3:  	protected override void Load(ContainerBuilder builder)
  4:  	{
  5:  		builder
  6:  			.RegisterType<ViewManager>()
  7:  			.As<IViewManager>()
  8:  			.SingleInstance();
  9:  	}
 10:  }

Właściwie… bez komentarza, wszystko powinno być jasne.

Lektura na koniec

Nie do końca na temat, ale polecam artykuł w MSDN autorstwa Ayende, na temat wykorzystania NHibernate w aplikacjach desktopowych: “Building a Desktop To-Do Application with NHibernate“.


Wszystko to powinno się skleić w łatwą do zarządzania i rozszerzania, elastyczną implementację Model-View-Presenter.

Ależ dziś byłem oschły i rzeczowy:). Jeśli nie wszystko jest jasne to proszę o pytania, postaram się rozwiać wątpliwości w komentarzach lub kolejnych postach.

Sugestie innych rozwiązań bądź ulepszeń tego przedstawionego poznam oczywiście z równą przyjemnością. (to tyle jeżeli chodzi o profesjonalną oschłość)

Share.

About Author

Programista, trener, prelegent, pasjonat, blogger. Autor podcasta programistycznego: DevTalk.pl. Jeden z liderów Białostockiej Grupy .NET i współorganizator konferencji Programistok. Od 2008 Microsoft MVP w kategorii .NET. Więcej informacji znajdziesz na stronie O autorze. Napisz do mnie ze strony Kontakt. Dodatkowo: Twitter, Facebook, YouTube.

5 Comments

  1. Mam prosbe(pytanie). Czy możesz udostępnić w dziale Samples przyklap aplikacji (robiącej nic), korzystającej takiej implementacji MVP.

  2. @Qdzio:
    Niestety w najbliższym czasie prawdopodobnie nie zdołam nic takiego sklecić. Ale będę miał to na uwadze gdy powrócę do blogowania "w pełnym wymiarze godzin":).

  3. Podpinam się pod prośbę Qdzio :)
    ps. pełen nadziei trzymam kciuki za "włączenie regular-blog-mode-on"

  4. @Kopacz:
    Dzięki za zainteresowanie:). Co do blog-mode on… to niestety jeszcze trochę może potrwać.

Newsletter devstyle!
Dołącz do 2000 programistów!
  Zero spamu. Tylko ciekawe treści.
Dzięki za zaufanie!
Do przeczytania wkrótce!
Niech DEV będzie z Tobą!