Łukasz Rybka
infoShare Academy
Łukasz RybkaTrener @ infoShare Academy

Od kodu spaghetti do kontenerów IoC w JavaScript (i TypeScript)

Sprawdź, jak skutecznie wprowadzić Dependency Injection przy pomocy wzorca Inversion of Control.
27.05.201911 min
Od kodu spaghetti do kontenerów IoC w JavaScript (i TypeScript)

W dzisiejszych czasach znaczna większość komercyjnych projektów front-endowych pisana jest z użyciem frameworków takich, jak Angular, React czy Vue.js. Ma to szereg zalet, m.in. wyższą wydajność programisty, dostęp do gotowych i wbudowanych narzędzi, dużą ilość materiałów oraz sporą społeczność chętną do dzielenia się wiedzą i pomagania sobie wzajemnie. Narzędzia takie jak frameworki posiadają relatywnie niski próg wejścia i nie wymagają zazwyczaj zaawansowanej wiedzy zarówno samego języka JavaScript, jak i technik programistycznych.

W momencie, gdy jesteśmy już zaznajomieni z wybranym przez nas frameworkiem, warto poznać podstawowe zasady i mechanizmy stojące u jego podstaw - umożliwi nam to korzystanie z niego w znacznie efektywniejszy sposób oraz da znacząco lepsze zrozumienie w momencie rozwiązywania trudnych problemów.

Na potrzeby tego artykułu wcielimy się w rolę Mariusza. Mariusz jest programistą JavaScript (który tworzy swoje aplikacje za pomocą TypeScript) i pracuje obecnie nad kolejną wersją sklepu internetowego dla swojego klienta. Projekt trwa już 2 lata i jego kolejne wersje szczęśliwie trafiają na produkcję, ma on użytkowników i przynosi właścicielowi znaczne zyski. W trakcie sesji planowania kolejnej wersji sklepu klient prosi Mariusza o rozszerzenie systemu do zarządzania dostawami o wysłanie powiadomienia SMS do klienta, kiedy tylko pojawi się problem z dostawą i zakupiony przedmiot dotrze do niego z opóźnieniem.

Przyjrzyjmy się zatem systemowi do zarządzania dostawami, który został stworzony na początku powstawania sklepu (2 lata temu) i od tej pory nie był zmieniany (według starego przysłowia “Jeśli nie jest zepsute - nie naprawiaj tego”).

import { ShippingService } from './shipping.service';

export class ShippingController {
    private service;

    constructor() {
        this.service = new ShippingService();
    }

    public ship(oderId: number) {
        this.service.ship(oderId);
    }
}


Jak widać, cała logika dostawy została umieszczona w klasie ShippingService, a więc zajrzyjmy do niej:

import { ProductLocatorService } from './product-locator.service';
import { PricingService } from './pricing.service';
import { InventoryService } from './inventory.service';
import { TrackingService } from './tracking.service';
import { LoggerService } from './logger.service';

export class ShippingService {
    private locator: ProductLocatorService;
    private pricing: PricingService;
    private inventory: InventoryService;
    private tracking: TrackingService;
    private logger: LoggerService;

    constructor() {
        this.locator = new ProductLocatorService();
        this.pricing = new PricingService();
        this.inventory = new InventoryService();
        this.tracking = new TrackingService();
        this.logger = new LoggerService();
    }

    public ship(oderId: number): void {
        // Complicated business logic here...
    }
}


Mariusz odnajduje fragment aplikacji i przystępuje do implementacji. Ponieważ jest on programistą z pewnym doświadczeniem, logika odpowiedzialna za wysyłanie SMS-ów nie jest umieszczona w klasie ShippingService, lecz została stworzona nowa klasa - SMSService. Jej wykorzystanie wygląda następująco:

import { ProductLocatorService } from './product-locator.service';
import { PricingService } from './pricing.service';
import { InventoryService } from './inventory.service';
import { TrackingService } from './tracking.service';
import { LoggerService } from './logger.service';
import { SMSService } from './sms.service';

export class ShippingService {
    private locator: ProductLocatorService;
    private pricing: PricingService;
    private inventory: InventoryService;
    private tracking: TrackingService;
    private logger: LoggerService;
    private messaging: SMSService;

