Grudzień 4, 2019

Jak użyć strategii w Springu?

Strategy pattern

Zanim przejdę do pokazania Ci części praktycznej, czyli do tego jak to zaimplementować, należy na początek zastanowić się na czym polega wzorzec projektowy strategia oraz dlaczego akurat go opisuję.

Zacznijmy od końca – czyli dlaczego opisuję akurat ten wzorzec projektowy w kontekście Springa? Odpowiedź jest prosta, jest bardzo popularny. Jest bardzo powszechnie używany w projektach, gdzie jest Spring – reasumując – w większości projektów.

Dlaczego tak się dzieje? Odpowiedź ponownie jest trywialna – kontekst Springa ułatwia tworzenie wielu implementacji danej strategii i ich wstrzykiwanie do kontekstu gdzie tego potrzebujemy. Może na początku wydawać się trochę zagmatwane, ale bez obaw – przy kodzie wszystko się wyjaśni.

Obiecuję.

Czas odpowiedzieć na kolejne pytanie – na czym polega strategia?

W skrócie ten wzorzec projektowy jest oparty o jeden wspólny interfejs zwany strategią oraz o wiele implementacji owej strategii, w których już trzymamy rozwiązania konkretnych problemów.

Strategia w skrócie niweluje największy problem – nadmiarowość instrukcji warunkowych w naszym kodzie. Dzięki temu kod staje się prostszy, a co najważniejsze zachowana jest zasada Open-closed Principle.

Wniosek jest prosty – kod jest lepszy, ponieważ jest otwarty na rozszerzanie, ale zamknięty na zmiany.

I tym razem tylko tyle Ci opowiem o strategy pattern, więcej możesz doczytać w dosyć obszernym artykule na ten temat – warto od niego zacząć, jeśli w ogóle nie wiesz na czym polega ten wzorzec.

Warsztat

Jeśli przeczytałeś już wcześniej kilka moich artykułów to wiesz, że nieodłączną częścią moich wpisów jest warsztat. Według mnie jest to najważniejsza część, dlatego to na niej skupiam się najmocniej. Czyli po prostu skupiam się na kodzie.

W drodze na warsztat opowiem Ci trochę o domenie problemu, czyli z czym w ogóle musimy się zmierzyć.

W pewnej części aplikacji klienckiej mamy byt zwany Recipient, czyli odbiorca czegoś – kij wie czego, mało nas to interesuje.

Owy odbiorca może być różnego typu, na początku są to: USER oraz EMAIL. Klient, który poprosił nas o stworzenie aplikacji od samego początku zaznaczał, że typy recipientów będą się w przyszłości zmieniać.

Jeśli klient sobie zażyczy będziemy musieli dodać nowy lub usunąć stary typ odbiorcy. Biznes daje kasę, więc się na to zgadzamy, jednak również chcemy, aby zmiany w przyszłości były dla nas bardzo proste – pomimo tego, że teraz np. będziemy musieli zainwestować trochę więcej czasu.

No trudno, taką drogę obraliśmy.

Skoro już znasz po części domenę aplikacji, możemy przejść do zadania, które zostało nam zlecone.

Zadanie jest proste, mamy dodać walidację odbiorców podczas ich tworzenia – co ważne, każdy typ recipienta ma być walidowany w inny sposób.

Typ user ma mieć w swojej wartości prefix „user:„, zaś typ email ma być poprawnego formatu.

Wymagania proste, czas zabrać się za implementację. Pokażę, więc Ci teraz kawałek kodu, do którego musimy dodawać ową walidację.

Cała logika jest oparta o prostą klasę Recipient:

package pl.blog.spring.kb.SpringStrategyPattern.recipient.model;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;

@RequiredArgsConstructor
@Getter
@ToString
public class Recipient {
    private final RecipientType type;
    private final String value;
}

W całym projekcie korzystam z biblioteki Lombok, który potrafi za nas generować takie cuda jak: konstruktory, gettery, settery, equals i hashCode i wiele innych cudów. Mam nadzieję, że adnotacje Lomboka nad klasą mają zrozumiałe nazwy. 😉

Zaś RecipientType ma aktualnie tylko dwie wartości:

package pl.blog.spring.kb.SpringStrategyPattern.recipient.model;

public enum RecipientType {
    USER, EMAIL
}

W tym wszystkim mamy RecipientService, który umożliwia tworzenie nowych odbiorców. W tym o to miejscu będziemy musieli dodać moduł walidacji.

package pl.blog.spring.kb.SpringStrategyPattern.recipient;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.Recipient;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.validation.RecipientValidationException;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.validation.RecipientValidationService;

