Walidacja niestandardowych danych tekstowych z formularzy może wymagać od nas pisania skomplikowanych wyrażeń regularnych lub złożonych obiektów (Yup, Joi). Podatność na błędy takich rozwiązań rośnie wraz ze wzrostem stopnia skomplikowania danych wejściowych. Alternatywą wartą rozważenia mogą być parsery kombinatoryczne, które definiowane w sposób deklaratywny są dużo bardziej “plastyczne” i czytelne. W dalszej części artykułu spróbuję przedstawić Wam w przystępny sposób, jak taki parser można napisać i wykorzystać do rozwiązania problemu, na który moglibyście trafić w realnym projekcie.
Na pewno wiele osób zetknęło się z problemem polegającym na tym, że dane wejściowe, które należy zwalidować, nie poddawały się typowej weryfikacji jako isEmail(), isString().lengthMin(…).lengthMax(…), etc. Wtedy najczęściej sięgamy po wyrażenia regularne.
O ile korzystamy z regex-ów stosunkowo często i czujemy się z nimi w miarę pewnie, jest to całkiem sensowne rozwiązanie. Gorzej, jeśli używamy ich raz na pół roku i za każdym razem przypominamy sobie wszystko od nowa. Lub co jeszcze gorsze- kopiujemy jakieś wyrażenie regularne z sieci i modyfikujemy pod nasze wymagania. Bez zrozumienia co “siedzi pod spodem” jesteśmy de facto na kursie kolizyjnym np. z Catastrophic Backtrackingiem.o
Im bardziej złożony regex, tym mniej czytelny. Zwiększa to szansę pomyłki oraz wymaga większej ilości testów, które powinny pokryć jak najwięcej możliwych kombinacji wejściowych. Jeżeli format danych wejściowych lub wymagania często się zmieniają, wymusza to na nas wyrównywanie walidatorów i testów. Co w konsekwencji znowu zwiększa szansę pomyłki.
Opis problemu
Na potrzeby tego artykułu rozważmy następujący problem biznesowy. Użytkownik musi podać numer telefonu (stacjonarny lub inaczej geograficzny) w formacie przyjętym w Polsce. Format numeru musi spełniać kilka warunków:
– składa się z 9 cyfr
– cyfry są pogrupowane
– może zawierać prefix kierunkowy (w przypadku połączenia spoza kraju musi)
Wobec tego poprawny numer może przyjąć jedną z dwóch postaci:
– xx xxx xx xx
– +48 xx xxx xx xx
Dodatkowo załóżmy, że w przypadku gdy użytkownik poda niepoprawny numer, chcemy, aby otrzymał jak najbardziej precyzyjną informację o błędzie. Na przykład:
– 71 123 4567 – niepoprawnie pogrupowane cyfry
– 48 xx xxx xx xx – niepoprawny prefix kraju
– 71 123 – za krótki numer
Według tak zdefiniowanych wymagań wyrażenia regularne mogą nie być najlepszym rozwiązaniem. Podobnie jak wszelkie inne algorytmy, które szukają dopasowania wzorca w tekście, działają w sposób binarny (jest dopasowanie lub go nie ma). Nie informują nas natomiast o powodzie niedopasowania. Aby obejść ten problem, musielibyśmy najpierw napisać regex sprawdzający, czy istnieje dopasowanie. Jeśli nie to odpalać kolejne regex-y próbujące dopasować się do konkretnego kodu błędu.
Co więc zrobić?
Alternatywne podejście – parsery kombinatoryczne
Na ratunek mogą nam przyjść parsery kombinatoryczne. Formalna definicja mówi, że parser kombinatoryczny to funkcja wyższego rzędu, która jako argumenty przyjmuje jedną lub więcej funkcji będących parserami, a jako wynik zwraca ich “kombinację”. Można o tym myśleć, jak o elementarnych klockach (proste parsery), z których budujemy bardziej zaawansowany parser, wykorzystując do tego celu pewne kombinatory.
Aby móc zacząć sprawnie implementować nasze proste parsery i kombinatory potrzebujemy jeszcze odrobinę teorii. Nieodłącznym elementem związanym z parserami jest gramatyka, którą musimy zdefiniować, aby rozumieć, co konkretnie chcemy sparsować i według jakich reguł. Intuicyjnie gramatyką będziemy nazywali pewien zestaw reguł, które mówią nam, z jakich elementów składa się nasz język (opisując problem, tak naprawdę stosujemy pewien rodzaj języka domenowego). Istnieje kilka rodzajów stosowanej notacji, my zostańmy przy jednej z prostszych tj. notacji BNF.
Notacja operuje na symbolach, które mogą być symbolami terminalnymi lub nie, rozdzielonymi opcjonalnie znakiem “|” (jeżeli programowaliśmy w TypeScript od razu widzimy analogię do Union Type). Aby lepiej to zobrazować, spójrzmy, w jaki sposób możemy opisać kod pocztowy przy pomocy notacji BNF:
<zip_code> ::= <digit> <digit> “-” <digit> <digit> <digit> <digit> ::= “0” | “1” | “2” | “3” | “4” | “5” | “6” | “7” | “8” | “9”
Jak widać, zdefiniowaliśmy dwa symbole, pierwszy to <zip_code> (nieterminalny, bo po prawej stronie występują inne symbole) a drugi to <digit>, który może być jedną z 10 cyfr. Każda cyfra (w zasadzie to jej reprezentacja tekstowa) jest już symbolem terminalnym. Nie da się jej rozłożyć na nic prostszego.
Definiowanie gramatyki
Możemy teraz przełożyć nasz problem z numerami telefonów na gramatykę. Możliwości zrobienia tego jest oczywiście kilka. Możemy zacząć rozkładać symbole bardziej złożone na prostsze (podejście Top-Down) albo zaczynając od elementarnych cegiełek budować właśnie te złożone (Bottom-Up). Preferuję to pierwsze podejście i zgodnie z nim stworzyłem poniższą gramatykę:
<phoneNumber> ::= <fullNumber> | <basicNumber> <fullNumber> ::= <countryCode> <spaces> <basicNumber> <countryCode> ::= "+" “4” “8” <basicNumber> ::= <areaCode> <spaces> <threeDigits> <spaces> <twoDigits> <spaces> <twoDigits> <areaCode> ::= <twoDigits> <twoDigits> ::= <digit> <digit> <threeDigits> ::= <digit> <digit> <digit> <digit> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9“ <spaces> ::= " " <spaces> | ""
Jak widać, gramatyka jest nadal bardzo prosta. Dokonujemy dekompozycji symboli złożonych na coraz prostsze, aż osiągniemy symbole atomowe, których nie da się już bardziej rozłożyć.
Wyjaśnienia może wymagać symbol <spaces>, który jest zdefiniowany rekurencyjnie. Oznacza to, że białych znaków może być 0 lub więcej. Symbol “” jest symbolem terminalnym, aby przerwać naszą rekurencyjną definicję.
Pierwsze parsery i kombinatory
Przyjrzyjmy się teraz dwóm prostym parserom. Jeden potrafi parsować tylko symbol “5”, drugi jedynie symbol “7”. Bez wchodzenia w szczegóły implementacyjne (które znajdziecie na GitHub) użyjmy jeszcze dwóch kombinatorów: seq oraz oneOf. Ich nazwy mówią nam już dokładnie to, co realizują. Jeden z nich tworzy sekwencję parserów a drugi działa jak operator “choice” biorąc pierwsze dopasowanie.
export const fiveParser = charParser("5"); export const sevenParser = charParser("7"); export const fiveAndSevenParser = seq(fiveParser, sevenParser); export const fiveOrSevenParser = oneOf(fiveParser, sevenParser);
Możemy zatem w sposób deklaratywny definiować parsery wyższych rzędów, operując elementarnymi parserami. Pójdźmy teraz krok dalej i zmapujmy naszą gramatykę na parsery. Idąc od dołu, definiujemy atomowe parsery:
export const digitCharParser = oneOf( charParser("0"), charParser("1"), charParser("2"), charParser("3"), charParser("4"), charParser("5"), charParser("6"), charParser("7"), charParser("8"), charParser("9") ); export const spacesParser = many(charParser(" "));
Takie wynikowe parsery posłużą nam dalej jako dane wejściowe dla kombinatorów. W konsekwencji pozwoli nam to generować struktury jeszcze wyższych rzędów:
export const twoDigitsCharParser: ReadonlyArray<Parser<string>> = [ digitCharParser, digitCharParser, ]; export const threeDigitsCharParser: ReadonlyArray<Parser<string>> = [ digitCharParser, digitCharParser, digitCharParser, ]; export const twoDigitsParser = seq(...twoDigitsCharParser); export const threeDigitsParser = seq(...threeDigitsCharParser); export const areaCodeParser = twoDigitsParser; export const basicNumberParser = pipe( seq( areaCodeParser, spacesParser, threeDigitsParser, spacesParser, twoDigitsParser, spacesParser, twoDigitsParser ), map((arr: any): any => [].concat.apply([], arr)) ); export const countryCodeParser = seq( charParser("+"), charParser("4"), charParser("8") ); export const fullNumberParser = seq( countryCodeParser, spacesParser, basicNumberParser ); export const phoneNumberParser = oneOf(fullNumberParser, basicNumberParser);
Nie pozostaje nam nic innego jak finalny parser wykorzystać jako walidator. Na poniższym zrzucie ekranu z aplikacji widać udaną walidację przy pomocy ww. parsera kombinatorycznego:
Podsumowanie
Jak każde podejście tak i to ma wady i zalety.
Do niewątpliwych zalet należą na pewno:
– proste i bezpośrednie mapowanie gramatyki na parsery,
– oraz łatwość w rozszerzaniu i modyfikowaniu gramatyki czy parserów.
– Testowanie w izolacji
– i możliwość skorzystania z gotowych bibliotek, takich jak fp-ts, parser-ts, aby nie pisać skomplikowanych kombinatorów własnoręcznie.
Jeżeli chodzi o wady – wszystkie bazują na zaawansowaniu teoretycznym.
– Szczegóły implementacyjne kombinatorów mogą być trudne do zrozumienia.
– Wymagają dogłębnej znajomości paradygmatu funkcyjnego oraz takich pojęć jak monady, funktory czy rachunek lambda
– Kod będzie trudny w utrzymaniu, jeżeli członkowie zespołu nie są zaznajomieni z tematem.