    constructor() {
        this.locator = new ProductLocatorService();
        this.pricing = new PricingService();
        this.inventory = new InventoryService();
        this.tracking = new TrackingService();
        this.logger = new LoggerService();
        this.messaging = new SMSService();
    }

    public ship(oderId: number): void {
        // Complicated business logic here...
    }
}


Kod został stworzony, przetestowany i wdrożony, a klienci od tego momentu dostają powiadomienia SMS, kiedy tylko pojawia się problem z ich zamówieniem. Po jakimś czasie, w trakcie kolejnej sesji planowania rozwoju sklepu, klient informuje Mariusza, że otrzymał wiele próśb od klientów, aby zamienić medium powiadomień z SMS-ów na e-mail.

Tym razem Mariusz zamiast od razu po sesji zacząć pisać nową wersję kodu, zaczął się zastanawiać, czy dotychczasowe podejście jest dobre i po chwili przemyśleń przyszło mu na myśl kilka problemów z obecnym podejściem:

  • Kodu w obecnej formie nie da się (łatwo) testować jednostkowo - gdyby Mariusz chciał przetestować przykładowo kolejność czynności, które składają się na dostarczenie zamówienie, musiałby do tego testu przygotować wiele zależności, takich jak system SMS-owy, system śledzenia przesyłek itp.,
  • Z każdą kolejną nową funkcjonalnością jej implementacja zajmuje coraz więcej czasu - system zaczyna być coraz bardziej skomplikowany (co jest naturalne) oraz zależności między jego fragmentami zaczynają być coraz ściślejsze,
  • Zmiana obecnie istniejącej funkcjonalności również staje się problematyczna - klient już zapowiedział, że będzie chciał za jakiś czas móc zarządzać dostawami jako administrator sklepu, tak więc moduł dostaw będzie musiał być wykorzystany w więcej niż jednym miejscu lub będzie musiał być tworzony jego odpowiednik dla adminów.


Po pewnym czasie spędzonym z wyszukiwarką Google, licznymi postami na blogach, wątkami na StackOverflow, komentarzami na Reddit i Twitter, Mariusz napotkał zasadę programowania obiektowego o nazwie SOLID, która uświadomiła mu co jest nie tak z architekturą sklepu, nad którym pracuje. Zasada SOLID to zbiór pięciu zasad:

  • S- Single responsibility principle - wedle tej zasady, każda klasa/moduł/komponent/jednostka powinna być odpowiedzialna wyłącznie za jedną czynność. W przypadku sklepu internetowego, ta zasada była zachowana od samego początku - pojedyncze funkcjonalności, takie jak logowanie czy też wysyłanie SMS-ów, były tworzone w osobnych klasach (serwisach),
  • O - Open/closed principle- oprogramowanie powinno być otwarte na rozszerzanie (tak jak wysyłanie powiadomień za pomocą nowych kanałów komunikacji), lecz zamknięte na modyfikacje (istniejący kod nie powinien musieć być modyfikowany w tym celu),
  • L- Liskov substitution principle - każdy obiekt powinien móc być zamieniony na obiekt jego podtypu, a nasza aplikacja nadal powinna działać poprawnie,
  • I- Interface segregation principle - zamiast tworzyć jeden duży interfejs ogólnego przeznaczenia, lepiej tworzyć małe, wyspecyfikowane interfejsy,
  • D- Dependency inversion principle - nasza aplikacja powinna opierać się na abstrakcji (czyli np. na interfejsie), aniżeli na konkretnej implementacji (klasie).


Kiedy tylko Mariusz przeanalizował zasadę SOLID, zdał sobie sprawę, że jego serwis do zarządzania dostawami ma jedną podstawową wadę - kiedy tylko klient chce dokonać relatywnie prostej zmiany (dodanie nowego kanału komunikacji), musi on ręcznie zmieniać to w wielu miejscach aplikacji, także tych podstawowych, których działanie było już wielokrotnie testowanie. Jest to sprzeczne z zasadą Open/Closed.

Jednym ze sposobów na poradzenie sobie z tym problemem jest zastosowanie wzorca Dependency Injection. Mariusz przeczytał na Wikipedii następujący opis tego wzorca: Polega na przekazywaniu gotowych, utworzonych instancji obiektów udostępniających swoje metody i właściwości obiektom, które z nich korzystają (np. jako parametry konstruktora). Stanowi alternatywę do podejścia, gdzie obiekty tworzą instancję obiektów, z których korzystają np. we własnym konstruktorze. Naszemu programiście spodobało się to rozwiązanie, tak więc zmienił on serwis dostaw w następujący sposób:

