Sfruttare la programmazione dichiarativa per creare app Web gestibili

Pubblicato: 2022-03-11

In questo articolo, mostro come l'adozione giudiziosa di tecniche di programmazione in stile dichiarativo possa consentire ai team di creare applicazioni Web più facili da estendere e mantenere.

"... la programmazione dichiarativa è un paradigma di programmazione che esprime la logica di un calcolo senza descriverne il flusso di controllo." —Remo H. Jansen, Programmazione funzionale pratica con TypeScript

Come la maggior parte dei problemi nel software, decidere di utilizzare tecniche di programmazione dichiarativa nelle applicazioni richiede un'attenta valutazione dei compromessi. Dai un'occhiata a uno dei nostri articoli precedenti per una discussione approfondita di questi.

Qui, l'attenzione si concentra su come i modelli di programmazione dichiarativa possono essere gradualmente adottati sia per le applicazioni nuove che per quelle esistenti scritte in JavaScript, un linguaggio che supporta più paradigmi.

Innanzitutto, discutiamo come utilizzare TypeScript sia sul back-end che sul front-end per rendere il codice più espressivo e resiliente alle modifiche. Quindi esploriamo le macchine a stati finiti (FSM) per semplificare lo sviluppo front-end e aumentare il coinvolgimento delle parti interessate nel processo di sviluppo.

Gli FSM non sono una nuova tecnologia. Sono stati scoperti quasi 50 anni fa e sono popolari in settori come l'elaborazione dei segnali, l'aeronautica e la finanza, dove la correttezza del software può essere fondamentale. Sono anche molto adatti per la modellazione dei problemi che sorgono frequentemente nello sviluppo web moderno, come il coordinamento di complessi aggiornamenti di stato asincroni e animazioni.

Questo vantaggio nasce a causa di vincoli sul modo in cui viene gestito lo stato. Una macchina a stati può trovarsi in un solo stato contemporaneamente e ha stati vicini limitati a cui può passare in risposta a eventi esterni (come clic del mouse o risposte di recupero). Il risultato è solitamente una percentuale di difetti significativamente ridotta. Tuttavia, gli approcci FSM possono essere difficili da scalare per funzionare bene in applicazioni di grandi dimensioni. Le recenti estensioni di FSM chiamate statecharts consentono la visualizzazione di FSM complessi e la scalabilità per applicazioni molto più grandi, che è il sapore delle macchine a stati finiti su cui si concentra questo articolo. Per la nostra dimostrazione, utilizzeremo la libreria XState, che è una delle migliori soluzioni per FSM e diagrammi di stato in JavaScript.

Dichiarativo sul back-end con Node.js

La programmazione di un back-end di un server Web utilizzando approcci dichiarativi è un argomento ampio e in genere potrebbe iniziare valutando un linguaggio di programmazione funzionale lato server adatto. Supponiamo invece che tu stia leggendo questo in un momento in cui hai già scelto (o stai considerando) Node.js per il tuo back-end.

Questa sezione descrive in dettaglio un approccio alla modellazione delle entità sul back-end che presenta i seguenti vantaggi:

  • Migliore leggibilità del codice
  • Refactoring più sicuro
  • Possibilità di miglioramento delle prestazioni grazie alle garanzie fornite dalla modellazione del tipo

Garanzie di comportamento attraverso la modellazione del tipo

JavaScript

Considera il compito di cercare un determinato utente tramite il suo indirizzo email in 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.. }

Questa funzione accetta un indirizzo email come stringa e restituisce l'utente corrispondente dal database quando c'è una corrispondenza.

Il presupposto è che lookupUser() verrà chiamato solo una volta eseguita la convalida di base. Questo è un presupposto fondamentale. E se diverse settimane dopo venisse eseguito un refactoring e questa ipotesi non fosse più valida? Incrociamo le dita che gli unit test catturino il bug, o potremmo inviare del testo non filtrato al database!

TypeScript (primo tentativo)

Consideriamo un equivalente TypeScript della funzione di convalida:

 function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }

Questo è un leggero miglioramento, con il compilatore TypeScript che ci ha evitato di aggiungere un ulteriore passaggio di convalida del runtime.

