Styczeń 16, 2019

Delivery service – Wstrzykiwanie zależności

Wstrzykiwanie zależności

W czasach, gdy dopiero rozpoczynałem swoją przygodę z programowaniem w Javie – czyli czasy, w których nie znałem żadnego wzorca projektowego oprócz singletonu – Pablo poprosił mnie o stworzenie prostego serwisu dla dostarczania paczek. Prosty serwis, który miałby usprawnić mu wysyłanie przesyłek.

Pomyślałem – czemu nie? W końcu upiekę dwie pieczenie na jednym ogniu – nie dość, że będę uczył się języka na praktycznym projekcie to dodatkowo pomogę osobie w potrzebie. Brzmi pięknie, obgadaliśmy szczegóły i wziąłem się do pracy.

Zanim zabiorę Cię ponownie do swojego labolatorium to oczywiście wyjaśnię na czym polega problem do rozwiązania.

Celem projektu jest możliwość wysyłania paczek na przeróżne sposoby, na początku mamy do wyboru dostarczenie paczki:

  • samochodem ciężarowem,
  • samolotem,
  • statkiem.

Jak wiadomo świat idzie do przodu i codziennie mogą się pojawiać nowe to sposoby dostarczania paczek. Skoro wiesz na czym już polega problem, który mamy rozwiązać to możemy już zakładać rękawice i wejść do labolatorium.

Model paczki

Skoro chcemy coś wysyłać to musimy wiedzieć co. Jest to prosty model, który ma w sobie takie właściwości jaki:

  • nazwa paczki,
  • zawartość,
  • adres.

Prezentuje się on tak:

package org.blog;

public class DeliveryPackage {
    private final String packageName;
    private final String content;
    private final String address;

    public DeliveryPackage(String packageName, String content, String address) {
        this.packageName = packageName;
        this.content = content;
        this.address = address;
    }

    public String getPackageName() {
        return packageName;
    }

    public String getContent() {
        return content;
    }

    public String getAddress() {
        return address;
    }

    @Override
    public String toString() {
        return "DeliveryPackage{" +
                "packageName='" + packageName + '\'' +
                ", content='" + content + '\'' +
                ", address='" + address + '\'' +
                '}';
    }
}

Do tej pory powinno być wszystko zrozumiałe – przejdźmy dalej.

DeliveryService

DeliveryService jest klasą tylko z jedną metodą sendPackage, która jako argument oczywiście przyjmuje paczkę jaką ma wysłać oraz rodzaj transportu jakim ma tą paczkę wysłać.

public void sendPackage(DeliveryPackage deliveryPackage, String transportType) {
 
}

Zanim jednak wypełnimy metodę kodem to musimy stworzyć klasy odpowiadające każdemu z transportów.

Samolot

package org.blog.transport;

import org.blog.DeliveryPackage;

public class AirplaneTransport {
    public void delivery(DeliveryPackage deliveryPackage) {
        System.out.println("Airplane delivered package: " + deliveryPackage);
    }
}

Statek

package org.blog.transport;

import org.blog.DeliveryPackage;

public class ShipTransport {
    public void delivery(DeliveryPackage deliveryPackage) {
        System.out.println("Ship delivered package: " + deliveryPackage);
    }
}

Samochód ciężarowy

package org.blog.transport;

import org.blog.DeliveryPackage;

public class TruckTransport {
    public void delivery(DeliveryPackage deliveryPackage) {
        System.out.println("Truck delivered package: " + deliveryPackage);
    }
}

Żadna z klas nie powinna być dla Ciebie póki co problematyczna – dla mnie przynajmniej nie była na początku nauki programowania. 😉

Serwis

Wróćmy jednak do implementacji metody w DeliveryService.

Skoro mamy już nasze środki transportu to warto będzie oczywiście utworzyć te obiekty w klasie jako pola:

package org.blog;

import org.blog.transport.AirplaneTransport;
import org.blog.transport.ShipTransport;
import org.blog.transport.TruckTransport;

public class DeliveryService {
    private final AirplaneTransport airplaneTransport = new AirplaneTransport();
    private final ShipTransport shipTransport = new ShipTransport();
    private final TruckTransport truckTransport = new TruckTransport();

    public void sendPackage(DeliveryPackage deliveryPackage, String transportType) {
    }
}

A następnie na podstawie podanego typu wywołać metodę z odpowiedniej klasy.

package org.blog;

import org.blog.transport.AirplaneTransport;
import org.blog.transport.ShipTransport;
import org.blog.transport.TruckTransport;

public class DeliveryService {
    private final AirplaneTransport airplaneTransport = new AirplaneTransport();
    private final ShipTransport shipTransport = new ShipTransport();
    private final TruckTransport truckTransport = new TruckTransport();