import { ProductLocatorService } from './product-locator.service';
import { PricingService } from './pricing.service';
import { InventoryService } from './inventory.service';
import { TrackingService } from './tracking.service';
import { LoggerService } from './logger.service';
import { SMSService } from './sms.service';

export class ShippingService {
    private locator: ProductLocatorService;
    private pricing: PricingService;
    private inventory: InventoryService;
    private tracking: TrackingService;
    private logger: LoggerService;
    private messaging: SMSService;

    constructor(_locator: ProductLocatorService, _pricing: PricingService, _inventory: InventoryService,
        _tracking: TrackingService, _logger: LoggerService, _messaging: SMSService) {
        this.locator = _locator;
        this.pricing = _pricing;
        this.inventory = _inventory;
        this.tracking = _tracking;
        this.logger = _logger;
        this.messaging = _messaging;
    }

    public ship(oderId: number): void {
        // Complicated business logic here...
    }
}


Mimo zastosowania wzorca Dependency Injection i przekazywania obiektów jako argumenty konstruktora, zamiast tworzenia ich w bezpośrednio w nim, pozostał jeden spory problem z tym serwisem - jest on zależny od konkretnych typów serwisów (jak np. SMSService) i wciąż, jeżeli chcemy zmienić implementację (jak ma to miejsce teraz, gdy klient poprosił o zmianę SMS-ów na wiadomości e-mail) musimy zmieniać kontrakt klasy ShippingService. Mariusz wrócił do materiałów, jakie znalazł na temat zasady SOLID i tym razem zwrócił uwagę na “Dependency inversion principle”, która dokładnie opisuje jego problem. Aby nasza aplikacja była bardziej elastyczna na rozwój, zostały stworzone interfejsy dla wszystkich serwisów i od teraz kod wygląda następująco:

import { IProductLocator, IPricing, IInventory, ITracking, ILogger, IMessaging } from './interfaces';

export class ShippingService {
    private locator: IProductLocator;
    private pricing: IPricing;
    private inventory: IInventory;
    private tracking: ITracking;
    private logger: ILogger;
    private messaging: IMessaging;

    constructor(_locator: IProductLocator, _pricing: IPricing, _inventory: IInventory,
        _tracking: ITracking, _logger: ILogger, _messaging: IMessaging) {
        this.locator = _locator;
        this.pricing = _pricing;
        this.inventory = _inventory;
        this.tracking = _tracking;
        this.logger = _logger;
        this.messaging = _messaging;
    }

    public ship(oderId: number): void {
        // Complicated business logic here...
    }
}


Obecnie wykorzystanie klasy ShippingService wygląda następująco:

import { ShippingService } from './shipping.service';
import { IProductLocator, IPricing, IInventory, ITracking, ILogger, IMessaging } from './interfaces';
import { ProductLocatorService } from './product-locator.service';
import { PricingService } from './pricing.service';
import { InventoryService } from './inventory.service';
import { TrackingService } from './tracking.service';
import { LoggerService } from './logger.service';
import { SMSService } from './sms.service';

export class ShippingController {
    private service;

    constructor() {
        const locator: IProductLocator = new ProductLocatorService();
        const pricing: IPricing = new PricingService();
        const inventory: IInventory = new InventoryService();
        const tracking: ITracking = new TrackingService();
        const logger: ILogger = new LoggerService();
        const messaging: IMessaging = new SMSService();

        this.service = new ShippingService(locator, pricing, inventory, tracking, logger, messaging);
    }

    public ship(oderId: number) {
        this.service.ship(oderId);
    }
}


Na tym etapie możemy się zastanawiać (jak najbardziej słusznie), po co poświęciliśmy cały ten czas, skoro wymaganie klienta nie zostało jeszcze spełnione? Otóż zasada SOLID i wzorzec Dependency Injection pozwoliły nam osiągnąć kilka rzeczy niewidocznych na pierwszy rzut oka:

  • Klasa ShippingService może od teraz być w łatwy sposób testowana jednostkowo. Nie musimy używać do tego tzw. mechanizmów mockowania (imitacji). Wystarczy że tworząc obiekt serwisu do jego konstruktora przekażemy obiekt klasy stworzonej w ramach testu (którego zachowanie będzie pomagało nam zweryfikować, czy testowana klasa zachowuje się poprawnie),
  • Obiekty serwisów w klasie kontrolera mogą być wykorzystane w wielu miejscach - możemy je przekazywać do konstruktorów innych klas,
  • Jeżeli będziemy chcieli podmienić implementację dowolnego z interfejsów na inną - nie będzie to od tego momentu wpływać w żaden sposób na kod klasy ShippingService.