Le garanzie di sicurezza che può portare una digitazione forte non sono state ancora sfruttate. Diamo un'occhiata a quello.

TypeScript (secondo tentativo)

Miglioriamo la sicurezza dei tipi e non consentiamo il passaggio di stringhe non elaborate come input a 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); }

Questo è meglio, ma è ingombrante. Tutti gli usi di ValidEmail accedono all'indirizzo effettivo tramite email.value . TypeScript utilizza la tipizzazione strutturale piuttosto che la tipizzazione nominale impiegata da linguaggi come Java e C#.

Sebbene potente, ciò significa che qualsiasi altro tipo che aderisce a questa firma è considerato equivalente. Ad esempio, il seguente tipo di password può essere passato a lookupUser() senza reclami da parte del compilatore:

 type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.

TypeScript (terzo tentativo)

Possiamo ottenere la digitazione nominale in TypeScript usando l'intersezione:

 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.

Ora abbiamo raggiunto l'obiettivo che solo le stringhe di posta elettronica convalidate possono essere passate a lookupUser() .

Suggerimento per professionisti: applica facilmente questo modello utilizzando il seguente tipo di supporto:

 type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;

Professionisti

Digitando fortemente le entità nel tuo dominio, possiamo:

  1. Riduci il numero di controlli che devono essere eseguiti in fase di esecuzione, che consumano preziosi cicli della CPU del server (sebbene una quantità molto piccola, questi si sommano quando servono migliaia di richieste al minuto).
  2. Mantieni meno test di base grazie alle garanzie fornite dal compilatore TypeScript.
  3. Sfrutta il refactoring assistito da editor e compilatore.
  4. Migliora la leggibilità del codice attraverso un migliore rapporto segnale/rumore.

contro

La modellazione dei tipi include alcuni compromessi da considerare:

  1. L'introduzione di TypeScript di solito complica la toolchain, portando a tempi di esecuzione della build e della suite di test più lunghi.
  2. Se il tuo obiettivo è quello di prototipare una funzionalità e metterla nelle mani degli utenti al più presto, lo sforzo aggiuntivo richiesto per modellare esplicitamente i tipi e propagarli attraverso la base di codice potrebbe non valere la pena.

Abbiamo mostrato come il codice JavaScript esistente sul server o il livello di convalida back-end/front-end condiviso può essere esteso con tipi per migliorare la leggibilità del codice e consentire un refactoring più sicuro, requisiti importanti per i team.

Interfacce utente dichiarative

Le interfacce utente sviluppate utilizzando tecniche di programmazione dichiarativa concentrano gli sforzi sulla descrizione del "cosa" rispetto al "come". Due dei tre principali ingredienti di base del web, CSS e HTML, sono linguaggi di programmazione dichiarativi che hanno superato la prova del tempo e di oltre 1 miliardo di siti web.

I principali linguaggi che alimentano il web
I principali linguaggi che alimentano il web.

React è stato open source da Facebook nel 2013 e ha alterato in modo significativo il corso dello sviluppo del front-end. Quando l'ho usato per la prima volta, ho adorato il modo in cui potevo dichiarare la GUI in funzione dello stato dell'applicazione. Ora sono stato in grado di comporre interfacce utente grandi e complesse da blocchi costitutivi più piccoli senza occuparmi dei dettagli disordinati della manipolazione del DOM e monitorare quali parti dell'app devono essere aggiornate in risposta alle azioni dell'utente. Potrei in gran parte ignorare l'aspetto temporale durante la definizione dell'interfaccia utente e concentrarmi sul garantire che la mia applicazione passi correttamente da uno stato all'altro.

Evoluzione di JavaScript front-end da come a cosa
Evoluzione di JavaScript front-end da come a cosa .

Per ottenere un modo più semplice per sviluppare UI, React ha inserito un livello di astrazione tra lo sviluppatore e la macchina/browser: il DOM virtuale .

Anche altri moderni framework di interfaccia utente Web hanno colmato questa lacuna, anche se in modi diversi. Ad esempio, Vue utilizza la reattività funzionale tramite getter/setter JavaScript (Vue 2) o proxy (Vue 3). Svelte porta la reattività attraverso un passaggio aggiuntivo di compilazione del codice sorgente (Svelte).

