Podczas zabaw z Dependency Injection można się trochę zapędzić używając kontenerów, uzależniając od nich cały kod naszej aplikacji. A to źle. Na dobra sprawę logika aplikacji nie powinna nawet wiedzieć z jakiego kontenera korzystamy. Do kontenera nie powinniśmy się przyzwyczajać – jeśli wykorzystujemy go poprawnie, to jego podmiana na inną bibliotekę nie będzie nastręczać żadnych trudności. Tak naprawdę to całkowite pozbycie się kontenera z systemu również powinno być proste. Pisałem już trochę o tym w poście “Profesjonalne kontenery“.
Ale jak to: “kod ma nie wiedzieć o kontenerze”? Brzmi abstrakcyjnie? Otóż wcale nie: dochodzimy po prostu do “kolejnego poziomu wtajemniczenia” jeśli chodzi o Dependency Injection. Do wzorca (tudzież praktyki) nazywanego “Three Calls Pattern” (Krzysztof Koźmic / Castle Windsor) lub “The Register Resolve Release pattern” (Mark Seemann). Reguła nie jest skomplikowana.
Używaj kontenera tylko:
- w momencie jego konfiguracji (start aplikacji)
- do utworzenia pierwszego/głównego obiektu (również start aplikacji / obsługi żądania)
- w momencie pozbywania się kontenera (zakończenie aplikacji)
W przypadku aplikacji konsolowej zatem: tylko w Main(). A ASP.NET: w Global.asax. A w Windows Service: w Start(). A w…. łapiecie zasadę, prawda?
Ale dobra: jak się za to zabrać? Szczerze i gorąco polecam przeczytanie podlinkowanych wyżej tekstów, bo są baaardzo dobre. Ja skupię się dziś na pokazaniu na żywym kodzie jak tę zasadę można wyegzekwować używając… a jakże, Autofac!
Autofac ma tę bardzo miłą cechę, że wyraźnie oddziela fakt konfiguracji kontenera od jego wykorzystania. Separacja jest na tyle silna, że do konfiguracji służy kompletnie osobna klasa: ContainerBuilder. Konfigurację budujemy za pomocą modułów (klasa Module). Dopiero instancja takiego skonfigurowanego Buildera tworzy gotowy do użycia IContainer.
Zoabczmy jak można tego użyć. Cały kod znajdziecie na GitHubie: https://github.com/maniserowicz/autofac-modules-demo.
Przykładowa aplikacja składa się z trzech części:
- Core – logika aplikacji
- Infrastructure – konfiguracja kontenera (za pomocą modułów Autofac)
- Cmdline – uruchamialny komponent: exec z Main(), prawie pusty projekt
Aplikacja ma za zadanie znaleźć wszystkie implementacje interfejsu IExecuteAction i… wykonać je :).
Interfejs jest bardzo prosty:
public interface IExecuteAction { void Execute(); }
Pierwsza implementacja to: zasymulować wysłanie e-maili ze skonfigurowanego adresu:
public class SendEmails : IExecuteAction { private readonly EmailConfiguration _configuration; public void Execute() { Console.WriteLine("Sending emails from {0}", _configuration.FromAddress); } }
Nie będziemy w tym miejscu czytać konfiguracji z AppSettings, bo na tym etape nie interesuje nas gdzie ta konfiguracja faktycznie się znajduje. Może nie jest w pliku, a w bazie, albo nawet na sztywno wpisana w kodzie? Zamiast tego ubiorę konfigurację w dedykowaną klasę:
public class EmailConfiguration { public string FromAddress { get; private set; } public EmailConfiguration(string fromAddress) { FromAddress = fromAddress; } }
I przyjmę ją sobie w konstruktorze:
public SendEmails(EmailConfiguration configuration) { _configuration = configuration; }
Oczywiście wszystko jest tutaj uproszczone na maxa. Mad Maxa.
Zerknijmy w pierwszy moduł, mający za zadanie skonfigurować wszystko do zadania wysyłki e-maili:
public class SendingEmailsModule : Module { protected override void Load(ContainerBuilder builder) { base.Load(builder); var email_config = new EmailConfiguration( ConfigurationManager.AppSettings["email-from"] ); builder.RegisterInstance(email_config) .SingleInstance(); } }
Żadne mecyje, prawa? Jedyne czego potrzebujemy to klasa zawierająca odpowiednie wpisy (a raczej odpowiedni wpis) z AppSettings. Nie będziemy za każdym razem czytali z tego pliku, więc rejestrujemy taki twór jako singleton (tutaj: SingleInstance).
Przejdźmy zatem do taska drugiego. Tutaj sprawa jest bardziej skomplikowana, bo postawiliśmy sobie za zadanie porozmawianie z dwoma bazami danych, jedna będzie “nasza lokalna” a druga “zewnętrzna”:
public enum DatabaseType { Own, External }
W konfiguracji będziemy mieli wpisane dwa connection stringi. Te connection stringi będą nam potrzebne w tasku:
public class CommunicateWithDatabase : IExecuteAction { private readonly Func<DatabaseType, string> _connectionStringFactory; public CommunicateWithDatabase(Func<DatabaseType, string> connectionStringFactory) { _connectionStringFactory = connectionStringFactory; } public void Execute() { communicate(DatabaseType.Own); communicate(DatabaseType.External); } void communicate(DatabaseType databaseType) { string connection_string = _connectionStringFactory(databaseType); Console.WriteLine("communicating with DB {0}: {1}", databaseType, connection_string); } }
Jak widzicie: pojawia się coś takiego jak “connection string factory”. Jest to po prostu funkcja, która na podstawie wartości powyższego enuma zwróci odpowiednią wartość. Autofac (i inne kontenery pewnie też) posiada wbudowane wsparcie dla takich scenariuszy (udokumentowane tutaj: “Named and Keyed Services“), ale… Ale nie chcemy mieć naszych własnych klas upstrzonych interfejsami z dllek Autofac. Podmiana kontenera ma być prosta, pamiętacie? Więc pójdziemy dalej: zauważcie, że nasz projekt Core nie ma nawet referencji do Autofac! Z perspektywy “logiki” aplikacji: kontener nie istnieje. Dlatego też jako zależność przyjmujemy wyłącznie czyste .NETowe funkcje.
No dobra, ale jak to skonfigurować? Tutaj sprawa jest nieco bardziej skomplikowana, zobaczmy:
public class DatabaseCommunicationModule : Module { protected override void Load(ContainerBuilder builder) { base.Load(builder); var local_conn_string = ConfigurationManager .ConnectionStrings["local-db"].ConnectionString; var external_conn_string = ConfigurationManager .ConnectionStrings["external-db"].ConnectionString; builder.Register(ctx => local_conn_string).Keyed<string>(DatabaseType.Own); builder.Register(ctx => external_conn_string).Keyed<string>(DatabaseType.External); builder.Register<Func<DatabaseType, string>>(ctx => { var cc = ctx.Resolve<IComponentContext>(); return dbType => cc.ResolveKeyed<string>(dbType); }); } }
Po pierwsze: czytamy odpowiednie connection stringi z konfiguracji. So far so good.
Po drugie: każdy z nich – jako zwykły .NETowy string! – rejestrujemy w kontenerze… ale przypisując do niego klucz. Klucz, którego wartością będzie wartość enuma wskazującego na odpowiednią bazę.
Na tym etapie wszystko jest już gotowe do działania. Zostaje jeszcze tylko – po trzecie – dorzucić rejestrację fabryki zwracającej odpowiedni connection string. Po to, żeby móc w naszych klasach uwolnić się od klas z bibliotek Autofaca. W internalsy tej składni zagłębiać się nie będę, trzeba po prostu wiedzieć że jeśli rejestrujemy Func<> to musimy z przekazanego ctx pobrać IComponentContext (u mnie: cc), a dopiero z niego wyciągać to co jest nam faktycznie potrzebne.
Ale skąd kontener będzie wiedział jakie akcje w ogóle mamy do dyspozycji? Te rejestruję w osobnym module, który po prostu bierze wszystkie implementacje interfejsu i dorzuca je do buildera:
public class ExecutorsModule : Module { protected override void Load(ContainerBuilder builder) { base.Load(builder); builder.RegisterAssemblyTypes(typeof (IExecuteAction).Assembly) .Where(x => typeof (IExecuteAction).IsAssignableFrom(x)) .AsImplementedInterfaces(); } }
A jak to spiąć w całość? Odpowiedź znajdziecie w Main():
static void Main(string[] args) { var builder = new ContainerBuilder(); builder.RegisterAssemblyModules(typeof (IDetermineInfrastructureAssembly).Assembly); using (IContainer container = builder.Build()) { using (var scope = container.BeginLifetimeScope()) { var executors = scope.Resolve<IEnumerable<IExecuteAction>>(); foreach (var executor in executors) { executor.Execute(); } } } }
Mówimy builderowi które assembly ma przeskanować w poszukiwaniu modułów (specjalnie po to dodałem pusty interfejs IDetermineInfrastructureAssembly)… a on robi resztę.
Całości dopełnia wywołanie BeginLifetimeScope(). Tutaj jest ono nadmiarowe, ale nie powinniśmy nigdy operować bezpośrednio na głównym kontenerze, a zawsze na stworzonym dla wykonania jednej czynności/iteracji/żądania kontenerze-dziecku. To temat na osobny tekst.
Da się to jeszcze bardziej zautomatyzować, na przykład automatycznie rejestrująć w kontenerze wszystkie napotkane typy. Albo automatycznie skanując wszystkie załadowane assemblies w poszukiwaniu modułów. Ale zademonstrowane podejście wydaje mi się sensowne. I – co najważniejsze – sprawdziło się w bojach.
Spostrzeżenia? Pytania? Dawajcie znać, można temat pociąnąć dalej w razie potrzeby.