Po pewnym czasie, kiedy Mariusz przyswoił sobie wszystkie nowe informacje i zrozumiał konsekwencje, jakie wynikają ze stosowania nowych technik, uświadomił sobie, jak mało jeszcze pracy potrzebuje, aby spełnić nowe wymaganie klienta. W tym celu wystarczy, że stworzy nową klasę EmailService, która będzie implementować interfejs IMessaging i podmieni go w klasie kontrolera:

import { ShippingService } from './shipping.service';
import { IProductLocator, IPricing, IInventory, ITracking, ILogger, IMessaging } from './interfaces';
import { ProductLocatorService } from './product-locator.service';
import { PricingService } from './pricing.service';
import { InventoryService } from './inventory.service';
import { TrackingService } from './tracking.service';
import { LoggerService } from './logger.service';
import { EmailService } from './email.service';

export class ShippingController {
    private service;

    constructor() {
        const locator: IProductLocator = new ProductLocatorService();
        const pricing: IPricing = new PricingService();
        const inventory: IInventory = new InventoryService();
        const tracking: ITracking = new TrackingService();
        const logger: ILogger = new LoggerService();
        const messaging: IMessaging = new EmailService();

        this.service = new ShippingService(locator, pricing, inventory, tracking, logger, messaging);
    }

    public ship(oderId: number) {
        this.service.ship(oderId);
    }
}


Nowa wersja sklepu internetowego została przekazana klientowi, po weryfikacji wdrożono ją na produkcję i użytkownicy od tego momentu otrzymywali powiadomienia mailowe, kiedy tylko następowało opóźnienie w dostawie zamówienia. Minęło trochę czasu i w trakcie jednej z kolejnych sesji planowania rozwoju produktu, klient w rozmowie z Mariuszem przyznał się, że wysyłanie maili (które dotyczy znacznie większej części sklepu aniżeli tylko modułu dostaw) stało się zbyt drogie w utrzymywaniu - zarządzanie własną flotą serwerów wymaga zbyt dużo czasu administratorów.

W związku z tym, postanowiono zmigrować system wysyłania maili na jedno z popularnych na rynku rozwiązań SaaS (Software as a Service), w którym wysłanie wiadomości email to jedno zapytanie HTTP. Taka migracja oznacza zmianę w klasie EmailService i jej inicjalizacji, a ponieważ jest ona wykorzystywana obecnie w ponad 20 miejscach aplikacji, Mariusz zaczął zastanawiać się, jak na przyszłość zabezpieczyć się przed takimi problemami…

Po kolejnym czasie spędzonym z literaturą fachową, natrafił on na wzorzec Inversion of Control. Wzorzec ten mówi, że sterowanie wykonywaniem programu (w naszym przypadku tworzenia obiektów serwisów), zostaje przeniesione z kodu naszej aplikacji do osobnego narzędzia, potocznie nazywanego kontenerem. Dla Mariusza oznacza to, że zamiast tworzyć w różnych miejscach aplikacji obiekty serwisów, będzie on mógł “sięgnąć” do takiego kontenera i “wyjąć z niego” potrzebny mu serwis. Ponieważ nasz dzielny programista dopiero co poznał wzorzec wstrzykiwania zależności, nie chciał na własną rękę tworzyć całego tego mechanizmu i zaczął szukać gotowych rozwiązań. Ponieważ jego ulubionym narzędziem do tworzenia aplikacji JavaScript jest TypeScript, postanowił wykorzystać framework TypeDI.

TypeDI jest narzędziem wspomagającym wstrzykiwanie zależności zarówno w JavaScript, jak i TypeScript. Po zainstalowaniu i skonfigurowaniu narzędzia zgodnie z dokumentacją, Mariusz przystąpił do adaptowania tego narzędzia w sklepie internetowym. Proces ten składał się z 3 kroków.

