czwartek, 29 czerwca 2017

Na granicy systemu

Nie wiadomo czy to miejska legenda czy przypadek prawdziwy ale podobno w Indiach mają taką sztuczkę na łapanie małp : wsadzają banan w butelkę a butelkę zakopują. Teraz małpka wyciąga łapkę, łapie banan zaciskając paluszki i już łapka tam zostaje. Małpka banana nie puści i będzie tak czekała zaklinowana aż kłusownicy po nią przyjdą.

Przykład jest tak absurdalny, że doczekał się znaczenia metaforycznego kiedy to ludzie chwytają tak swoje myśli, które to wiążą ich w mentalnych klatkach. I ludzie ci tak będą czekać w tej klatce a myśli nie puszczą. Jaki to ma związek z dzisiejszym artykułem. W zasadzie żaden ale to fajna anegdota a zawsze te pierwsze akapity są najtrudniejsze.

Granice Systemu

Dzisiejszy artykuł sponsorują dwie funkcje :

  • toHex : String => Hex
  • createUser : Json => User
Obie w założeniu działają na granicy systemu czyli zupełnie niekontrolowany 'input' zamieniają w typ domenowy. Coś jak na rysunku

Hex i User... z pozoru mają ze sobą niewiele wspólnego - tak jak i obydwie funkcje toHex oraz createUser. Ale jest pewna wspólna rzecz, jedna niespodziewana i siejąca spustoszenie w systemach IT rzecz. Nawet bez zaglądania do ich definicji można z dużym prawdopodobieństwem rzecz - obydwie te funkcje kłamią!

Program jako funkcja całkowita

