Java Developer? Przejdź na wyższy poziom wiedzy 🔥💪  Sprawdź

Team Leader? Podnieś efektywność swojego zespołu 👌 Sprawdź

Fail Fast 🔥 w Java

utworzone przez 21 marca 2021Clean Code, Java

Uwielbiam kod, który nie zaskakuje. Kiedy praca na modelu domenowym ułatwia rozwiązywanie problemu. Kiedy pojęcie w systemie jest spójne i nie czyhają na mnie dodatkowe pułapki związane z tym, że zaraz użyję istniejącego już fragmentu.

Właściwie w takim celu powstaje model dziedzinowy (domain model) – żeby za pomocą zdefiniowanych pojęć, które wyrażają: agregaty, parametry procesu lub jego wynik, pomogły mi ich użyć w przewidywalny sposób.

Dlatego wolę, żeby niewłaściwie użyty kod wywrócił się od razu, niż ukrył problem, którego skutki objawią się w innym, dalszym miejscu w procesie.

Takie podejście/technika nazywa się Fail fast.

Przykład: Rozważmy zastosowanie tej techniki na przykładzie naliczania rabatu dla całkowitej kwoty produktu, aby uzyskać kwotę końcową do zapłaty. Aby obliczyć końcową kwotę do zapłaty, należy od kwoty całkowitej odjąć kwotę rabatu.

W tym artykule przeczytasz o:

  • Fail fast w standardowej bibliotece Java
  • Heizenbugs
  • Fail fast w modelu domenowym
  • Fail fast na przykładzie nulla
  • Krytycznych danych aplikacji

Nie piszę tutaj o iteratorach kolekcji typu Fail Fast, tylko generalnie o zagadnieniu na paru przykładach.

Fail fast w standardowej bibliotece Java

Zaprezentuję przykład Fail Fast na obiekcie w standardowej bibliotece Java. Wiele z obiektów sprawdza parametry wejściowe podczas tworzenia obiektów. Rozważmy następujący przykład:

BigDecimal totalPrice = new BigDecimal("100.00");
BigDecimal discount = new BigDecimal("incorrect data");

BigDecimal finalPrice = totalPrice.subtract(discount);

Zdefiniowane zostały potrzebne obiekty: kwota całkowita (totalPrice) oraz kwota rabatu (discount). Kwoty zostały zaprezentowane jako obiekty BigDecimal. Na takich obiektach możemy przeprowadzić operację matematyczną, odejmując jedną kwotę od innej.

Problem pojawia się wtedy, kiedy jeden z obiektów zawiera niepoprawną wartość. Jeżeli by się tak stało, metoda substract nie powiodłaby się, ponieważ nie może operować na niepoprawnych danych. Co należy wtedy zrobić? Zgłosić wyjątek działania metody?

Kiedy uruchomisz powyższy fragment kodu, zauważysz następujący wyjątek zgłoszony w 2 linijce kodu:

java.lang.NumberFormatException: Character i is neither a decimal digit number, decimal point, nor „e” notation exponential mark.

Jest to przykład podejścia Fail fast, czyli zgłoszenia błędu jak najszybciej jest to możliwe.

Daje nam to następujące korzyści:

  • Błąd jest zgłaszany już przy próbie utworzenia niepoprawnego obiektu BigDecimal (np. z napisu, a nie z liczby).
  • Od momentu utworzenia obiektu, wszystkie operacje na nim wykonywane zwracają już poprawne wyniki. Nie trzeba sprawdzać wyników pośrednich.

Heizenbugs

Gdyby nie wprowadzić takiego sprawdzenia, wtedy problem wystąpiłby podczas wywołania metody substract. Kiedy ta metoda jest wykonywana? Jeżeli logika naliczania rabatu miałaby jakiś dodatkowy warunek (albo zestaw warunków), wtedy w jakiś sytuacjach błąd by się objawił, a w jakichś nie. Oznaczałoby to, że błąd jest ukryty. A to tylko prosta operacja na obiekcie (może domenowym?), a nie długotrwały proces wymagający interakcji z mniej stabilnym systemem.