@Component
@Slf4j
@RequiredArgsConstructor
public class RecipientService {
    private final RecipientValidationService recipientValidatorService;

    public void createRecipient(Recipient recipient) {
        try {
            recipientValidatorService.validate(recipient);
            log.info("Recipient has been created: {}.", recipient);
        } catch (RecipientValidationException e) {
            log.error(e.getMessage(), e);
        }
    }
}

Adnotacja @Slf4j pochodzi z Lomboka i jest odpowiedzialna za wstrzyknięcie Loggera do klasy, którego wykorzystuję do wyświetlania informacji na konsolę.

Logika aplikacji jest uruchamiana przy użyciu CommandLineRunner, czyli po uruchomieniu całego kontekstu Springa kod w metodzie run jest wykonywany.

package pl.blog.spring.kb.SpringStrategyPattern;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.RecipientService;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.Recipient;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.RecipientType;

@SpringBootApplication
public class SpringStrategyPatternApplication implements CommandLineRunner {

	private final RecipientService recipientService;

	public SpringStrategyPatternApplication(RecipientService recipientService) {
		this.recipientService = recipientService;
	}


	public static void main(String[] args) {
		SpringApplication.run(SpringStrategyPatternApplication.class, args);
	}

	@Override
	public void run(String... args) throws Exception {
		recipientService.createRecipient(new Recipient(RecipientType.USER, "user:pablo"));
		recipientService.createRecipient(new Recipient(RecipientType.EMAIL, "qwe@wp.pl"));
		recipientService.createRecipient(new Recipient(RecipientType.USER, "pablofail"));
	}
}

Realizacja zadania

Skoro poznałeś już z grubsza cały kod aplikacji to możemy przejść do interesującej części kodu – czyli walidacji, która jest już użyta w RecipientService.

private final RecipientValidationService recipientValidatorService;

Na tę chwilę klasa RecipientValidationService jest pusta, ale to właśnie naszym zadaniem jest jej zapełnienie.

package pl.blog.spring.kb.SpringStrategyPattern.recipient.validation;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.Recipient;

@Component
@Slf4j
public class RecipientValidationService {
    public void validate(Recipient recipient) {
    }
}

Klasa została oczywiście oznaczona jako Bean Springowy adnotacją @Component, tak abyśmy mogli bez problemu go wstrzyknąć w innych klasach.

Przypomnijmy jakie są wymagania klienta.

[…] dodać walidację odbiorców podczas ich tworzenia – co ważne każdy typ recipienta ma być walidowany w inny sposób.

Pierwsze co przychodzi do głowy to pewnie stworzenie bloku switch-case, w którym obsłużymy dwie wartości enuma RecipientType i ewentualnie odpowiednio zareagujemy w przypadku, gdy przyjdzie jego inna wartość.

Czemu nie? Jesteśmy na warsztacie, a nie na produkcji to czemu by nie spróbować.

Przypomnę tylko, że według wymagań klienta walidacja ma działać w następujący sposób.

Typ user ma mieć w swojej wartości prefix user:, zaś typ email ma być poprawnego formatu.

Oczywiście dla nas żaden problem!

package pl.blog.spring.kb.SpringStrategyPattern.recipient.validation;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.validator.routines.EmailValidator;
import org.springframework.stereotype.Component;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.Recipient;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.RecipientType;

@Component
@Slf4j
public class RecipientValidationService {
    private final static String USER_PREFIX = "user:";

    public void validate(Recipient recipient) {
        RecipientType type = recipient.getType();
        switch (type) {
            case USER:
                if (!recipient.getValue().startsWith(USER_PREFIX)) {
                    throw new RecipientValidationException("User recipient format is incorrect.");
                }
                break;
            case EMAIL:
                EmailValidator validator = EmailValidator.getInstance();
                if (!validator.isValid(recipient.getValue())) {
                    throw new RecipientValidationException("Email format is incorrect.");
                }
                break;
            default:
                throw new UnsupportedOperationException("Type: " + type + " is not supported yet.");
        }
    }
}

Nie można zarzucić, że implementacja nie jest prosta, kod jest czytelny, ale na pewno nie spełnia wspomnianej na początku zasady Open-closed Principle. Reasumując, dodanie nowego typu odbiorcy wymaga od nas modyfikowania istniejącego kodu, co może przypadkiem spowodować błędy regresyjne.

Chcąc tego uniknąć zaimplementujemy oczywiście wzorzec projektowy strategia, czyli na początku potrzebujemy interfejsu dla wszystkich strategii.

package pl.blog.spring.kb.SpringStrategyPattern.recipient.validation;

import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.Recipient;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.RecipientType;

