GenServer (server generico) è un comportamento che astrae le interazioni client-server comuni tra i processi Elixir.
Ricordi il ciclo di ricezione di quando abbiamo imparato a conoscere i processi? Il comportamento GenServer fornisce astrazioni per implementare tali cicli e per scambiare messaggi con un processo che esegue un ciclo del genere. Rende più facile mantenere lo stato ed eseguire codice asincrono.
Attenzione: il nome GenServer è carico di significati. Viene usato anche per descrivere un modulo che usa il comportamento GenServer, così come un processo avviato da un modulo che usa il comportamento GenServer.
Il comportamento GenServer definisce una callback obbligatoria, init/1, e alcune interessanti callback opzionali: handle_call/3, handle_cast/2 e handle_info/3. I client che usano un GenServer non dovrebbero chiamare direttamente quelle callback. Al contrario, il modulo GenServer fornisce funzioni che i client possono usare per comunicare con un processo GenServer.
Spesso un unico modulo definisce sia un'API client, un insieme di funzioni che altre parti della tua app Elixir possono chiamare per comunicare con questo processo GenServer, sia l'implementazione delle callback del server, che contiene la logica di questo GenServer.
Diamo prima un'occhiata a un semplice esempio di GenServer, poi vediamo cosa significa ciascuna callback.
Questo è un esempio di server in grado di rispondere alle ripetitive insistenze di passeggeri fastidiosi durante un lungo viaggio in auto, più precisamente alla domanda: «siamo già arrivati?». Tiene traccia di quante volte è stata posta questa domanda e restituisce risposte sempre più seccate.
defmodule AnnoyingPassengerAutoresponder do
use GenServer
# Client API
def start_link(init_arg) do
GenServer.start_link(__MODULE__, init_arg)
end
def are_we_there_yet?(pid) do
GenServer.call(pid, :are_we_there_yet?)
end
# Server callbacks
@impl GenServer
def init(_init_arg) do
# the initial count of questions asked is always 0
state = 0
{:ok, state}
end
@impl GenServer
def handle_call(:are_we_there_yet?, _from, state) do
reply =
cond do
state <= 3 -> "No."
state <= 10 -> "I told you #{state} times already. No."
true -> "..."
end
# increase the count of questions asked
new_state = state + 1
# reply to the caller
{:reply, reply, new_state}
end
end
init/1Un server può essere avviato chiamando GenServer.start/3 o GenServer.start_link/3. Abbiamo imparato la differenza tra queste funzioni nel concetto di link.
Queste due funzioni:
GenServer.init_arg. Come suggerisce il nome, questo argomento viene passato alla callback init/1.Avviare un server chiamando GenServer.start/3 o GenServer.start_link/3 invoca la callback init/1 in modo bloccante. Il valore restituito da init/1 determina se il server può essere avviato con successo.
La callback init/1 di solito restituisce uno di questi valori:
{:ok, state}. Il server avvierà il suo ciclo di ricezione usando state come stato iniziale. state può essere di qualsiasi tipo.{:stop, reason}. reason può essere di qualsiasi tipo. Il server non avvierà il suo ciclo di ricezione. Il processo terminerà con il motivo indicato.Esistono anche possibilità più avanzate che non tratteremo ora.
Se il ciclo di ricezione del server parte, le funzioni GenServer.start/3 e GenServer.start_link/3 restituiscono una tupla {:ok, pid}. Altrimenti restituiscono {:error, reason}
handle_call/3Un messaggio che richiede una risposta può essere inviato a un processo server con GenServer.call/2. Questa funzione si aspetta il pid di un processo server in esecuzione come primo argomento e il messaggio come secondo argomento. Il messaggio può essere di qualsiasi tipo.
La callback handle_call/3 si occupa di gestire e rispondere ai messaggi sincroni. Riceve tre argomenti:
message: il valore passato come secondo argomento a GenServer.call/2.from: il pid del processo che chiama GenServer.call/2. Il più delle volte questo argomento può essere ignorato.state: lo stato attuale del server. Ricorda che il suo valore iniziale è stato impostato nella callback init/1.La callback handle_call/3 di solito restituisce una tupla di 3 elementi {:reply, reply, state}. Questo significa che il secondo elemento della tupla, una reply che può essere di qualsiasi tipo, verrà inviata al chiamante. Il terzo elemento della tupla, state, è il nuovo stato del server dopo aver gestito questo messaggio.
Esistono anche possibilità più avanzate che non tratteremo ora.
Per memorizzare cosa fa questa callback dal suo nome, pensa a quando «chiami» qualcuno al telefono.
Se quella persona è disponibile, ricevi subito una risposta (in modo sincrono).
handle_cast/2Un messaggio che non richiede una risposta può essere inviato a un processo server con GenServer.cast/2. I suoi argomenti sono identici a quelli di GenServer.call/2.
La callback handle_cast/2 si occupa di gestire quei messaggi. Riceve due argomenti, message e state, che sono gli stessi argomenti della callback handle_call/3 (tranne from).
La callback handle_cast/2 di solito restituisce una tupla di 2 elementi {:noreply, state}.
Esistono anche possibilità più avanzate che non tratteremo ora.
Per memorizzare cosa fa questa callback dal suo nome, ricorda che «to cast» significa anche «lanciare».
Se lanci un messaggio in una bottiglia in mare, non ti aspetti di ricevere subito una risposta, né forse mai.
call o cast?Usa quasi sempre call, anche se il tuo codice client non ha bisogno della risposta del server.
Usare call attende la risposta, che funge da meccanismo di contropressione (per evitare che i client inviino troppi messaggi in una volta). Ricevere una risposta dal server è anche l'unico modo per essere certi che il server abbia ricevuto e gestito il messaggio del client.
handle_info/2I messaggi possono anche finire nella casella di posta del server per vie diverse dalla chiamata a GenServer.call/2 o GenServer.cast/2, per esempio chiamando la semplice funzione send/2.
Per gestire tali messaggi, usa la callback handle_info/2. Questa callback funziona esattamente come handle_cast/2.
Il comportamento GenServer fornisce un'implementazione che intercetta tutto di handle_info/2 e registra gli errori relativi ai messaggi inattesi. Se sovrascrivi quell'implementazione predefinita, assicurati di includere sempre la tua implementazione che intercetta tutto. Se te ne dimentichi, il server andrà in crash se riceve un messaggio inatteso.
Il valore restituito da ciascuna delle quattro callback descritte sopra può essere esteso con un ulteriore elemento della tupla, un timeout. Ad esempio, invece di restituire {:ok, state} da init/1, restituisci {:ok, state, timeout}.
Il timeout può essere usato per rilevare l'assenza di messaggi nella casella di posta per un periodo specifico. Se il server restituisce un timeout da una delle sue callback e sono trascorsi il numero di millisecondi specificato senza che sia arrivato alcun messaggio, viene chiamata handle_info/2 con :timeout come primo argomento.
La macchina base Take-A-Number vendeva davvero bene, ma alcuni utenti si lamentavano della mancanza di funzionalità avanzate rispetto agli altri modelli disponibili sul mercato.
Il produttore ha ascoltato il feedback degli utenti e ha deciso di lanciare un modello deluxe con più funzionalità, ed ancora una volta ti è stato affidato il compito di scrivere il software per questa macchina.
Le nuove funzionalità aggiunte al modello deluxe includono:
La logica di business della macchina è già stata implementata dal tuo collega e si trova nel modulo TakeANumberDeluxe.State. Ora il tuo compito è incapsularla in un GenServer.
Usa il behaviour GenServer nel modulo TakeANumberDeluxe.
Implementa la funzione start_link/1 e il callback GenServer necessario.
L'argomento passato a start_link/1 è una keyword list. Contiene le chiavi :min_number e :max_number. I valori sotto quelle chiavi devono essere passati alla funzione TakeANumberDeluxe.State.new/2.
Se TakeANumberDeluxe.State.new/2 restituisce una tupla {:ok, state}, la macchina deve avviarsi, usando lo stato restituito come proprio stato. Se invece restituisce una tupla {:error, error}, la macchina deve fermarsi, dando l'errore restituito come motivo dell'arresto.
TakeANumberDeluxe.start_link(min_number: 1, max_number: 9)
# => {:ok, #PID<0.174.0>}
TakeANumberDeluxe.start_link(min_number: 9, max_number: 1)
# => {:error, :invalid_configuration}
Potresti aver notato che la funzione TakeANumberDeluxe.State.new/2 accetta anche un terzo argomento opzionale, auto_shutdown_timeout. Lo useremo nell'ultimo passo di questo esercizio.
Implementa la funzione report_state/1 e il callback GenServer necessario. La macchina deve rispondere al chiamante con il suo stato attuale.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.report_state(machine)
# => %TakeANumberDeluxe.State{
# max_number: 10,
# min_number: 1,
# queue: %TakeANumberDeluxe.Queue{in: [], out: []},
# auto_shutdown_timeout: :infinity,
# }
Implementa la funzione queue_new_number/1 e il callback GenServer necessario.
Deve chiamare la funzione TakeANumberDeluxe.State.queue_new_number/1 con lo stato attuale della macchina.
Se TakeANumberDeluxe.State.queue_new_number/1 restituisce una tupla {:ok, new_number, new_state}, la macchina deve rispondere al chiamante con il nuovo numero e impostare il nuovo stato come proprio stato. Se invece restituisce una tupla {:error, error}, la macchina deve rispondere al chiamante con l'errore e non cambiare il proprio stato.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 2)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 2}
TakeANumberDeluxe.queue_new_number(machine)
# => {:error, :all_possible_numbers_are_in_use}
Implementa la funzione serve_next_queued_number/2 e il callback GenServer necessario.
Deve chiamare la funzione TakeANumberDeluxe.State.serve_next_queued_number/2 con lo stato attuale della macchina ed il suo secondo argomento opzionale, priority_number.
Se TakeANumberDeluxe.State.serve_next_queued_number/2 restituisce una tupla {:ok, next_number, new_state}, la macchina deve rispondere al chiamante con il prossimo numero e impostare il nuovo stato come proprio stato. Se invece restituisce una tupla {:error, error}, la macchina deve rispondere al chiamante con l'errore e non cambiare il proprio stato.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:error, :empty_queue}
Implementa la funzione reset_state/1 e il callback GenServer necessario.
Deve chiamare la funzione TakeANumberDeluxe.State.new/2 per creare un nuovo stato usando min_number e max_number dello stato attuale. La macchina deve impostare il nuovo stato come proprio stato. Non deve rispondere al chiamante.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.reset_state(machine)
# => :ok
Modifica l'avvio della macchina. Deve leggere il valore sotto la chiave :auto_shutdown_timeout nella keyword list passata come init_arg e passarlo come terzo argomento a TakeANumberDeluxe.State.new/3. Usa il valore predefinito :infinity se :auto_shutdown_timeout non è stato fornito.
Modifica la reimpostazione dello stato della macchina in modo che passi auto_shutdown_timeout anche a TakeANumberDeluxe.State.new/3.
Modifica i valori restituiti da tutti i callback implementati (init/1 e tutti i callback handle_*) per impostare un timeout. Usa il valore sotto la chiave :auto_shutdown_timeout nello stato attuale della macchina. Non aggiungere il timeout al valore restituito {:stop, reason} di init/1: i timeout si applicano solo dopo che il server ha avviato il suo ciclo di ricezione.
Implementa un callback GenServer per gestire il messaggio :timeout che verrà inviato alla macchina se non riceve altri messaggi entro il timeout indicato. Deve terminare il processo con motivo :normal.
Assicurati anche di gestire eventuali messaggi imprevisti ignorandoli.
{:ok, machine} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 10,
auto_shutdown_timeout: :timer.hours(2)
)
# after 3 hours...
TakeANumberDeluxe.queue_new_number(machine)
# => ** (exit) exited in: GenServer.call(#PID<0.171.0>, :queue_new_number, 5000)
# ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
# (elixir 1.13.0) lib/gen_server.ex:1030: GenServer.call/3
Iscriviti a Exercism per imparare e padroneggiare Elixir con 58 concetti168 esercizi e il mentoring di persone reali, tutto gratis.