Archiwum kategorii: Wzorce projektowe

Sprawdzone w praktyce rozwiązanie często pojawiających się problemów projektowych

Strategia

Załóżmy, że w zależności od warunków, aplikacja musi wykonać inne działania. Można wydzielić taką grupę wymiennych działań (algorytmów) do osobnych klas. Te działania to strategie. Wzorzec projektowy Strategia pozwala dynamicznie (w czasie działania aplikacji) zmieniać stosowane strategie.

Dzięki Strategii można oddzielić konkretne działania od klas. Kod staje się bardziej czytelny i łatwiejszy w rozbudowie. Jednocześnie dzięki temu użytkownik nie musi znać wszystkich interfejsów używanych klas – musi tylko ustawić strategię i ją wywołać.

Rozważmy aplikację, która łączy się z kilkoma różnymi API. Aplikacja musi się uwierzytelnić w API ale każde z nich wymaga innego sposobu uwierzytelnienia.

Typ wyliczeniowy AuthType przechowuje przewidziane w aplikacji sposoby uwierzytenienia:

public enum AuthType {
    BASIC,
    OAUTH,
    JWT;
}

Klasa APIConnector odpowiada za uwierzytelnienie:

public class APIConnector {
    
    public void authBasic (AuthType authType) {
        if (authType.equals(AuthType.BASIC)) {
            System.out.println("Uwierzytelnienie przy użyciu Basic Auth");
        } else {
            System.err.println("Błędny typ uwierzytelnienia");
        }
    }

    public void authOauth (AuthType authType) {
        if (authType.equals(AuthType.OAUTH)) {
            System.out.println("Uwierzytelnienie przy użyciu OAuth2");
        } else {
            System.err.println("Błędny typ uwierzytelnienia");
        }
    }

    public void authJwt (AuthType authType) {
        if (authType.equals(AuthType.JWT)) {
            System.out.println("Uwierzytelnienie przy użyciu JWT");
        } else {
            System.err.println("Błędny typ uwierzytelnienia");
        }
    }
}

Uwierzytelnienie wymaga utworzenia obiektu klasy APIConnector i wywołania na nim odpowiedniej metody:

public class Main {
    public static void main (String[] args) {

        APIConnector connector = new APIConnector();
        connector.authBasic(AuthType.BASIC);
    }
}

Powyższe rozwiązanie ma 2 wady:

  • użytkownik (klasa Main) musi znać interfejsy klasy APIConnector żeby jej używać,
  • każda nowa metoda uwierzytelnienia wymaga modyfikacji klasy APIConnector.

Można zmodyfikować klasę APIConnector tak, żeby miała tylko 1 interfejs:

public class APIConnector {

    public void authenticate (AuthType authType) {
        switch (authType) {
            case BASIC:
                System.out.println("Uwierzytelnienie przy użyciu Basic Auth");
                break;
            case OAUTH:
                System.out.println("Uwierzytelnienie przy użyciu OAuth2");
                break;
            case JWT:
                System.out.println("Uwierzytelnienie przy użyciu JWT");
                break;
            default:
                System.err.println("Nieznany typ uwierzytelnienia");
        }
    }
} 

W ten sposób użycie klasy APIConnector jest łatwiejsze (Main musi znać 1 interfejs, authenticate()), ale dalej każda nowa metoda uwierzytelnienia wymaga modyfikacji w APIConnector. Strategia pozwala rozwiązać ten problem.

Implementacja wzorca Strategia

Interfejs AuthStrategy deklaruje 1 metodę, authenticate():

public interface AuthStrategy {
    void authenticate();
}

Klasy BasicAuthStrategy, OAuthStrategy i JwtAuthStrategy implementują interfejs. To są konkretne strategie. Każda z nich implementuje metodę authenticate() inaczej odpowiadając za inny sposób uwierzytelnienia:

public class BasicAuthStrategy implements AuthStrategy{
    @Override
    public void authenticate() {
        System.out.println("Uwierzytelnienie przy użyciu Basic Auth");
    }
}
public class OAuthStrategy implements AuthStrategy{
    @Override
    public void authenticate() {
        System.out.println("Uwierzytelnienie przy użyciu OAuth2");
    }
}
public class JwtAuthStrategy implements AuthStrategy{
    @Override
    public void authenticate() {
        System.out.println("Uwierzytelnienie przy użyciu JWT");
    }
}