interface RecipientValidator {
    void validate(Recipient recipient);
    RecipientType getType();
}

W interfejsie znajduje się nie tylko metoda validate, ale również getType, która identyfikuje strategię z wartością enum – dlaczego tak? O tym za chwilę.

Mając już gotowy interfejs stwórzmy implementację dwóch strategii – Usera oraz Email. Jak dobrzy programiści oczywiście ją skopiujemy z klasy RecipientValidationService.

package pl.blog.spring.kb.SpringStrategyPattern.recipient.validation;

import org.springframework.stereotype.Component;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.Recipient;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.RecipientType;

@Component
public class UserRecipientValidator implements RecipientValidator {
    private final static String USER_PREFIX = "user:";
    @Override
    public void validate(Recipient recipient) {
        if (!recipient.getValue().startsWith(USER_PREFIX)) {
            throw new RecipientValidationException("User recipient format is incorrect.");
        }
    }

    @Override
    public RecipientType getType() {
        return RecipientType.USER;
    }
}

Oraz druga implementacja.

package pl.blog.spring.kb.SpringStrategyPattern.recipient.validation;

import org.apache.commons.validator.routines.EmailValidator;
import org.springframework.stereotype.Component;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.Recipient;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.RecipientType;

@Component
public class EmailRecipientValidator implements RecipientValidator {
    @Override
    public void validate(Recipient recipient) {
        EmailValidator validator = EmailValidator.getInstance();

        if (!validator.isValid(recipient.getValue())) {
            throw new RecipientValidationException("Email format is incorrect.");
        }
    }

    @Override
    public RecipientType getType() {
        return RecipientType.EMAIL;
    }
}

Mając już wszystkie implementacje możemy przejść do ich użycia, czyli potrzebujemy instancji powyższych klas. Klasy są beanami Springowymi, więc możemy je bez problemu wstrzyknąć i teraz zaczyna się mała magia Springowa…

Jeśli nie wiesz do końca na czym polega magia Spring Container oraz czym są beany to koniecznie zajrzyj do tego artykułu.

Zamiast wstrzykiwać każdą z implementacji osobno, czyli np. w taki sposób.

    public RecipientValidationService(UserRecipientValidator userRecipientValidator, EmailRecipientValidator emailRecipientValidator) {
        
    }

