Dependency Injection potrafi… zaskoczyć. Historia pewnego Singletona.

6

Poruszałem ostatnio temat kontenerów Dependency Injection. Zadeklarowałem, że moim zdaniem WARTO ich używać. ALE! Jak ze wszystkim… trzeba to robić świadomie.

Tępe kopiowanie kodu ze StackOverflow może skończyć się bardzo źle, SZCZEGÓLNIE w tak wrażliwym aspekcie jak konfiguracja DI. Przekonajmy się na przykładzie. Mi gałeczki z oczodołków prawie wyskoczyły. Ale spokojnie, na koniec wyjaśniam co, jak i dlaczego.

Akcja: BLOGvember! Post nr 19.
W listopadzie w każdy roboczy poranek na devstyle.pl znajdziesz nowy, świeżutki tekst. W sam raz do porannej kawy na dobry początek dnia.
Miłej lektury i do przeczytania jutro! :)

Co to jest singleton?

Każdy pewnie wie, ale dla formalności… Singleton to (anty)wzorzec projektowy:

Singleton – (…) restricts the instantiation of a class to one object
Wikipedia

Czyli mamy tylko jedną instancję danej klasy w naszym systemie. Więcej o wzorcach i antywzorcach możesz posłuchać tutaj: DevTalk#43 – O wzorcach z Łukaszem Olbromskim.

Singleton w świecie DI

Każdy sensowny (i większość bezsensownych) kontener pozwala na skonfigurowanie rejestrowanej klasy jako Singleton. Tylko różnie to nazywają: Single Instance, Container-Controller lifetime manager, Singleton Scope…

Spójrzmy na kilka popularnych kontenerów i sposoby na rejestrację w nich takiej klasy jako singleton.

public interface IWantToBeASingleton
{
}

public class SingletonImpl : IWantToBeASingleton
{
}

Użyjemy tego w połączeniu z inną ciekawą cechą kontenerów, czyli “automatyczną rejestracją”. Chcemy osiągnąć takie zachowanie: zarejestruj mi WSZYSTKIE klasy z aktualnego assembly, ale niech implementacja powyższego interfejsu będzie singletonem. Jasne, prawda?

W Autofac mamy Moduły, więc taki sobie napiszemy:

public class SingletonModule_Autofac : Module
{
    protected override void Load(ContainerBuilder builder)
    {
        base.Load(builder);

        // register everything
        builder.RegisterAssemblyTypes(ThisAssembly)
            .AsImplementedInterfaces();

        // overwrite SingletonImpl registration
        builder.RegisterType<SingletonImpl>()
            .AsImplementedInterfaces()
            .SingleInstance();
    }
}

I sprawdzimy, czy jest OK:

[Fact]
public void Autofac_singleton_verification()
{
    var builder = new ContainerBuilder();
    builder.RegisterModule<SingletonModule_Autofac>();
    var container = builder.Build();

    var instance1 = container.Resolve<IWantToBeASingleton>();
    var instance2 = container.Resolve<IWantToBeASingleton>();

    Assert.Same(instance1, instance2);
}

Zaświeci się na zielono: gitara.

W StructureMap znajdziemy Rejestry i wygląda to tak:

public class SingletonRegistry_StructureMap : Registry
{
    public SingletonRegistry_StructureMap()
    {
        // register everything
        Scan(x =>
        {
            x.TheCallingAssembly();
            x.RegisterConcreteTypesAgainstTheFirstInterface();
        });

        // overwrite SingletonImpl registration
        For<IWantToBeASingleton>().Use<SingletonImpl>()
            .Singleton();
    }
}

A teścik:

[Fact]
public void StructureMap_singleton_verification()
{
    var test_registry = new Registry();
    test_registry.IncludeRegistry<SingletonRegistry_StructureMap>();
    var container = new Container(test_registry);

    var instance1 = container.GetInstance<IWantToBeASingleton>();
    var instance2 = container.GetInstance<IWantToBeASingleton>();

    Assert.Same(instance1, instance2);
}

