CodeGym /Kursy /Spring Core /Inwersja punktu sterowania i IoC

Inwersja punktu sterowania i IoC

Spring Core
Poziom 2, Lekcja 0
Dostępny

1. Punkt sterowania w aplikacji

Gdy new żyje wewnątrz serwisu, scenariusz biznesowy szybko miesza się ze składaniem aplikacji. Teraz omówimy ten problem na plain Java: kto w ogóle powinien decydować, jakie zależności tworzyć, i co się zmienia, gdy ta kontrola wyjeżdża z klasy biznesowej na zewnątrz. Potem tę pracę przejmie kontener, ale najpierw warto zobaczyć wszystko własnymi rękami.

Jeśli patrzeć na program oczami nowicjusza, wydaje się, że „najważniejszy" jest main(). I to prawda, ale tylko częściowo. Dziś interesuje nas nie to, skąd startuje kod, lecz kto podejmuje decyzje: jakie obiekty tworzyć, kiedy i jaką konkretną implementację wybrać. W małym kodzie te decyzje są prawie niezauważalne, ale właśnie z nich potem wyrasta chaos.

Wyobraź sobie, że twój serwis to pracownik, któremu zlecono zadanie: „utwórz zamówienie i wyślij powiadomienie". Jeśli ten pracownik sam też decyduje, skąd wziąć pocztę, jaki laptop kupić, jakiego komunikatora używać i jak skonfigurować internet, staje się nie wykonawcą, lecz małym „dyrektorem działu IT". I właśnie to „dyrektorowanie" będziemy wynosić na zewnątrz.

W terminach kodu punkt sterowania — to miejsce, gdzie decyduje się, jakich zależności będzie używać klasa. Przed IoC często żyje on wprost wewnątrz klasy biznesowej. Po IoC wynosi się go na zewnątrz: do punktu wejścia lub osobnego miejsca, które składa aplikację.

2. new wewnątrz klasy biznesowej

Najczęstsza sytuacja startowa wygląda całkiem niewinnie. Jest nadawca powiadomień i serwis, który składa zamówienie. Na początku po prostu piszemy „jak najszybciej", a najszybciej — to prawie zawsze new.

class ConsoleNotificationSender {
    void send(String orderId) {
        // Szczegół infrastrukturalny: konkretny sposób powiadamiania (na razie — konsola)
        System.out.println("notify " + orderId); // notify ORD-1
    }
}

class OrderPlacementService {
    void place(String orderId) {
        // Problem: serwis sam wybiera implementację i sam tworzy zależność
        ConsoleNotificationSender sender = new ConsoleNotificationSender();
        sender.send(orderId);
    }
}

Na poziomie wyniku wszystko świetnie. Kod krótki, oczy się cieszą, kot też zadowolony. Ale jest jeden ważny szczegół: klasa OrderPlacementService nie tylko wykonuje działanie biznesowe, lecz i decyduje, jakiego nadawcę powiadomień stworzyć.

To znaczy wewnątrz place() mieszają się jednocześnie dwa rodzaje logiki. Pierwszy — logika biznesowa („gdy składamy zamówienie, trzeba wysłać powiadomienie"). Drugi — logika składania aplikacji („powiadomienia wysyłamy przez ConsoleNotificationSender, tworzymy go tu i teraz"). Dopóki projekt jest mały, nikt nie narzeka. Gdy projekt rośnie, właśnie te decyzje zaczynają przeszkadzać.

Żeby poczuć problem mocniej, dodajmy jeszcze jedną zależność. Na przykład audyt: chcemy zapisywać, że zamówienie zostało utworzone.

class ConsoleAuditWriter {
    void write(String message) {
        // Kolejny szczegół infrastrukturalny: gdzie i jak piszemy audyt
        System.out.println("AUDIT: " + message); // AUDIT: created ORD-1
    }
}

