Ce este GenServer și de ce ar trebui să vă îngrijiți?

În acest articol veți învăța elementele de bază ale concursului în Elixir și veți vedea cum să reproducem procesele, să trimiteți și să primiți mesaje și să creați procese de lungă durată. De asemenea, veți afla despre GenServer, puteți vedea modul în care poate fi folosit în aplicația dvs. și puteți descoperi câteva bunuri pe care le oferă pentru dvs..

După cum probabil știți, Elixir este un limbaj funcțional folosit pentru a construi sisteme rezistente la vâsc, concurente care se ocupă de o mulțime de cereri simultane. BEAM (mașină virtuală Erlang) utilizează procese să îndeplinească simultan diferite sarcini, ceea ce înseamnă, de exemplu, că o singură cerere nu blochează altul. Procesele sunt ușoare și izolate, ceea ce înseamnă că nu împărtășesc nici o memorie și chiar dacă un proces se blochează, altele pot continua să ruleze.

Procesele BEAM sunt foarte diferite de procesele Procesele OS. Practic, BEAM rulează într-un proces OS și utilizează propriile sale Programatoare. Fiecare programator ocupă unul CPU de bază, rulează într-un fir separat și poate gestiona simultan mii de procese (care se transformă în executare). Puteți citi un pic mai multe despre BEAM și multithreading pe StackOverflow.

Deci, după cum vedeți, procesele BEAM (de acum înainte voi spune doar "procese") sunt foarte importante în Elixir. Limba vă oferă niște instrumente de nivel inferior pentru procesarea manuală a proceselor, pentru a menține starea și pentru a face față cererilor. Cu toate acestea, puțini oameni le folosesc - este mai frecvent să se bazeze pe Platforma Open Telecom (OTP) cadru pentru a face acest lucru. 

În prezent, OTP nu are nimic de-a face cu telefoanele - este un cadru general pentru construirea unor sisteme concurente complexe. Acesta definește modul în care aplicațiile ar trebui să fie structurate și furnizează o bază de date, precum și o grămadă de instrumente foarte utile pentru a crea procese server, a recupera de la erori, a efectua înregistrări etc. În acest articol vom vorbi despre comportamentul serverului numit GenServer furnizat de OTP.  

Vă puteți gândi la GenServer ca la o abstractizare sau la un ajutor care simplifică lucrul cu procesele serverului. În primul rând, veți vedea cum să se dezvolte procesele utilizând anumite funcții de nivel scăzut. Apoi vom trece la GenServer și vom vedea cum simplifică lucrurile pentru noi prin eliminarea necesității de a scrie coduri obositoare (și destul de generice) de fiecare dată. Să începem!

Totul începe cu spawn

Dacă m-ai întrebat cum să creez un proces în Elixir, aș răspunde: icre aceasta! spawn / 1 este o funcție definită în interiorul Nucleu modul care returnează un nou proces. Această funcție acceptă o lambda care va fi executată în procesul creat. De îndată ce execuția sa terminat, procesul se oprește de asemenea:

spawn (fn -> IO.puts ("hi") sfârșit) |> IO.inspect # => hi # => #PID<0.72.0>

Deci aici icre a întors un id de proces nou. Dacă adăugați o întârziere la lambda, șirul "hi" va fi imprimat după un timp:

spawn (fn ->: timer.sleep (5000) IO.puts ("hi") capăt) |> IO.inspect # => #PID<0.82.0> # => (după 5 secunde) "hi"

Acum putem da naștere la fel de multe procese pe care le dorim și ele se vor desfășura simultan:

spawn_it = fn (num) -> spawn (fn ->: timer.sleep (5000)) IO.puts ("hi # num") end Enum.each (1 ... 10, fn (= rand.uniform (100))) # => (toate imprimate în același timp, după 5 secunde) # => hi 5 # => hi 10 etc ... 

Aici vom da naștere la zece procese și vom tipări un șir de testare cu un număr aleatoriu. : Rand este un modul furnizat de Erlang, deci numele său este un atom. Ce este cool este că toate mesajele vor fi imprimate în același timp, după cinci secunde. Se întâmplă deoarece toate cele zece procese sunt executate simultan.

Comparați-l cu exemplul următor care efectuează aceeași sarcină, dar fără a utiliza miceliu / 1:

dont_spawn_it = fn (num) ->: timer.sleep (5000) IO.puts ("hi # num" sfârșitul Enum.each (1 ... 10, 100))) = = (după 5 secunde) hi 70 # => (după alte 5 secunde) hi 45 # => etc. 