Questi esempi sembrano dimostrare un grande desiderio nel nostro settore di fornire strumenti migliori e più semplici per consentire agli sviluppatori di esprimere il comportamento delle applicazioni attraverso approcci dichiarativi.

Stato e logica dell'applicazione dichiarativa

Mentre il livello di presentazione continua a ruotare attorno a una qualche forma di HTML (ad esempio, JSX in React, modelli basati su HTML trovati in Vue, Angular e Svelte), postulo che il problema di come modellare lo stato di un'applicazione in un modo che sia facilmente comprensibile per altri sviluppatori e manutenibile man mano che l'applicazione cresce è ancora irrisolta. Ne vediamo la prova attraverso una proliferazione di biblioteche e approcci di gestione statali che continua ancora oggi.

La situazione è complicata dalle crescenti aspettative delle moderne app web. Alcune sfide emergenti che i moderni approcci di gestione statale devono supportare:

  • Prime applicazioni offline che utilizzano tecniche avanzate di abbonamento e memorizzazione nella cache
  • Codice conciso e riutilizzo del codice per requisiti di dimensione del pacchetto sempre più ridotti
  • Richiedi esperienze utente sempre più sofisticate attraverso animazioni ad alta fedeltà e aggiornamenti in tempo reale

(Ri)emergere di macchine a stati finiti e diagrammi di stato

Le macchine a stati finiti sono state ampiamente utilizzate per lo sviluppo di software in alcuni settori in cui la robustezza delle applicazioni è fondamentale come l'aviazione e la finanza. Sta inoltre guadagnando costantemente popolarità per lo sviluppo front-end di app Web attraverso, ad esempio, l'eccellente libreria XState.

Wikipedia definisce una macchina a stati finiti come:

Una macchina astratta che può trovarsi esattamente in uno di un numero finito di stati in un dato momento. L'FSM può passare da uno stato all'altro in risposta ad alcuni input esterni; il passaggio da uno stato all'altro è chiamato transizione. Un FSM è definito da un elenco dei suoi stati, dal suo stato iniziale e dalle condizioni per ciascuna transizione.

E inoltre:

Uno stato è una descrizione dello stato di un sistema in attesa di eseguire una transizione.

Gli FSM nella loro forma base non si adattano bene a sistemi di grandi dimensioni a causa del problema dell'esplosione dello stato. Recentemente, sono stati creati diagrammi di stato UML per estendere gli FSM con gerarchia e concorrenza, che sono fattori abilitanti per un ampio utilizzo degli FSM nelle applicazioni commerciali.

Dichiara la tua logica applicativa

Innanzitutto, che aspetto ha un FSM come codice? Esistono diversi modi per implementare una macchina a stati finiti in JavaScript.

  • Macchina a stati finiti come istruzione switch

Ecco una macchina che descrive i possibili stati in cui può trovarsi un JavaScript, implementato utilizzando un'istruzione 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; } }

Questo stile di codice risulterà familiare agli sviluppatori che hanno utilizzato la popolare libreria di gestione dello stato Redux.

  • Macchina a stati finiti come oggetto JavaScript

Ecco la stessa macchina implementata come oggetto JavaScript utilizzando la libreria 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, }), }, }, });

Sebbene la versione XState sia meno compatta, la rappresentazione dell'oggetto presenta diversi vantaggi:

  1. La stessa macchina a stati è un semplice JSON, che può essere facilmente mantenuto.
  2. Poiché è dichiarativo, la macchina può essere visualizzata.
  3. Se si utilizza TypeScript, il compilatore verifica che vengano eseguite solo transizioni di stato valide.

XState supporta i diagrammi di stato e implementa la specifica SCXML, che lo rende adatto all'uso in applicazioni molto grandi.

Visualizzazione diagrammi di stato di una promessa:

Macchina a stati finiti di una promessa
Macchina a stati finiti di una promessa.

Migliori pratiche di XSstate

Di seguito sono riportate alcune best practice da applicare quando si utilizza XState per mantenere i progetti gestibili.

