Wzorce Projektowe: Dependency Injection

Wiele razy wspominałem o wstrzykiwaniu zależności, nigdy jednak nie wytłumaczyłem tego konceptu do końca. Na czym polega wzorzec Dependency Injection i jakie problemy rozwiązuje? W tym artykule chciałbym odpowiedzieć na te pytania oraz omówić teorię stojącą za wstrzykiwaniem zależności.

Problem powiązanych zależności

Technologia nie ma tutaj dużego znaczenia, gdyż te same koncepcje i problemy pojawiają się w dowolnym języku programowania. Przejdźmy od razu do konkretnego przykładu napisanego w JavaScripcie. Wyobraźmy sobie, że tworzymy aplikację, której jednym z zadań jest wysyłanie maili. Aplikacja w celu wysłania wiadomości potrzebuje stworzyć instancję serwisu odpowiedzialnego za obsługę maili:

class App {  
    constructor() {
        this.emailService = new EmailService();
    }

    sendEmail(message, receiver) {
        this.emailService.send(message, receiver);
    }
}

Wysłanie maila następowałoby w taki sposób:

// bootstrap
const app = new App();  
app.sendEmail(myMessage, address);  

Na pierwszy rzut oka nie widać właściwie żadnego problemu z tym kodem, prawda? Jednak jeśli się bardziej zastanowimy to dojdziemy do wniosku, że już w tak prostym przypadku pojawia się kilka kłopotów:

  • klasa App jest odpowiedzialna za stworzenie instancji serwisu mailService – oznacza to zakodowaną na stałe zależność. W przypadku gdybyśmy chcieli w przyszłości zamienić EmailService na EmailService2 musielibyśmy zmodyfikować kod klasy App
  • jeśli zechcemy do klasy EmailService dodać np. obsługę wiadomości SMS to będziemy musieli nie tylko zmodyfikować tę klasę, ale także klasę App
  • jeśli EmailService samemu będzie potrzebował mieć jakieś zależności to klasa App również musi być ich świadoma i mu je przekazać
  • testowanie aplikacji jest utrudnione, gdyż przy każdym uruchomieniu testów odpalana jest także funkcja emailService.send(…) na prawdziwej instancji emailService co powoduje wysłanie maili; aby temu zaradzić, musielibyśmy przechwycić te wywołania gdzieś na wyższym poziomie, bo nie ma łatwej możliwości podmiany zależności tylko na czas testów
  • dodatkowo, co ważne szczególnie w przypadku aplikacji JavaScriptowych, musimy zadbać o to, aby plik emailService.js był koniecznie wczytany przez przeglądarkę przed plikiem app.js – w przeciwnym wypadku klasa EmailService będzie niezdefiniowana i dostaniemy błąd.

Tyle problemów, a napisaliśmy ledwie jedną prostą metodę do wysyłania maili! W przypadku rozwoju aplikacji, im bardziej rozbudowana by ona była, tym utrudnień byłoby więcej. Ostatecznie skończylibyśmy z gąszczem całkowicie nieczytelnych i niezrozumiałych zależności pomiędzy klasami. Na szczęście istnieje jednak sposób, aby wszystkim tym trudnościom zaradzić: Odwrócenie Sterowania.

Inversion of Control

Inversion of Control (IoC) czyli Odwrócenie Sterowania to paradygmat programowania polegający na zamianie odpowiedzialności pewnych części aplikacji. Bardzo często Odwrócenie Sterowania opisuje się humorystycznie tzw. Zasadą Hollywood:

Nie dzwoń do nas. My zadzwonimy do ciebie.

Mówiąc jaśniej, w klasycznie napisanej aplikacji to kod programisty wywołuje funkcje z zewnętrznych bibliotek. Natomiast gdy zastosowane jest IoC, to zewnętrzny framework wywołuje kod programisty w odpowiednich momentach. Prosty przykład bez IoC1:

const name = window.prompt('Podaj imie!');  
const validatedName = validateName(name);  
const quest = window.prompt('Czego chcesz?');  
const validatedQuest = validateQuest(quest);  
…