class OrderPlacementService {
    void place(String orderId) {
        // Serwis zaczyna "składać sobie świat" wewnątrz metody: to właśnie punkt sterowania w kodzie biznesowym
        ConsoleNotificationSender sender = new ConsoleNotificationSender();
        ConsoleAuditWriter auditWriter = new ConsoleAuditWriter();

        sender.send(orderId);
        auditWriter.write("created " + orderId);
    }
}

Teraz serwis już „składa sobie zespół pracowników" wprost wewnątrz metody. W jego głowie jednocześnie żyją i scenariusz biznesowy, i szczegóły infrastrukturalne. To jakby kucharz przed ugotowaniem makaronu najpierw budował kuchenkę, potem sprowadzał krowę, potem prowadził księgowość — i dopiero potem gotował. Czy makaron jest smaczniejszy? Nie. Czy życie jest znacznie trudniejsze? Tak.

3. new jako decyzja architektoniczna

Wielu postrzega new jako „po prostu operator". Ale w kontekście architektury oznacza on: „wewnątrz klasy utrwalam konkretną implementację". A więc utrwalam i zachowanie, i sposób działania, i miejsce, gdzie tworzony jest obiekt.

Oto do czego to prowadzi, gdy new żyje wewnątrz klasy biznesowej.

Na początku tracisz możliwość łatwej zmiany zachowania. Powiedzmy, że jutro zdecydujesz, że powiadomienia powinny być wysyłane nie do konsoli, lecz mailem — choćby w trybie demo. Jeśli new ConsoleNotificationSender jest ukryty w trzech serwisach, trzeba będzie zmienić wszystkie trzy. Czyli zmiana infrastruktury wymusza edycję kodu biznesowego. To bardzo zły handel.

Potem staje się trudno testować. Powiedzmy, że chcesz sprawdzić, że „przy tworzeniu zamówienia wysyłane jest powiadomienie". Jeśli OrderPlacementService sam tworzy ConsoleNotificationSender, nie będziesz mógł normalnie podmieniać go testową implementacją, która po prostu zapamiętuje fakt wywołania. Trzeba będzie albo grzebać w prywatnych polach, albo robić dziwne sztuczki, albo pogodzić się z tym i patrzeć na System.out oczami smutnego człowieka.

I jest jeszcze jedna subtelność: czasem programista próbuje „poprawić" sytuację tak, żeby new nie rzucał się w oczy, ale znaczeniowo zostawia wszystko na miejscu.

class OrderPlacementService {

    void place(String orderId) {
        // Z zewnątrz wygląda, że wszystko stało się "ładne", ale tworzenie wciąż jest wewnątrz serwisu
        var sender = buildSender();
        sender.send(orderId);
    }

    private ConsoleNotificationSender buildSender() {
        // Punkt sterowania się nie zmienił: decyzja o implementacji wciąż tu
        return new ConsoleNotificationSender();
    }
}

Wygląda schludniej, ale punkt sterowania się nie zmienił. Decyzja o tym, jakiego nadawcę tworzyć, wciąż żyje wewnątrz klasy biznesowej. Po prostu schowaliśmy problem za zasłonką.

4. IoC: tworzenie zależności na zewnątrz

Teraz nazwijmy rzecz po imieniu. Inversion of Control (IoC) — to idea, że klasa biznesowa nie zarządza tworzeniem swoich zależności. Nie powinna decydować, jakich konkretnych implementacji używać i kiedy je tworzyć. Zamiast tego klasa otrzymuje zależności z zewnątrz, a sama skupia się na swoim zadaniu.

Słowo „inwersja" oznacza tu, że zmienia się kierunek kontroli. Przed IoC serwis mówi: „potrzebuję nadawcy — teraz go sam stworzę". Po IoC serwis mówi: „potrzebuję nadawcy — przekażcie mi go, a ja będę pracować". Kontrola nad tworzeniem przenosi się z klasy biznesowej do zewnętrznego kodu, który zajmuje się składaniem aplikacji.