Separare gli effetti collaterali dalla logica

XState consente di specificare gli effetti collaterali (che includono attività come la registrazione o le richieste API) dalla logica della macchina a stati.

Questo ha i seguenti vantaggi:

  1. Aiuta il rilevamento degli errori logici mantenendo il codice della macchina a stati il ​​più pulito e semplice possibile.
  2. Visualizza facilmente la macchina a stati senza dover rimuovere prima il boilerplate aggiuntivo.
  3. Test più semplici della macchina a stati iniettando servizi fittizi.
 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.. }, });

Sebbene sia allettante scrivere macchine a stati in questo modo mentre stai ancora facendo funzionare le cose, una migliore separazione delle preoccupazioni si ottiene passando gli effetti collaterali come opzioni:

 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 } });

Ciò consente anche un facile test unitario della macchina a stati, consentendo la presa in giro esplicita dei recuperi degli utenti:

 async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });

Dividere macchine di grandi dimensioni

Non è sempre immediatamente ovvio il modo migliore per strutturare un dominio problematico in una buona gerarchia di macchine a stati finiti all'inizio.

Suggerimento: utilizza la gerarchia dei componenti dell'interfaccia utente per guidare questo processo. Vedere la sezione successiva su come mappare le macchine a stati ai componenti dell'interfaccia utente.

Uno dei principali vantaggi dell'utilizzo delle macchine a stati consiste nel modellare in modo esplicito tutti gli stati e le transizioni tra gli stati nelle applicazioni in modo che il comportamento risultante sia chiaramente compreso, rendendo gli errori logici o le lacune facili da individuare.

Affinché ciò funzioni bene, le macchine devono essere mantenute piccole e concise. Fortunatamente, comporre gerarchicamente macchine a stati è facile. Nell'esempio dei diagrammi di stato canonici di un sistema a semaforo, lo stato "rosso" stesso diventa una macchina a stati figlio. La macchina "luce" madre non è a conoscenza degli stati interni di "rosso" ma decide quando entrare in "rosso" e quale è il comportamento previsto all'uscita:

Esempio di semaforo utilizzando diagrammi di stato
Esempio di semaforo utilizzando diagrammi di stato.

1-1 Mappatura di macchine a stati su componenti dell'interfaccia utente con stato

Prendi, ad esempio, un sito di eCommerce fittizio molto semplificato che ha le seguenti visualizzazioni React:

 <App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>

Il processo per la generazione di macchine a stati corrispondenti alle viste precedenti potrebbe essere familiare a coloro che hanno utilizzato la libreria di gestione dello stato Redux:

  1. Il componente ha uno stato che deve essere modellato? Ad esempio, l'amministratore/i prodotti non possono; potrebbero essere sufficienti recuperi paginati sul server più una soluzione di memorizzazione nella cache (come SWR). D'altra parte, componenti come SignInForm o il Carrello contengono solitamente lo stato che deve essere gestito, come i dati inseriti nei campi o il contenuto del carrello corrente.
  2. Le tecniche dello stato locale (ad esempio, setState() / useState() ) di React sono sufficienti per catturare il problema? Controllare se il modale popup del carrello è attualmente aperto non richiede l'uso di una macchina a stati finiti.
  3. È probabile che la macchina a stati risultante sia troppo complessa? In tal caso, dividere la macchina in più macchine più piccole, identificando le opportunità per creare macchine secondarie che possono essere riutilizzate altrove. Ad esempio, le macchine SignInForm e RegistrationForm possono richiamare istanze di un textFieldMachine figlio per modellare la convalida e lo stato per i campi e-mail, nome e password dell'utente.

Quando utilizzare un modello di macchina a stati finiti

Mentre i diagrammi di stato e gli FSM possono risolvere elegantemente alcuni problemi complessi, la scelta degli strumenti e degli approcci migliori da utilizzare per una particolare applicazione di solito dipende da diversi fattori.