Przepływ informacji jest całkowicie pod kontrolą programisty. Natomiast w przypadku użycia IoC podalibyśmy tylko dwie funkcje służące do walidacji, ale nie od nas zależałoby kiedy zostaną one użyte:

const framework = new Framework();  
framework.nameValidator = validateName;  
framework.questValidator = validateQuest;  
framework.start();  

Widzimy tutaj ogromną różnicę w przepływie informacji, a także w sposobie myślenia na temat programu. W przykładzie bez IoC – całkowita kontrola programisty nad wszystkim. Tutaj – brak kotroli. To framework wywołuje nasz kod, a nie my. Stąd nazwa tego wzorca projektowego – Odwrócenie Sterowania.

Różnica pomiędzy biblioteką a frameworkiem jest płynna, ale większość architektów aplikacji zgadza się, że główną różnicą pomiędzy nimi jest fakt, że biblioteki to zbiór funkcji, które są wywoływane przez programistę, natomiast framework korzysta ze wzorca Inversion of Control i to on wywołuje kod programisty.

Wzorzec projektowy Dependency Injection

Dependency Injection, czyli Wstrzykiwanie Zależności, jest wzorcem projektowym oryginalnie pochodzącym ze środowiska programistów Java. Jest to implementacja paradygmatu Odwrócenia Sterowania. Właściwie jest to implementacja tak bardzo popularna, że powszechne jest używanie tych dwóch pojęć zamiennie – co nie jest do końca poprawne.

Podstawową zasadą działania Dependency Injection jest posiadanie serwisu, który zajmuje się uzupełnianiem potrzebnych zależności. Sam pomysł Wstrzykiwania Zależności można zrealizować na wiele różnych sposobów. Omówmy po krótce kilka z nich2:

Constructor Injection

Koncepcja ta opiera się o pomysł, aby wykorzystać konstruktor do przekazywania zależności. Najprostsza implementacja może wyglądać tak, że zależności tworzymy ręcznie, a następnie przekazujemy je do konstruktora:

// app.js
class App {  
    constructor(emailService) {
        this.emailService = emailService;
    }

    sendEmail(message, receiver) {
        this.emailService.send(message, receiver);
    }
}

// main.js
const emailService = new EmailService();  
const app = new App(emailService);  

Nie jest to jednak rozwiązanie wygodne, głównie dlatego, że musimy pamiętać także o zależnościach zależności… Lepiej byłoby, gdyby nasz framework mógł za nas zarządzać wszystkimi zależnościami i o wszystkich pamiętać! W wielu językach może to zostać zrealizowane automatycznie przez framework już na postawie typów argumentów podanych w konstruktorze, jednak nie w JavaScripcie. Z powodu braku rozbudowanego systemu typów częstym pomysłem jest więc identyfikowanie zależności po nazwach. Kod z przykładu z mailem mógłby zostać przepisany w ten sposób3:

// app.js
class App {  
    static $inject = ['EmailService'];

    constructor(emailService) {
        this.emailService = emailService;
    }

    sendEmail(message, receiver) {
        this.emailService.send(message, receiver);
    }
}

// main.js
const framework = new Framework();  
framework.registerDependency('EmailService', EmailService);  
const app = framework.instantiate(App);  

Deklarujemy, że klasa App potrzebuje zależności o nazwie EmailService. Następnie podajemy frameworkowi informację o tym w jaki sposób może stworzyć sobie taką zależność – w tym przypadku jest to instancja klasy EmailService. Od razu widoczne jest z jaką łatwością moglibyśmy tę zależność podmienić na dowolną inną bez konieczności modyfikowania pliku app.js. Na przykład w trakcie testów jednostkowych możemy podstawić EmailServiceMock zamiast prawdziwego EmailService:

const framework = new Framework();  
framework.registerDependency('EmailService', EmailServiceMock);  
const app = framework.instantiate(App);