Dorzućmy jeszcze Simple Injector, na którego zwróciłem uwagę dopiero niedawno (z powodu głupiej nazwy) i bardzo przypadł mi do gustu. Tam nie znajdziemy wsparcia dla “modułowości prosto z pudełka”, ale możemy użyć banalnego w swej implementacji rozszerzenia z Pakietami:

public class SingletonPackage_SimpleInjector : IPackage
{
    public void RegisterServices(Container container)
    {
        container.Options.AllowOverridingRegistrations = true;

        // register everything
        var registrations =
            from type in typeof(IWantToBeASingleton).Assembly.GetExportedTypes()
            where type.GetInterfaces().Length > 0
            select new { Services = type.GetInterfaces(), Implementation = type };

        foreach (var reg in registrations)
        {
            foreach (var service in reg.Services)
            {
                container.Register(service, reg.Implementation, Lifestyle.Transient);
            }
        }

        // overwrite SingletonImpl registration
        container.Register<IWantToBeASingleton, SingletonImpl>(Lifestyle.Singleton);
    }
}

I sprawdzenie, czy działa, hyc:

[Fact]
public void SimpleInjector_singleton_verification()
{
    var container = new Container();
    container.RegisterPackages();

    var instance1 = container.GetInstance<IWantToBeASingleton>();
    var instance2 = container.GetInstance<IWantToBeASingleton>();

    Assert.Same(instance1, instance2);
}

Wszystko zielone, śmiga!

Ultra-weryfikacja

Upewnijmy się jednak, czy aby na pewno wszystko jest w porządku. Niby: co może nie być, prawda? Ale jednak… dodatkowy teścik jeszcze nigdy nikogo nie zabił. Spróbujmy pobrać nie “jedną instancję”, a “wszystkie instancje” singletona.

Najpierw Autofac:

[Fact]
public void Autofac_singleton_collection_verification()
{
    var builder = new ContainerBuilder();
    builder.RegisterModule<SingletonModule_Autofac>();
    var container = builder.Build();

    IEnumerable<IWantToBeASingleton> instances = container.Resolve<IEnumerable<IWantToBeASingleton>>();

    Assert.Equal(1, instances.Count());
}

Teraz StructureMap:

[Fact]
public void StructureMap_singleton_collection_verification()
{
    var test_registry = new Registry();
    test_registry.IncludeRegistry<SingletonRegistry_StructureMap>();
    var container = new StructureMap.Container(test_registry);

    IEnumerable<IWantToBeASingleton> instances = container.GetAllInstances<IWantToBeASingleton>();

    Assert.Equal(1, instances.Count());
}

No i SimpleInjector:

[Fact]
public void SimpleInjector_singleton_collection_verification()
{
    var container = new SimpleInjector.Container();
    container.RegisterPackages();

    IEnumerable<IWantToBeASingleton> instances = container.GetAllInstances<IWantToBeASingleton>();

    Assert.Equal(1, instances.Count());
}

Kto zgadnie, co się stanie? Najbardziej logiczna wydawałaby się odpowiedź: nic, wszystkie testy będą zielone.

Ale wtedy nie pisałbym tego posta, prawda? :)

Surprise!

Każdy z testów zachowa się inaczej! Każdy kontener ma inną implementację i inne założenia. Szok i niedowierzanie!

Autofac zaserwuje nam największy opad szczęki:

clip_image001

NOBODY EXPECTS TWO SINGLETONS!!!111!!11!1oneoneone

Wytłumaczenie jest jednak dość proste: najpierw rejestrujemy WSZYSTKO jako “transient”, a potem dokładamy kolejną rejestrację – singletona. Dodatkowa rejestracja nie nadpisuje poprzedniej, tylko jest dorzucona na górę. Dwie otrzymane instancje pochodzą z dwóch rejestracji: automatycznej i ręcznej.

No to teraz StructureMap. Co to będzie, co to będzie…?

clip_image002

Nic :). SM nadpisał poprzednią rejestrację i tyle. Bardziej podoba mi się takie zachowanie. Co ciekawe: zmiana kolejności rejestracji nie wpływa na działanie kontenera! Więc nawet jeśli najpierw ręcznie zarejestrujemy singleton a POTEM wywołamy automatyczną rejestrację przez konwencje, to i tak singleton pozostanie w mocy. Werynajs!

