Strona główna » 3 behawioralne wzorce projektowe które warto znać

3 behawioralne wzorce projektowe które warto znać

by Grzegorz Sowa

Behawioralne wzorce projektowe, chociaż często mniej znane niż strukturalne czy kreacyjne, mogą przynieść bardzo dużą wartość w projekcie. Postaram się więc przedstawić trzy wzorce, które moim zdaniem naprawdę warto znać. Są to: strategia, obserwujący i odwiedzający. Mam wrażenie, że w kilku projektach, które robiłem w przeszłości te wzorce mogły mi zaoszczędzić wiele trudu.

Kod zawarty w tym artykule znajdziecie tutaj. Nie zapomnijcie przejrzeć innych branchy, zawierających kod odpowiadający kolejnym sekcjom artykułu.

Strategia

Strategia to chyba najczęściej używany z przedstawionych tutaj behawioralnych wzorców projektowych. Warto go używać w miejscach, gdzie logika biznesowa jest dość złożona i często ulega zmianie. Dzięki strategii możemy dynamicznie podstawiać różne operacje do wykonania, w zależności od zdefiniowanych warunków. Strategia pozwala też wynieść część logiki z naszej domeny, dzięki czemu staje się ona bardziej uporządkowana i czytelna.

behawioralne wzorce projektowe strategia

Idealnym problemem, aby przedstawić wzorzec strategii, wydaje się określanie ceny produktu w supermarkecie na podstawie różnych promocji. Promocje dla każdego produktu muszą być dobierane dynamicznie, na podstawie kategorii, daty, ceny i różnych innych czynników. Co więcej, wartość promocji też może się zmieniać, w zależności od decyzji biznesu. Zobaczmy najprostszą implementację logiki promocji przy określaniu zbiorczej ceny pozycji w zamówieniu:

public class PriceService {

    Integer calculatePrice(OrderPosition position) {
        int price = position.getQuantity() * position.getProduct().getPrice();
        final LocalDateTime now = LocalDateTime.now();

        // multi item promotion for fashion and health
        if (Arrays.asList(FASHION, HEALTH).contains(position.getProduct().getCategory())
                && position.getQuantity() > 3) {
            price = (int) Math.ceil(price * 0.8);
        }
        // cyber monday electronics promotion
        if (now.toLocalDate().equals(getCyberMondayFor(now.getYear()))
                && position.getProduct().getCategory().equals(ELECTRONICS)) {
            price = (int) Math.ceil(price * 0.65);
        }
        // new year's happy hours
        if (now.toLocalDate().isEqual(LocalDate.of(now.getYear(), JANUARY, 1))
                && now.getHour() > 6 && now.getHour() < 10) {
            price = (int) Math.ceil(price * 0.5);
        }

        return price;
    }

    private LocalDate getCyberMondayFor(Integer year) {
        return LocalDate.of(year, NOVEMBER, 1)
                .with(TemporalAdjusters.dayOfWeekInMonth(4, DayOfWeek.THURSDAY));
    }
}

Mamy tutaj już 3 dosyć skomplikowane instrukcje warunkowe, a promocji na pewno będzie więcej. Po jakimś czasie utrzymywania takiej aplikacji to miejsce będzie pewnie jednym z najczęściej zmienianych przez programistów. To może prowadzić do ogromnego „spuchnięcia” klasy i licznych konfliktów przy merge’owaniu.

Aby temu zapobiec spróbujmy zastosować wzorzec strategii. Musimy zacząć od zdefiniowania dodatkowego interfejsu Discount, reprezentującego reguły promocji:

public interface Discount {

    Predicate<OrderPosition> applicable();

    int apply(int price);

}

Przykładowa implementacja interfejsu Discount to po prostu przeniesiona instrukcja if z naszego PriceService:

public class CyberMondayDiscount implements Discount {

    @Override
    public Predicate<OrderPosition> applicable() {
        return position -> {
            final LocalDateTime now = LocalDateTime.now();
            return now.toLocalDate().equals(getCyberMondayFor(now.getYear()))
                    && position.getProduct().getCategory().equals(ELECTRONICS);
        };
    }

    @Override
    public int apply(int price) {
        return (int) Math.ceil(price * 0.65);
    }

    private LocalDate getCyberMondayFor(Integer year) {
        return LocalDate.of(year, NOVEMBER, 1)
                .with(TemporalAdjusters.dayOfWeekInMonth(4, DayOfWeek.THURSDAY));
    }
}

Teraz mamy bardzo schludnie wyciągniętą logikę biznesową promocji z serwisu cen, który teraz jest bardzo uproszczony i wygląda tak:

public class PriceService {

    private List<Discount> discounts;

    Integer calculatePrice(OrderPosition position) {
        int price = position.getQuantity() * position.getProduct().getPrice();

        for (Discount discount : discounts) {
            if (discount.applicable().test(position)) {
                price = discount.apply(price);
            }
        }

        return price;
    }

}