Możemy użyć cudownego ficzera Springowego, czyli wstrzyknąć całą listę implementacji danego interfejsu i to w bardzo prosty sposób.

    public RecipientValidationService(List<RecipientValidator> recipientValidators) {

I boom, wszystko działa – mamy właśnie do dyspozycji WSZYSTKIE implementacje interfejsu RecipientValidator. Dla mnie czad.

Wykorzystajmy i pokażę Ci teraz mój ulubiony sposób na użycie strategii. W skrócie polega on na tym, aby zbudować z owych implementacji jedną mapę, gdzie kluczem jest RecipientType.

@Component
@Slf4j
public class RecipientValidationService {
    private final EnumMap<RecipientType, RecipientValidator> validators;


    public RecipientValidationService(List<RecipientValidator> recipientValidators) {
        validators = recipientValidators.stream()
                .collect(Collectors.toMap(
                        RecipientValidator::getType,
                        Function.identity(),
                        detectDuplicatedImplementations(),
                        () -> new EnumMap<>(RecipientType.class)));
    }

    private BinaryOperator<RecipientValidator> detectDuplicatedImplementations() {
        return (l,r) -> {
            throw new RecipientValidationException("Found duplicated strategies assigned to one recipient type value: " + l.getClass() + " " + r.getClass());
        };
    }
...

I co tutaj się zadziało? Jest tutaj trochę programowania funkcyjnego z Javy 8, ale w skrócie polega to na tym, że do EnumMap wrzucamy każdy z walidatorów pod klucz, który jest zdefiniowany w danym walidatorze. W przypadku, gdy będą dwie strategie dla jednego enuma zostanie rzucony RecipientValidationException.

Dzięki napisaniu trochę bardziej skomplikowanego kodu nasze życie staje się teraz prostsze – wystarczy, że dopiszemy kilka linii i mamy już działający walidator.

    private RecipientValidator getValidator(RecipientType type) {
        return Optional.ofNullable(validators.get(type))
                .orElseThrow(() -> new RecipientValidationException(
                        "Validator for: " + type + " has not been found."
                ));
    }

    public void validate(Recipient recipient) {
        log.info("Validating recipient: {}", recipient);
        getValidator(recipient.getType())
                .validate(recipient);
    }

Metoda getValidator – jak sama nazwa wskazuje wyciąga, na podstawie typu z wcześniej utworzonej mapy odpowiedni walidator. Metodzie validate pozostaje już tylko użycie wcześniej wyciągniętego walidatora.

Cała klasa wygląda na koniec tak:

package pl.blog.spring.kb.SpringStrategyPattern.recipient.validation;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.Recipient;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.RecipientType;

import java.util.EnumMap;
import java.util.List;
import java.util.Optional;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.stream.Collectors;

@Component
@Slf4j
public class RecipientValidationService {
    private final EnumMap<RecipientType, RecipientValidator> validators;


    public RecipientValidationService(List<RecipientValidator> recipientValidators) {
        validators = recipientValidators.stream()
                .collect(Collectors.toMap(
                        RecipientValidator::getType,
                        Function.identity(),
                        detectDuplicatedImplementations(),
                        () -> new EnumMap<>(RecipientType.class)));
    }

    private BinaryOperator<RecipientValidator> detectDuplicatedImplementations() {
        return (l,r) -> {
            throw new RecipientValidationException("Found duplicated strategies assigned to one recipient type value: " + l.getClass() + " " + r.getClass());
        };
    }

    private RecipientValidator getValidator(RecipientType type) {
        return Optional.ofNullable(validators.get(type))
                .orElseThrow(() -> new RecipientValidationException(
                        "Validator for: " + type + " has not been found."
                ));
    }

    public void validate(Recipient recipient) {
        log.info("Validating recipient: {}", recipient);
        getValidator(recipient.getType())
                .validate(recipient);
    }

}

I to tyle wystarczy, żeby zaimplementować strategię przy użyciu Springa, jednak to jeszcze nie koniec. Chcę Ci teraz pokazać benefit jaki zyskaliśmy pisząc to rozwiązanie.

Magia

Okazało się, że klient zażyczył sobie dodanie nowego typa odbiorcy – Field.

package pl.blog.spring.kb.SpringStrategyPattern.recipient.model;

public enum RecipientType {
    USER, EMAIL, FIELD
}

I razem z tym zażyczył sobie dodanie walidacji – każde pole musi posiadać prefix „field:„.

Co robimy w takim przypadku? Rzecz jasna, że tworzymy nową implementację interfejsu RecipientValidator, która wygląda tak.

package pl.blog.spring.kb.SpringStrategyPattern.recipient.validation;

import org.springframework.stereotype.Component;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.Recipient;
import pl.blog.spring.kb.SpringStrategyPattern.recipient.model.RecipientType;

@Component
public class FieldRecipientValidator implements RecipientValidator {
    private final static String FIELD_PREFIX = "field:";
    @Override
    public void validate(Recipient recipient) {
        if (!recipient.getValue().startsWith(FIELD_PREFIX)) {
            throw new RecipientValidationException("Field recipient format is incorrect.");
        }
    }

    @Override
    public RecipientType getType() {
        return RecipientType.FIELD;
    }
}

Dodajmy jeszcze tylko użycie RecipientType.FIELD w naszym kodzie, aby mieć możliwość przetestowania rozwiązania.

	@Override
	public void run(String... args) throws Exception {
		recipientService.createRecipient(new Recipient(RecipientType.USER, "user:pablo"));
		recipientService.createRecipient(new Recipient(RecipientType.EMAIL, "qwe@wp.pl"));
		recipientService.createRecipient(new Recipient(RecipientType.USER, "pablofail"));
		recipientService.createRecipient(new Recipient(RecipientType.FIELD, "fiel:pole"));
		recipientService.createRecipient(new Recipient(RecipientType.FIELD, "field:pole"));
	}

Czy musimy zrobić coś więcej? Odpowiedź brzmi – nie. Po uruchomieniu wszystko zadziała z prostego powodu.

Spring wykrył, że została dodana nowa implementacja interfejsu i wstrzyknął ją razem z innymi i BOOM! Pomimo tego, że nie została zmieniona klasa RecipientValidationService wszystko działa jak należy. Czego dowodem może być wycinek z konsoli.

pl.blog.spring.kb.SpringStrategyPattern.recipient.validation.RecipientValidationException: Field recipient format is incorrect.

Podsumowanie

Pokazałem Ci właśnie jak w bardzo fajny sposób można użyć wzorca projektowego strategia w połączeniu ze Springiem, tak by kod był czytelniejszy i prostszy w utrzymaniu.

Dzięki takiemu prostemu zabiegowi dodanie kolejnego case’u walidacji lub usunięcie starego nie stanowi już problemu.

Myślę, że jeszcze jakieś zastosowania wstrzykiwania listy beanów by się znalazły, ale uważam, że ten na pewno warto znać i używać w swoim kodzie.

Cały kod z tego artykułu znajdziesz w tym repozytorium.