Ten akapit będzie streszczeniem innego artykułu : Program jako funkcja całkowita i generalnie chodzi o to, że bug w systemie jest wtedy jak myślimy że nasz program-funkcja jest zdefiniowany dla konkretnych danych wyjściowych a nie jest :( . Na przykład nasz program to aplikacja webowa przetwarzająca request w response czyli Request=>Response no i dla requestu z sqlinjection zachowanie poprawne nie było zdefiniowane - czyli na przykład wyświetlenie błędu - i całość zakończyła się katastrofą.

No i teraz jak mamy "miniprogram" Json => User to o ile dla każdego błędnego jsona nie zwracamy użytkownika Roman to to nie może działać!!!

Null object (czy coś takiego)

Kiedyś pamiętam był na tapecie taki koncept by nie zwracać z funkcji nuli tylko na przykład puste listy ,puste tablice, puste mapy, puste... puste k**wa cokolwiek. Potem pamiętam serię artykułów, że ludzie trochę zaczęli przekombinowywać i zwracać "puste" obiekty nie tylko gdy brakowało danych w bazie czy coś w ten deseń ale gdy np. wystąpił błąd. Jest to zamiatanie problemu pod dywan. Bardzo złe. Bardzo niedobre. Co możemy zwrócić jak jest zły JSON? No jeśli to nie jest na chama robiony CRUD to latający obiekt z "wrong variance" czy jak to się mówi po angielsku - spowoduje tylko kłopoty

Podobnie dla HEX -> "FFAA00" to dobry hex , "DUPA" to zły hex (acz trywialna implementacja String.getBytes obydwa zamieni w poprawny typ z poprawnymi pojedynczymi wartościami ale złym znaczeniem!!!) . I teraz weźmy dwie szkoły. Po pierwsze możemy rozszerzyć rezultat funkcji String => Option[Hex] czy JSON => Option[User]

Druga szkoła to dobrze znane nam wyjątki. Tutaj sztuczka polega na tym, że blok try-catch niejako ogranicza dziedzine funkcji ale w trochę nieintuicyjny sposób bo albo działamy na poprawnym zbiorze wartości i dostaniemy poprawny rezultat albo działamy na niepoprawnym zbiorze i ... zakrzywiamy czasoprzestrzeń lądując "kiedyś" i "gdzieś".

Problem z null

Na nulla możemy także spojrzeć z perspektywy funkcji częściowej i całkowitej. Otóż jeśli np. mam funkcje String => Option[Hex] i jest ona całkowita ze względu na każdy element zbioru String to można szybko zamienić ją na częściową wprowadzając null gdyż wtedy domena zamienia się na "każdy element zbioru string i do tego null" czyli aby znowu uzyskać funkcję całkowita potrzebujemy dodać dedykowaną obsługę nulla.

Czas ma znaczenie

Znak zapytania na ostatnim diagramie to bardzo często jakiś tam ErrorHandlingController. To jedno ale druga sprawa to, że nie za bardzo mamy czas pomyśleć bo to dzieje się teraz!! . Skok w try następuje od razu. W przypadku szkoły pierwszej wspomnianej wcześniej mamy pewien typ. Ten typ mamy tu i teraz i możemy się chwile zastanowić - on nie ucieknie. Innymi słowami try-catch to wykonanie instrukcji a Option[User] czy Option[Hex] to obliczenia w toku. Co więcej obliczenia, które nawet nie muszą być jeszcze wykonane jeśli mamy leniwy język!

No i mając ten Option mogę stworzyć jego dalsze przetwarzania przy pomocy dostępnych kompozycji jak option.orElse(otherOption) . Przy skokach w try oczywiście też mogę dać obsługę wyjątków ale zazwyczaj nie wynika taka operacja z typów co dla RuntimeException zwiększa ryzyko, że o tym zapomnimy faktycznie o obsłudze. No i oczywiście sam skok to nie jest jakaś zwracana wartość tylko no... "skok" także nie można tego za bardzo komponować co może zakończyć się tuzinem zagnieżdżonych klamerek przy bardziej wyszukanej obsłudze.

Szkoła dwa_i_pół

W GO popularne jest inne podejście. Często funkcja zwraca potencjalny rezultat i potencjalny błąd przez co jest całkowita.

func aleBezpieczneDzielenie(a, b float64) (float64, error) {
 if b == 0.0 {
  return 0.0,errors.New("oj neidobrze")
 } else {
  return a/b , nil
 }
} 
Problem z tym przykładem jest taki, że pomimo, iż potencjalne - to jednocześnie wartość i błąd musza być konkretne stad mimo wszystko zwracane jest 0.0 co jest dziwne - no i w sumie to ode mnie zależy czy błąd sprawdzę czy nie także nadal może się popsuć. Oczywiście nie znam tak dobrze jeszcze tego języka i być może jakaś opcja jeszcze gdzieś tam inna istnieje by to inaczej obsłużyć.

Szkoła trzecia - siła typów

Zaczęliśmy od A=>B by dojść do teoretycznie bezpieczniejszego A=>Option[B] czyli bezpiecznie przyjąć dane z zewnątrz systemu

  • intoSystem: String => Option[Hex]
  • intosystem: JSON => Option[User]

Ale co gdy mimo wszystko chcemy zachować funkcję String => Hex ? Cóż wtedy, wtedy cóż? Być może to jakaś funkcja biblioteczna a może po prostu nam pasuje tak jak jest? Zawsze można to zrobić to podejściem ze szkoły pierwszej czyli coś w stylu lift : (A=>B) => (A=>Option[B]) ale można też rozwiazac problem od drugiej strony ograniczając dziedzinę do tylko prawilnych User-JSONów lub Hex-Stringów!

Lub na jeszcze innym rysunku zobaczymy porównanie rozwiązania błędnego z funkcją częściową, funkcję całkowitą poprzez rozbudowanie domeny wyniku, oraz funkcję całkowitą poprzez ograniczenie domeny wejścia do bezpiecznego typu

W kodzie by to wyglądało mniej więcej tak:

val validate: String => Option[SafeHexString] = ...

val toHex : SafeHexString => Hex

validate(input).map(toHex)

Podsumowanie

Artykuł zaczęliśmy od śmiesznej anegdoty o małpce złapanej w butelkę (po która przyjdą kłusownicy - czyli może nie tak śmiesznej). Miało to nie być związane z samym artykułem ale jednak będzie. Metafora banana i trzymania się kurczowo nawyków. Gdy Optional pojawił się w Javie wielu programistów, z którymi miałem styczność traktował go jako takiego "wrapper na nulla". "Wrapper na nulla" należy do domeny Javy, być może dobrze czasem wznieść się na bardziej abstrakcyjny poziom by zobaczyć inną istotę używanych konstrukcji. Jeśli komuś nie pasuje metafora z małpką która będzie zjedzona w Indiach to inna opowieść to "Brzydkie Kaczątko" ale trzeba by ją trochę nagiąć by kaczątko stało się monadą niewykorzystanego potencjału.

8 komentarzy:

  1. "Ale co gdy mimo wszystko chcemy zachować funkcję String => Hex ?"
    w przykładzie nie udało nam się zachować tej funkcji, bo teraz mamy "SafeHexString => Hex" :(

    Co da nam rozbicie tego na "validate" i "convert"? tak czy inaczej zostajemy z tym Optionalem, trochę w innym miejscu, ale jednak.

    Czy np. gdybyśmy konwertowali JSONa do obiektu domenowego to musielibyśmy wykonać operacje przetwarzania dwa razy? Raz przy weryfikacji i raz przy konwersji?

    OdpowiedzUsuń
    Odpowiedzi
    1. W skali większego systemu możesz czasem chcieć oddzielić walidacje od samej konwersji i niekoniecznie muszą to być te same operacje (np. isNonEmpty może ograniczyć deomenę list do niepustych tak, że .head nie jebnie).No i jak masz optionala w innym miejscu to też mozesz komponować funkcje na innym poziomie. W tym artykule głównym celem było pokazanie, że masz taka możliwość i masz narzędzie więc gdy kiedyś napotkasz sytuację gdy nie możesz ruszyć sygnatury funkcji to możesz spróbować tego podejścia. A być może i samo teoretyczne rozważanie, że tak można zrobić przyniesie wartość edukacyjną

      Usuń
    2. Dodatkowo Rafał to się, może przydać jeśli chcesz "wpuścić" dane do systemu ale niekoniecznie od razu chcesz rozpoczynać konwersje i obliczenia. Coś takiego może wydarzyć się gdy to nie jest prosty request-response ale w momencie otrzymania sygnału/danych wejściowych przygotowujesz taki wstępny szablon do operacji, która zostanie wykonana później i w ramach tej operacji będziesz maił na przykład więcej informacji z runtime, które pozwolą ci lepiej wykonać konwersję.

      Przykład bardziej realny acz bardzo trywialny. Dostajesz na wejście inta z protokołem tekstowym. Wstępnie możesz sprawdzić czy to legalny int ale nie możesz stwierdzić, że to prawidłowe UserId bo może wcale takiego UserId nie ma.

      Usuń
  2. Mam takie wrażenie, że niepotrzebnie komplikujesz temat (albo ja źle zrozumiałem do czego to prowadzi). Jeżeli mamy funkcję, np. pierwiastek, to ona jest określona dla jakiejś dziedziny. Np. dla rzeczywistych >= 0 daje jakieś wyniki rzeczywiste. Jeżeli chcemy rozszerzyć dziedzinę na ujemne liczby, to musimy zwracać wyniki zespolone (czyli zmienił się typ zwracanej wartości, ale również część "obliczeniowa" funkcji). Jeżeli na wejściu podamy coś spoza dziedziny, to w przypadku obliczeń na papierze po prostu się zatrzymamy i zaczniemy odwijać stos, żeby sprawdzić gdzie się pomyliliśmy, czyli rzucamy wyjątek. A dalej to już tylko podejście do obsługi wyjątek, jeden rzuci kartkę w kąt (wyświetli durny komunikat użytkownikowi), inny znajdzie winnego (pomyłkę w obliczeniach, wzorach, kodzie) a użytkownik zobaczy tylko komunikat: "Twoje obliczenia się nie udały, ale nasi najlepsi ludzie pracują nad rozwiązaniem problemu."

    OdpowiedzUsuń
    Odpowiedzi
    1. "Mam takie wrażenie, że niepotrzebnie komplikujesz temat (albo ja źle zrozumiałem do czego to prowadzi). Jeżeli mamy funkcję, np. pierwiastek, to ona jest określona dla jakiejś dziedziny. Np. dla rzeczywistych >= 0 daje jakieś wyniki rzeczywiste."

      W swoim przykładzie ograniczyłeś cały system do prostej funkcji. Mi chodziło o samo wejście do systemu by upewnić się, że w samym systemie posługujesz się prawidłowymi typami. Co jeśli dostaniesz dane do systemu i częścią ich odbioru będzie kontakt z innym serwisem który zwróci ci złożony typ Future[Option[ValidationResult]] i teraz komponowanie tego z wyjątkami zacznie generować coraz większą złożoność kodu. Ja użyłem prostego przykładu i Ty użyłeś prostego przykładu ale musimy pamietać, ze systemy nie są proste jakoś do wyobrażeń trzeba dodać efekt skali. Jeśli trzymając się metafory będziemy mieli złożony system ksiegowy gdzie liczenie pierwiastków jest jedną z miliona funkcji i źle obsłużymy wyjątek co ogólnie da nam wynik z poprawnej dziedziny ale ze złą wartością to jeśli np. wywaliła się procedura licząca vat to może się to skończyć nie rzucaniem kartki w kąt a więzieniem :)

      Dlaczego operacje na poprawnych typach powinny pomóc nam się przed tym ustrzec? odpowiedź - kompilator. No i na pewno trudno będzie takie podejście obronić w prostych CRUDach bo tam ludzie od lat walą wyjątkami i jakoś to działa bo filozofii wielkiej nie ma. Jak już pojawią się bardziej wymagające obliczenia wtedy poprawność typów nabiera moim zdaniem znaczenia. Mam nadzieję, że ta odpowiedź pomogła.

      Usuń
    2. plus jeszcze w temacie bezpieczeństwa i poprawności typów może to będzie dla Ciebie ciekawe : http://pawelwlodarski.blogspot.com/2016/11/bezpieczny-numerek.html chociaż tam użyty jest dosyć egzotyczny język jako przykład.

      Usuń
  3. W praktyce często zamiast Option bardziej przydaje się Try albo Either. Mają tę zaletę, że pozwalają ci przekazać _co_ jest źle, a nie tylko że jest źle.

    Czasem różnica między "system leży", "zły format" a "brak uprawnień" ma znaczenie. Na niższym poziomie też się przydaje, żeby powiedzieć np. w którym dokładnie miejscu ten JSON się nie waliduje.

    OdpowiedzUsuń
    Odpowiedzi
    1. Zgadza się. W tym przykładzie użyłem Option jedynie dla uproszczenia przekazu edukacyjnego.

      Usuń