Obserwator

Obserwator to wzorzec, który warto zaimplementować w sytuacji, gdy wiele obiektów musi być powiadomionych o jakimś wydarzeniu. Powiadamianie wszystkich zainteresowanych w logice domenowej może być żmudne i sprawiać, że kod staje się nieczytelny. Dlatego wzorzec obserwatora definiuje mechanizm subskrypcji, dzięki czemu nasz obiekt domenowy może udostępnić metodę do subskrypcji i notyfikować wszystkich zainteresowanych, nie wiedząc nawet, kto się zapisał.

behawioralne wzorce projektowe obserwator

Wyobraźmy sobie logikę zamówień internetowego supermarketu, w której w pewnym momencie zamówienie użytkownika zostaje zamknięte. Oznacza to kilka rzeczy:

  • trzeba utworzyć płatność, aby użytkownik mógł zamówienie opłacić,
  • należy wysłać maila podsumowującego zamówienie,
  • dane marketingowe muszą zostać zaktualizowane, aby w przyszłości proponować użytkownikowi produkty podobne do tych, które właśnie zakupił

Każda z tych rzeczy dotyczy innego kontekstu, dlatego w najprostszej implementacji stworzymy odpowiednie interfejsy i „podepniemy” je pod naszą logikę domenową:

public class OrderFacade {

    private OrderRepository orderRepository;

    private MailService mailService;
    private MarketingService marketingService;
    private PaymentService paymentService;

    public void closeOrder(UUID orderId) {
        final Order order = orderRepository.findById(orderId)
                .orElseThrow(EntityNotFoundException::new);
        order.close();

        mailService.notifyOrderClosed(orderId);
        marketingService.notifyOrderClosed(orderId);
        paymentService.notifyOrderClosed(orderId);

    }

}

Jak widać, nasza metoda closeOrder() jest bardzo zagracona logiką powiadamiania różnych części aplikacji o zamknięciu zamówienia. W dodatku będzie się ona wciąż zmieniać, bo w przyszłości dodamy moduł raportowania, fakturowania itp. Znacznie lepiej byłoby po prostu powiadamiać wszystkich zainteresowanych, którzy taką chęć zakomunikują. W najprostszej wersji możemy zaimplementować wzorzec obserwatora definiując dwa interfejsy, Publisher i Subscriber:

public interface Publisher<T> {

    void subscribe(Subscriber<T> subscriber);

}

public interface Subscriber<T> {

    void onNext(T event);

}

Publisher udostępnia metodę służącą do „zapisania się” do subskrypcji jakiegoś wydarzenia T, a potem rozgłasza to wydarzenia, na każdym swoim subskrybencie wywołując metodę onNext(). Dzięki temu rozpoczęcie subskrypcji spada na samego zainteresowanego, implementującego interfejs Subscriber.

Zobaczmy więc, jak będzie wyglądać metoda closeOrder() po zaimplementowaniu wzorca obserwatora:

public class OrderFacade implements Publisher<OrderClosedEvent> {

    private OrderRepository orderRepository;

    private final List<Subscriber<OrderClosedEvent>> orderClosedSubscribers = new ArrayList<>();

    @Override
    public void subscribe(Subscriber<OrderClosedEvent> subscriber) {
        orderClosedSubscribers.add(subscriber);
    }

    public void closeOrder(UUID orderId) {
        final Order order = orderRepository.findById(orderId)
                .orElseThrow(EntityNotFoundException::new);
        order.close();

        final OrderClosedEvent event = new OrderClosedEvent(orderId, Instant.now().getEpochSecond());
        for (var subscriber : orderClosedSubscribers) {
            subscriber.onNext(event);
        }

    }

}

Jak widać kod jest prostszy, ale przede wszystkim – mniej podatny na zmiany. OrderFacade implementuje interfejs Publisher, który wymusza na niej nadpisanie metody subscribe(), do której każdy obiekt zainteresowany wydarzeniem OrderClosedEvent może się „zgłosić”, jeśli tylko implementuje interfejs Subscriber. Może to wyglądać przykładowo tak:

class MailService implements Subscriber<OrderClosedEvent> {

    public MailService(Publisher<OrderClosedEvent> publisher) {
        publisher.subscribe(this);
    }

    @Override
    public void onNext(OrderClosedEvent event) {
        sendConfirmationMail(event.orderId(), event.timestamp());
    }

    private void sendConfirmationMail(UUID orderId, Long timestamp) {
        // sending mail
    }

}

Jeśli natomiast dodamy MonitoringService i on też będzie musiał reagować na OrderClosedEvent, logika zamówień nie musi o tym wiedzieć, nic się w niej nie zmienia.

Odwiedzający