Krok 1

Aby serwisy mogły zostać umieszczone w kontenerze, należy je oznaczyć specjalnym dekoratorem @Service. Przykładowo nasz serwis do wysyłania wiadomości e-mail wygląda obecnie tak:

import "reflect-metadata";
import { Service } from "typedi";

import { IMessaging } from "./interfaces";

@Service()
export class EmailService implements IMessaging {
    // Complicated business logic here...
}


Krok 2

Wyciągnięcie potrzebnych serwisów z kontenera w klasie ShippingService:

import "reflect-metadata";
import { Inject, Service } from "typedi";

import { IProductLocator, IPricing, IInventory, ITracking, ILogger, IMessaging } from './interfaces';

@Service()
export class ShippingService {
    @Inject() locator: IProductLocator;
    @Inject() pricing: IPricing;
    @Inject() inventory: IInventory;
    @Inject() tracking: ITracking;
    @Inject() logger: ILogger;
    @Inject() messaging: IMessaging;

    public ship(oderId: number): void {
        // Complicated business logic here...
    }
}


Krok 3

Wykorzystanie klasy ShippingService w kontrolerze:

import "reflect-metadata";
import { Inject } from "typedi";

import { ShippingService } from './shipping.service';

export class ShippingController {
    @Inject() service: ShippingService;

    public ship(oderId: number) {
        this.service.ship(oderId);
    }
}


Mając tak zaadaptowany mechanizm Inversion of Control, Mariusz nie musi się już nigdy więcej zastanawiać, jak zainicjalizować daną klasę, którą potrzebuje wykorzystać - jest ona rozwiązana na poziomie rejestracji klasy w kontenerze IoC (wykorzystanie dekoratora @Service).

Po kolejnym wdrożeniu nowej wersji sklepu Mariusz wyjechał na urlop i gdy tak siedział z drinkiem nad basenem, zaczął zastanawiać się nad rzeczami, których się nauczył i które udało mu się osiągnąć ostatnimi czasy w projekcie. Oto jakie przemyślenia przyszły mu do głowy:

  • Zasada SOLID gromadzi szereg praktycznych zasad pomagających w przyszłości łatwiej i szybciej pracować nad rozwojem aplikacji,
  • Stosowanie zasady “open/closed” pozwala na tworzenie bardziej elastycznych aplikacji, w których wprowadzanie zmian jest znacznie prostsze,
  • Dzięki wzorcowi Dependency Injection można łatwiej testować jednostkowo aplikację, łatwiej dokonywać w niej zmian oraz jaśniejsze jest, jakie zależności mają jaki fragment naszego programu (ponieważ te zależności są przekazywane, a nie tworzone),
  • Wzorzec Inversion of Control, chociaż z początku jest trudny do zrozumienia, okazuje się z czasem bardzo przydatny i w znaczny sposób upraszcza zarządzanie powiązaniami w aplikacji.

O autorze

Łukasz Rybka to CTO i współzałożyciel firmy Cloud Corridor, Trener w infoShare Academy. Full-stack Developer z ponad 10-letnim doświadczeniem na rynku IT. W swojej karierze pełnił już rolę programisty, konsultanta, freelancera, team leadera, architekta i dyrektora technicznego. Pasjonat samodoskonalenia i dzielenia się swoją wiedzą z innymi, spełniający się jako współzałożyciel firmy Cloud Corridor oraz trener w infoShare Academy, jednej z największych akademii IT w Polsce, która prowadzi intensywne warsztaty z zakresu front-end, back-end (w tym Java, Python), Data Science, a także zwinnych metodyk prowadzenia projektów.

Łukasz uwielbia kontakt z biznesem i nie boi się klienta. Człowiek orkiestra – Frontend Developer z zamiłowania, Fullstack Developer z ciekawości, DevOps, gdy jest taka potrzeba. Do tej pory związany z branżą edukacyjną, systemami automatyzującymi, oraz rozwiązaniami klasy ALM (Application Lifecycle Management). Zafascynowany problemami integracji i projektów legacy.


Odwiedź fanpage infoShare Academy na Facebooku, aby być na bieżąco z wydarzeniami, warsztatami i webinarami, które regularnie organizuje.

Rejestrując się na szkolenia przez Bulldogjob, otrzymujesz zniżkę w wysokości 100zł.

<p>Loading...</p>