Problemy, które czasem się objawiają, a czasem nie, są kłopotliwe do uchwycenia. Ukłuła się nawet na nie nazwa: Heizenbugs. Gdy się patrzy na system – to działa – a gdy się nie patrzynie działa. Zatem czy sam proces obserwacji ma wpływ na działanie systemu? (sakrzm)

Fail fast w domain model

Przykład z BigDecimal dotyczył samego formatu wartości. W modelu domenowym same wartości oprócz formatu mogą mieć pewne semantyczne znaczenie. Mowa wtedy o bogatej domenie (Rich Domain Model).

Rozważmy następujący przykład, naliczający procentowy rabat od całkowitej kwoty:

class PriceCalculator {

    private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);

    public BigDecimal calculateFinalPrice(BigDecimal totalPrice, int discountPercent) {
        BigDecimal discount = totalPrice.multiply(BigDecimal.valueOf(discountPercent)).divide(HUNDRED);
        return totalPrice.subtract(discount);
    }
}

Przyjmowana kwota całkowita (totalPrice) oraz procentowy rabat (discountPercent, wyrażony jako liczba od 0-100%). Aby wyliczyć kwotę rabatu, mnożymy całkowitą kwotę razy udzielony rabat (który musimy podzielić przez 100%) i wychodzi nam kwota rabatu. Tę kwotę odejmujemy od kwoty całkowitej. Proste. Piszemy test, który potwierdza, że kod działa:

@Test
void calculatesFinalPriceWithPercentDiscount() {
	BigDecimal totalPrice = new BigDecimal("100.00");
	int discountPercent = 20;

	BigDecimal finalPrice = calculator.calculateFinalPrice(totalPrice, discountPercent);

	assertThat(finalPrice)
			.isEqualByComparingTo("80");
}

Właściwie to mógłbym przyjąć taki parametr rabatu, że mógłby on troszkę napsuć w systemie:

@Test
void calculatesFinalPriceWithPercentDiscount() {
	BigDecimal totalPrice = new BigDecimal("100.00");
	int discountPercent = 200;

	BigDecimal finalPrice = calculator.calculateFinalPrice(totalPrice, discountPercent);
}

Przyjąłem procent rabatu 200. Kwestia parametru systemowego. Do zapłaty za zamówienie jest 100 PLN. Jaką wartość na finalPrice? Otóż ma wartość -100 PLN. Czyli co, ujemna sprzedaż?

Ten błąd mógłby słono kosztować, gdyby objawił się później w systemie księgowym, bo nie został sprawdzony na początku.

Dobrze, poprawny ten błąd i wprowadźmy drobną poprawkę, która sprawdzi, czy procent udzielonego rabatu jest poprawny, czyli zawiera się pomiędzy 0%, a 100%:

class PriceCalculator {

    private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);

    public BigDecimal calculateFinalPrice(BigDecimal totalPrice, int discountPercent) {
        validatePercentDiscount(discountPercent);

        BigDecimal discount = totalPrice.multiply(BigDecimal.valueOf(discountPercent)).divide(HUNDRED);
        return totalPrice.subtract(discount);
    }

    private static void validatePercentDiscount(int discountPercent) {
        if (discountPercent > 100 || discountPercent < 0) {
            throw new IllegalArgumentException("Discount should be between 0 and 100%. Actual value is: " + discountPercent + "%");
        }
    }
}

No i oczywiście testy:

@Test
void raisesInvalidPercentDiscountAbove100() {
	BigDecimal totalPrice = new BigDecimal("100.00");
	int discountPercent = 200;

	assertThatCode(() -> calculator.calculateFinalPrice(totalPrice, discountPercent))
			.isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Discount should be between 0 and 100%. Actual value is: 200%");
}

@Test
void raisesInvalidPercentDiscountBelow0() {
	BigDecimal totalPrice = new BigDecimal("100.00");
	int discountPercent = -50;

	assertThatCode(() -> calculator.calculateFinalPrice(totalPrice, discountPercent))
			.isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Discount should be between 0 and 100%. Actual value is: -50%");
}

Po sprawie? Po sprawie. Ale czekaj… niepoprawna wartość parametru rabatu nadal krąży sobie po systemie i sprawdzana jest tylko w momencie próby naliczenia tego rabatu. Czyli taki trochę Heizenbug.

