Utilizarea programării declarative pentru a crea aplicații web care pot fi întreținute
Publicat: 2022-03-11În acest articol, arăt cum adoptarea judicioasă a tehnicilor de programare în stil declarativ poate permite echipelor să creeze aplicații web care sunt mai ușor de extins și de întreținut.
„...programarea declarativă este o paradigmă de programare care exprimă logica unui calcul fără a descrie fluxul său de control.” —Remo H. Jansen, Programare funcțională practică cu TypeScript
La fel ca majoritatea problemelor din software, a decide să utilizați tehnici de programare declarativă în aplicațiile dvs. necesită o evaluare atentă a compromisurilor. Consultați unul dintre articolele noastre anterioare pentru o discuție aprofundată despre acestea.
Aici, accentul se pune pe modul în care modelele de programare declarative pot fi adoptate treptat atât pentru aplicațiile noi, cât și pentru cele existente scrise în JavaScript, un limbaj care acceptă mai multe paradigme.
În primul rând, discutăm despre cum să utilizați TypeScript atât pe partea din spate, cât și pe partea frontală, pentru a face codul mai expresiv și mai rezistent la schimbare. Apoi explorăm mașinile cu stări finite (FSM) pentru a eficientiza dezvoltarea front-end și pentru a crește implicarea părților interesate în procesul de dezvoltare.
FSM-urile nu sunt o tehnologie nouă. Au fost descoperite cu aproape 50 de ani în urmă și sunt populare în industrii precum procesarea semnalului, aeronautică și finanțe, unde corectitudinea software-ului poate fi critică. Ele sunt, de asemenea, foarte potrivite pentru problemele de modelare care apar frecvent în dezvoltarea web modernă, cum ar fi coordonarea actualizărilor și animațiilor complexe de stare asincronă.
Acest beneficiu apare din cauza constrângerilor asupra modului în care este gestionat statul. O mașină de stări poate fi într-o singură stare simultan și are stări învecinate limitate la care poate trece ca răspuns la evenimente externe (cum ar fi clicurile mouse-ului sau preluarea răspunsurilor). Rezultatul este de obicei o rată de defecte semnificativ redusă. Cu toate acestea, abordările FSM pot fi dificil de extins pentru a funcționa bine în aplicații mari. Extensiile recente ale FSM-urilor numite diagrame de stat permit vizualizarea FSM-urilor complexe și scalarea la aplicații mult mai mari, care este aroma mașinilor cu stări finite pe care se concentrează acest articol. Pentru demonstrația noastră, vom folosi biblioteca XState, care este una dintre cele mai bune soluții pentru FSM-uri și diagrame de stat în JavaScript.
Declarativ pe back-end cu Node.js
Programarea back-end-ului unui server web folosind abordări declarative este un subiect amplu și, de obicei, ar putea începe prin evaluarea unui limbaj de programare funcțional adecvat pe partea de server. În schimb, să presupunem că citiți acest lucru într-un moment în care ați ales deja (sau luați în considerare) Node.js pentru back-end.
Această secțiune detaliază o abordare a entităților de modelare pe back-end care are următoarele beneficii:
- Lizibilitatea codului îmbunătățită
- Refactorizare mai sigură
- Potențial de performanță îmbunătățită datorită garanțiilor oferite de modelarea de tip
Garanții de comportament prin modelarea tipului
JavaScript
Luați în considerare sarcina de a căuta un anumit utilizator prin adresa sa de e-mail în 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.. }
Această funcție acceptă o adresă de e-mail ca șir și returnează utilizatorul corespunzător din baza de date atunci când există o potrivire.
Presupunerea este că lookupUser()
va fi apelat numai după ce validarea de bază a fost efectuată. Aceasta este o presupunere cheie. Ce se întâmplă dacă câteva săptămâni mai târziu, se efectuează o refactorizare și această ipoteză nu mai este valabilă? Încrucișăm degetele pentru ca testele unitare să prindă eroarea, sau s-ar putea să trimitem text nefiltrat în baza de date!
TypeScript (prima încercare)
Să considerăm un echivalent TypeScript al funcției de validare:
function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }
Aceasta este o ușoară îmbunătățire, compilatorul TypeScript ne-a scutit de adăugarea unui pas suplimentar de validare a runtimei.
Garanțiile de siguranță pe care le poate aduce tastarea puternică nu au fost încă profitate. Să ne uităm la asta.
TypeScript (a doua încercare)
Să îmbunătățim siguranța tipului și să interzicem transmiterea șirurilor de caractere neprocesate ca intrare către 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); }
Acesta este mai bine, dar este greoi. Toate utilizările ValidEmail
accesează adresa reală prin email.value
. TypeScript utilizează tastarea structurală mai degrabă decât tastarea nominală folosită de limbaje precum Java și C#.
Deși puternic, aceasta înseamnă că orice alt tip care aderă la această semnătură este considerat echivalent. De exemplu, următorul tip de parolă ar putea fi transmis către lookupUser()
fără reclamație din partea compilatorului:
type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.
TypeScript (a treia încercare)
Putem realiza tastarea nominală în TypeScript folosind intersecția:
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.
Am atins acum obiectivul ca numai șirurile de e-mail validate să poată fi transmise către lookupUser()
.
Sfat profesionist: aplicați acest model cu ușurință folosind următorul tip de ajutor:
type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;
Pro
Tastând cu putere entitățile din domeniul dvs., putem:
- Reduceți numărul de verificări care trebuie efectuate în timpul execuției, care consumă cicluri prețioase ale procesorului serverului (deși o cantitate foarte mică, acestea se adună atunci când deservesc mii de solicitări pe minut).
- Mențineți mai puține teste de bază datorită garanțiilor oferite de compilatorul TypeScript.
- Profitați de refactorizarea asistată de editor și compilator.
- Îmbunătățiți lizibilitatea codului prin raportul semnal-zgomot îmbunătățit.
Contra
Modelarea tipului vine cu câteva compromisuri de luat în considerare:
- Introducerea TypeScript complică, de obicei, lanțul de instrumente, ceea ce duce la timpi mai lungi de execuție a suitei de construire și de testare.
- Dacă scopul dvs. este să prototipați o caracteristică și să o puneți în mâinile utilizatorilor cât mai curând posibil, efortul suplimentar necesar pentru a modela în mod explicit tipurile și a le propaga prin baza de cod ar putea să nu merite.
Am arătat cum codul JavaScript existent pe server sau stratul de validare back-end/front-end partajat poate fi extins cu tipuri pentru a îmbunătăți lizibilitatea codului și a permite o refactorizare mai sigură - cerințe importante pentru echipe.
Interfețe declarative pentru utilizator
Interfețele de utilizator dezvoltate folosind tehnici de programare declarativă concentrează eforturile pe descrierea „ce” în detrimentul „cum”. Două dintre principalele trei ingrediente de bază ale web-ului, CSS și HTML, sunt limbaje de programare declarative care au trecut testul timpului și peste 1 miliard de site-uri web.
React a fost deschis de Facebook în 2013 și a modificat semnificativ cursul dezvoltării front-end. Când l-am folosit prima dată, mi-a plăcut cum aș putea declara interfața grafică în funcție de starea aplicației. Acum am reușit să compun interfețe de utilizare mari și complexe din blocuri mai mici, fără să mă ocup de detaliile dezordonate ale manipulării DOM și să urmăresc care părți ale aplicației au nevoie de actualizare ca răspuns la acțiunile utilizatorului. Aș putea ignora în mare măsură aspectul de timp atunci când definesc interfața de utilizare și să mă concentrez pe asigurarea tranziției corecte a aplicației mele de la o stare la alta.
Pentru a realiza o modalitate mai simplă de a dezvolta interfețe de utilizare, React a inserat un strat de abstractizare între dezvoltator și mașină/browser: DOM-ul virtual .
Alte cadre moderne de interfață de utilizare web au acoperit, de asemenea, acest decalaj, deși în moduri diferite. De exemplu, Vue folosește reactivitate funcțională fie prin intermediul getters/setters JavaScript (Vue 2) fie prin proxy (Vue 3). Svelte aduce reactivitate printr-un pas suplimentar de compilare a codului sursă (Svelte).
Aceste exemple par să demonstreze o mare dorință în industria noastră de a oferi dezvoltatorilor instrumente mai bune și mai simple pentru a exprima comportamentul aplicației prin abordări declarative.
Starea și logica aplicației declarative
În timp ce stratul de prezentare continuă să se învârte în jurul unei forme de HTML (de exemplu, JSX în React, șabloane bazate pe HTML găsite în Vue, Angular și Svelte), postulez că problema modului de modelare a stării unei aplicații într-un mod care este ușor de înțeles de alți dezvoltatori și de întreținut pe măsură ce aplicația crește este încă nerezolvată. Vedem dovezi în acest sens printr-o proliferare de biblioteci de management de stat și abordări care continuă până în zilele noastre.
Situația este complicată de așteptările tot mai mari ale aplicațiilor web moderne. Câteva provocări emergente pe care abordările moderne de management de stat trebuie să le suporte:
- Aplicații offline mai întâi folosind tehnici avansate de abonament și de stocare în cache
- Codul concis și reutilizarea codului pentru cerințele de dimensiune a pachetului din ce în ce mai mici
- Cererea de experiențe de utilizator din ce în ce mai sofisticate prin animații de înaltă fidelitate și actualizări în timp real
(Re)apariția mașinilor cu stări finite și a diagramelor de stat
Mașinile cu stări finite au fost utilizate pe scară largă pentru dezvoltarea de software în anumite industrii în care robustețea aplicațiilor este critică, cum ar fi aviația și finanțele. De asemenea, câștigă în mod constant popularitate pentru dezvoltarea front-end a aplicațiilor web, de exemplu, prin excelenta bibliotecă XState.
Wikipedia definește o mașină cu stări finite ca:
O mașină abstractă care poate fi în exact una dintr-un număr finit de stări la un moment dat. FSM se poate schimba de la o stare la alta ca răspuns la unele intrări externe; schimbarea de la o stare la alta se numeste tranzitie. Un FSM este definit de o listă a stărilor sale, a stării sale inițiale și a condițiilor pentru fiecare tranziție.
Și mai departe:
O stare este o descriere a stării unui sistem care așteaptă să execute o tranziție.
FSM-urile în forma lor de bază nu se scalează bine la sisteme mari din cauza problemei exploziei de stat. Recent, diagramele de stat UML au fost create pentru a extinde FSM-urile cu ierarhie și concurență, care sunt elemente care permit utilizarea pe scară largă a FSM-urilor în aplicații comerciale.