Klasa ApiConnector ma prywatne pole przechowujące obiekt implementujący interfejs AuthStrategy i odpowiedni setter. W metodzie authenticate() zamiast całej logiki jest tylko wywołanie metody authenticate() na tym obiekcie:

public class APIConnector {

    private AuthStrategy authStrategy;

    public void authenticate () {
        this.authStrategy.authenticate();
    }

    public void setAuthStrategy(AuthStrategy authStrategy) {
        this.authStrategy = authStrategy;
    }
}

W klasie Main trzeba utworzyć obiekt klasy APIConnector (bez zmian), a następnie ustawić odpowiednią strategię i wywołać metodę authenticate():

public class Main {
    public static void main (String[] args) {

        APIConnector connector = new APIConnector();

        connector.setAuthStrategy(new BasicAuthStrategy());
        connector.authenticate();
    }
}

Dzięki temu, że pole authStrategy jest typu AuthStrategy można do niego zapisać obiekt każdej z klas implementujących ten interfejs: BasicAuthStrategy, OAuthStrategy i JwtAuthStrategy. AuthStrategy służy jako dodatkowa warstwa abstrakcji pośrednicząca między klasą APIConnector a konkretnymi strategiami. W momencie wywołania metody authenticate() aplikacja automatycznie wywoła jej właściwą implementację.

Enum AuthType nie jest już potrzebny.

Dodanie nowej metody uwierzytelniania nie wymaga już modyfikacji klasy APIConnector. Wystarczy dodać nową strategię.

Podsumowanie

Wzorzec Strategia to praktyczna implementacja zasady otwarte-zamknięte oraz zasady odwrócenia zależności.

Strategia to popularny i prosty wzorzec. Jest stosowana tam, gdzie potrzebna jest możliwość elastycznej zmiany zachowania aplikacji. Wzorzec ułatwia testowanie, rozszerzanie i utrzymanie kodu.


Wzorzec Strategia i wzorzec Stan umożliwiają dynamiczną modyfikację działania aplikacji, ale mają inny cel:

  • Strategia służy enkapsulacji poszczególnych algorytmów które są potem wstrzykiwane z zewnątrz, a klient wybiera którego algorytmu użyć,
  • Stan z góry implementuje jakie zachowanie ma wystąpić przy jakim stanie obiektu, a zmiana zachowania następuje w sposób automatyczny.

Fasada

Głównym celem wzorca projektowego Fasada jest uproszczenie komunikacji między klientem a złożonym systemem. W dużych aplikacjach, gdzie logika biznesowa rozproszona jest między wieloma komponentami, Fasada oferuje pojedynczy interfejs, który ukrywa złożoność systemu wewnętrznego.

Dzięki Fasadzie klient nie musi znać szczegółów działania poszczególnych klas – wystarczy, że użyje interfejsu udostępnionego przez Fasadę.

Rozważmy aplikację Report Generator służącą do zarządzania dokumentami. Aplikacja składa się z kilku różnych klas i metod, które wywołane w odpowiedniej kolejności pozwalają wygenerować raport.

Typ wyliczeniowy Role przechowuje przewidziane w aplikacji role użytkowników:

public enum Role {
    ADMIN,
    USER;
}

Klasa User reprezentuje użytkownika który próbuje wygenerować dokument i musi zostać autoryzowany:

public class User {
    private String username;
    private Role role;

    public User(String username, Role role) {
        this.username = username;
        this.role = role;
    }

    public Role getRole() {
        return role;
    }
}

Klasa Report reprezentuje raport który ma zostać wygenerowany:

public class Report {
    private String content;

    Report(String content) { this.content = content; }

    public void exportToPDF() {
        System.out.println("Eksportowanie raportu do PDF");
        System.out.println(content);
    }
}

Klasa AuthService odpowiada za autoryzację użytkownika:

public class AuthService {
    public boolean hasAccess(User user) {
        return "ADMIN".equals(user.getRole());
    }
}

Klasa DataService generuje dane do raportu:

public class DataService {
    public String fetchDataForReport() {
        return "Dane finansowe Q2: [przychody, koszty, zysk...]";
    }
}

Wreszcie klasa ReportService służy do generowania raportów i zwraca obiekt klasy Report:

public class ReportService {
    public Report generateReport(String data) {
        return new Report("Raport wygenerowany na podstawie:\n" + data);
    }
}