Co w przypadku, kiedy wartość rabatu jest używana w innym miejscu w kodzie? Albo dopiero będzie, a ktoś zapomni sprawdzić tej wartości i problem objawi się ponownie w innym procesie?

Fail fast w modelu domenowym (Value Object)

Dlatego zastosujemy technikę Fail fast w naszym modelu dziedzinowym. Zdefiniujemy Value Object reprezentujący rabat (Discount). Zdefiniujemy regułę biznesową, która mówi o tym, że rabat procentowy może mieścić się od 0%-100%. Taka reguła będzie sprawdzana już przy tworzeniu obiektu, zatem wartość nie będzie krążyła po systemie:

class Discount {

    private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
    private final BigDecimal percent;

    Discount(int percent) {
        if (percent > 100 || percent < 0) {
            throw new IllegalArgumentException("Discount should be between 0 and 100%. Actual value is: " + percent + "%");
        }

        this.percent = BigDecimal.valueOf(percent);
    }

    public static Discount ofPercent(int percent) {
        return new Discount(percent);
    }

    public BigDecimal applyTo(BigDecimal value) {
        BigDecimal discount = value.multiply(percent).divide(HUNDRED);
        return value.subtract(discount);
    }
}

Oraz testy:

@Test
void appliesDiscountToPrice() {
	BigDecimal totalPrice = new BigDecimal("100.00");
	Discount discount = Discount.ofPercent(20);

	assertThat(discount.applyTo(totalPrice))
			.isEqualByComparingTo("80");
}

@Test
void validatesInvalidPercentDiscountAbove100() {
	assertThatCode(() -> Discount.ofPercent(200))
			.isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Discount should be between 0 and 100%. Actual value is: 200%");
}

@Test
void validatesInvalidPercentDiscountBelow0() {
	assertThatCode(() -> Discount.ofPercent(-50))
			.isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Discount should be between 0 and 100%. Actual value is: -50%");
}

Tym sposobem uniemożliwiliśmy utworzenie niepoprawnego obiektu reprezentującego rabat. Dzięki temu:

  • Logika związana z poprawnością rabatu zawarta jest w jednym miejscu. Nie trzeba powielać sprawdzania wartości w wielu miejscach.
  • Raz utworzony obiekt rabatu jest zawsze poprawny.
  • Extra benefit: rabat potrafi zrobić coś więcej, niż przechować wartość, np. przeliczyć kwotę

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

Fail fast na przykładzie nulla

W Java obiekty są przekazywane przez referencję, nie przez wartość. Oznacza to, że ktoś mógłby przekazać pustą referencję, albo przekazujemy ją nieświadomie z innego miejsca (np. z bazy danych, jeżeli wartość nie istnieje, jest zwracany null).

Swoją drugą, polecam przeczytać o One Billion Dollar Mistake.

Oprócz sprawdzenia poprawności obiektów domenowych można sprawdzić, czy przekazany obiekt nie jest nullem. Można by było zastosować następującą konstrukcję:

public BigDecimal applyTo(BigDecimal value) {
	Objects.requireNonNull(value, "value is null");

	BigDecimal discount = value.multiply(percent).divide(HUNDRED);
	return value.subtract(discount);
}

Zabezpieczamy się przed tym, że jakiś przekazany argument nie jest nullem. Czyli takie Fail Fast. Jeżeli ten argument byłby użyty jeszcze w jakiejś logice (instrukcje warunkowe) to można by powiedzieć, że pozbywamy się Heizenbugów.

Zastanówmy się jednak, co to podejście nam daje. Tak naprawdę kod zgłosi wyjątek jedynie dwie linijki wyżej. Gdyby nie było tego sprawdzenia, wywróciłby się dokładnie z takim skutkiem nieco dalej.

Chcąc się zabezpieczyć przed tego typu sytuacjami, musielibyśmy paranoicznie dodawać takie sprawdzenie dla każdego argumentu każdej metody w kodzie. Czy to by poprawiło czytelność? Nie.

Brak null w modelu domenowym?

Rozwiązaniem może być umówienie się w zespole, że ogóle nie zwracamy null w modelu dziedzinowym. Jeżeli coś faktycznie może nie istnieć, udekorujmy taką wartość w Optional, a każdą inną sytuację uznajemy za błąd do naprawy. Wtedy kod staje się bardzo prosty, zakładamy, że referencja istnieje.

