To jest trzecia część z serii artykułów o Fail Fast. W części tej podam kilka podstawowych wskazówek na temat tego, gdzie moim zdaniem warto, a gdzie nie, stosować walidacje Fail Fast. W tym artykule kontynuuję temat zakładając, że zapoznałeś się z dwiema poprzednimi częściami tej serii. Jeżeli tego nie uczyniłeś, to proszę zrób to, zanim przejdziesz do kolejnego akapitu na tej stronie. Pierwszą część tej serii znajdziesz tutaj.
Gdzie warto dodawać walidacje Fail Fast?
Jak już wspomniałem w poprzednim artykule tej serii, im więcej walidacji Fail Fast dodamy do kodu, tym mniejsze bloki kodu mamy do przejrzenia w poszukiwaniu błędów. Tak więc im więcej walidacji Fail Fast, tym lepiej. Takie stwierdzenie jest jednak nieco zbyt ogólne. Spróbujmy je uszczegółowić.
Po pierwsze, jedną ze złotych zasad, którymi bezwzględnie kieruję się pisząc soft, jest następująca:
wszelkie obiekty, w tym encje i value objecty, z punktu widzenia klientów tych obiektów, muszą być zawsze spójne i w każdej chwili muszą mieć sens biznesowy
Oto przykład, co mam tutaj na myśli.
Załóżmy, że tworzymy system do wsparcia sprzedaży. Taki system musi umożliwiać m.in. wystawianie ofert handlowych. Na takich ofertach będzie definiowanych mnóstwo parametrów takich jak: kto jest autorem oferty, do kogo jest skierowana, jaki ma numer, itd., itd. Jednym z elementów takiej oferty są warunki płatności. W naszym systemie będą one pozwalały na określenie:
jaki procent całkowitej kwoty oferty zamawiający musi zapłacić dostawcy zanim ten w ogóle zabierze się za realizację zamówienia (tzw. przedpłata)
jaki procent całkowitej kwoty oferty zamawiający musi zapłacić dostawcy zanim ten wyśle towar zamawiającemu (tzw. płatność przed odbiorem towaru)
jaki procent całkowitej kwoty oferty zamawiający musi zapłacić dostawcy po odbiorze towaru
...i w jakim terminie musi to zrobić
Nasz system musi również umożliwić wystawianie drugiego typu dokumentów - jest to tzw. potwierdzenie przyjęcia zamówienia. Ten dokument ma bardzo wiele wspólnego z ofertą m.in. również warunki płatności. Tworzymy więc taki oto model obiektowy:
Jak widzisz jest tam klasa PaymentTerms, która reprezentuje warunki płatności. Zawiera ona cztery pola odpowiadające czterem parametrom wypunktowanym powyżej. Kod źródłowy tej klasy, jak na razie, wygląda tak:
public final class PaymentTerms {
private int prepayment;
private int beforeAcceptanceOfGoods;
private int afterAcceptanceOfGoods;
private Integer timeToPayAfterAcceptanceOfGoods;
}
Rozszerzmy tę klasę o konstruktor oraz metody aktualizujące wartości pól. Spójrz na poniższy kod i zastanów się czy jest poprawny.
public final class PaymentTerms {
private int prepayment;
private int beforeAcceptanceOfGoods;
private int afterAcceptanceOfGoods;
private Integer timeToPayAfterAcceptanceOfGoods;
public PaymentTerms(final int prepayment,
final int beforeAcceptanceOfGoods,
final int afterAcceptanceOfGoods,
final Integer timeToPayAfterAcceptanceOfGoods) {
this.prepayment = prepayment;
this.beforeAcceptanceOfGoods = beforeAcceptanceOfGoods;
this.afterAcceptanceOfGoods = afterAcceptanceOfGoods;
this.timeToPayAfterAcceptanceOfGoods =
timeToPayAfterAcceptanceOfGoods;
}
public void setPrepayment(final int prepayment) {
this.prepayment = prepayment;
}
public void setBeforeAcceptanceOfGoods(
final int beforeAcceptanceOfGoods) {
this.beforeAcceptanceOfGoods = beforeAcceptanceOfGoods;
}
public void setAfterAcceptanceOfGoods(
final int afterAcceptanceOfGoods) {
this.afterAcceptanceOfGoods = afterAcceptanceOfGoods;
}
public void setTimeToPayAfterAcceptanceOfGoods(
final Integer timeToPayAfterAcceptanceOfGoods) {
this.timeToPayAfterAcceptanceOfGoods =
timeToPayAfterAcceptanceOfGoods;
}
}
No cóż, ewidentnie łamie wyżej przytoczoną zasadę, która mówi, że wszelkie obiekty, w tym encje i value objecty, z punktu widzenia klientów tych obiektów, muszą być zawsze spójne i w każdej chwili muszą mieć sens biznesowy. Dlaczego łamie? Dlatego, że pozwala na coś takiego:
final PaymentTerms paymentTerms = new PaymentTerms(0, 0, 0, null);
paymentTerms.setPrepayment(30);
Po wykonaniu pierwszej linijki kodu obiekt paymentTerms jest niespójny. Reprezentuje bezsensowne warunki płatności, które biznesowo mają takie oto znaczenie: odbiorca oferty (dla uproszczenia nie będę dalej pisał, że tak samo dotyczy to potwierdzenia przyjęcia zamówienia) nie musi za produkty w ogóle nic płacić, gdyż przedpłata wynosi 0%, płatność przed odbiorem towaru wynosi 0% i płatność po odbiorze towaru wynosi 0%. Z tego przykładu wynika pierwsza bardzo precyzyjna zasada kiedy należy stosować walidacje Fail Fast.
Walidacje Fail Fast należy stosować zawsze w konstruktorach i/lub statycznych metodach tworzących.
Załóżmy więc, że zmodyfikowaliśmy kod konstruktora i dodaliśmy do niego walidacje argumentów wejściowych. Jego wywołanie, z wartościami jak wyżej, nie jest już teraz możliwe. Musimy więc poprawić kod korzystający z klasy PaymentTerms. Oto ten kod po zmianach:
final PaymentTerms paymentTerms = new PaymentTerms(100, 0, 0, null);
paymentTerms.setPrepayment(30);
// ups... obiekt paymentTerms właśnie przestał być spójny
W powyższym kodzie pierwsza linijka nie stanowi już problemu. Tworzymy tam bowiem spójny, biznesowo sensowny obiekt klasy PaymentTerms. Reprezentuje on warunki płatności, w których zamawiający ma obowiązek przekazania na rzecz dostawcy 100% wartości oferty w ramach przedpłaty.
Problemem staje się teraz druga linijka. W linijce tej, bowiem, rozspójniamy obiekt paymentTerms ustawiając wysokość przedpłaty na 30%. Gdzie więc podziało się pozostałe 70%? Przecież może się zdarzyć, że developer, w pośpiechu, zapomniał o ustawieniu pozostałych wartości - i co wtedy? Wtedy, w świat idzie obiekt paymentTerms, który zaniży wartość oferty o 70%.
Idźmy jednak dalej. Załóżmy, że nasz developer pamiętał o wywołaniu wszystkich setterów i popełnił taki oto kod:
final PaymentTerms paymentTerms = new PaymentTerms(100, 0, 0, null);
paymentTerms.setPrepayment(30);
// ups... obiekt paymentTerms właśnie przestał być spójny
paymentTerms.setBeforeAcceptanceOfGoods(50);
// wciąż nie jest spójny
paymentTerms.setAfterAcceptanceOfGoods(10);
// i wciąż
paymentTerms.setTimeToPayAfterAcceptanceOfGoods(30);
// i wciąż?... a miał być spójny, wywołaliśmy przecież
// wszystkie settery
Widzisz na czym polega błąd? Podpowiem Ci. Zsumuj wartości ustawione dla poszczególnych parametrów. Przedpłata: 30%, płatność przed odbiorem 50% i płatność po odbiorze 10%, łącznie.... 90%. A gdzie pozostałe 10%? Ups... mamy buga! Pomimo wywołania wszystkich setterów ostatecznie obiekt paymentTerms został rozspójniony!
Z tych dwóch przykładów wynika druga, bardzo precyzyjna zasada kiedy należy stosować walidacje Fail Fast.
Walidacje Fail Fast należy stosować zawsze we wszystkich metodach publicznych.
Chodzi po prostu o to, aby pomiędzy wywołaniami metod na danym obiekcie, obiekt ten był spójny i miał sens biznesowy.
W oddzielnym artykule uszczegółowimy co to znaczy metoda publiczna. Nie każda, bowiem, metoda prywatna jest... cóż,... jakby to powiedzieć... prywatna - niektóre są publiczne (bez obaw, jestem trzeźwy, wierz mi, wiem co piszę, to ma sens).
Wróćmy do kodu. Jak go teraz naprawić? Dodanie walidacji argumentów wejściowych w poszczególnych setterach niewiele nam pomoże. To dlatego, że wszystkie cztery wartości przechowywane przez klasę PaymentTerms są ze sobą powiązane:
prepayment, beforeAcceptanceOfGoods oraz afterAcceptanceOfGoods muszą sumować się do 100
timeToPayAfterAcceptanceOfGoods ma sens tylko jeżeli wartość afterAcceptanceOfGoods jest większa od zera
Proponuję zmienić API klasy PaymentTerms. Zamiast czterech setterów wprowadźmy dwa:
public void set(final int prepayment,
final int beforeAcceptanceOfGoods,
final int afterAcceptanceOfGoods,
final Integer timeToPayAfterAcceptanceOfGoods);
public void setTimeToPayAfterAcceptanceOfGoods(
final int timeToPayAfterAcceptanceOfGoods);
Oczywiście możliwe są i inne rozwiązania. Np. możemy zachować wszystkie cztery settery tak, jak były one zaimplementowane do tej pory, i do klasy PaymentTerms dodać metodę validate(), która sprawdziłaby swoją własną spójność. Możemy tak zrobić... ale jaką mamy gwarancję, że developerzy używający obiektów klasy PaymentTerms będą pamiętali o tym, aby wywołać metodę validate() po wywołaniu setterów? Powiem Ci jaką... żadnej. A wręcz przeciwnie - możemy być wręcz pewni, że częściej niż rzadziej nie będą pamiętali o jej wywołaniu.
Jak więc można poprawić powyższy kod. Zacznijmy od konstruktora. Jego deklaracja jest następująca:
public PaymentTerms(final int prepayment,
final int beforeAcceptanceOfGoods,
final int afterAcceptanceOfGoods,
final Integer timeToPayAfterAcceptanceOfGoods) {
this.prepayment = prepayment;
this.beforeAcceptanceOfGoods = beforeAcceptanceOfGoods;
this.afterAcceptanceOfGoods = afterAcceptanceOfGoods;
this.timeToPayAfterAcceptanceOfGoods =
timeToPayAfterAcceptanceOfGoods;
}
Teraz, w pierwszym kroku, zaczynamy weryfikować argumenty wejściowe. Zacznijmy od pierwszego: prepayment. Jakie wartości może przyjmować? Od 0 do 100. Dodajmy więc taką oto walidację:
public PaymentTerms(final int prepayment,
final int beforeAcceptanceOfGoods,
final int afterAcceptanceOfGoods,
final Integer timeToPayAfterAcceptanceOfGoods) {
// nowy kod
if ((prepayment < 0) || (prepayment > 100))
throw new IllegalArgumentException();
// kod ustawiający pola (pominięty ze względu na zwięzłość)
}
Dla zachowania zwięzłości przykładu konstruuję wyjątki za pomocą konstruktora bezargumentowego. W produkcyjnym kodzie, jednak, z pewnością skorzystałbym z konstruktora przyjmującego String i przekazałbym do niego treść nieco więcej mówiącą o przyczynie rzucenia wyjątku.
Teraz zajmijmy się drugim i trzecim argumentem, czyli odpowiednio: beforeAcceptanceOfGoods oraz afterAcceptanceOfGoods.
public PaymentTerms(final int prepayment,
final int beforeAcceptanceOfGoods,
final int afterAcceptanceOfGoods,
final Integer timeToPayAfterAcceptanceOfGoods) {
if ((prepayment < 0) || (prepayment > 100))
throw new IllegalArgumentException();
// nowy kod
if ((beforeAcceptanceOfGoods < 0) || (beforeAcceptanceOfGoods > 100))
throw new IllegalArgumentException();
if ((afterAcceptanceOfGoods < 0) || (afterAcceptanceOfGoods > 100))
throw new IllegalArgumentException();
// kod ustawiający pola (pominięty ze względu na zwięzłość)
}
A teraz, skoro już wiemy, że każdy z tych argumentów ma poprawną wartość, sprawdźmy czy one łącznie mają sens. Musimy więc sprawdzić, czy suma tych argumentów równa się 100. Dodajemy więc kolejną walidację.
public PaymentTerms(final int prepayment,
final int beforeAcceptanceOfGoods,
final int afterAcceptanceOfGoods,
final Integer timeToPayAfterAcceptanceOfGoods) {
if ((prepayment < 0) || (prepayment > 100))
throw new IllegalArgumentException();
if ((beforeAcceptanceOfGoods < 0) || (beforeAcceptanceOfGoods > 100))
throw new IllegalArgumentException();
if ((afterAcceptanceOfGoods < 0) || (afterAcceptanceOfGoods > 100))
throw new IllegalArgumentException();
// nowy kod
if ((prepayment
+ beforeAcceptanceOfGoods + afterAcceptanceOfGoods) != 100)
throw new IllegalArgumentException();
// kod ustawiający pola (pominięty ze względu na zwięzłość)
}
Teraz czas na kolejny argument: timeToPayAfterAcceptanceOfGoods. Oznacza on w ile dni, po otrzymaniu towaru, zamawiający musi dokonać płatności na rzecz dostawcy. Logiczne jest więc, że:
wartość ta nie może być mniejsza od zera
wartość ta me sens jedynie wówczas kiedy w argumencie afterAcceptanceOfGoods otrzymaliśmy wartość większą od zera
Tak więc walidacja tego argumentu będzie wyglądała następująco:
public PaymentTerms(final int prepayment,
final int beforeAcceptanceOfGoods,
final int afterAcceptanceOfGoods,
final Integer timeToPayAfterAcceptanceOfGoods) {
if ((prepayment < 0) || (prepayment > 100))
throw new IllegalArgumentException();
if ((beforeAcceptanceOfGoods < 0) || (beforeAcceptanceOfGoods > 100))
throw new IllegalArgumentException();
if ((afterAcceptanceOfGoods < 0) || (afterAcceptanceOfGoods > 100))
throw new IllegalArgumentException();
if ((prepayment
+ beforeAcceptanceOfGoods + afterAcceptanceOfGoods) != 100)
throw new IllegalArgumentException();
// nowy kod
if (afterAcceptanceOfGoods > 0) {
if (timeToPayAfterAcceptanceOfGoods == null)
throw new IllegalArgumentException();
if (timeToPayAfterAcceptanceOfGoods < 0)
throw new IllegalArgumentException();
} else {
if (timeToPayAfterAcceptanceOfGoods != null)
throw new IllegalArgumentException()
}
this.prepayment = prepayment;
this.beforeAcceptanceOfGoods = beforeAcceptanceOfGoods;
this.afterAcceptanceOfGoods = afterAcceptanceOfGoods;
this.timeToPayAfterAcceptanceOfGoods =
timeToPayAfterAcceptanceOfGoods;
}
Przypuszczam, że niektórzy z czytających ten artykuł, w tym właśnie miejscu pomyślą sobie mniej więcej tak: "no nie, to jakiś chory pomysł, kto ma czas na pisanie takiej ilości kodu". Jeżeli jesteś jedną z tych osób, mam dla Ciebie taką oto odpowiedź. Napisanie tych walidacji zajęło mi 1 minutę i 47 sekund - to niewiele, biorąc pod uwagę, że być może właśnie uniknąłem scenariusza podobnego do tego, jaki opisałem w pierwszej części tej serii, że być może właśnie oszczędziłem:
komuś zakładania zgłoszenia w issue trackerze (zadanie to na pewno zajęło więcej niż 1 minutę i 47 sekund)
testerom odtworzenia błędu
sobie i innym developerom siedzenia w stresie i bugfixowania (może po godzinach, może w weekend - kto wie)
sobie i administratorom bazy danych poprawiania danych w bazie, a także wszelkich stresów z tym związanych (wierz mi... nie robi się bez bólu żołądka modyfikacji w bazach danych, których rozmiar wyraża się w dziesiątkach terabajtów)
testerom testów regresji aplikacji (oj! te to na pewno zajęły więcej niż 1 minutę i 47 sekund)
release managerom wydawania wersji na kolejne środowiska
kierownikowi projektu krzyków klienta
użytkownikom systemu siedzenia po godzinach, żeby nadrobić czas, który stracili w oczekiwaniu na poprawę błędu
Nieźle jak na mniej niż dwie minuty pracy. Moim zdaniem warto.
Zajmijmy się teraz metodą set. Ona również musi zweryfikować argumenty wejściowe. Sprawa jest tutaj prosta. Walidacje z konstruktora przeniesiemy do metody set i wywołamy tę metodę w konstruktorze - kod będzie wyglądał tak:
public PaymentTerms(final int prepayment,
final int beforeAcceptanceOfGoods,
final int afterAcceptanceOfGoods,
final Integer timeToPayAfterAcceptanceOfGoods) {
// nowy kod
set(prepayment, beforeAcceptanceOfGoods, afterAcceptanceOfGoods,
timeToPayAfterAcceptanceOfGoods);
}
// nowy kod
public void set(final int prepayment,
final int beforeAcceptanceOfGoods,
final int afterAcceptanceOfGoods,
final Integer timeToPayAfterAcceptanceOfGoods) {
// walidacje przeniesione z konstruktora
if ((prepayment < 0) || (prepayment > 100))
throw new IllegalArgumentException();
if ((beforeAcceptanceOfGoods < 0) || (beforeAcceptanceOfGoods > 100))
throw new IllegalArgumentException();
if ((afterAcceptanceOfGoods < 0) || (afterAcceptanceOfGoods > 100))
throw new IllegalArgumentException();
if ((prepayment
+ beforeAcceptanceOfGoods + afterAcceptanceOfGoods) != 100)
throw new IllegalArgumentException();
if (afterAcceptanceOfGoods > 0) {
if (timeToPayAfterAcceptanceOfGoods == null)
throw new IllegalArgumentException();
if (timeToPayAfterAcceptanceOfGoods < 0)
throw new IllegalArgumentException();
} else {
if (timeToPayAfterAcceptanceOfGoods != null)
throw new IllegalArgumentException();
}
this.prepayment = prepayment;
this.beforeAcceptanceOfGoods = beforeAcceptanceOfGoods;
this.afterAcceptanceOfGoods = afterAcceptanceOfGoods;
this.timeToPayAfterAcceptanceOfGoods =
timeToPayAfterAcceptanceOfGoods;
}
Do tej pory zajmowaliśmy się tylko walidacją argumentów wejściowych. Walidacja stanu obiektu nie miała bowiem sensu ani w konstruktorze (to oczywiste, w czasie konstrukcji obiektu ciężko mówić o tym, żeby miał on jakikolwiek stan), ani w metodzie set, która nadpisuje wszystkie pola klasy. Sprawa ma się jednak inaczej z metodą setTimeToPayAfterAcceptanceOfGoods.
Metoda ta przyjmuje w argumencie liczbę oznaczającą w ile dni, po otrzymaniu towaru, zamawiający musi dokonać płatności na rzecz dostawcy. Wywołanie tej metody ma sens tylko wówczas jeżeli pole afterAcceptanceOfGoods jest ustawione na wartość inną niż zero. Tak więc implementacja tej metody, zgodna z techniką Fail Fast, mogłaby wyglądać następująco:
public void setTimeToPayAfterAcceptanceOfGoods(
final int timeToPayAfterAcceptanceOfGoods) {
// walidacja argumentów
if (timeToPayAfterAcceptanceOfGoods < 0)
throw new IllegalArgumentException();
// walidacja stanu
if (this.afterAcceptanceOfGoods == 0)
throw new IllegalStateException();
this.timeToPayAfterAcceptanceOfGoods =
timeToPayAfterAcceptanceOfGoods;
}
OK, podsumujmy sobie krótko to, co dotychczas ustaliliśmy. Ustaliliśmy, bowiem, dwie bardzo jednoznaczne zasady kiedy warto stosować technikę Fail Fast. Oto one:
zawsze w konstruktorach i/lub statycznych metodach tworzących
zawsze we wszystkich metodach publicznych
Kolejne pytanie brzmi...
Gdzie nie warto dodawać walidacji Fail Fast?
Nie ma prostej odpowiedzi na to pytanie. Nie ma jasnej granicy, która mówiłaby gdzie takiej walidacji nie warto robić. Kiedy piszę soft z reguły nie robię walidacji Fail Fast w metodach prywatnych. Dlaczego?
Po pierwsze w metodach prywatnych walidacja stanu obiektu często nie ma sensu. To dlatego, że metoda prywatna jest wywoływana jako jedna z instrukcji w ciele metody publicznej. A w metodzie publicznej spójność obiektu jest gwarantowana jedynie na jej wejściu i wyjściu. Zadaniem metody publicznej (o ile nie jest to metoda read-only) jest przekształcenie obiektu z jednego spójnej stanu do drugiego spójnego stanu. Po drodze jednak, w czasie tego przekształcania, stan obiektu jest niespójny. Jeżeli w trakcie takiej transformacji zostanie wywołana metoda prywatna to nie może ona czynić zbyt wielu założeń co do spójności stanu obiektu, na którym została wywołana.
Po drugie dlatego, że kiedy piszę soft ufam sobie. Wyobraź sobie, że sytuacja wygląda jak na poniższym diagramie.
Ty jesteś autorem klasy PaymentTerms. Kiedy ją pisałeś, wiedziałeś co chcesz osiągnąć. Miałeś jasny cel istnienia tej klasy, rozumiałeś co ma robić, jakie ma ograniczenia i wreszcie jak ją zaimplementować. Ponieważ jesteś autorem tej klasy, to Ty jesteś za nią odpowiedzialny. Jeżeli ta klasa zrobić coś nie tak, lub choćby padnie cień podejrzenia, że klasa ta nie zadziałała tak, jak powinna, to do Ciebie przyjdą koledzy z zespołu ze skargą.
Wacek i Zdzisiek są skupieni na innych klasach, na swoich klasach. Są zarobieni, nie mają czasu. Nie łudzisz się, że będą wnikać w to, jak Twoja klasa jest napisana. Nie liczysz na to, że przeczytają Javadoc, który pisałeś przez tydzień. Z Twojego punktu widzenia Wacek i Zdzisiek są wrogami Twojej klasy - prędzej, czy później użyją jej w sposób niewłaściwy. Twoim zadaniem jest chronić tę klasę (a co za tym idzie i siebie) przed nimi. Nie musisz chronić swojego kodu przed sobą. Oczywiście możesz to zrobić, możesz dodawać walidacje argumentów (a nawet stanu) do metod prywatnych - ja jednak tego nie robię.
Jest jeszcze jeden powód. Jest nim rozsądek w połączeniu z doświadczeniem. Wyobraź sobie dwie skrajności. Z jednej strony masz kod bez jakichkolwiek walidacji Fail Fast. Z drugiej strony masz kod, w których 80% kodu to walidacje Fail Fast - są wszędzie,weryfikujesz argumenty, stany obiektów w metodach publicznych i prywatnych, sprawdzasz stan encji zaraz po zbudowaniu jej na podstawie danych z bazy danych, w metodach weryfikujesz poprawność wyników przychodzących z innych metod wywołanych na innych obiektach. Naprawdę, wszędzie, gdzie to tylko możliwe, sadzisz walidacje Fail Fast.
Pierwszy kod jest niebezpieczny i trudny w bugfixingu. Drugi jest nieczytelny, a jego development zajmuje wiele czasu. Teraz wyobraź sobie suwak między dwoma skrajnościami. Gdzie chcesz go ustawić? Ustawienie suwaka na którejkolwiek ze skrajności jest na pewno złym pomysłem. Trzeba wybrać jakąś pozycję w środku, najlepiej "złotą" pozycję (cokolwiek to znaczy). Dla mnie przebiega ona właśnie tutaj:
umieszczam walidacje Fail Fast w konstruktorach, statycznych metodach tworzących i w metodach publicznych
nie dodaję walidacje Fail Fast do metod prywatnych
Ty, oczywiście, możesz tę granicę ustawić w innym miejscu.
Co dalej?
No cóż, pojawi się jeszcze jeden artykuł w tej serii. Opiszę w nim kilka bardzo ciekawych przypadków, kiedy dodanie walidacji Fail Fast jest po prostu kluczowe. Tymczasem... happy coding!
"One more thing..."
Ten i inne artykuły piszę po to, żeby podzielić się swoją wiedzą i doświadczeniem z innymi. Jednakże napisanie artykułu to dopiero połowa sukcesu - drugą połowę stanowi dotarcie z treścią do innych. Możesz mi bardzo pomóc w tym zadaniu - wystarczy, że klikniesz w jedną z czterech ikon poniżej i udostępnisz innym link do tego posta. Za tę pomoc będę Ci bardzo wdzięczny.
Comments