Ważne: IoC — to idea, a nie konkretne API. Spring jest rzeczywiście kontenerem IoC, ale dziś nie jest nam w ogóle potrzebny, żeby zrozumieć sedno. IoC można zrobić w czystej Javie — dosłownie w kilku linijkach.

Żeby obraz stał się zupełnie konkretny, oto mała tabela porównawcza:

Przed IoC (sterowanie wewnątrz serwisu) Po IoC (sterowanie na zewnątrz)
Serwis tworzy zależności przez new Serwis otrzymuje zależności „gotowe"
Decyzja o implementacji ukryta wewnątrz metody biznesowej Decyzja widoczna tam, gdzie składają aplikację
Trudno zmieniać zachowanie bez poprawiania serwisu Zachowanie zmienia się przez podmianę przekazanego obiektu
Testy często trafiają w „a jak podmienić zależność?" Testy łatwo podkładają fałszywe zależności

5. IoC w plain Java: tworzymy na zewnątrz

Teraz zrobimy dokładnie jeden krok: wyniesiemy tworzenie zależności z klasy biznesowej i zostawimy tam tylko korzystanie. To jeszcze nie „cała architektura świata", a po prostu dyscyplina. Ale nawet ten krok już zmienia odczucie od kodu: staje się on uczciwszy, bo klasa biznesowa przestaje ukrywać decyzje o składaniu.

Najpierw zdefiniujmy „rolę" nadawcy. Tak, to już przypomina interfejsy, ale tu potrzebna nam jest dosłownie jedna metoda i jeden kontrakt — żeby pokazać: serwis zależy od zachowania, a nie od konkretnej klasy.

// Kontrakt: serwisowi zależy na „umiejętności wysyłania", a nie na „byciu konsolowym nadawcą"
interface NotificationSender {
    void send(String orderId);
}

Teraz zrobimy implementację, która pisze do konsoli.

class ConsoleNotificationSender implements NotificationSender {
    @Override
    public void send(String orderId) {
        // Implementacja szczegółu infrastrukturalnego: gdzie dokładnie wysyłamy powiadomienie
        System.out.println("notify " + orderId); // notify ORD-1
    }
}

I przepiszemy serwis tak, żeby nie tworzył nadawcy, lecz otrzymywał go.

class OrderPlacementService {
    private final NotificationSender sender;

    // DI przez konstruktor: zależność jest obowiązkowa do działania serwisu
    OrderPlacementService(NotificationSender sender) {
        this.sender = sender;
    }

    void place(String orderId) {
        // Działanie biznesowe: serwis tylko używa zależności, nie tworząc jej
        sender.send(orderId);
    }
}

Zwróć uwagę na to, co najważniejsze: OrderPlacementService nie wie już, jakiego konkretnie nadawcy się używa. Wszystko mu jedno: konsolowy, mailowy, SMS-owy czy telepatyczny. Ważne, żeby była metoda send().

Pozostało przenieść tworzenie obiektów do punktu wejścia. W naszej aplikacji konsolowej to zwykle main().

public class Main {
    public static void main(String[] args) {
        // Punkt składania: tu wybieramy konkretne implementacje
        NotificationSender sender = new ConsoleNotificationSender();

        // Wstrzykujemy zależność do serwisu jawnie
        OrderPlacementService service = new OrderPlacementService(sender);

        service.place("ORD-1"); // notify ORD-1
    }
}

Właśnie tu i nastąpiła inwersja sterowania. Wcześniej OrderPlacementService zarządzał tym, jakiego nadawcę stworzyć. Teraz tą decyzją zarządza Main, a serwis po prostu pracuje z tym, co mu dano.

Żeby poczuć efekt zamienialności, dodamy jeszcze jedną implementację: „nadawca mailowy" (na razie po prostu imitacja).

class EmailNotificationSender implements NotificationSender {
    @Override
    public void send(String orderId) {
        // Inna implementacja infrastrukturalna tego samego kontraktu
        System.out.println("email to customer: " + orderId); // email to customer: ORD-1
    }
}

Teraz zachowanie zmienia się bez zmiany serwisu:

public class Main {
    public static void main(String[] args) {
        // Zmieniamy implementację — serwisu nie ruszamy
        NotificationSender sender = new EmailNotificationSender();
        OrderPlacementService service = new OrderPlacementService(sender);

        service.place("ORD-1"); // email to customer: ORD-1
    }
}

Klasy biznesowej nie przepisaliśmy ani na jedno słowo. Po prostu przekazaliśmy inną zależność. W tym właśnie momencie zaczyna się szacunek do IoC nie jako „mądrego słowa", lecz jako oszczędności nerwów.

6. IoC w ContextFlow: audyt i powiadomienia

Teraz ostrożnie przyziemijmy tę ideę do naszego projektu przekrojowego ContextFlow. Weźmiemy na razie tylko jeden przekrój — powiadomienia i audyt. To wystarczy, żeby zobaczyć samo przeniesienie sterowania na zewnątrz, bez ciągania całego projektu. W scenariuszu „utwórz zamówienie" szczególnie szybko widać: jeśli wszystkie działania poboczne zbierać wewnątrz serwisu, staje się on „kombajnem", który i biznes robi, i infrastrukturę sobie sam składa.

Zacznijmy od mini-wersji: mamy powiadomienia i audyt (bez magazynu i rabatów — to będzie później). Zdefiniujemy dwie role:

// Rola „audyt": serwisowi zależy na umiejętności pisania audytu, a gdzie — decyduje się na zewnątrz
interface AuditWriter {
    void write(String message);
}

class ConsoleAuditWriter implements AuditWriter {
    @Override
    public void write(String message) {
        // Konkretna implementacja: piszemy audyt do konsoli
        System.out.println("AUDIT: " + message); // AUDIT: created ORD-1
    }
}

I teraz serwis składania zamówienia będzie otrzymywał obie zależności z zewnątrz:

class OrderPlacementService {
    private final NotificationSender sender;
    private final AuditWriter auditWriter;

    // Obie zależności są obowiązkowe: bez nich scenariusz „złóż zamówienie" jest niepełny
    OrderPlacementService(NotificationSender sender, AuditWriter auditWriter) {
        this.sender = sender;
        this.auditWriter = auditWriter;
    }

    void place(String orderId) {
        // Scenariusz biznesowy używa zależności jako narzędzi, nie tworząc ich
        sender.send(orderId);
        auditWriter.write("created " + orderId);
    }
}

Punkt wejścia — nasz mały „punkt składania" — teraz jawnie pokazuje, z czego składa się scenariusz:

public class Main {
    public static void main(String[] args) {
        // Punkt sterowania: wybieramy implementacje (konsolowe) w jednym miejscu
        NotificationSender sender = new ConsoleNotificationSender();
        AuditWriter auditWriter = new ConsoleAuditWriter();

        // Jawnie składamy serwis z zależności
        OrderPlacementService service = new OrderPlacementService(sender, auditWriter);

        service.place("ORD-1");
        // notify ORD-1
        // AUDIT: created ORD-1
    }
}

Dlaczego jest to ważne właśnie dla ContextFlow? Ponieważ wybór implementacji w tym projekcie będzie normalną częścią życia. W jednym trybie będziemy chcieli pisać audyt do pliku, w innym — do konsoli. Dla powiadomień — podobnie. Jeśli serwis wybiera implementacje sam, nie będziemy mieli normalnego miejsca, gdzie ten wybór jest widoczny i zarządzalny. A IoC wymusza uczynienie go jawnym — i to wielkie zwycięstwo nad „magią w głowie programisty".

7. Schemat sterowania i odciążenie kodu

Żeby utrwalić model mentalny, przydatne jest patrzenie na IoC nie jako na zestaw new, lecz jako na zmianę struktury. Poniżej prosty schemat: po lewej klasa biznesowa jako „sam sobie reżyser", po prawej — klasa biznesowa jako „aktor", któremu wydano rekwizyty.