A SimpleInjector? Jego twórcy w Wiki piszą wyraźnie, że świadomymi decyzjami podjętymi podczas implementacji SI są: Never fail silently i Fail Fast. Dlatego też ten kontener wywaliłby się nam już przy pierwszym teście. Dostalibyśmy ostrzeżenie (wyjątek), że próbujemy wielokrotnie zarejestrować ten sam interfejs. Ostrzeżenie to zostało chamsko wyłączone linijką:

container.Options.AllowOverridingRegistrations = true;

Ale nawet po wyłączeniu tego ostrzeżenia, kontener pięknie informuje nas, że… coś jest nie tak:

clip_image003

Jakie piękne wyjaśnienie! KUPUJĘ TO! Totalnie, piździec.

Co z tym fantem?

Nie trzeba mieć bardzo bujnej wyobraźni, żeby zwizualizować sobie skalę potencjalnego problemu wybuchającego na produkcji z powodu “dziwnego” zachowania kontenera. Zakasujemy rękawy, przynosimy śpiwory do biura i wskakujemy na 2 tygodnie w debagery i profilery. Kaksa na maksa.

Jak można się przed tym uchronić? Czytać dokumentację! Choć takie dziwne przypadki nie zawsze będą opisane.

Więc dodatkowo: pisać testy! Tak, testy do zewnętrznych bibliotek. Jako własną dokumentację do używanych komponentów.

A w tym konkretnym przypadku…

StructureMap zachowuje się poprawnie.
W Autofac i SimpleInjector trzeba wyeliminować interfejs z auto-rejestracji. Tyle tylko, że w tym drugim narzędzie wskazuje nam drogę.

Rodzi się od razu w głowie dobra praktyka: nie nadużywajmy automatycznej rejestracji w kontenerach. Ona jest fajna, ale NIGDY nie powinniśmy “zgadywać” i rejestrować “na zapas” wszystkich typów jak leci. Zawsze powinien pojawić się jakiś filtr, jakiś where, który jednak nieco ograniczy zakres konwencji użytych w scenariuszu “autoregister”.

Przed niektórymi problemami może nas także uchronić mądra zasada: moduły/pakiety/rejestry/instalatory/… nie powinny na siebie “nachodzić”. Typ zarejestrowany przez jeden taki komponent nie powinien być nigdy ruszany nigdzie indziej. A do tego to już można napisać jakiś mądry teścik, więc… do dzieła!

Inne kontenery

Tutaj zobaczyliśmy jak zachowują się Autofac, Structure Map i Simple Injector. A kontenerów mamy całą masę. A to jeszcze Castle Windsor, a to jeszcze Unity (Boże uchowaj), a to Ninject, a to LightInject, a to Tiny IoC, a to… No, jak uprzedziłem: całą masę. Nie zapominajmy też o moim YeTi, który je wszystkie jeszcze zdominuje :).

Czy wiesz, jak zachowa się w takiej sytuacji TWÓJ kontener? A jak zachowa się w wielu innych sytuacjach, których nie spotyka się na co dzień, ale SĄ MOŻLIWE?

Jak dobrze znasz swoje narzędzia? I jak tę wiedzę utrwalasz, dokumentujesz?

No to teraz… miłych snów ;),
Procent, The Nightmare On Container Street

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.

6 Comments

  1. Zawsze można Singletona stworzyć jako singletona a w kontenerze DI podać nazwę metody, która go pobiera np:

  2. Dzięki za tip z Autofac. Aż przejżałem kod czy przypadkiem używam gdzieś nieświadomie ‘AsImplementedInterfaces’. Na szczęście nie popełniłem tego błędu. :)

    Wcześniej używałem długo StructureMap i żyłem w “błogiej nieświadomości”. :)

    • Dario,
      To nie AsImplementedInterfaces() jest problemem, a zbyt słabe filtrowanie typów przy autorejestracji. A raczej brak tego filtrowania.