În timp ce acest cod este în curs de funcționare, puteți merge la bucătărie și să faceți o altă cană de cafea, deoarece va dura aproape un minut pentru a finaliza. Fiecare mesaj este afișat secvențial, ceea ce, desigur, nu este optim!

S-ar putea să întrebați: "Cât de mult memorie consumă un proces?" Ei bine, depinde, dar initial ocupa cateva kilobytes, ceea ce este un numar foarte mic (chiar laptopul meu vechi are 8GB de memorie, ca sa nu mai vorbim de servere moderne cool).

Până acum, bine. Înainte de a începe să lucrăm cu GenServer, totuși, să discutăm încă un alt lucru important: trecerea și primirea mesaje.

Lucrul cu mesajele

Nu este o surpriză faptul că procesele (care sunt izolate, după cum vă amintiți) trebuie să comunice într-un fel, mai ales când vine vorba de construirea unor sisteme mai mult sau mai puțin complexe. Pentru a realiza acest lucru, putem folosi mesaje.

Un mesaj poate fi trimis folosind o funcție cu un nume destul de evident: send / 2. Acceptă o destinație (port, id de proces sau un nume de proces) și mesajul real. După trimiterea mesajului, acesta apare în secțiunea cutie poștală a unui proces și pot fi procesate. După cum vedeți, ideea generală este foarte asemănătoare cu activitatea noastră zilnică de schimb de e-mailuri.

O cutie poștală este, în principiu, o coadă "prima în prima ieșire" (FIFO). După procesarea mesajului, acesta este eliminat din coada de așteptare. Pentru a începe să primești mesaje, ai nevoie - ghici ce-a-primi macro. Această macrocomandă conține una sau mai multe clauze și un mesaj se potrivește cu acestea. Dacă se găsește o potrivire, mesajul este procesat. În caz contrar, mesajul este readus în căsuța poștală. În plus, puteți seta opțiunea după clauza care rulează dacă un mesaj nu a fost primit în timpul dat. Puteți citi mai multe despre trimite / 2 și a primi în documentele oficiale.

Bine, suficient cu teoria - să încercăm să lucrăm cu mesajele. Mai întâi de toate, trimiteți ceva la procesul actual:

trimite (self (), "salut!")

Macro-ul 0/0 întoarce un proces de apel, ceea ce este exact ceea ce avem nevoie. Nu omiteți paranteze rotunde după ce veți primi un avertisment cu privire la meciul de ambiguitate.

Acum primiți mesajul în timp ce setați după clauză:

primi mesaj msg -> IO.puts "Yay, un mesaj: # msg" msg după 1000 -> IO.puts: stderr, "Vreau mesaje!" end |> IO.puts # => Yay, un mesaj: salut! # => salut!

Rețineți că clauza returnează rezultatul evaluării ultimei linii, așa că obținem "salut!" şir.

Rețineți că puteți introduce cât mai multe clauze după cum este necesar:

trimite ok, "hello!") primeste do : ok, msg -> IO.puts "Yay, un mesaj: # msg" msg : .put: stderr, "Oh, nu, ceva rău sa întâmplat: ## msg #" _ -> IO.puts "Nu știu ce este acest mesaj ..." după 1000 -> IO.puts: stderr, " end |> IO.puts

Aici avem patru clauze: una care să se ocupe de un mesaj de succes, altul să se ocupe de erori, apoi o clauză "de rezervă" și o expirare.

Dacă mesajul nu corespunde niciuneia dintre clauze, acesta este păstrat în căsuța poștală, ceea ce nu este întotdeauna de dorit. De ce? De câte ori apare un mesaj nou, cele vechi sunt procesate în primul cap (deoarece căsuța poștală este o coadă FIFO), încetinind programul în jos. Prin urmare, o clauză "de rezervă" poate fi utilă.

Acum, că știți cum să procesați procese, să trimiteți și să primiți mesaje, să aruncăm o privire la un exemplu ușor mai complex, care presupune crearea unui server simplu care răspunde la diverse mesaje.

Lucrul cu procesul serverului

În exemplul anterior, am trimis un singur mesaj, l-am primit și am efectuat niște lucrări. E bine, dar nu foarte funcțional. De obicei, ceea ce se întâmplă este că avem un server care poate răspunde la diverse mesaje. Prin "server" vreau să spun un proces de lungă durată construit cu o funcție recurentă. De exemplu, să creăm un server pentru a efectua unele ecuații matematice. Va primi un mesaj care conține operația solicitată și câteva argumente.