flowchart TD
    subgraph Before["Przed IoC: sterowanie wewnątrz klasy biznesowej"]
        S1["OrderPlacementService"] -->|new| N1["ConsoleNotificationSender"]
        S1 -->|new| A1["ConsoleAuditWriter"]
    end
flowchart TD
    subgraph After["Po IoC: sterowanie na zewnątrz"]
        M["Main / punkt wejścia"] --> N2["ConsoleNotificationSender"]
        M --> A2["ConsoleAuditWriter"]
        M --> S2["OrderPlacementService"]
        S2 --> N2
        S2 --> A2
    end

Ważna uwaga: zależności OrderPlacementService nie zniknęły. On wciąż zależy od powiadomień i audytu. Ale zrobiliśmy dwie ważne rzeczy. Po pierwsze, przestaliśmy ukrywać decyzję o konkretnych implementacjach wewnątrz serwisu. Po drugie, wydzieliliśmy osobne miejsce, które odpowiada za składanie aplikacji. Gdy graf obiektów staje się duży, jego składanie chce się automatyzować. Na razie wystarczy nauczyć się nie mieszać odpowiedzialności.

8. Typowe błędy przy IoC

Błąd №1: sądzić, że IoC zaczyna się dopiero po podłączeniu Springa.
Bardzo łatwo pomylić ideę z narzędziem. Spring rzeczywiście implementuje IoC na poziomie kontenera, ale sama inwersja sterowania zaczyna się w momencie, gdy klasa biznesowa przestaje tworzyć swoje zależności i zaczyna je otrzymywać z zewnątrz. Można to zrobić w czystej Javie — i właśnie to dziś robimy.

Błąd №2: przenosić new do prywatnej metody tej samej klasy i myśleć, że jest lepiej.
Kod wygląda czyściej, ale znaczenie się nie zmienia. Punkt sterowania wciąż jest wewnątrz serwisu. Jeśli serwis sam decyduje, jaką implementację tworzyć, kontrola nie jest odwrócona — jest po prostu głębiej ukryta.

Błąd №3: wynosić na zewnątrz tylko część zależności, a resztę dalej tworzyć wewnątrz serwisu.
Tak często powstaje hybryda: jedną zależność „wstrzykujemy", drugą — „no tu już niech będzie new". W efekcie kontrakt klasy wciąż jest niejasny: po konstruktorze nie wiadomo, czego serwis naprawdę potrzebuje, a po metodzie — jakie decyzje są ukryte wewnątrz. IoC jest wartościowy właśnie konsekwencją: zależności albo uczciwie na zewnątrz, albo znowu żyjemy w świecie ukrytych decyzji.

Błąd №4: mylić IoC z „przekazywaniem parametrów do metody".
Gdy przekazujesz orderId do place(orderId), to są dane wejściowe wywołania. IoC i DI dotyczą długo żyjących zależności klasy: tych obiektów, bez których serwis w ogóle nie może wykonywać swojej pracy (powiadomienia, audyt, magazyn itp.). To różne rzeczy i mieszanie ich powoduje ciągłe dezorientacje.

Błąd №5: próbować „rozwiązywać wariacyjność" wewnątrz metody biznesowej przez if/else i new.
Czasem chce się napisać: „jeśli tryb demo — tworzymy Console…, jeśli prod — Email…". To zwraca punkt sterowania z powrotem do kodu biznesowego i czyni serwis odpowiedzialnym za konfigurację środowiska. Nawet w małej aplikacji szkoleniowej szybko prowadzi to do bałaganu. IoC jest właśnie po to, żeby takie decyzje żyły na zewnątrz i nie zaśmiecały scenariuszy.

1
Zadanie
Spring Core, poziom 2, lekcja 0
Niedostępne
Kasowy serwis bez wewnętrznego new
Kasowy serwis bez wewnętrznego new
1
Zadanie
Spring Core, poziom 2, lekcja 0
Niedostępne
Kalkulator dostawy pod kontrolą Main
Kalkulator dostawy pod kontrolą Main
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION