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:
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:
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:
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),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.
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...
}
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...
}
}
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:
Ł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ł.