Odwiedzający to wzorzec zapewne mniej popularny niż pozostałe dwa, jednak wciąż rozwiązuje poważny problem w bardzo sprytny sposób. Wyobraźmy sobie grafową strukturę obiektów, gdzie każdy węzeł może mieć kilka innych zagnieżdżonych węzłów. Jeśli teraz musimy przekazać naszą strukturę w innym formacie, np. xml czy pdf, od razu przychodzi na myśl rekursywne wywoływanie metody od głównego obiektu, do kolejnych obiektów zależnych. To jednak nie zawsze jest najlepsze rozwiązanie, zwłaszcza, gdy nasz graf reprezentuje logikę biznesową. Nie chcemy przecież naszego kodu zaśmiecać technicznymi metodami. Tu właśnie najbardziej pomaga wzorzec odwiedzającego, pozwalający tą logikę wyeksportować do innych klas, „odwiedzających” węzły grafu aby wyciągnąć potrzebne informacje.

behawioralne wzorce projektowe odwiedzający

Wyobraźmy sobie, że nasza klasa zamówienia Order składa się z kilku pozycji OrderPosition, które to zawierają ilość i klasę danego produktu Product. Aby wyeksportować zamówienie do xml w każdej z tych klas musimy dodać „brzydką” metodę toXml(), która może być wywoływana rekursywnie:

public class Order {

    private UUID id;
    private UUID clientId;
    private Set<OrderPosition> positions;
    private Boolean closed;

    // other methods

    String toXml() {
        return """
                <order>
                    <id>%s</id>
                    <clientId>%s</clientId>
                    <positions>
                    %s
                    </positions>
                </order>
                """.formatted(id, clientId,
                positions.stream()
                .map(OrderPosition::toXml)
                .collect(Collectors.joining())
                );
    }
}

Dużo dodatkowego kodu, który nie pasuje do logiki biznesowej. Co więcej, co z eksportem do pdf? Albo do json? To dodatkowe metody, a nasza klasy domenowe „puchną” coraz bardziej. Zobaczmy teraz, czy obserwator rozwiąże wszystkie problemy. Na początek zdefiniujmy interfejs Visitor:

public interface Visitor {

    void doForOrder(Order order);

    void doForOrderPosition(OrderPosition position);

    void doForProduct(Product product);

}

Odwiedzający musi mieć gotową metodę dla każdego obiektu domenowego. Jednak dzięki temu w obiektach domenowych pozostaje nam już tylko zdefiniować metodę accept():

public class Order {

    private UUID id;
    private UUID clientId;
    private Set<OrderPosition> positions;
    private Boolean closed;

    // other methods

    public void accept(Visitor visitor) {
        visitor.doForOrder(this);
    }
}

Wprawdzie musimy ją zdefiniować w każdej klasie, jednak teraz możemy już akceptować dowolną ilość odwiedzających, bez wiedzy klasy domenowej. Co więcej, logika eksportu do xml została już wyniesiona do oddzielnej klasy:

class XmlExportVisitor implements Visitor {

    private final StringBuilder result;

    public XmlExportVisitor() {
        result = new StringBuilder();
    }

    public String getResult() {
        return result.toString();
    }

    @Override
    public void doForOrder(Order order) {
        result.append(
                """
                        <order>
                            <id>%s</id>
                            <clientId>%s</clientId>
                            <positions>
                        """.formatted(order.getId(), order.getClientId())
        );
        order.getPositions()
                .forEach(position -> position.accept(this));
        result.append(
                """
                            </positions>
                        </order>
                        """
        );
    }

    @Override
    public void doForOrderPosition(OrderPosition position) {
        // some logic…
    }

    @Override
    public void doForProduct(Product product) {
        // some logic…    
    }
}

Podsumowanie

Trzy wzorce projektowe opisane w artykule pozwalają zaimplementować skomplikowaną logikę w bardzo spójny i błyskotliwy sposób, a jednocześnie zachowują czytelność kodu. Umiejętność wybrania dobrego wzorca i dostosowania go do sytuacji to oczywiście kwestia doświadczenia, jednak mam nadzieję że powyższe przykłady pozwoliły zapoznać się z benefitami, które oferują 3 opisane wzorce 🙂

Więcej o podobnych wzorcach możesz poczytać na tym blogu.

You may also like

2 komentarze

Rafał Leżanko 5 września, 2021 - 12:59 pm

Fajny, przystępny artykuł. Znam te mechanizmy i korzystam. Dzięki!

Duża zaleta użycia tych wzorców jest łatwość pisania testów jednostkowych tych wydzielonych klas (konkretnych strategii/wizytorów) niż jakiegoś długiego bloku kodu zawierającego same if-y 🙂

Reply
Grzegorz Sowa 6 września, 2021 - 12:47 am

Dzięki Rafał 🙂 Masz rację, też myślę że testy dla takiego kodu łatwiej się pisze i utrzymuje.

Reply

Leave a Comment