Un ghid pentru programarea orientată pe proces în Elixir și OTP
Publicat: 2022-03-11Oamenilor le place să clasifice limbajele de programare în paradigme. Există limbaje orientate pe obiecte (OO), limbaje imperative, limbaje funcționale etc. Acest lucru poate fi util pentru a afla care limbi rezolvă probleme similare și ce tipuri de probleme este intenționat să rezolve un limbaj.
În fiecare caz, o paradigmă are, în general, un focus „principal” și o tehnică care este forța motrice pentru acea familie de limbi:
În limbajele OO, este clasa sau obiectul ca o modalitate de a încapsula starea (date) cu manipularea acelei stări (metode).
În limbajele funcționale, poate fi manipularea funcțiilor în sine sau datele imuabile transmise de la o funcție la alta.
În timp ce Elixir (și Erlang înainte de el) sunt adesea clasificate ca limbaje funcționale, deoarece prezintă datele imuabile comune limbilor funcționale, aș spune că reprezintă o paradigmă separată de multe limbaje funcționale . Ele există și sunt adoptate datorită existenței OTP, așa că le-aș cataloga drept limbaje orientate pe proces .
În această postare, vom surprinde semnificația a ceea ce este programarea orientată pe proces atunci când folosim aceste limbaje, vom explora diferențele și asemănările cu alte paradigme, vom vedea implicațiile atât pentru instruire, cât și pentru adoptare și vom încheia cu un scurt exemplu de programare orientată pe proces.
Ce este programarea orientată pe proces?
Să începem cu o definiție: Programarea orientată pe proces este o paradigmă bazată pe Comunicarea proceselor secvențiale, inițial dintr-o lucrare a lui Tony Hoare din 1977. Acesta este denumit popular și modelul actor al concurenței. Alte limbi cu o anumită legătură cu această lucrare originală includ Occam, Limbo și Go. Lucrarea formală se ocupă doar de comunicarea sincronă; majoritatea modelelor de actori (inclusiv OTP) folosesc și comunicarea asincronă. Este întotdeauna posibil să construiți o comunicare sincronă pe deasupra comunicării asincrone, iar OTP acceptă ambele forme.
Pe această istorie, OTP a creat un sistem de calcul cu toleranță la erori prin comunicarea proceselor secvențiale. Facilitățile tolerante la erori provin dintr-o abordare „lasă eșuarea” cu recuperare solidă a erorilor sub formă de supraveghetori și utilizarea procesării distribuite activată de modelul actor. „Lasă-l să eșueze” poate fi comparat cu „prevenirea eșecului”, deoarece primul este mult mai ușor de adaptat și s-a dovedit în OTP a fi mult mai fiabil decât cel din urmă. Motivul este că efortul de programare necesar pentru a preveni eșecurile (după cum se arată în modelul de excepție verificat Java) este mult mai implicat și mai solicitant.
Deci, programarea orientată pe proces poate fi definită ca o paradigmă în care structura procesului și comunicarea dintre procesele unui sistem sunt preocupările primare .
Programare orientată pe obiecte vs. Programare orientată pe proces
În programarea orientată pe obiecte, structura statică a datelor și a funcției este preocuparea principală. Ce metode sunt necesare pentru a manipula datele incluse și care ar trebui să fie conexiunile dintre obiecte sau clase. Astfel, diagrama de clasă a UML este un prim exemplu al acestui focus, așa cum se vede în Figura 1.
Se poate observa că o critică comună a programării orientate pe obiect este că nu există un flux de control vizibil. Deoarece sistemele sunt compuse dintr-un număr mare de clase/obiecte definite separat, poate fi dificil pentru o persoană mai puțin experimentată să vizualizeze fluxul de control al unui sistem. Acest lucru este valabil mai ales pentru sistemele cu multă moștenire, care folosesc interfețe abstracte sau nu au o tastare puternică. În cele mai multe cazuri, devine important ca dezvoltator să memoreze o cantitate mare din structura sistemului pentru a fi eficient (ce clase au ce metode și care sunt utilizate în ce moduri).
Punctul forte al abordării de dezvoltare orientată pe obiect este că sistemul poate fi extins pentru a suporta noi tipuri de obiecte cu impact limitat asupra codului existent, atâta timp cât noile tipuri de obiecte se conformează așteptărilor codului existent.
Programare funcțională versus programare orientată pe proces
Multe limbaje de programare funcționale abordează concurența în diferite moduri, dar obiectivul lor principal este trecerea de date imuabile între funcții sau crearea de funcții din alte funcții (funcții de ordin superior care generează funcții). În cea mai mare parte, accentul limbajului este încă un singur spațiu de adrese sau executabil, iar comunicațiile între astfel de executabile sunt gestionate într-o manieră specifică sistemului de operare.
De exemplu, Scala este un limbaj funcțional construit pe Java Virtual Machine. Deși poate accesa facilitățile Java pentru comunicare, nu este o parte inerentă a limbajului. Deși este un limbaj comun folosit în programarea Spark, este din nou o bibliotecă folosită împreună cu limbajul.
Un punct forte al paradigmei funcționale este capacitatea de a vizualiza fluxul de control al unui sistem având în vedere funcția de nivel superior. Fluxul de control este explicit prin faptul că fiecare funcție apelează alte funcții și transmite toate datele de la una la alta. În paradigma funcțională nu există efecte secundare, ceea ce face mai ușoară determinarea problemei. Provocarea cu sistemele funcționale pure este că „efectele secundare” trebuie să aibă o stare persistentă. În sistemele bine arhitecturate, persistența stării este gestionată la nivelul superior al fluxului de control, permițând ca majoritatea sistemului să nu aibă efecte secundare.
Elixir/OTP și programare orientată pe proces
În Elixir/Erlang și OTP, primitivele de comunicare fac parte din mașina virtuală care execută limbajul. Abilitatea de a comunica între procese și între mașini este încorporată și centrală pentru sistemul de limbaj. Acest lucru subliniază importanța comunicării în această paradigmă și în aceste sisteme de limbaj.
În timp ce limbajul Elixir este predominant funcțional în ceea ce privește logica exprimată în limbaj, utilizarea sa este orientată spre proces .
Ce înseamnă să fii orientat către proces?
A fi orientat pe proces, așa cum este definit în această postare, înseamnă a proiecta mai întâi un sistem sub forma proceselor care există și a modului în care acestea comunică. Una dintre principalele întrebări este ce procese sunt statice și care sunt dinamice, care sunt generate la cerere la solicitări, care servesc unui scop de lungă durată, care dețin o stare partajată sau o parte a stării partajate a sistemului și ce caracteristici ale sistemele sunt în mod inerent concurente. La fel cum OO are tipuri de obiecte, iar funcțional are tipuri de funcții, programarea orientată pe proces are tipuri de procese.
Ca atare, un design orientat spre proces este identificarea setului de tipuri de proces necesare pentru a rezolva o problemă sau a răspunde unei nevoi .
Aspectul timpului intră rapid în eforturile de proiectare și cerințe. Care este ciclul de viață al sistemului? Ce nevoi personalizate sunt ocazionale și care sunt constante? Unde este sarcina în sistem și care este viteza și volumul așteptate? Abia după ce aceste tipuri de considerații sunt înțelese, un proiect orientat pe proces începe să definească funcția fiecărui proces sau logica care trebuie executată.
Implicații ale antrenamentului
Implicația acestei clasificări în formare este că formarea ar trebui să înceapă nu cu sintaxa limbajului sau exemplele „Hello World”, ci cu gândirea de inginerie a sistemelor și un accent pe proiectare pe alocarea proceselor .
Preocupările privind codificarea sunt secundare proiectării și alocării procesului, care sunt cel mai bine abordate la un nivel superior și implică o gândire interfuncțională despre ciclul de viață, QA, DevOps și cerințele de afaceri ale clienților. Orice curs de formare în Elixir sau Erlang trebuie (și în general include) să includă OTP și ar trebui să aibă o orientare către proces de la început, nu ca abordarea de tip „Acum poți codifica în Elixir, deci să facem concurență”.
Implicații ale adopției
Implicația pentru adoptare este că limbajul și sistemul sunt mai bine aplicate problemelor care necesită comunicare și/sau distribuție de calcul. Problemele care reprezintă o singură sarcină de lucru pe un singur computer sunt mai puțin interesante în acest spațiu și pot fi rezolvate mai bine cu o altă limbă. Sistemele de procesare continuă cu durată lungă de viață sunt o țintă principală pentru acest limbaj, deoarece are toleranță la erori integrată de la zero.
Pentru documentare și lucrări de proiectare, poate fi foarte util să folosiți o notație grafică (cum ar fi figura 1 pentru limbajele OO). Sugestia pentru Elixir și programarea orientată pe proces de la UML ar fi diagrama de secvență (exemplu în figura 2) pentru a arăta relațiile temporale dintre procese și a identifica ce procese sunt implicate în deservirea unei cereri. Nu există un tip de diagramă UML pentru capturarea ciclului de viață și a structurii procesului, dar ar putea fi reprezentată cu o casetă simplă și diagramă cu săgeți pentru tipurile de proces și relațiile lor. De exemplu, Figura 3:
Un exemplu de orientare a procesului
În cele din urmă, vom parcurge un scurt exemplu de aplicare a orientării procesului la o problemă. Să presupunem că avem sarcina de a oferi un sistem care să sprijine alegerile globale. Această problemă este aleasă prin faptul că multe activități individuale sunt efectuate în rafale, dar agregarea sau rezumarea rezultatelor este de dorit în timp real și ar putea avea o sarcină semnificativă.