Declarați logica aplicației dvs
În primul rând, cum arată un FSM ca cod? Există mai multe moduri de a implementa o mașină cu stări finite în JavaScript.
- Mașină cu stări finite ca declarație de comutare
Iată o mașină care descrie stările posibile în care poate fi un JavaScript, implementată folosind o instrucțiune 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; } }
Acest stil de cod va fi familiar dezvoltatorilor care au folosit populara bibliotecă de management de stat Redux.
- Mașină cu stări finite ca obiect JavaScript
Iată aceeași mașină implementată ca un obiect JavaScript folosind biblioteca 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, }), }, }, });
În timp ce versiunea XState este mai puțin compactă, reprezentarea obiectului are câteva avantaje:
- Mașina de stări în sine este un simplu JSON, care poate fi persistent.
- Deoarece este declarativ, mașina poate fi vizualizată.
- Dacă utilizați TypeScript, compilatorul verifică dacă sunt efectuate numai tranziții de stare valide.
XState acceptă diagrame de stat și implementează specificația SCXML, ceea ce îl face potrivit pentru utilizare în aplicații foarte mari.
Vizualizarea Statecharts a unei promisiuni:
Cele mai bune practici XState
Următoarele sunt câteva dintre cele mai bune practici de aplicat atunci când utilizați XState pentru a ajuta la menținerea proiectelor.
Separați efectele secundare de logică
XState permite ca efectele secundare (care includ activități precum logare sau solicitări API) să fie specificate independent de logica mașinii de stări.
Aceasta are următoarele beneficii:
- Asistență la detectarea erorilor logice, păstrând codul mașinii de stat cât mai curat și simplu posibil.
- Vizualizați cu ușurință mașina de stat, fără a fi nevoie să îndepărtați mai întâi placa suplimentară.
- Testare mai ușoară a mașinii de stat prin injectarea de servicii simulate.
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.. }, });
Deși este tentant să scrieți mașinile de stare în acest fel în timp ce încă lucrați, se obține o mai bună separare a preocupărilor prin trecerea efectelor secundare ca opțiuni:
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 } });
Acest lucru permite, de asemenea, testarea unitară ușoară a mașinii de stare, permițând batjocura explicită a preluărilor utilizatorului:
async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });
Împărțirea mașinilor mari
Nu este întotdeauna imediat evident cum să structurați cel mai bine un domeniu cu probleme într-o ierarhie bună de mașini cu stări finite atunci când începeți.
Sfat: utilizați ierarhia componentelor interfeței dvs. de utilizator pentru a ghida acest proces. Consultați secțiunea următoare despre cum să mapați mașinile de stare la componentele UI.
Un avantaj major al utilizării mașinilor de stări este modelarea explicită a tuturor stărilor și tranzițiilor dintre stările din aplicațiile dvs., astfel încât comportamentul rezultat să fie înțeles clar, făcând erorile logice sau golurile ușor de identificat.
Pentru ca acest lucru să funcționeze bine, mașinile trebuie să fie păstrate mici și concise. Din fericire, compunerea mașinilor de stat ierarhic este ușoară. În exemplul de diagrame de stat canonice al unui sistem de semafor, starea „roșie” în sine devine o mașină de stat copil. Mașina „luminoasă” părinte nu este conștientă de stările interne de „roșu”, dar decide când să intre în „roșu” și care este comportamentul dorit la ieșire:
1-1 Maparea mașinilor de stări la componentele UI cu state
Luați, de exemplu, un site de comerț electronic fictiv, mult simplificat, care are următoarele vizualizări React:
<App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>
Procesul de generare a mașinilor de stare corespunzătoare vederilor de mai sus poate fi familiar pentru cei care au folosit biblioteca de gestionare a stării Redux:
- Are componenta stare care trebuie modelată? De exemplu, Administrator/Produse nu poate; Preluările paginate către server plus o soluție de stocare în cache (cum ar fi SWR) pot fi suficiente. Pe de altă parte, componente precum SignInForm sau Cart conțin de obicei stare care trebuie gestionată, cum ar fi datele introduse în câmpuri sau conținutul curent al coșului.
- Sunt tehnicile locale de stare (de exemplu,
setState() / useState()
de la React) suficiente pentru a surprinde problema? Urmărirea modalului pop-up pentru cărucior este deschis în prezent cu greu necesită utilizarea unei mașini cu stări finite. - Este probabil ca mașina de stări rezultată să fie prea complexă? Dacă da, împărțiți mașina în mai multe altele mai mici, identificând oportunități de a crea mașini copii care pot fi reutilizate în altă parte. De exemplu, mașinile SignInForm și RegistrationForm pot invoca instanțe ale unui textFieldMachine copil pentru validarea modelului și starea pentru câmpurile de e-mail, nume și parolă ale utilizatorului.
Când să utilizați un model de mașină cu stări finite
În timp ce diagramele de stat și FSM-urile pot rezolva în mod elegant unele probleme provocatoare, alegerea celor mai bune instrumente și abordări de utilizat pentru o anumită aplicație depinde de obicei de mai mulți factori.
Unele situații în care folosirea mașinilor cu stări finite strălucește:
- Aplicația dvs. include o componentă considerabilă de introducere a datelor în care accesibilitatea sau vizibilitatea câmpurilor este guvernată de reguli complexe: de exemplu, introducerea formularului într-o aplicație de daune de asigurări. Aici, FSM-urile ajută la asigurarea implementării robuste a regulilor de afaceri. În plus, caracteristicile de vizualizare ale diagramelor de stat pot fi utilizate pentru a ajuta la creșterea colaborării cu părțile interesate non-tehnice și pentru a identifica cerințele detaliate de afaceri la începutul dezvoltării.
- Pentru a funcționa mai bine pe conexiuni mai lente și pentru a oferi utilizatorilor experiențe de fidelitate mai mare , aplicațiile web trebuie să gestioneze fluxuri de date asincrone din ce în ce mai complexe. FSM-urile modelează în mod explicit toate stările în care se poate afla o aplicație, iar diagramele de stări pot fi vizualizate pentru a ajuta la diagnosticarea și rezolvarea problemelor de date asincrone.
- Aplicații care necesită multă animație sofisticată, bazată pe stare. Pentru animațiile complexe, tehnicile de modelare a animațiilor ca fluxuri de evenimente în timp cu RxJS sunt populare. Pentru multe scenarii, acest lucru funcționează bine, totuși, atunci când animația bogată este combinată cu o serie complexă de stări cunoscute, FSM-urile oferă „puncte de odihnă” bine definite între care circulă animațiile. FSM-urile combinate cu RxJS par combinația perfectă pentru a oferi următorul val de experiențe de înaltă fidelitate și expresive pentru utilizatori.
- Aplicații client bogate, cum ar fi editarea fotografiilor sau video, instrumentele de creare a diagramelor sau jocurile în care o mare parte din logica de afaceri rezidă din partea clientului. FSM-urile sunt decuplate în mod inerent de cadrul UI sau biblioteci și sunt teste ușor de scris pentru a permite aplicațiilor de înaltă calitate să fie iterate rapid și livrate cu încredere.
Avertismente privind mașinile cu stări finite
- Abordarea generală, cele mai bune practici și API pentru bibliotecile statechart, cum ar fi XState, sunt noi pentru majoritatea dezvoltatorilor front-end, care vor necesita investiții de timp și resurse pentru a deveni productivi, în special pentru echipele mai puțin experimentate.
- Similar cu avertismentul precedent, în timp ce popularitatea lui XState continuă să crească și este bine documentată, bibliotecile existente de management de stat, cum ar fi Redux, MobX sau React Context, au urmăritori uriașe care oferă o mulțime de informații online cu care XState nu se potrivește încă.
- Pentru aplicațiile care urmează un model CRUD mai simplu, tehnicile existente de gestionare a stării combinate cu o bibliotecă bună de stocare a resurselor, cum ar fi SWR sau React Query, vor fi suficiente. Aici, constrângerile suplimentare pe care le oferă FSM-urile, deși sunt incredibil de utile în aplicațiile complexe, pot încetini dezvoltarea.
- Instrumentul este mai puțin matur decât alte biblioteci de management de stat, lucru în curs de desfășurare pentru suport îmbunătățit pentru TypeScript și extensii de instrumente de dezvoltare a browserului.
Încheierea
Popularitatea și adoptarea programării declarative în comunitatea de dezvoltare web continuă să crească.
În timp ce dezvoltarea web modernă continuă să devină mai complexă, bibliotecile și cadrele care adoptă abordări de programare declarativă apar cu o frecvență tot mai mare. Motivul pare clar – trebuie create abordări mai simple și mai descriptive pentru scrierea software-ului.
Utilizarea limbajelor puternic tipizate, cum ar fi TypeScript, permite entităților din domeniul aplicației să fie modelate succint și explicit, ceea ce reduce șansa de erori și cantitatea de cod de verificare predispus la erori care trebuie manipulat. Adoptarea mașinilor cu stări finite și diagramelor de stat pe front-end permite dezvoltatorilor să declare logica de afaceri a unei aplicații prin tranziții de stare, permițând dezvoltarea unor instrumente bogate de vizualizare și crescând oportunitatea unei colaborări strânse cu non-dezvoltatorii.
Când facem acest lucru, ne mutăm atenția de la piulițele și șuruburile modului în care funcționează aplicația la o vedere de nivel superior care ne permite să ne concentrăm și mai mult pe nevoile clientului și să creăm valoare de durată.