Începeți prin crearea serverului și a funcției de looping:

defmodule MathServer nu poate începe să facă spawn & asculta / 0 end defp asculta nu primi sqrt, caller, arg -> IO.puts arg _-> IO.puts: stderr, "Not implemented". end end ()

Deci, vom începe un proces care continuă să asculte mesajele primite. După primirea mesajului, asculta / 0 funcția este chemată din nou, creând astfel o buclă nesfârșită. În interiorul asculta / 0 , adăugăm suport pentru : sqrt mesaj, care va calcula rădăcina pătrată a unui număr. arg va conține numărul real pentru a efectua operația împotriva. De asemenea, definim o clauză de rezervă.

Acum puteți să porniți serverul și să atribuiți ID-ul procesului acestuia unei variabile:

math_server = MathServer.start IO.inspect math_server # => #PID<0.85.0>

Sclipitor! Acum, să adăugăm un implementare pentru a efectua efectiv calculul:

defmodule MathServer face # ... def sqrt (server, arg) trimite ((: some_name, : sqrt, self (), arg

Utilizați această funcție acum:

MathServer.sqrt (math_server, 3) # => 3

Deocamdată, aceasta imprimă pur și simplu argumentul trecut, astfel încât să vă ajustați codul ca acesta pentru a efectua operația matematică:

defmodule MathServer nu # ... defp asculta nu primesc do : sqrt, caller, arg -> trimite (: some_name, : result, do_sqrt (arg)) _-> IO.puts: stderr, "nu este implementat". sfarsit asculta () sfarsit defp do_sqrt (arg) do: math.sqrt (arg) sfarsit sfarsit

Acum, un alt mesaj este trimis serverului care conține rezultatul calculului. 

Ce este interesant este faptul că sqrt / 2 funcția trimite pur și simplu un mesaj către serverul care cere să efectueze o operație fără a aștepta rezultatul. Deci, în principiu, efectuează un apel asincron.

Evident, dorim să luăm rezultatul la un moment dat, deci să codificăm o altă funcție publică:

def grab_result nu primesc do : result, result -> rezultatul după 5000 -> IO.puts: stderr, end time end end

Utilizați-l acum:

math_server = MathServer.start MathServer.sqrt (math_server, 3) MathServer.grab_result |> IO.puts # => 1.7320508075688772

Functioneaza! Desigur, puteți crea chiar un grup de servere și puteți distribui sarcinile între ele, obținând concurență. Este convenabil atunci când cererile nu se raportează una la cealaltă.

Faceți cunoștință cu GenServer

În regulă, am acoperit o mulțime de funcții care ne permit să creăm procese servere de lungă durată și să trimitem și să primim mesaje. Acest lucru este minunat, dar trebuie să scriem un cod de boilerplate prea mare care să pornească o buclă de server (începe / 0), răspunde la mesaje (asculta / 0 funcția privată) și returnează un rezultat (grab_result / 0). În situații mai complexe, s-ar putea să trebuiască să conducem o stare comună sau să rezolvăm erorile.

Așa cum am spus la începutul articolului, nu este nevoie să reinventăm o bicicletă. În schimb, putem folosi comportamentul GenServer care oferă deja toate codurile de boilerplate pentru noi și are un suport excelent pentru procesele de server (așa cum am văzut în secțiunea anterioară).

Comportament în Elixir este un cod care implementează un model comun. Pentru a utiliza GenServer, trebuie să definiți o caracteristică specială callback modul care satisface contractul așa cum este dictat de comportament. În mod specific, ar trebui să pună în aplicare anumite funcții de apel invers, iar implementarea reală depinde de dvs. După ce sunt scrise inversele, modul de comportament poate să le utilizeze.

După cum se precizează în documente, GenServer necesită implementarea a șase callback-uri, deși au și o implementare implicită. Aceasta înseamnă că puteți redefini numai acelea care necesită o anumită logică personalizată.

Mai intai lucrurile: trebuie sa incepem serverul inainte de a face orice altceva, asa ca treceti la sectiunea urmatoare!

Pornirea serverului

Pentru a demonstra utilizarea lui GenServer, să scriem a CalcServer care va permite utilizatorilor să aplice diverse operațiuni la un argument. Rezultatul operației va fi stocat într-un a starea serverului, și apoi se poate aplica și o altă operație. Sau un utilizator poate obține un rezultat final al calculelor.