Sytuacją, w której widzę zastosowanie sprawdzania to konstruowanie nowych obiektów, aby zapewnić ich spójność. Gdybyśmy tego nie zrobili, mógłby powstać niespójny obiekt. Na przykład:

class Price {
    private final BigDecimal value;

    Price(BigDecimal value) {
        this.value = requireNonNull(value, "value cannot be null");
    }
}

Dependency Injection

A co z Dependency Injection i sprawdzeniem wstrzykniętych referencji do konstruktora klasy typu Service, Controller itd? Są to elementy systemu, które rozwiązywanie zależności delegują do frameworka (Inversion of Control na poziomie konstruktora).

Chodzi o następujący przykład:

class BasketDetailsService {
    private final BasketRepository basketRepository;
    private final DiscountPolicy discountPolicy;
    private final CouponRepository couponRepository;

    public BasketDetailsService(BasketRepository basketRepository, DiscountPolicy discountPolicy, CouponRepository couponRepository) {
        this.basketRepository = requireNonNull(basketRepository, "basketRepository == null");
        this.discountPolicy = requireNonNull(discountPolicy, "discountPolicy == null");;
        this.couponRepository = requireNonNull(couponRepository, "couponRepository == null");;
    }
}

Tutaj również zalecam uspójnienie podejścia w projekcie/zespole i rozważenia wad i zalet.

Ja na to zagadnienie patrzę dość pragmatycznie. Jeżeli używany framework, którym posługujemy się do rozwiązywania zależności, weryfikuje, czy obiekty są powiązane/powstrzykiwane poprawnie – wtedy według mnie nie ma potrzeby weryfikowania tego ponownie. Byłaby to nadmiarowa część kodu.

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

Krytyczne dane dla aplikacji

Technikę Fail fast można stosować również dla dostępu do krytycznych danych aplikacji.

Dajmy na to, że podczas uruchamiania aplikacji na przykład:

  • pobierane są dane słownikowe ze źródeł danych
  • albo napełniany jest kluczowy cache, który pozwala szybko pracować na danych, albo przełączyć się na scenariusz alternatywny w przypadku awarii np. komunikacji z innym serwisem
  • albo brakuje jakiegoś ustawienia w pliku konfiguracyjnym

Wtedy może lepiej nie uruchomić aplikacji i zgłosić błąd, niż przyjąć dane, które w dalszej kolejności spowodują niestabilność obsługiwanych procesów.

Podsumowanie

Czym jest Fail fast

Fail fast to technika, w której błąd jest zgłaszany od razu w widoczny sposób. Dzięki temu w oprogramowaniu nie jest propagowany niepoprawny stan, który ukrywa problem. Nie trzeba sprawdzać przekazanych wartości w kodzie, podczas ich używania.

Jak mogę zastosować podejście Fail fast w Domain-Driven-Design

Pewne pojęcia domenowe można modelować jako obiekty wartości (Value Objects). Podczas ich tworzenia w konstruktorze, możesz sprawdzić ich semantyczne znaczenie za pomocą dostępnych reguł. Na przykład modelując procentową zniżkę, można sprawdzić, czy wartość zawiera się w przedziale od 0%-100% i nie dopuścić do utworzenia niepoprawnego obiektu.

Wpis który czytasz to zaledwie fragment wiedzy zawartej w Programie szkoleniowym Java Developera od SoftwareSkill. Mamy do przekazania sporo usystematyzowanej wiedzy z zakresu kluczowych kompetencji i umiejętności Java Developera. Program składa się z kilku modułów w cotygodniowych dawkach wiedzy w formie video.

Podoba Ci się ten artykuł? Weź więcej.

Jeżeli uważasz ten materiał za wartościowy i chcesz więcej treści tego typu – nie przegap ich i otrzymuj je prosto na swoją skrzynkę. Nawiążmy kontakt.

.

Gdybyś potrzebował jeszcze więcej:

Jesteś Java Developerem?

Przejdź na wyższy poziom wiedzy 
„Droga do Seniora” 🔥💪

Jesteś Team Leaderem? Masz zespół?

Podnieś efektywność i wiedzę swojego zespołu 👌

Dyskusja