Z aplikacją łączy się inna aplikacja – Klient. Klient jest zewnętrzną aplikacją która łączy się Generatorem Raportów i generuje raport:

public class Client {
    public static void main (String[] args) {
        User admin = new User("Jan", Role.ADMIN);

        AuthService authService = new AuthService();
        DataService dataService = new DataService();
        ReportService reportService = new ReportService();

        if (authService.hasAccess(admin)) {
            String data = dataService.fetchDataForReport();
            Report report = reportService.generateReport(data);
            report.exportToPDF();
        } else {
            System.out.println("Brak uprawnień do wygenerowania raportu.");
        }
    }
}

Klasa Client musi mieć wiedzę na temat działania aplikacji Report Generator, poszczególnych klas i metod. Client musi wiedzieć w jakiej kolejności wywoływać metody w celu wygenerowania raportu. Wreszcie Client ma dostęp do klas aplikacji. Fasada udostępnia Klientowi jeden interfejs ukrywając szczegóły implementacji.

Implementacja wzorca Fasada

Logika wygenerowanie raportu zostaje przeniesiona z klasy Client do klasy ReportFacade. Klasa ReportFacade ma jeden interfejs, generateReport(). Klient będzie korzystał z tego interfejsu:

public class ReportFacade {

    private AuthService authService = new AuthService();
    private DataService dataService = new DataService();
    private ReportService reportService = new ReportService();

    public void generateReport (User user) {
        if (authService.hasAccess(user)) {
            String data = dataService.fetchDataForReport();
            Report report = reportService.generateReport(data);
            report.exportToPDF();
        } else {
            System.out.println("Brak uprawnień do wygenerowania raportu.");
        }
    }
}

Dostęp do wszystkich pozostałych klas i metod zostaje zablokowany. W Java można przenieść klasy do oddzielnej paczki i usunąć modyfikator dostępu public. Dzięki temu dostęp do nich będzie miała tylko klasa ReportFacade, ale Client już nie.

Klasa Client wywołuje teraz tylko interfejs generateReport() z Fasady. W tym przypadku nie można ukryć tylko klasy User – Klient musi mieć do niej dostęp.

public class Client {
    public static void main (String[] args) {
        User admin = new User("Jan", Role.ADMIN);

        ReportFacade reportFacade = new ReportFacade();
        reportFacade.generateReport(admin);
    }
}

Klient nie musi już wiedzieć w jakiej kolejności wywoływać poszczególne metody. Cała logika generowania raportu jest przed nim ukryta. Klient musi tylko znać jeden interfejs.

Podsumowanie

Wzorzec Fasada to elegancki sposób na porządkowanie skomplikowanych systemów i oferowanie intuicyjnych, spójnych punktów dostępu. Fasada izoluje klienta od systemu czyli izoluje warstwę logiki biznesowej od wewnętrznych metod. Dzięki temu kod jest bezpieczniejszy i łatwiejszy do modyfikacji.

Fasada jest powszechnie stosowana w warstwie serwisowej aplikacji webowych i integracjach z zewnętrznymi usługami. Fasada pełni rolę bramy – spina wiele klas i komponentów w jeden logiczny punkt wejścia.


Wzorzec Fasada jest podobny do wzorca Adapter, ale różni się od niego intencją:

  • celem wzorca Fasada jest zapewnienie klientowi uproszczonego interfejsu dla danego systemu lub podsystemu,
  • celem wzorca Adapter jest modyfikacja interfejsu tak żeby dopasować go do potrzeb klienta

Adapter

W świecie programowania często spotykamy się z sytuacją, w której chcemy użyć istniejącej klasy, ale jej interfejs nie pasuje do tego, czego oczekuje nasz system. Wzorzec projektowy Adapter umożliwia współpracę klas o niekompatybilnych interfejsach.

Zamiast przepisywać kod lub zmieniać istniejące klasy, możemy zastosować wzorzec projektowy Adapter. Adapter „tłumaczy” interfejs jednej klasy na interfejs oczekiwany przez drugą klasę, bez modyfikacji istniejącego kodu.

Rozważmy aplikację pozwalającą sprawdzić prognozę pogody – WeatherClient. Aplikacja wymaga do działania obiektu implementującego interfejs WeatherService i wywołuje na tym obiekcie metodę getCurrentTemperature().

