Una guida alla programmazione orientata ai processi in elisir e OTP
Pubblicato: 2022-03-11Alla gente piace classificare i linguaggi di programmazione in paradigmi. Esistono linguaggi orientati agli oggetti (OO), linguaggi imperativi, linguaggi funzionali, ecc. Questo può essere utile per capire quali linguaggi risolvono problemi simili e quali tipi di problemi una lingua è destinata a risolvere.
In ogni caso un paradigma ha generalmente un focus e una tecnica "principali" che sono la forza trainante di quella famiglia di linguaggi:
Nei linguaggi OO, è la classe o l'oggetto come un modo per incapsulare lo stato (dati) con la manipolazione di quello stato (metodi).
Nei linguaggi funzionali, può essere la manipolazione delle funzioni stesse o dei dati immutabili passati da una funzione all'altra.
Mentre Elixir (e Erlang prima di esso) sono spesso classificati come linguaggi funzionali perché esibiscono i dati immutabili comuni ai linguaggi funzionali, direi che rappresentano un paradigma separato da molti linguaggi funzionali . Esistono e sono adottati a causa dell'esistenza di OTP, quindi li classificherei come linguaggi orientati ai processi .
In questo post, coglieremo il significato di cosa sia la programmazione orientata al processo quando si utilizzano questi linguaggi, esploreremo le differenze e le somiglianze con altri paradigmi, vedremo le implicazioni sia per la formazione che per l'adozione e concluderemo con un breve esempio di programmazione orientata al processo.
Che cos'è la programmazione orientata al processo?
Cominciamo con una definizione: la programmazione orientata al processo è un paradigma basato su Communicating Sequential Processes, originariamente da un articolo di Tony Hoare nel 1977. Questo è anche popolarmente chiamato il modello attore della concorrenza. Altre lingue con qualche relazione con questo lavoro originale includono Occam, Limbo e Go. Il documento formale si occupa solo di comunicazione sincrona; la maggior parte dei modelli di attori (incluso OTP) utilizza anche la comunicazione asincrona. È sempre possibile creare una comunicazione sincrona sulla comunicazione asincrona e OTP supporta entrambe le forme.
Su questa cronologia, OTP ha creato un sistema per il calcolo a tolleranza di errore comunicando processi sequenziali. Le strutture tolleranti ai guasti derivano da un approccio "lascia che fallisca" con un solido recupero degli errori sotto forma di supervisori e l'uso dell'elaborazione distribuita abilitata dal modello dell'attore. Il "lascia che fallisca" può essere contrapposto a "impedire che fallisca", poiché il primo è molto più facile da accontentare ed è stato dimostrato in OTP per essere molto più affidabile del secondo. Il motivo è che lo sforzo di programmazione richiesto per prevenire gli errori (come mostrato nel modello di eccezione verificata in Java) è molto più impegnativo e impegnativo.
Quindi, la programmazione orientata al processo può essere definita come un paradigma in cui la struttura del processo e la comunicazione tra i processi di un sistema sono le preoccupazioni primarie .
Programmazione orientata agli oggetti e programmazione orientata al processo
Nella programmazione orientata agli oggetti, la struttura statica dei dati e delle funzioni è la preoccupazione principale. Quali metodi sono necessari per manipolare i dati inclusi e quali dovrebbero essere le connessioni tra oggetti o classi. Pertanto, il diagramma delle classi di UML è un ottimo esempio di questo focus, come mostrato nella Figura 1.
Si può notare che una critica comune alla programmazione orientata agli oggetti è che non esiste un flusso di controllo visibile. Poiché i sistemi sono composti da un gran numero di classi/oggetti definiti separatamente, può essere difficile per una persona meno esperta visualizzare il flusso di controllo di un sistema. Ciò è particolarmente vero per i sistemi con molta ereditarietà, che utilizzano interfacce astratte o non hanno una tipizzazione forte. Nella maggior parte dei casi, diventa importante per lo sviluppatore memorizzare una grande quantità della struttura del sistema per essere efficace (quali classi hanno quali metodi e quali sono usati in quali modi).
Il punto di forza dell'approccio di sviluppo orientato agli oggetti è che il sistema può essere esteso per supportare nuovi tipi di oggetti con un impatto limitato sul codice esistente, purché i nuovi tipi di oggetti siano conformi alle aspettative del codice esistente.
Programmazione funzionale vs. orientata al processo
Molti linguaggi di programmazione funzionale affrontano la concorrenza in vari modi, ma il loro obiettivo principale è il passaggio di dati immutabili tra funzioni o la creazione di funzioni da altre funzioni (funzioni di ordine superiore che generano funzioni). Per la maggior parte, il focus del linguaggio è ancora un singolo spazio di indirizzi o eseguibile e le comunicazioni tra tali eseguibili vengono gestite in un modo specifico del sistema operativo.
Ad esempio, Scala è un linguaggio funzionale costruito sulla Java Virtual Machine. Sebbene possa accedere alle strutture Java per la comunicazione, non è una parte intrinseca del linguaggio. Sebbene sia un linguaggio comune utilizzato nella programmazione Spark, è di nuovo una libreria utilizzata insieme al linguaggio.
Un punto di forza del paradigma funzionale è la capacità di visualizzare il flusso di controllo di un sistema data la funzione di primo livello. Il flusso di controllo è esplicito in quanto ogni funzione chiama altre funzioni e passa tutti i dati da una all'altra. Nel paradigma funzionale non ci sono effetti collaterali, il che rende più facile la determinazione del problema. La sfida con i sistemi funzionali puri è che gli "effetti collaterali" devono avere uno stato persistente. In sistemi ben progettati, la persistenza dello stato viene gestita al livello più alto del flusso di controllo, consentendo alla maggior parte del sistema di essere esente da effetti collaterali.
Elisir/OTP e Programmazione orientata ai processi
In Elixir/Erlang e OTP, le primitive di comunicazione fanno parte della macchina virtuale che esegue il linguaggio. La capacità di comunicare tra processi e tra macchine è incorporata e centrale nel sistema linguistico. Ciò sottolinea l'importanza della comunicazione in questo paradigma e in questi sistemi linguistici.
Mentre il linguaggio Elisir è prevalentemente funzionale in termini di logica espressa nel linguaggio, il suo uso è orientato al processo .
Cosa significa essere orientati al processo?
Essere orientati al processo come definito in questo post significa progettare prima un sistema nella forma di quali processi esistono e come comunicano. Una delle domande principali è quali processi sono statici e quali sono dinamici, quali sono generati su richiesta alle richieste, che hanno uno scopo di lunga durata, che mantengono uno stato condiviso o parte dello stato condiviso del sistema e quali caratteristiche di il sistema è intrinsecamente simultaneo. Proprio come OO ha tipi di oggetti e funzionale ha tipi di funzioni, la programmazione orientata al processo ha tipi di processi.
In quanto tale, una progettazione orientata al processo è l'identificazione dell'insieme di tipi di processo necessari per risolvere un problema o soddisfare un'esigenza .
L'aspetto del tempo entra rapidamente negli sforzi di progettazione e requisiti. Qual è il ciclo di vita del sistema? Quali esigenze personalizzate sono occasionali e quali costanti? Dov'è il carico nel sistema e qual è la velocità e il volume previsti? È solo dopo aver compreso questo tipo di considerazioni che una progettazione orientata al processo inizia a definire la funzione di ciascun processo o la logica da eseguire.
Implicazioni di formazione
L'implicazione di questa categorizzazione per la formazione è che la formazione dovrebbe iniziare non con la sintassi del linguaggio o con esempi di "Hello World", ma con il pensiero ingegneristico dei sistemi e un focus progettuale sull'allocazione dei processi .
Le preoccupazioni relative alla codifica sono secondarie rispetto alla progettazione e all'allocazione del processo, che vengono affrontate al meglio a un livello superiore, e implicano un pensiero interfunzionale su ciclo di vita, QA, DevOps e requisiti aziendali dei clienti. Qualsiasi corso di formazione in Elixir o Erlang deve (e generalmente include) OTP e dovrebbe avere un orientamento al processo dall'inizio, non come l'approccio di tipo "Ora puoi programmare in Elixir, quindi facciamo concorrenza".
Implicazioni dell'adozione
L'implicazione per l'adozione è che il linguaggio e il sistema vengono applicati meglio a problemi che richiedono comunicazione e/o distribuzione dell'informatica. I problemi che rappresentano un singolo carico di lavoro su un singolo computer sono meno interessanti in questo spazio e possono essere affrontati meglio con un'altra lingua. I sistemi di elaborazione continua di lunga durata sono un obiettivo primario per questo linguaggio perché ha una tolleranza agli errori integrata da zero.
Per la documentazione e il lavoro di progettazione, può essere molto utile utilizzare una notazione grafica (come la figura 1 per i linguaggi OO). Il suggerimento per l'elisir e la programmazione orientata al processo da UML sarebbe il diagramma di sequenza (esempio nella figura 2) per mostrare le relazioni temporali tra i processi e identificare quali processi sono coinvolti nel soddisfare una richiesta. Non esiste un tipo di diagramma UML per l'acquisizione del ciclo di vita e della struttura del processo, ma potrebbe essere rappresentato con un semplice diagramma a scatola e freccia per i tipi di processo e le loro relazioni. Ad esempio, Figura 3:
Un esempio di orientamento al processo
Infine, analizzeremo un breve esempio di applicazione dell'orientamento del processo a un problema. Supponiamo di avere il compito di fornire un sistema che supporti le elezioni globali. Questo problema viene scelto in quanto molte attività individuali vengono eseguite a raffica, ma l'aggregazione o la sintesi dei risultati è auspicabile in tempo reale e potrebbe presentare un carico significativo.

Progettazione e allocazione del processo iniziale
Inizialmente possiamo vedere che il voto da parte di ciascun individuo è un'esplosione di traffico verso il sistema da molti input discreti, non è ordinato nel tempo e può avere un carico elevato. Per supportare questa attività, vorremmo che un gran numero di processi raccolga tutti questi input e li inoltri a un processo più centrale per la tabulazione. Questi processi potrebbero essere localizzati vicino alle popolazioni di ciascun paese che genererebbero voti e quindi fornire una bassa latenza. Manterrebbero i risultati locali, registrerebbero immediatamente i loro input e li inoltrano per la tabulazione in batch per ridurre la larghezza di banda e il sovraccarico.
Inizialmente possiamo vedere che saranno necessari processi che tengano traccia dei voti in ciascuna giurisdizione in cui devono essere presentati i risultati. Supponiamo per questo esempio di dover tenere traccia dei risultati per ogni paese e all'interno di ogni paese per provincia/stato. Per supportare questa attività, vorremmo che almeno un processo per paese esegua il calcolo e mantenga i totali attuali e un altro set per ogni stato/provincia in ogni paese. Ciò presuppone che dobbiamo essere in grado di rispondere ai totali per paese e stato/provincia in tempo reale oa bassa latenza. Se i risultati possono essere ottenuti da un sistema di database, potremmo scegliere un'allocazione di processo diversa in cui i totali vengono aggiornati da processi transitori. Il vantaggio dell'utilizzo di processi dedicati per questi calcoli è che i risultati si verificano alla velocità della memoria e possono essere ottenuti con una bassa latenza.
Infine, possiamo vedere che moltissime persone visualizzeranno i risultati. Questi processi possono essere partizionati in molti modi. Potremmo voler distribuire il carico posizionando i processi in ogni paese responsabili dei risultati di quel paese. I processi potrebbero memorizzare nella cache i risultati dei processi di calcolo per ridurre il carico di query sui processi di calcolo e/o i processi di calcolo potrebbero inviare i loro risultati ai processi dei risultati appropriati su base periodica, quando i risultati cambiano di una quantità significativa o in base al processo di calcolo che diventa inattivo indicando una velocità di cambiamento rallentata.
In tutti e tre i tipi di processo, possiamo ridimensionare i processi indipendentemente l'uno dall'altro, distribuirli geograficamente e garantire che i risultati non vadano mai persi grazie al riconoscimento attivo dei trasferimenti di dati tra processi.
Come discusso, abbiamo iniziato l'esempio con una progettazione di processo indipendente dalla logica di business in ogni processo. Nei casi in cui la logica di business ha requisiti specifici per l'aggregazione dei dati o l'area geografica che possono influire sull'allocazione del processo in modo iterativo. Il nostro progetto di processo finora è mostrato nella figura 4.
L'uso di processi separati per ricevere i voti consente di ricevere ciascun voto indipendentemente da qualsiasi altro voto, di registrarlo al momento della ricezione e di inviarlo in batch alla serie successiva di processi, riducendo significativamente il carico su quei sistemi. Per un sistema che consuma una grande quantità di dati, la riduzione del volume dei dati mediante l'uso di livelli di processi è un modello comune e utile.
Eseguendo il calcolo in un insieme isolato di processi, possiamo gestire il carico su quei processi e garantirne la stabilità e i requisiti di risorse.
Posizionando la presentazione dei risultati in un insieme isolato di processi, controlliamo il carico sul resto del sistema e consentiamo di ridimensionare dinamicamente l'insieme di processi per il carico.
Requisiti addizionali
Ora, aggiungiamo alcuni requisiti complicati. Supponiamo che in ogni giurisdizione (paese o stato), la tabulazione dei voti possa dare come risultato un risultato proporzionale, un risultato che vince tutto o nessun risultato se vengono espressi voti insufficienti rispetto alla popolazione di quella giurisdizione. Ogni giurisdizione ha il controllo su questi aspetti. Con questa modifica, quindi, i risultati dei paesi non sono una semplice aggregazione dei risultati grezzi del voto, ma sono un'aggregazione dei risultati stato/provincia. Ciò modifica l'allocazione del processo rispetto all'originale per richiedere che i risultati dei processi statali/provinciali confluiscano nei processi nazionali. Se il protocollo utilizzato tra la raccolta dei voti e i processi stato/provincia e provincia-paese è lo stesso, la logica di aggregazione può essere riutilizzata, ma sono necessari processi distinti che contengono i risultati e i loro percorsi di comunicazione sono diversi, come mostrato in Figura 5.
Il codice
Per completare l'esempio, esamineremo un'implementazione dell'esempio in Elixir OTP. Per semplificare le cose, questo esempio presuppone che un server Web come Phoenix venga utilizzato per elaborare richieste Web effettive e che tali servizi Web facciano richieste al processo sopra identificato. Questo ha il vantaggio di semplificare l'esempio e mantenere l'attenzione su Elixir/OTP. In un sistema di produzione, avere questi processi separati presenta alcuni vantaggi oltre a separare le preoccupazioni, consente un'implementazione flessibile, distribuisce il carico e riduce la latenza. Il codice sorgente completo con i test può essere trovato su https://github.com/technomage/voting. La fonte è abbreviata in questo post per leggibilità. Ciascun processo riportato di seguito si inserisce in un albero di supervisione OTP per garantire che i processi vengano riavviati in caso di errore. Vedere la fonte per ulteriori informazioni su questo aspetto dell'esempio.
Registratore di voti
Questo processo riceve i voti, li registra in un archivio permanente e invia in batch i risultati agli aggregatori. Il modulo VoteRecoder utilizza Task.Supervisor per gestire attività di breve durata per registrare ogni voto.
defmodule Voting.VoteRecorder do @moduledoc """ This module receives votes and sends them to the proper aggregator. This module uses supervised tasks to ensure that any failure is recovered from and the vote is not lost. """ @doc """ Start a task to track the submittal of a vote to an aggregator. This is a supervised task to ensure completion. """ def cast_vote where, who do Task.Supervisor.async_nolink(Voting.VoteTaskSupervisor, fn -> Voting.Aggregator.submit_vote where, who end) |> Task.await end endAggregatore di voti
Questo processo aggrega i voti all'interno di una giurisdizione, calcola il risultato per quella giurisdizione e inoltra i riepiloghi dei voti al processo successivo (una giurisdizione di livello superiore o un presentatore dei risultati).
defmodule Voting.Aggregator do use GenStage ... @doc """ Submit a single vote to an aggregator """ def submit_vote id, candidate do pid = __MODULE__.via_tuple(id) :ok = GenStage.call pid, {:submit_vote, candidate} end @doc """ Respond to requests """ def handle_call {:submit_vote, candidate}, _from, state do n = state.votes[candidate] || 0 state = %{state | votes: Map.put(state.votes, candidate, n+1)} {:reply, :ok, [%{state.id => state.votes}], state} end @doc """ Handle events from subordinate aggregators """ def handle_events events, _from, state do votes = Enum.reduce events, state.votes, fn e, votes -> Enum.reduce e, votes, fn {k,v}, votes -> Map.put(votes, k, v) # replace any entries for subordinates end end # Any jurisdiction specific policy would go here # Sum the votes by candidate for the published event merged = Enum.reduce votes, %{}, fn {j, jv}, votes -> # Each jourisdiction is summed for each candidate Enum.reduce jv, votes, fn {candidate, tot}, votes -> Logger.debug "@@@@ Votes in #{inspect j} for #{inspect candidate}: #{inspect tot}" n = votes[candidate] || 0 Map.put(votes, candidate, n + tot) end end # Return the published event and the state which retains # Votes by jourisdiction {:noreply, [%{state.id => merged}], %{state | votes: votes}} end endPresentatore dei risultati
Questo processo riceve voti da un aggregatore e li memorizza nella cache per soddisfare le richieste di presentazione dei risultati.
defmodule Voting.ResultPresenter do use GenStage … @doc """ Handle requests for results """ def handle_call :get_votes, _from, state do {:reply, {:ok, state.votes}, [], state} end @doc """ Obtain the results from this presenter """ def get_votes id do pid = Voting.ResultPresenter.via_tuple(id) {:ok, votes} = GenStage.call pid, :get_votes votes end @doc """ Receive votes from aggregator """ def handle_events events, _from, state do Logger.debug "@@@@ Presenter received: #{inspect events}" votes = Enum.reduce events, state.votes, fn v, votes -> Enum.reduce v, votes, fn {k,v}, votes -> Map.put(votes, k, v) end end {:noreply, [], %{state | votes: votes}} end endPorta via
Questo post ha esplorato Elixir/OTP dal suo potenziale come linguaggio orientato al processo, lo ha confrontato con i paradigmi orientati agli oggetti e funzionali e ha esaminato le implicazioni di ciò per la formazione e l'adozione.
Il post include anche un breve esempio di applicazione di questo orientamento a un problema di esempio. Nel caso in cui desideri rivedere tutto il codice, ecco di nuovo un collegamento al nostro esempio su GitHub, solo così non devi tornare indietro per cercarlo.
Il punto chiave è vedere i sistemi come un insieme di processi di comunicazione. Pianificare il sistema da un punto di vista della progettazione del processo in primo luogo e in secondo luogo da un punto di vista della codifica logica.