Mai întâi, folosiți macro-ul de utilizare pentru a conecta GenServer:

defmodule CalcServer nu folosesc sfârșitul GenServer

Acum, va trebui să redefinăm câteva apeluri de apel.

Primul este init / 1, care este invocat când un server este pornit. Argumentul trecut este folosit pentru a seta starea unui server inițial. În cel mai simplu caz, acest apel invers ar trebui să returneze : ok, initial_state tuple, deși există și alte valori posibile de revenire, cum ar fi stop, motiv, ceea ce face ca serverul să se oprească imediat.

Cred că putem permite utilizatorilor să definească starea inițială pentru serverul nostru. Cu toate acestea, trebuie să verificăm dacă argumentul dat este un număr. Deci, folosiți o clauză de pază pentru asta:

defmodule CalcServer foloseste GenServer def init (initial_value) atunci cand is_number (initial_value) do : ok, initial_value sfarsit def init (_) do : stop, "Valoarea trebuie sa fie un intreg!

Acum, porniți pur și simplu serverul folosind funcția start / 3 și furnizați-vă CalcServer ca modul de apel invers (primul argument). Al doilea argument va fi starea inițială:

GenServer.start (CalcServer, 5.1) |> IO.inspect # => : ok, #PID<0.85.0>

Dacă încercați să transmiteți un non-număr ca al doilea argument, serverul nu va fi pornit, ceea ce este exact ceea ce avem nevoie.

Grozav! Acum, când serverul nostru rulează, putem începe codarea operațiilor matematice.

Manipularea solicitărilor asincrone

Sunt solicitate cereri asincrone mulaje în termenii lui GenServer. Pentru a efectua o astfel de solicitare, utilizați funcția cast / 2, care acceptă un server și cererea efectivă. Este similar cu sqrt / 2 funcția pe care am codificat-o când vorbim despre procesele serverului. De asemenea, folosește abordarea "foc și uită", ceea ce înseamnă că nu așteptăm ca cererea să se termine.

Pentru a gestiona mesajele asincrone, se utilizează un callback handle_cast / 2. Acceptă o cerere și un stat și ar trebui să răspundă cu o tuplă : noreply, new_state în cel mai simplu caz (sau : stop, motiv, new_state pentru a opri buclă server). De exemplu, să abordăm un asincron : sqrt exprimate:

def handle_cast (: sqrt, state) do : noreply,: math.sqrt (state) end 

Așa păstrăm starea serverului nostru. Inițial, numărul (trecut când serverul a fost pornit) a fost 5.1. Acum actualizăm statul și îl setăm : Math.sqrt (5.1).

Codificați funcția de interfață utilizată turnat / 2:

sqrt (pid) face GenServer.cast (pid,: sqrt) sfârșit

Pentru mine, acest lucru seamănă cu un vrăjitor rău care aruncă o vrajă, dar nu-i pasă de impactul pe care îl produce.

Rețineți că avem nevoie de un id de proces pentru a efectua distribuția. Amintiți-vă că atunci când un server este pornit cu succes, un tuplu ok, pid este returnat. Prin urmare, să folosim modelul de potrivire pentru extragerea id-ului de proces:

: ok, pid = GenServer.start (CalcServer, 5.1) CalcServer.sqrt (pid)

Frumos! Aceeași abordare poate fi folosită pentru a implementa, de exemplu, multiplicarea. Codul va fi un pic mai complex, deoarece va trebui să transmitem al doilea argument, un multiplicator:

def multiplica (pid, multiplicator) genServer.cast (pid, : multiplicare, multiplicator) sfarsit

arunca funcția suportă doar două argumente, așa că trebuie să construiesc o tuplă și să trec un argument suplimentar acolo.

Acum apelul:

def handle_cast (: multiplicare, multiplicator, starea) do : noreply, state * multiplicator end

Putem scrie, de asemenea, un singur handle_cast apel invers care acceptă operarea, precum și oprirea serverului dacă operația nu este cunoscută:

Definiți funcția de operare a casetei: sqrt -> : noreply,: math.sqrt (state) : multiplicare, multiplicator -> : "Nu este implementat", state end end

Acum utilizați noua funcție de interfață:

CalcServer.multiply (pid, 2)

Mare, dar în prezent nu există nici o modalitate de a obține un rezultat al calculelor. Prin urmare, este timpul să definiți încă un apel invers.

Manipularea cererilor sincrone