Proiectarea și alocarea procesului inițial
Putem vedea inițial că exprimarea voturilor de către fiecare individ este o explozie de trafic către sistem de la multe intrări discrete, nu este ordonată în timp și poate avea o sarcină mare. Pentru a sprijini această activitate, am dori un număr mare de procese care să colecteze toate aceste intrări și să le transmită către un proces mai central pentru tabulare. Aceste procese ar putea fi localizate în apropierea populațiilor din fiecare țară care ar genera voturi, oferind astfel o latență scăzută. Ei ar păstra rezultatele locale, ar înregistra intrările lor imediat și le-ar transmite pentru tabulare în loturi pentru a reduce lățimea de bandă și suprasarcina.
Putem vedea inițial că va trebui să existe procese care să urmărească voturile în fiecare jurisdicție în care trebuie prezentate rezultatele. Să presupunem pentru acest exemplu că trebuie să urmărim rezultatele pentru fiecare țară și în fiecare țară în funcție de provincie/stat. Pentru a susține această activitate, am dori cel puțin un proces per țară care efectuează calculul și reține totalurile curente și un alt set pentru fiecare stat/provinție din fiecare țară. Aceasta presupune că trebuie să putem răspunde la totalurile pentru țară și stat/provinție în timp real sau cu latență scăzută. Dacă rezultatele pot fi obținute dintr-un sistem de bază de date, am putea alege o alocare diferită a procesului în care totalurile sunt actualizate prin procese tranzitorii. Avantajul utilizării proceselor dedicate pentru aceste calcule este că rezultatele apar cu viteza memoriei și pot fi obținute cu o latență scăzută.
În cele din urmă, putem vedea că o mulțime și o mulțime de oameni vor vedea rezultatele. Aceste procese pot fi partiționate în mai multe moduri. Este posibil să dorim să distribuim încărcătura prin plasarea proceselor în fiecare țară responsabilă pentru rezultatele țării respective. Procesele ar putea stoca în cache rezultatele proceselor de calcul pentru a reduce sarcina de interogare asupra proceselor de calcul și/sau procesele de calcul ar putea împinge rezultatele lor către procesele de rezultate adecvate în mod periodic, atunci când rezultatele se modifică într-o cantitate semnificativă, sau la procesul de calcul devine inactiv indicând o rată încetinită a schimbării.
În toate cele trei tipuri de procese, putem scala procesele independent unul de celălalt, le putem distribui geografic și ne putem asigura că rezultatele nu se pierd niciodată prin recunoașterea activă a transferurilor de date între procese.
După cum am discutat, am început exemplul cu un design de proces independent de logica de afaceri din fiecare proces. În cazurile în care logica de afaceri are cerințe specifice pentru agregarea datelor sau geografie care pot afecta alocarea procesului în mod iterativ. Proiectarea procesului nostru de până acum este prezentată în figura 4.
Utilizarea unor procese separate pentru a primi voturi permite ca fiecare vot să fie primit independent de orice alt vot, înregistrat la primire și grupat la următorul set de procese, reducând în mod semnificativ încărcarea acestor sisteme. Pentru un sistem care consumă o cantitate mare de date, reducerea volumului de date prin utilizarea straturilor de procese este un model comun și util.
Efectuând calculul într-un set izolat de procese, putem gestiona sarcina acestor procese și le putem asigura stabilitatea și cerințele de resurse.
Prin plasarea prezentării rezultatelor într-un set izolat de procese, amândoi controlăm încărcarea pentru restul sistemului și permitem setului de procese să fie scalat dinamic pentru încărcare.
Cerințe suplimentare
Acum, să adăugăm câteva cerințe complicate. Să presupunem că în fiecare jurisdicție (țară sau stat), întabularea voturilor poate avea ca rezultat un rezultat proporțional, un rezultat în care câștigătorul ia totul sau niciun rezultat dacă nu sunt exprimate suficiente voturi în raport cu populația acelei jurisdicții. Fiecare jurisdicție are control asupra acestor aspecte. Odată cu această schimbare, rezultatele țărilor nu sunt o simplă agregare a rezultatelor brute ale votului, ci sunt o agregare a rezultatelor statului/provinției. Acest lucru modifică alocarea procesului față de cea originală pentru a solicita ca rezultatele din procesele de stat/provincie să fie introduse în procesele din țară. Dacă protocolul utilizat între procesele de colectare a voturilor și procesele de stat/provincie și provincie la țară este același, atunci logica de agregare poate fi reutilizată, dar sunt necesare procese distincte care dețin rezultatele și căile lor de comunicare sunt diferite, așa cum se arată în figura 5.
Codul
Pentru a completa exemplul, vom revizui o implementare a exemplului în Elixir OTP. Pentru a simplifica lucrurile, acest exemplu presupune că un server web precum Phoenix este utilizat pentru a procesa cererile web reale, iar acele servicii web fac cereri către procesul identificat mai sus. Acest lucru are avantajul de a simplifica exemplul și de a menține accentul pe Elixir/OTP. Într-un sistem de producție, ca acestea să fie procese separate are unele avantaje, precum și preocupări separate, permite o implementare flexibilă, distribuie sarcina și reduce latența. Codul sursă complet cu teste poate fi găsit la https://github.com/technomage/voting. Sursa este prescurtată în această postare pentru a fi lizibilă. Fiecare proces de mai jos se încadrează într-un arbore de supraveghere OTP pentru a se asigura că procesele sunt repornite în caz de eșec. Consultați sursa pentru mai multe despre acest aspect al exemplului.
Înregistrator de voturi
Acest proces primește voturi, le înregistrează într-un magazin persistent și trimite rezultatele la agregatori. Modulul VoteRecoder folosește Task.Supervisor pentru a gestiona sarcini de scurtă durată pentru a înregistra fiecare vot.
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 end
Agregator de voturi
Acest proces adună voturile dintr-o jurisdicție, calculează rezultatul pentru acea jurisdicție și transmite rezumatele voturilor către următorul proces superior (o jurisdicție de nivel superior sau un prezentator de rezultate).
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 end
Prezentator de rezultate
Acest proces primește voturi de la un agregator și memorează acele rezultate în cererile de servicii pentru prezentarea rezultatelor.
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 end
La pachet
Această postare a explorat Elixir/OTP din potențialul său ca limbaj orientat pe proces, a comparat acest lucru cu paradigmele orientate pe obiect și funcționale și a trecut în revistă implicațiile acestui lucru asupra instruirii și adoptării.
Postarea include, de asemenea, un scurt exemplu de aplicare a acestei orientări la un exemplu de problemă. În cazul în care doriți să revizuiți tot codul, iată un link către exemplul nostru de pe GitHub din nou, pentru a nu fi necesar să derulați înapoi căutându-l.
Principala concluzie este de a vedea sistemele ca o colecție de procese de comunicare. Planificați sistemul mai întâi din punct de vedere al proiectării procesului și al doilea din punctul de vedere al codificării logice.