    public void sendPackage(DeliveryPackage deliveryPackage, String transportType) {
        switch (transportType) {
            case "airplane":
                airplaneTransport.delivery(deliveryPackage);
                break;

            case "ship":
                shipTransport.delivery(deliveryPackage);
                break;

            case "truck":
                truckTransport.delivery(deliveryPackage);
                break;

            default:
                System.out.println("Unrecognized transport type!");
        }
    }
}

Tadam i wszystko powinna już działać. Sprawdźmy oczywiście działanie w klasie Main.

package org.blog;


public class Main {

    public static void main(String[] args) {
        DeliveryPackage deliveryPackage = new DeliveryPackage("Książka", "Effective Java", "Księżyc 103");

        DeliveryService deliveryService = new DeliveryService();
        deliveryService.sendPackage(deliveryPackage, "airplane");
        deliveryService.sendPackage(deliveryPackage, "ship");
        deliveryService.sendPackage(deliveryPackage, "truck");
        deliveryService.sendPackage(deliveryPackage, "unknown");
    }
}

No i jak widać działa…

Airplane delivered package: DeliveryPackage{packageName='Książka', content='Effective Java', address='Księżyc 103'}
Ship delivered package: DeliveryPackage{packageName='Książka', content='Effective Java', address='Księżyc 103'}
Truck delivered package: DeliveryPackage{packageName='Książka', content='Effective Java', address='Księżyc 103'}
Unrecognized transport type!

…no prawie, jak widać w ostatnim przypadku nie rozpoznało nam rodzaju transportu. Musimy to jakoś naprawić.

Enum jest super

O ile kod, który widzisz powyżej mógł zostać napisany zaraz na samym początku mojej przygody z Javą to jednak z czasem on ewoluował. Bardzo się zmieniał – nie tylko pod względem niwelowania błędów (np. nierozpoznany typ), ale również pod względem czystości kodu.

I tak o to w dalszej części etapu mojej nauki poznałem enum – czyli typ wyliczeniowy. Piękna sprawa, kod stał się czystszy i o dziwo mniej podatny na błędy!

Dodałem enum, która określał środki transportu:

package org.blog.transport;

public enum TransportTypes {
    AIRPLANE,
    SHIP,
    TRUCK
}

I w taki oto sposób mogłem pozbyć się brzydkiego Stringa na rzecz typu wyliczeniowego:

package org.blog;

import org.blog.transport.AirplaneTransport;
import org.blog.transport.ShipTransport;
import org.blog.transport.TransportTypes;
import org.blog.transport.TruckTransport;

public class DeliveryService {
    private final AirplaneTransport airplaneTransport = new AirplaneTransport();
    private final ShipTransport shipTransport = new ShipTransport();
    private final TruckTransport truckTransport = new TruckTransport();

    public void sendPackage(DeliveryPackage deliveryPackage, TransportTypes transportType) {
        switch (transportType) {
            case AIRPLANE:
                airplaneTransport.delivery(deliveryPackage);
                break;

            case SHIP:
                shipTransport.delivery(deliveryPackage);
                break;

            case TRUCK:
                truckTransport.delivery(deliveryPackage);
                break;

            default:
                System.out.println("Unrecognized transport type!");
        }
    }
}

Niby mała zmiana, ale ten switch-case jest już przyjemniejszy dla oka… 😉

Oczywiście jako dobrzy programiści musimy sprawdzić działanie aplikacji.

package org.blog;

import org.blog.transport.TransportTypes;

public class Main {

    public static void main(String[] args) {
        DeliveryPackage deliveryPackage = new DeliveryPackage("Książka", "Effective Java", "Księżyć 103");

        DeliveryService deliveryService = new DeliveryService();
        deliveryService.sendPackage(deliveryPackage, TransportTypes.AIRPLANE);
        deliveryService.sendPackage(deliveryPackage, TransportTypes.SHIP);
        deliveryService.sendPackage(deliveryPackage, TransportTypes.TRUCK);
    }
}

No i jak widać poniżej aplikacja działa poprawnie:

Airplane delivered package: DeliveryPackage{packageName='Książka', content='Effective Java', address='Księżyć 103'}
Ship delivered package: DeliveryPackage{packageName='Książka', content='Effective Java', address='Księżyć 103'}
Truck delivered package: DeliveryPackage{packageName='Książka', content='Effective Java', address='Księżyć 103'}

Warto też zauważyć, że taka prosta zmiana – jak wprowadzenie enuma – uniemożliwiła użycie transportu, który nie istnieje. No to super, jeden błąd usunięty. 😉

Rozszerzanie aplikacji

Po jakimś czasie rozwijania prostego serwisu do wysyłania paczek przyszedł do mnie Pablo i powiedział:

Ty no, trzeba rozszerzyć serwis o 10 nowych rodzajów transportów. Masz tu listę i zrób to szybko.