app.sendEmail('test', [email protected]');  
expect(EmailServiceMock.send).toHaveBeenCalledWith('test', [email protected]');  

Setter Injection

Założenia podobne, tylko sposób realizacji nieco inny: Zamiast podawać zależności do konstruktora, najpierw tworzymy instancję, a dopiero później ustawiamy zależności na stworzonym obiekcie:

// app.js
class App {  
    sendEmail(message, receiver) {
        this.emailService.send(message, receiver);
    }
}

// main.js
const app = new App();  
app.emailService = new EmailService();  

W niektórych platformach i środowiskach powyższy kod można zamienić na całkowicie deklaratywny plik .xml lub .json z konfiguracją, co ma ogromny sens! Na przykład w Javie:

<beans>  
    <bean id="App" class="App">
        <property name="emailService">
            <ref local="EmailService" />
        </property>
    </bean>
</beans>  

Interface Injection

Chodzi o używanie do DI interfejsów, a nie konkretnych klas – jednak całkowicie pominę ten koncept, gdyż JavaScript nie zna pojęcia interfejsów w ogóle. Sygnalizuję tylko, że coś takiego istnieje – więcej można doczytać na przykład u Martina Fowlera.

Service Locator

Alternatywną w stosunku do Dependency Injection implementacją zarządzania zależnościami jest wzorzec Service Locator. Opiera się on o wykorzystanie obiektu zwanego Service Locator, który służy do pobierania zależności wtedy, gdy są one nam potrzebne:

// app.js
class App {  
    constructor() {
        this.emailService = ServiceLocator.get('EmailService');
    }

    sendEmail(message, receiver) {
        this.emailService.send(message, receiver);
    }
}

// emailService.js
ServiceLocator.provide('EmailService', EmailService);

// main.js
const app = new App();  

Ciekawostką jest to, że Dependency Injection i Service Locator absolutnie się wzajemnie nie wykluczają. Oba te wzorce mogą być wykorzystywane w tej samej aplikacji, a nawet w tej samej klasie. Możemy na przykład za pomocą DI wstrzyknąć obiekt ServiceLocator, a potem z niego korzystać:

// app.js
class App {  
    static $inject = ['ServiceLocator'];

    constructor(ServiceLocator) {
        this.emailService = ServiceLocator.get('EmailService');
    }

    sendEmail(message, receiver) {
        this.emailService.send(message, receiver);
    }
}

// emailService.js
ServiceLocator.provide('EmailService', EmailService);

// main.js
const framework = new Framework();  
framework.registerDependency('ServiceLocator', ServiceLocator);  
const app = framework.instantiate(App);  

Kluczową zaletą obu rozwiązań jest usunięcie ścisłej zależności pomiędzy klasami.

Implementacje

Ponieważ zajmuję się głównie webdevelopmentem, to przy okazji podam kilka implementacji DI w JavaScripcie:

Dowolną z tych bibliotek możemy wykorzystać we własnym projekcie.

Na przykładzie…

Dependency Injection jest używane w wielu frameworkach do budowania aplikacji. Pierwszym z brzegu przykładem jest AngularJS 1.x, w którym wstrzykiwanie zależności od samego początku było jedną z głównych cech. Co prawda ta konkretna implementacja pozostawia wiele do życzenia, jednak niezwykle ułatwia na przykład testowanie aplikacji – w czasie testów bez problemu można było podstawić mocki zamiast prawdziwych zależności:

// definiujemy serwis o nazwie 'MyService'
// od tej pory może być używany w Dependency Injection
angular.service('MyService', class MyService {  
    showMessage() {
        alert('Message!');
    }
});

// wstrzykujemy zależność np. do kontrolera
angular.controller('MyController', class MyController {  
    static $inject = ['MyService'];

    constructor(myService) {
        myService.showMessage();
    }
});

Dodatkową ciekawostką jest to, że w AngularJS zaimplementowano zarówno Dependency Injection (a dokładnie to Constructor Injection), jak i Service Locator ($injector):

// używamy Service Locator
const myService = $injector.get('MyService');  

Dependency Injection jest oczywiście używane również w innych frameworkach, np. Ember.js, czy Angular 2. Konkretnie implementacji w Angular 2 poświęcę cały osobny wpis, gdyż jest to temat bardzo ciekawy i rozbudowany. Według dokumentacji DI z Angular 2 ma być dostępne jako biblioteka, niezależnie od całego frameworka.

Problemy z DI

Znacznie bardziej doświadczeni ode mnie programiści zauważają, że Dependency Injection, jak każdy wzorzec projektowy, może być nadużywane – przez co efekty są odwrotne do zamierzonych, a kod staje się mniej czytelny i trudniejszy w zarządzaniu. Uncle Bob napisał na Twitterze:

Odwrócenie Sterowania to dobry pomysł. I tak jak wszystkie dobre pomysły, powinien być stosowany z umiarem.

Chciwy kontroler

Mam przed sobą kod, który samemu napisałem parę lat temu. Jest to akurat fragment aplikacji w AngularJS, gdzie wykorzystywane było Dependency Injection. Konstruktor jednego z kontrolerów wygląda tak:

function ($scope, $rootScope, $routeParams, $location, $filter, conferenceApi, shortUrlMetaService, timeAndDateService, appState, metaService) { … }  

Zależności jest zdecydowanie zbyt wiele. A to tylko jeden z wielu kontrolerów, w którym DI było nadużywany w taki sposób. W czym problem? Jasne jest, że ten kontroler nie ma tylko jednego zadania – musi być świadomy istnienia i potrafić obsłużyć tak różne aspekty aplikacji jak formatowanie daty (timeAndDateService), ustawianie meta danych (metaService) czy sprawdzanie aktualnego adresu URL ($location). Uważajmy na podobne zapędy. Chciwe kontrolery powinny być zrestrukturyzowane.

Co jednak w sytuacji, gdy naprawdę potrzebujemy tak wielu zależności do wypełnienia tylko jednego zadania? Przykładowo rozwiązując problem cyklicznych zależności (poniżej) rozbijamy jedną zależność na pięć różnych i każdej z nich potrzebujemy w jednym miejscu. Co w takim wypadku? Warto wtedy zainteresować się wzorcem projektowym o nazwie fasada (façade). Więcej szczegółów można przeczytać na przykład w świetnym artykule Marka Seemanna: Refactoring to Aggregate Facade Services.

Zależności cykliczne

Częstym problemem jest również powstawanie zależności cyklicznych (Circular Dependency) – to znaczy takich sytuacji, w których dwie klasy polegają na sobie wzajemnie. W zasadzie to najprostszy przypadek, znacznie gorzej jest gdy klasa A polega na klasie B, a ta na klasie C, która polega znowu na klasie A… Refaktoryzacja takiego kodu nie jest czymś trywialnym.

Jeśli kiedykolwiek zdarzy się nam taki błąd to jest to jasny objaw, że któraś z klas nie spełnia Zasady Jednej Odpowiedzialności (Single Responsibility Principle). Na pewno uda się ją rozbić na dwie (lub więcej) mniejszych klas wypełniających tylko jedno proste zadanie. W ten sposób zależności cykliczne zostaną wyeliminowane.

Podsumowanie

Pamiętając o potencjalnych problemach i nadużyciach Dependency Injection, korzystajmy z niego świadomie. DI bez wątpienia może poprawić modularność aplikacji, ułatwić jej rozbudowę, zarządzanie kodem oraz testowanie. Dzięki temu łatwiejsze staje również pisanie kodu zgodnie z SOLID, a w szczególności z Zasadą Jednej Odpowiedzialności oraz Zasadą otwarte-zamknięte (Open/Closed Principle). Jak powiedział Ward Cunningham, wstrzykiwanie zależności jest kluczowym elementem zwinnej architektury:

Dependency Injection is a key element of agile architecture.

Warto poznawać wzorce projektowe, niezależnie od konkretnych implementacji i trendów. Szczególnie w webdevelopmencie, frameworki przychodzą i odchodzą, ale znajomość wzorców projektowych jest czymś uniwersalnym i sprawdzi się niezależnie od kontekstu, aplikacji, frameworka, czy technologii.

  1. Luźno na podstawie http://martinfowler.com/bliki/InversionOfControl.html

  2. Na podstawie http://martinfowler.com/articles/injection.html

  3. Dla poprawy czytelności zastosowałem składnię statycznego pola, której nie ma jeszcze w ECMAScript – jest dopiero propozycją do standardu

Michał Miszczyszyn

Programista z doświadczeniem w JavaScripcie po stronie klienta i serwera. Wielki fan TypeScripta.

Subscribe to Type of Web

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!