Alcune situazioni in cui l'utilizzo di macchine a stati finiti brilla:

  • La tua domanda include una notevole componente di immissione dei dati in cui l'accessibilità o la visibilità del campo è regolata da regole complesse: ad esempio, l'inserimento di moduli in un'app per i reclami assicurativi. Qui, gli FSM aiutano a garantire che le regole aziendali siano implementate in modo solido. Inoltre, le funzionalità di visualizzazione dei diagrammi di stato possono essere utilizzate per aumentare la collaborazione con le parti interessate non tecniche e identificare i requisiti aziendali dettagliati nelle prime fasi dello sviluppo.
  • Per funzionare meglio su connessioni più lente e offrire agli utenti esperienze più fedeli , le app Web devono gestire flussi di dati asincroni sempre più complessi. Gli FSM modellano in modo esplicito tutti gli stati in cui può trovarsi un'applicazione e i diagrammi di stato possono essere visualizzati per aiutare a diagnosticare e risolvere problemi di dati asincroni.
  • Applicazioni che richiedono molte animazioni sofisticate basate sullo stato. Per le animazioni complesse, le tecniche per modellare le animazioni come flussi di eventi nel tempo con RxJS sono popolari. Per molti scenari, questo funziona bene, tuttavia, quando un'animazione ricca è combinata con una serie complessa di stati noti, gli FSM forniscono "punti di riposo" ben definiti tra i quali scorrono le animazioni. Gli FSM combinati con RxJS sembrano la combinazione perfetta per aiutare a fornire la prossima ondata di esperienze utente ad alta fedeltà ed espressive.
  • Applicazioni rich client come l'editing di foto o video, strumenti per la creazione di diagrammi o giochi in cui gran parte della logica aziendale risiede sul lato client. Gli FSM sono intrinsecamente disaccoppiati dal framework o dalle librerie dell'interfaccia utente e sono facili da scrivere test per consentire l'iterazione rapida di applicazioni di alta qualità e la spedizione con sicurezza.

Avvertenze sulle macchine a stati finiti

  • L'approccio generale, le migliori pratiche e l'API per le librerie di diagrammi di stato come XState sono nuovi per la maggior parte degli sviluppatori front-end, che richiederanno un investimento di tempo e risorse per diventare produttivi, in particolare per i team meno esperti.
  • Simile all'avvertenza precedente, mentre la popolarità di XState continua a crescere ed è ben documentata, le librerie di gestione statali esistenti come Redux, MobX o React Context hanno un seguito enorme che fornisce una vasta gamma di informazioni online che XState non corrisponde ancora.
  • Per le applicazioni che seguono un modello CRUD più semplice, saranno sufficienti le tecniche di gestione dello stato esistenti combinate con una buona libreria di memorizzazione nella cache delle risorse come SWR o React Query. Qui, i vincoli extra forniti dagli FSM, sebbene incredibilmente utili nelle app complesse, possono rallentare lo sviluppo.
  • Gli strumenti sono meno maturi rispetto ad altre librerie di gestione dello stato, con il lavoro ancora in corso per migliorare il supporto di TypeScript e le estensioni degli strumenti di sviluppo del browser.

Avvolgendo

La popolarità e l'adozione della programmazione dichiarativa nella comunità di sviluppo web continuano a crescere.

Mentre lo sviluppo web moderno continua a diventare più complesso, le librerie e i framework che adottano approcci di programmazione dichiarativa emergono con frequenza crescente. Il motivo sembra chiaro: è necessario creare approcci più semplici e descrittivi per scrivere il software.

L'uso di linguaggi fortemente tipizzati come TypeScript consente di modellare in modo succinto ed esplicito le entità nel dominio dell'applicazione, riducendo la possibilità di errori e la quantità di codice di controllo soggetto a errori che deve essere manipolato. L'adozione di macchine a stati finiti e diagrammi di stato sul front-end consente agli sviluppatori di dichiarare la logica di business di un'applicazione attraverso transizioni di stato, consentendo lo sviluppo di strumenti di visualizzazione avanzati e aumentando l'opportunità di una stretta collaborazione con i non sviluppatori.

Quando lo facciamo, spostiamo la nostra attenzione dai dadi e bulloni di come funziona l'applicazione a una visione di livello superiore che ci consente di concentrarci ancora di più sulle esigenze del cliente e creare valore duraturo.