Mówię ok, w końcu nie będzie to problemem. Dodam tylko 10 nowych klas, 10 obiektów w typie wyliczeniowym i 10 case w switchu. No przecież to chwila roboty.

public void sendPackage(DeliveryPackage deliveryPackage, TransportTypes transportType) {
       switch (transportType) {
           case AIRPLANE:
               airplaneTransport.delivery(deliveryPackage);
               break;

           case SHIP:
               shipTransport.delivery(deliveryPackage);
               break;

           case TRUCK:
               truckTransport.delivery(deliveryPackage);
               break;
               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

               
           case ANY_NEW_TRANSPORT:
               anyNewTransport.delivery(deliveryPackage);
               break;

           default:
               System.out.println("Unrecognized transport type!");
       }

I na koniec powstał taki o to piękny, wielki – nowotwór, który staje się coraz mniej czytelny. Nawet jeśli jest nadal czytelny (w tym przypadku nie ma dużo instrukcji) to ciągle się rozrasta. Mi się to nie podoba – bardzo.

Wiem, że wszędzie jest ANY_NEW_TRANSPORT – to było tak straszne, że już nie chcę pamiętać wszystkich rodzajów transportów jakie musiałem dodawać. :/

Niestety taki kod poszedł na produkcje, chociaż się nigdy nie wywalił to współczuję osobie, która musiała go dalej rozwijać.

Wstrzyknij to

Po jakimś czasie nadszedł czas na przełom w mojej przygodzie programowania – poznałem cudowny wzorzec projektowy. Bardzo popularny, zna (a przynajmniej powinien znać) go każdy szanujący się programista.

Mowa oczywiście o dependency injection – czyli wstrzykiwaniu zależności. Polega on na wstrzykiwaniu odpowiedniej implementancji w konkretne miejsce – możemy to robić albo przez konstruktor, albo poprzez tzw. settera.

Główną zaletą jest to, że możemy wydzielić sobię pewną abstrakcję (np. interfejs) i nie przejmować się żadnymi implementacjami. Implementację dostarcza już klient klasy, to on decyduje co nam (klasie) da.

Trochę teorii, przejdźmy do zniszczenia nowotworu przykładu. W starej wersji nasz nowotwór metoda wyglądała tak:

public void sendPackage(DeliveryPackage deliveryPackage, TransportTypes transportType) {
    switch (transportType) {
        case AIRPLANE:
            airplaneTransport.delivery(deliveryPackage);
            break;

        case SHIP:
            shipTransport.delivery(deliveryPackage);
            break;

        case TRUCK:
            truckTransport.delivery(deliveryPackage);
            break;

        default:
            System.out.println("Unrecognized transport type!");
    }
}

Korzystając z Dependency Injection (DI) spróbujmy zoptymalizować powyższą metodę. 😉

Zacznijmy od początku – powinniśmy coś wstrzyknać mając jakąś abstrakcję – interfejs – stwórzmy, więc interfejs:

package org.blog.transport;

import org.blog.DeliveryPackage;

public interface TransportService {
    void delivery(DeliveryPackage deliveryPackage);
}

Abstrakcja już z góry nam mówi co każdy środek transportu musi potrafić – wysłać paczkę. 😉

Następnie niech nasze środku transportu zaimplementują ten interfejs:

Samolot

package org.blog.transport;

import org.blog.DeliveryPackage;

public class AirplaneTransport implements TransportService {
    @Override
    public void delivery(DeliveryPackage deliveryPackage) {
        System.out.println("Airplane delivered package: " + deliveryPackage);
    }
}

Statek

package org.blog.transport;

import org.blog.DeliveryPackage;

public class ShipTransport implements TransportService {
    @Override
    public void delivery(DeliveryPackage deliveryPackage) {
        System.out.println("Ship delivered package: " + deliveryPackage);
    }
}

Samochód ciężarowy

package org.blog.transport;

import org.blog.DeliveryPackage;

public class TruckTransport implements TransportService {
    @Override
    public void delivery(DeliveryPackage deliveryPackage) {
        System.out.println("Truck delivered package: " + deliveryPackage);
    }
}

Wstrzykiwanie

Skoro mamy już przygotowane małe klocki to czas je wstrzyknąć do naszego serwisu – my to zrobimy przez konstruktor.

public class DeliveryService {
    private final TransportService transportService;

    public DeliveryService(TransportService transportService) {
        this.transportService = transportService;
    }
}

Skup się na chwilę na tym etapie – w momencie tworzenia obiektu DeliveryService decydujemy jakiego środka transportu użyć. Skoro już zdecydowaliśmy jakiego środka transportu mamy użyć – to w tym momencie drugi argument w metodzie sendPackage jest zbędny.

public void sendPackage(DeliveryPackage deliveryPackage)

Zostało już tylko wysłać paczkę:

public void sendPackage(DeliveryPackage deliveryPackage) {
    transportService.delivery(deliveryPackage);
}

I została tylko… jedna linia. Takiej klasy nie można już nazwać nowotworem:

package org.blog;

import org.blog.transport.TransportService;

public class DeliveryService {
    private final TransportService transportService;

    public DeliveryService(TransportService transportService) {
        this.transportService = transportService;
    }

    public void sendPackage(DeliveryPackage deliveryPackage) {
        transportService.delivery(deliveryPackage);
    }
}

Sprawdźmy jak teraz możemy używać DeliveryService.

package org.blog;

import org.blog.transport.AirplaneTransport;
import org.blog.transport.ShipTransport;
import org.blog.transport.TruckTransport;

public class Main {

    public static void main(String[] args) {
        DeliveryPackage deliveryPackage = new DeliveryPackage("Książka", "Effective Java", "Ksiezyc 103");

        DeliveryService airplaneDeliveryServie = new DeliveryService(new AirplaneTransport());
        DeliveryService truckDeliveryService = new DeliveryService(new TruckTransport());
        DeliveryService shipDeliveryService = new DeliveryService(new ShipTransport());

        airplaneDeliveryServie.sendPackage(deliveryPackage);
        truckDeliveryService.sendPackage(deliveryPackage);
        shipDeliveryService.sendPackage(deliveryPackage);
    }
}

Podczas tworzenia obiektu DeliveryService w konstruktorze podajemy jakiego środka transportu klasa ma używać – co można zaobserwować poniżej.

Airplane delivered package: DeliveryPackage{packageName='Książka', content='Effective Java', address='Ksiezyc 103'}
Truck delivered package: DeliveryPackage{packageName='Książka', content='Effective Java', address='Ksiezyc 103'}
Ship delivered package: DeliveryPackage{packageName='Książka', content='Effective Java', address='Ksiezyc 103'}

Refleksja

Na trzecim etapie dopiero dostrzegłem jak poprzedni kod był zły. W miarę rozszerzania funkcjonalności musieliśmy ciągle karmić nasz nowotwór. Na początku było wszystko ładnie, ale ciągłe rozwijanie projektu było męczące.

Na szczęście udało się to zmienić przy użyciu prostego wzorca projektowego Dependency Injection. Wzorca, który jest używany m.in przez framework Spring. Nauka Springa jest dużo prostsza mając już opanowany wzorzec Dependency Injection. Jest to jego podstawa, reszta modułów to tylko kolejne klocki, które konfigurujemy i używamy. Jednak nie do tego dąże, warto podsumować co jeszcze zyskaliśmy korzystając z DI.

    1. Niezależne moduły – oba moduły (TransportService i DeliveryService) nic tak naprawdę o sobie nie wiedzą. DeliveryService nic nie wie o implementacji, zna tylko interfejs. TransportService jest wkładany do jakiegoś pudełka i tam używany.
    2. Łatwość w testowaniu – dzięki temu, że nasze moduły są niezależne są prostsze w testowaniu. W środowisku testowym możemy bez problemu podmieniać nasze „klocki” i testować na różne sposoby – książkowym przykładem jest podmiana bazy danych na czas testów (np. na hsql).
    3. Nawiązując do drugiej zasady SOLIDklasa DeliveryService została zamknięta na modyfikację, a otwarta na rozszerzanie. Aby rozszerzyć klasę o kolejny środek transportu wystarczy dodać nowy typ transportu – nie musimy już grzebać w implementacji sendPackage i dodawać kolejnych if-ów.
    4. Luźne połączenia – tak jak wspomniałem wcześniej, wstrzykujemy jakie chcemy implementacje i kiedy tylko chcemy. Żadna klasa nie jest ściśle związana z implementacją drugiej.

Podsumowanie

Na prostym przykładzie serwisu transportowego pokazałem Ci zasadę działania dependency injection. Do DI nawiązywałem też trochę podczas szukania istoty istnienia interfejsów Javie.

O ile historia nigdy w 100% się nie wydarzyła to etapy, po których przeszliśmy w tym artykule już tak. I życzę Ci tego, abyś za każdym razem, gdy spojrzysz na swój kod miał pomysł na jego udoskonalenie. Oczywiście nie popadajmy w skrajność – wszystko z umiarem.

Na samym początku napisałem takie słowa:

[…] nie dość, że będę uczył się języka na praktycznym projekcie to dodatkowo pomogę osobie w potrzebie.

A na sam koniec zachęcam również do nauki programowania poprzez wykonywanie praktycznych projektów – projektów, które mogą się przydać Tobie w codziennym życiu, na których może zarobisz fortunę, albo po prostu pomożesz osobie w potrzebie. 😉

Kod z całego artykułu znajdziesz na githubie, został on podzielony etapami:

  1. switch + string
  2. switch + enum
  3. dependency injection