Dacă sunt exprimate cereri asincrone, atunci cele sincrone sunt numite apeluri. Pentru a rula astfel de solicitări, utilizați funcția de apel / 3, care acceptă un server, o solicitare și un timeout opțional, care este egal cu cinci secunde în mod implicit.

Cererile sincrone sunt utilizate atunci când dorim să așteptăm până când răspunsul ajunge de fapt din server. Cazul tipic de utilizare este obținerea unor informații ca rezultat al calculelor, ca în exemplul de astăzi (amintiți-vă grab_result / 0 din una din secțiunile anterioare).

Pentru a procesa cererile sincrone, a handle_call / 3 apelul de apel este utilizat. Acceptă o cerere, o tuplă care conține pid-ul serverului și un termen care identifică apelul, precum și starea actuală. În cel mai simplu caz, ar trebui să răspundă cu o tuplă : răspuns, răspuns, new_state

Codificați acest apel invers acum:

def handle_call (: result, _, state) do : reply, state, state end

După cum vedeți, nimic complex. răspuns iar noul stat este egal cu starea actuală, deoarece nu vreau să schimb nimic după ce rezultatul a fost returnat.

Acum interfața rezultat / 1 funcţie:

rezultatul def (pid) genServer.call (pid,: result) final

Asta este! Utilizarea finală a programului CalcServer este demonstrată mai jos:

: ok, pid = GenServer.start (CalcServer, 5.1) CalcServer.sqrt (pid) CalcServer.multiply (pid, 2) CalcServer.result (pid) |> IO.puts = = 4.516635916254486

Alianta

Se face oarecum plictisitor să se furnizeze întotdeauna un id de proces atunci când se apelează funcțiile de interfață. Din fericire, este posibil să oferiți procesului un nume sau un nume alias. Acest lucru se face la pornirea serverului prin setare Nume:

GenServer.start (CalcServer, 5.1, nume:: calc) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts

Rețineți că nu stochez acum pid, deși poate doriți să faceți potrivirea modelului pentru a vă asigura că serverul a fost inițial inițiat.

Acum funcțiile de interfață devin un pic mai simple:

(sql): GenServer.cast (: calc,: sqrt) end def multiplicare (multiplicator) genServer.cast (: calc, : multiplicare, multiplicator

Doar nu uitați că nu puteți porni două servere cu același pseudonim.

Alternativ, puteți introduce o altă funcție a interfeței start / 1 în interiorul modulului dvs. și profitați de macrocomanda __MODULE __ / 0, care returnează numele modulului curent ca un atom:

defmodule CalcServer folosesc GenServer def start (initial_value) genServer.start (CalcServer, initial_value, nume: __MODULE__) sfarsit def sqrt genServer.cast (__ MODULE__,: sqrt) end def multiplicare (multiplicator) genServer.cast (__ MODULE__, : multiplicare, multiplicator) final rezultat def genServer.call (__ MODULE__,: rezultat) sfarsit # ... sfarsit CalcServer.start (6.1) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts

terminare

Un alt apel invers care poate fi redefinit în modulul dvs. se numește terminate / 2. Acceptă un motiv și starea actuală și se numește atunci când un server este pe punctul de a ieși. Acest lucru se poate întâmpla când, de exemplu, treceți un argument incorect la multiplica / 1 funcție de interfață:

# ... CalcServer.multiply (2)

Callback-ul poate arăta cam așa:

def terminate (_reason, _state) do IO.puts "Server terminat" sfârșitul

Concluzie

În acest articol am abordat elementele de bază ale concurrencyului în Elixir și am discutat funcții și macrocomenzi asemănătoare icre, a primi, și trimite. Ați învățat ce procese sunt, cum să le creați și cum să trimiteți și să primiți mesaje. De asemenea, am văzut cum să construim un proces server de lungă durată, care să răspundă atât mesajelor sincrone cât și celor asincrone.

În plus, am discutat despre comportamentul lui GenServer și am văzut cum simplifică codul introducând diverse apeluri de apel. Am lucrat cu init, termina, handle_call și handle_cast apelurile inverse și a creat un server de calcul simplu. Dacă vă părea ceva neclar, nu ezitați să vă postați întrebările!

Mai sunt multe pentru GenServer, și, bineînțeles, este imposibil să acoperim totul într-un singur articol. În următorul post, voi explica ce autoritățile de supraveghere sunt și cum le puteți folosi pentru a vă monitoriza procesele și a le recupera de la erori. Până atunci, codarea fericită!

Cod