Wykorzystanie programowania deklaratywnego do tworzenia aplikacji internetowych, które można konserwować
Opublikowany: 2022-03-11W tym artykule pokażę, w jaki sposób rozsądne przyjęcie technik programowania w stylu deklaratywnym może umożliwić zespołom tworzenie aplikacji internetowych, które są łatwiejsze w rozbudowie i utrzymaniu.
„… programowanie deklaratywne to paradygmat programowania, który wyraża logikę obliczenia bez opisywania jego przepływu sterowania”. —Remo H. Jansen, praktyczne programowanie funkcjonalne z użyciem TypeScript
Podobnie jak w przypadku większości problemów z oprogramowaniem, decyzja o użyciu deklaratywnych technik programowania w aplikacjach wymaga starannej oceny kompromisów. Sprawdź jeden z naszych poprzednich artykułów, aby zapoznać się z dogłębną dyskusją na ich temat.
Tutaj koncentrujemy się na tym, jak deklaratywne wzorce programowania mogą być stopniowo adaptowane zarówno dla nowych, jak i istniejących aplikacji napisanych w JavaScript, języku obsługującym wiele paradygmatów.
Najpierw omówimy, jak używać TypeScript na zapleczu i interfejsie, aby kod był bardziej ekspresyjny i odporny na zmiany. Następnie badamy maszyny skończone (FSM), aby usprawnić rozwój front-end i zwiększyć zaangażowanie interesariuszy w proces rozwoju.
FSM nie są nową technologią. Zostały odkryte prawie 50 lat temu i są popularne w branżach takich jak przetwarzanie sygnałów, aeronautyka i finanse, gdzie poprawność oprogramowania może mieć krytyczne znaczenie. Są również bardzo dobrze dopasowane do problemów modelowania, które często pojawiają się we współczesnym tworzeniu stron internetowych, takich jak koordynowanie złożonych asynchronicznych aktualizacji stanów i animacji.
Ta korzyść wynika z ograniczeń w sposobie zarządzania państwem. Automat stanów może znajdować się jednocześnie tylko w jednym stanie i ma ograniczone sąsiednie stany, do których może przechodzić w odpowiedzi na zdarzenia zewnętrzne (takie jak kliknięcia myszą lub odpowiedzi pobierania). Rezultatem jest zwykle znacznie zmniejszony odsetek wad. Jednak podejścia FSM mogą być trudne do skalowania, aby działały dobrze w dużych aplikacjach. Najnowsze rozszerzenia FSM, zwane wykresami stanów, umożliwiają wizualizację złożonych FSM i skalowanie ich do znacznie większych aplikacji, na czym skupia się ten artykuł. Do naszej demonstracji użyjemy biblioteki XState, która jest jednym z najlepszych rozwiązań dla FSM i wykresów stanów w JavaScript.
Deklaratywny na zapleczu z Node.js
Programowanie zaplecza serwera WWW przy użyciu podejść deklaratywnych jest dużym tematem i zazwyczaj zaczyna się od oceny odpowiedniego języka programowania funkcjonalnego po stronie serwera. Zamiast tego załóżmy, że czytasz to w czasie, gdy już wybrałeś (lub rozważasz) Node.js dla swojego zaplecza.
W tej sekcji szczegółowo opisano podejście do modelowania jednostek na zapleczu, które ma następujące zalety:
- Poprawiona czytelność kodu
- Bezpieczniejsza refaktoryzacja
- Potencjał poprawy wydajności ze względu na gwarancje, jakie zapewnia modelowanie typu
Gwarancje zachowania dzięki modelowaniu typu
JavaScript
Rozważ zadanie wyszukania danego użytkownika po jego adresie e-mail w JavaScript:
function validateEmail(email) { if (typeof email !== "string") return false; return isWellFormedEmailAddress(email); } function lookupUser(validatedEmail) { // Assume a valid email is passed in. // Safe to pass this down to the database for a user lookup.. }
Ta funkcja akceptuje adres e-mail jako ciąg znaków i zwraca odpowiedniego użytkownika z bazy danych w przypadku dopasowania.
Założenie jest takie, że lookupUser()
zostanie wywołana dopiero po wykonaniu podstawowej walidacji. To jest kluczowe założenie. A co, jeśli kilka tygodni później zostanie przeprowadzona refaktoryzacja i to założenie nie jest już aktualne? Trzymamy kciuki, aby testy jednostkowe wyłapały błąd, albo możemy wysyłać niefiltrowany tekst do bazy danych!
TypeScript (pierwsza próba)
Rozważmy odpowiednik funkcji walidacji w języku TypeScript:
function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }
Jest to niewielkie ulepszenie, ponieważ kompilator TypeScript uchronił nas przed dodaniem dodatkowego kroku walidacji środowiska wykonawczego.
Gwarancje bezpieczeństwa, które może przynieść mocne pisanie, nie zostały jeszcze wykorzystane. Przyjrzyjmy się temu.
TypeScript (druga próba)
Poprawmy bezpieczeństwo typów i zabrońmy przekazywania nieprzetworzonych ciągów jako danych wejściowych do looukupUser
:
type ValidEmail = { value: string }; function validateEmail(input: string): Email | null { if (!isWellFormedEmailAddress(input)) return null; return { value: email }; } function lookupUser(email: ValidEmail): User { // No need to perform validation. Compiler has already ensured only valid emails have been passed in. return lookupUserInDatabase(email.value); }
Tak jest lepiej, ale jest kłopotliwe. Wszystkie zastosowania ValidEmail
dostęp do rzeczywistego adresu za pośrednictwem adresu email.value
. TypeScript wykorzystuje typowanie strukturalne , a nie typowanie nominalne stosowane w językach takich jak Java i C#.
Chociaż potężny, oznacza to, że każdy inny typ, który jest zgodny z tym podpisem, jest uważany za równoważny. Na przykład następujący typ hasła może zostać przekazany do lookupUser()
bez skargi ze strony kompilatora:
type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.
TypeScript (trzecia próba)
Możemy uzyskać nominalne pisanie w TypeScript za pomocą przecięcia:
type ValidEmail = string & { _: "ValidEmail" }; function validateEmail(input: string): ValidEmail { // Perform email validation checks.. return input as ValidEmail; } type ValidPassword = string & { _: "ValidPassword" }; function validatePassword(input: string): ValidPassword { ... } lookupUser("[email protected]"); // Error: expected type ValidEmail. lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail. lookupUser(validateEmail("[email protected]")); // Ok.
Osiągnęliśmy teraz cel, że do lookupUser()
mogą być przekazywane tylko zweryfikowane ciągi e-maili.
Pro Tip: Zastosuj ten wzór łatwo, korzystając z następującego typu pomocnika:
type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;
Plusy
Silnie wpisując encje w Twojej domenie, możemy:
- Zmniejsz liczbę kontroli, które należy wykonać w czasie wykonywania, które pochłaniają cenne cykle procesora serwera (chociaż bardzo niewielka ilość, sumują się one podczas obsługi tysięcy żądań na minutę).
- Utrzymuj mniej podstawowych testów ze względu na gwarancje zapewniane przez kompilator TypeScript.
- Skorzystaj z refaktoryzacji wspomaganej przez edytor i kompilator.
- Popraw czytelność kodu dzięki lepszemu stosunkowi sygnału do szumu.
Cons
Modelowanie typów wiąże się z pewnymi kompromisami do rozważenia:
- Wprowadzenie TypeScript zwykle komplikuje łańcuch narzędzi, prowadząc do dłuższych czasów kompilacji i wykonania zestawu testów.
- Jeśli Twoim celem jest stworzenie prototypu funkcji i jak najszybsze przekazanie jej w ręce użytkowników, dodatkowy wysiłek wymagany do jawnego modelowania typów i propagowania ich w kodzie może nie być tego wart.
Pokazaliśmy, jak istniejący kod JavaScript na serwerze lub współdzielonej warstwie walidacji back-end/front-end można rozszerzyć o typy, aby poprawić czytelność kodu i umożliwić bezpieczniejszą refaktoryzację — ważne wymagania dla zespołów.
Deklaratywne interfejsy użytkownika
Interfejsy użytkownika opracowane przy użyciu technik programowania deklaratywnego skupiają wysiłek na opisaniu „co” zamiast „jak”. Dwa z trzech głównych podstawowych składników sieci, CSS i HTML, to deklaratywne języki programowania, które przetrwały próbę czasu i ponad miliard stron internetowych.
React był open-source udostępniony przez Facebooka w 2013 roku i znacząco zmienił kierunek rozwoju front-endu. Kiedy po raz pierwszy go użyłem, podobało mi się, jak mogę zadeklarować GUI jako funkcję stanu aplikacji. Mogłem teraz komponować duże i złożone interfejsy użytkownika z mniejszych bloków konstrukcyjnych bez zajmowania się niechlujnymi szczegółami manipulacji DOM i śledzenia, które części aplikacji wymagają aktualizacji w odpowiedzi na działania użytkownika. Mógłbym w dużej mierze zignorować aspekt czasu podczas definiowania interfejsu użytkownika i skupić się na zapewnieniu prawidłowego przejścia mojej aplikacji z jednego stanu do drugiego.
Aby osiągnąć prostszy sposób tworzenia interfejsów użytkownika, React umieścił warstwę abstrakcji między programistą a maszyną/przeglądarką: wirtualny DOM .
Inne nowoczesne frameworki internetowego interfejsu użytkownika również wypełniły tę lukę, choć na różne sposoby. Na przykład, Vue wykorzystuje reaktywność funkcjonalną poprzez pobieranie/ustawianie JavaScript (Vue 2) lub proxy (Vue 3). Svelte zapewnia reaktywność poprzez dodatkowy etap kompilacji kodu źródłowego (Svelte).
Te przykłady zdają się wskazywać, że w naszej branży istnieje wielkie pragnienie zapewnienia programistom lepszych, prostszych narzędzi do wyrażania zachowania aplikacji za pomocą podejść deklaratywnych.
Deklaratywny stan i logika aplikacji
Podczas gdy warstwa prezentacji nadal kręci się wokół jakiejś formy HTML (np. JSX w React, szablony oparte na HTML znalezione w Vue, Angular i Svelte), postuluję, że problem modelowania stanu aplikacji w sposób, który jest łatwo zrozumiałe dla innych programistów i możliwe do utrzymania w miarę rozwoju aplikacji, wciąż nierozwiązane. Dowodem na to jest mnożenie się bibliotek państwowych i metod zarządzania, które trwają do dziś.
Sytuację komplikują rosnące oczekiwania wobec nowoczesnych aplikacji internetowych. Niektóre pojawiające się wyzwania, które współczesne podejście do zarządzania państwem musi wspierać:
- Aplikacje do pracy w trybie offline korzystające z zaawansowanych technik subskrypcji i buforowania
- Zwięzły kod i ponowne wykorzystanie kodu w celu uzyskania coraz mniejszych wymagań dotyczących rozmiaru pakietu
- Zapotrzebowanie na coraz bardziej wyrafinowane wrażenia użytkowników dzięki animacjom o wysokiej wierności i aktualizacjom w czasie rzeczywistym
(Ponowne) pojawienie się maszyn skończonych i map stanowych
Maszyny skończone są szeroko stosowane do tworzenia oprogramowania w niektórych branżach, w których niezawodność aplikacji ma kluczowe znaczenie, takich jak lotnictwo i finanse. Stale zyskuje również na popularności w przypadku front-endowego tworzenia aplikacji internetowych dzięki, na przykład, doskonałej bibliotece XState.
Wikipedia definiuje maszynę skończoną jako:
Abstrakcyjna maszyna, która w danym momencie może znajdować się dokładnie w jednym ze skończonej liczby stanów. FSM może zmieniać się z jednego stanu w inny w odpowiedzi na niektóre zewnętrzne dane wejściowe; przejście z jednego stanu do drugiego nazywa się przejściem. FSM jest zdefiniowany przez listę jego stanów, stan początkowy i warunki dla każdego przejścia.
I dalej:
Stan to opis stanu systemu oczekującego na wykonanie przejścia.
FSM w swojej podstawowej formie nie skalują się dobrze do dużych systemów ze względu na problem wybuchu stanu. Ostatnio stworzono schematy stanów UML w celu rozszerzenia FSM o hierarchię i współbieżność, które umożliwiają szerokie zastosowanie FSM w zastosowaniach komercyjnych.

Zadeklaruj logikę aplikacji
Po pierwsze, jak FSM wygląda jako kod? Istnieje kilka sposobów implementacji automatu skończonego w JavaScript.
- Maszyna skończona jako instrukcja przełącznika
Oto maszyna opisująca możliwe stany, w których może znajdować się JavaScript, zaimplementowana za pomocą instrukcji switch:
const initialState = { type: 'idle', error: undefined, result: undefined }; function transition(state = initialState, action) { switch (action) { case 'invoke': return { type: 'pending' }; case 'resolve': return { type: 'completed', result: action.value }; case 'error': return { type: 'completed', error: action.error ; default: return state; } }
Ten styl kodu będzie znany programistom, którzy korzystali z popularnej biblioteki zarządzania stanami Redux.
- Maszyna skończona jako obiekt JavaScript
Oto ta sama maszyna zaimplementowana jako obiekt JavaScript przy użyciu biblioteki JavaScript XState:
const promiseMachine = Machine({ id: "promise", initial: "idle", context: { result: undefined, error: undefined, }, states: { idle: { on: { INVOKE: "pending", }, }, pending: { on: { RESOLVE: "success", REJECT: "failure", }, }, success: { type: "final", actions: assign({ result: (context, event) => event.data, }), }, failure: { type: "final", actions: assign({ error: (context, event) => event.data, }), }, }, });
Chociaż wersja XState jest mniej kompaktowa, reprezentacja obiektu ma kilka zalet:
- Sama maszyna stanów to prosty JSON, który można łatwo utrwalić.
- Ponieważ jest to deklaratywne, maszynę można wizualizować.
- W przypadku korzystania z języka TypeScript kompilator sprawdza, czy wykonywane są tylko prawidłowe przejścia stanu.
XState obsługuje wykresy stanów i implementuje specyfikację SCXML, dzięki czemu nadaje się do użycia w bardzo dużych aplikacjach.
Wizualizacja schematów stanowych obietnicy:
Najlepsze praktyki XSstate
Poniżej przedstawiono niektóre najlepsze praktyki, które należy zastosować podczas korzystania z XState, aby pomóc w utrzymaniu projektów.
Oddziel efekty uboczne od logiki
XState umożliwia niezależne określanie efektów ubocznych (obejmujących czynności takie jak logowanie lub żądania API) od logiki maszyny stanów.
Ma to następujące zalety:
- Wspomagaj wykrywanie błędów logicznych, utrzymując kod automatu stanowego tak czysty i prosty, jak to tylko możliwe.
- Łatwo wizualizuj maszynę stanową bez konieczności wcześniejszego usuwania dodatkowego kotła.
- Łatwiejsze testowanie maszyny stanowej poprzez wstrzykiwanie usług próbnych.
const fetchUsersMachine = Machine({ id: "fetchUsers", initial: "idle", context: { users: undefined, error: undefined, nextPage: 0, }, states: { idle: { on: { FETCH: "fetching", }, }, fetching: { invoke: { src: (context) => fetch(`url/to/users?page=${context.nextPage}`).then((response) => response.json() ), onDone: { target: "success", actions: assign({ users: (context, event) => [...context.users, ...event.data], // Data holds the newly fetched users nextPage: (context) => context.nextPage + 1, }), }, onError: { target: "failure", error: (_, event) => event.data, // Data holds the error }, }, }, // success state.. // failure state.. }, });
Chociaż kuszące jest pisanie maszyn stanów w ten sposób, gdy nadal wszystko działa, lepsze rozdzielenie problemów można osiągnąć, przekazując efekty uboczne jako opcje:
const services = { getUsers: (context) => fetch( `url/to/users?page=${context.nextPage}` ).then((response) => response.json()) } const fetchUsersMachine = Machine({ ... states: { ... fetching: { invoke: { // Invoke the side effect at key: 'getUsers' in the supplied services object. src: 'getUsers', } on: { RESOLVE: "success", REJECT: "failure", }, }, ... }, // Supply the side effects to be executed on state transitions. { services } });
Pozwala to również na łatwe testowanie jednostkowe maszyny stanów, umożliwiając jawne kpinie się z pobierań użytkowników:
async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });
Dzielenie dużych maszyn
Na początku nie zawsze jest od razu oczywiste, jak najlepiej ustrukturyzować problematyczną domenę w dobrą hierarchię maszyn skończonych.
Porada: Użyj hierarchii składników interfejsu użytkownika, aby poprowadzić ten proces. Zobacz następną sekcję dotyczącą mapowania maszyn stanów na składniki interfejsu użytkownika.
Główną zaletą korzystania z automatów stanów jest jawne modelowanie wszystkich stanów i przejść między stanami w aplikacjach, tak aby wynikowe zachowanie było jasno rozumiane, co ułatwia wykrycie błędów logicznych lub luk.
Aby to działało dobrze, maszyny muszą być małe i zwięzłe. Na szczęście hierarchiczne tworzenie maszyn stanów jest łatwe. W kanonicznym schemacie stanu systemu sygnalizacji świetlnej sam „czerwony” stan staje się potomną maszyną stanów. Nadrzędna „lekka” maszyna nie jest świadoma wewnętrznych stanów „czerwonego”, ale decyduje, kiedy wprowadzić „czerwony” i jakie jest zamierzone zachowanie po wyjściu:
1-1 Odwzorowanie automatów stanowych na komponenty UI z obsługą stanów
Weźmy na przykład znacznie uproszczoną, fikcyjną witrynę eCommerce, która ma następujące widoki React:
<App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>
Proces generowania automatów stanów odpowiadający powyższym widokom może być znany osobom, które korzystały z biblioteki zarządzania stanami Redux:
- Czy komponent ma stan, który należy zamodelować? Na przykład Administrator/Produkty nie mogą; może wystarczyć pobieranie stronicowane na serwer oraz rozwiązanie do buforowania (takie jak SWR). Z drugiej strony komponenty takie jak SignInForm czy Koszyk zazwyczaj zawierają stany, którymi należy zarządzać, takie jak dane wpisane w pola lub aktualna zawartość koszyka.
- Czy techniki stanu lokalnego (np.
setState() / useState()
) Reacta są wystarczające, aby uchwycić problem? Śledzenie, czy okno podręczne koszyka jest obecnie otwarte, prawie nie wymaga użycia maszyny skończonej. - Czy wynikowa maszyna stanu może być zbyt złożona? Jeśli tak, podziel maszynę na kilka mniejszych, identyfikując możliwości tworzenia maszyn podrzędnych, które można ponownie wykorzystać w innym miejscu. Na przykład maszyny SignInForm i RegistrationForm mogą wywoływać wystąpienia podrzędnego textFieldMachine w celu modelowania walidacji i stanu pól e-mail, nazwy i hasła użytkownika.
Kiedy używać modelu maszyny skończonej?
Chociaż schematy stanów i FSM mogą w elegancki sposób rozwiązać niektóre trudne problemy, wybór najlepszych narzędzi i podejść do użycia w konkretnej aplikacji zwykle zależy od kilku czynników.
Niektóre sytuacje, w których użycie maszyn skończonych błyszczy:
- Twoja aplikacja zawiera znaczny komponent do wprowadzania danych, w którym dostępność lub widoczność w terenie rządzą się złożonymi regułami: na przykład wpis formularza w aplikacji do roszczeń ubezpieczeniowych. W tym przypadku FSM pomagają zapewnić solidne wdrożenie reguł biznesowych. Co więcej, funkcje wizualizacji wykresów stanów można wykorzystać do zwiększenia współpracy z interesariuszami nietechnicznymi i identyfikacji szczegółowych wymagań biznesowych na wczesnym etapie opracowywania.
- Aby lepiej działać na wolniejszych połączeniach i zapewniać użytkownikom bardziej wierne środowisko , aplikacje internetowe muszą zarządzać coraz bardziej złożonymi asynchronicznymi przepływami danych. FSM jawnie modelują wszystkie stany, w jakich może się znajdować aplikacja, a wykresy stanów mogą być wizualizowane, aby pomóc w diagnozowaniu i rozwiązywaniu problemów z danymi asynchronicznymi.
- Aplikacje wymagające dużej ilości wyrafinowanej animacji opartej na stanie. W przypadku złożonych animacji popularne są techniki modelowania animacji jako strumieni zdarzeń w czasie za pomocą RxJS. W wielu scenariuszach działa to dobrze, jednak gdy bogata animacja jest połączona ze złożoną serią znanych stanów, FSM zapewniają dobrze zdefiniowane „punkty spoczynku”, między którymi przepływają animacje. FSM w połączeniu z RxJS wydają się idealną kombinacją, która pomaga dostarczyć kolejną falę wysokiej jakości, ekspresyjnych doświadczeń użytkowników.
- Bogate aplikacje klienckie, takie jak edycja zdjęć lub wideo, narzędzia do tworzenia diagramów lub gry, w których znaczna część logiki biznesowej znajduje się po stronie klienta. FSM są z natury oddzielone od struktury lub bibliotek interfejsu użytkownika i są łatwe do napisania testów, aby umożliwić szybkie iterowanie wysokiej jakości aplikacji i dostarczanie ich z pewnością.
Zastrzeżenia dotyczące maszyn skończonych
- Ogólne podejście, najlepsze praktyki i interfejs API dla bibliotek schematów stanów, takich jak XState, są nowością dla większości programistów front-end, którzy będą wymagać inwestycji czasu i zasobów, aby osiągnąć produktywność, szczególnie w przypadku mniej doświadczonych zespołów.
- Podobnie jak w poprzednim zastrzeżeniu, chociaż popularność XState wciąż rośnie i jest dobrze udokumentowana, istniejące biblioteki zarządzania stanami, takie jak Redux, MobX lub React Context, mają ogromną liczbę fanów, którzy dostarczają bogactwo informacji online, do których XState jeszcze nie pasuje.
- W przypadku aplikacji korzystających z prostszego modelu CRUD wystarczą istniejące techniki zarządzania stanem w połączeniu z dobrą biblioteką buforowania zasobów, taką jak SWR lub React Query. W tym przypadku dodatkowe ograniczenia zapewniane przez FSM, choć niezwykle pomocne w złożonych aplikacjach, mogą spowolnić rozwój.
- Narzędzia są mniej dojrzałe niż inne biblioteki zarządzania stanem, a prace nad ulepszoną obsługą TypeScript i rozszerzeniami narzędzi do tworzenia przeglądarek wciąż trwają.
Zawijanie
Popularność programowania deklaratywnego i jego przyjęcie w społeczności twórców stron internetowych stale rośnie.
Podczas gdy współczesne tworzenie stron internetowych staje się coraz bardziej złożone, biblioteki i frameworki, które przyjmują podejścia do programowania deklaratywnego, pojawiają się coraz częściej. Powód wydaje się jasny — należy stworzyć prostsze, bardziej opisowe podejścia do pisania oprogramowania.
Korzystanie z silnie typizowanych języków, takich jak TypeScript, umożliwia zwięzłe i jawne modelowanie jednostek w domenie aplikacji, co zmniejsza ryzyko wystąpienia błędów i ilość podatnego na błędy kodu sprawdzającego, którym należy manipulować. Przyjęcie maszyn o skończonych stanach i wykresów stanów w interfejsie użytkownika umożliwia programistom deklarowanie logiki biznesowej aplikacji za pomocą przejść między stanami, umożliwiając opracowywanie bogatych narzędzi do wizualizacji i zwiększając możliwość bliskiej współpracy z osobami niebędącymi programistami.
Kiedy to robimy, przenosimy naszą uwagę z zasad działania aplikacji na widok wyższego poziomu, który pozwala nam jeszcze bardziej skoncentrować się na potrzebach klienta i tworzyć trwałą wartość.