public class WeatherClient {
    WeatherService weatherService;

    public WeatherClient(WeatherService weatherService) {
        this.weatherService = weatherService;
    }

    public void showTemperature (String city) {
        System.out.println(city + ": " + weatherService.getCurrentTemperature(city));
    }
}

Interfejs WeatherService deklaruje 1 metodę, getCurrentTemperature(), zwracającą String:

public interface WeatherService {
    String getCurrentTemperature(String city);
}

Ale prognoza pogody jest pobierana z zewnętrznego API. Interfejs ExternalWeatherApi deklaruje 1 metodę getTemperatureCelsius() zwracającą double:

public interface ExternalWeatherApi {
    public double getTemperatureCelsius (String city);
}

Do kontaktu z API potrzebny jest obiekt implementujący ExternalWeatherApi.
Niestety nie można go przekazać do WeatherClient bo WeatherClient wymaga obiektu implementującego WeatherService :

public class Main {
    public static void main (String[] args) {
        ExternalWeatherApi weatherApi = new ExternalWeatherApi() {
            @Override
            public double getTemperatureCelsius(String city) {
                return 15.2;
            }
        };

        // WeatherClient weatherClient = new WeatherClient(weatherApi); // ERROR
    }
}

Próba przekazania obiektu weatherApi do WeatherClient skutkuje błędem. Adapter pozwala rozwiązać ten problem.

Implementacja wzorca Adapter

Adapter dopasowuje obiekt implementujący ExternalWeatherApi do WeatherService. Klasa ExternalApiToWeatherServiceAdapter implementuje interfejs WeatherService (wymagany przez WeatherClient) i posiada obiekt implementujący ExternalWeatherApi (potrzebny do kontaktu z API) jako pole.

public class ExternalApiToWeatherServiceAdapter implements WeatherService{

    ExternalWeatherApi weatherApi;

    public ExternalApiToWeatherServiceAdapter(ExternalWeatherApi weatherApi) {
        this.weatherApi = weatherApi;
    }

    @Override
    public String getCurrentTemperature(String city) {
        return weatherApi.getTemperatureCelsius(city) + " st. C";
    }
}

Dzięki temu do można przekazać obiekt klasy ExternalApiToWeatherServiceAdapter do WeatherClient i wywołać na nim metodę getCurrentTemperature() zaimplementowaną z interfejsu WeatherService:

public class Main {
    public static void main (String[] args) {
        ExternalWeatherApi weatherApi = new ExternalWeatherApi() {
            @Override
            public double getTemperatureCelsius(String city) {
                return 15.2;
            }
        };

        ExternalApiToWeatherServiceAdapter apiAdapter = 
             new ExternalApiToWeatherServiceAdapter(weatherApi);
        WeatherClient weatherClient = new WeatherClient(apiAdapter);
        weatherClient.showTemperature("Łódź");
    }
}

Po uruchomieniu programu w konsoli zostanie wypisany komunikat: Łódź: 15.2 st. C

Podsumowanie

Wzorzec Adapter to eleganckie rozwiązanie problemu niekompatybilnych interfejsów. Pozwala na ponowne wykorzystanie istniejącego kodu bez jego modyfikacji. Dzięki Adapterowi możemy łatwo integrować różne komponenty, zachowując spójność i elastyczność architektury.

Adapter jest powszechnie stosowany w integracjach z zewnętrznymi API, bibliotekami i systemami legacy. Jest szczególnie przydatny w integracji starszych systemów z nowymi komponentami. W komercyjnym użyciu adapter rzadko składa się z wywołania jednej metody. Przeważnie dochodzi w nim do przepakowania całych obiektów tak żeby przenieść je w całości z jednego interfejsu do innego.

Istnieje też adapter dwukierunkowy, który pozwala za pomocą jednego obiektu adaptować interfejsy w obie strony. Taki adapter musi implementować oba interfejsy. Adapter dwukierunkowy jest jednak mniej czytelny i rzadziej używany.


Wzorzec Adapter jest podobny do wzorca Fasada, ale różni się od niego intencją:

  • celem wzorca Adapter jest modyfikacja interfejsu tak żeby dopasować go do potrzeb klienta,
  • celem wzorca Fasada jest zapewnienie klientowi uproszczonego interfejsu dla danego systemu lub podsystemu.