GenServer (servidor genérico) es un comportamiento que abstrae las interacciones cliente-servidor habituales entre procesos de Elixir.
¿Recuerdas el bucle de recepción de cuando aprendimos sobre los procesos? El comportamiento GenServer proporciona abstracciones para implementar esos bucles y para intercambiar mensajes con un proceso que ejecuta dicho bucle. Facilita mantener el estado y ejecutar código asíncrono.
Ten cuidado: el nombre GenServer está cargado de significado. También se usa para describir un módulo que usa el comportamiento GenServer, así como un proceso que se inició desde un módulo que usa el comportamiento GenServer.
El comportamiento GenServer define un callback obligatorio, init/1, y algunos callbacks opcionales interesantes: handle_call/3, handle_cast/2 y handle_info/3. Se supone que los clientes que usan un GenServer no deben llamar a esos callbacks directamente. En su lugar, el módulo GenServer proporciona funciones que los clientes pueden usar para comunicarse con un proceso GenServer.
A menudo, un único módulo define tanto una API de cliente, un conjunto de funciones que otras partes de tu aplicación Elixir pueden llamar para comunicarse con este proceso GenServer, como implementaciones de callbacks de servidor, que contienen la lógica de este GenServer.
Vamos a ver primero un ejemplo sencillo de un GenServer y después aprenderemos qué significa cada callback.
Este es un servidor de ejemplo que puede responder a las repetitivas preguntas de unos pasajeros molestos durante un largo viaje por carretera, más concretamente a la pregunta: «¿ya hemos llegado?». Lleva la cuenta de cuántas veces se ha hecho esa pregunta y devuelve respuestas cada vez más irritadas.
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 servidor se puede iniciar llamando a GenServer.start/3 o GenServer.start_link/3. Aprendimos la diferencia entre esas funciones en el concepto de enlaces.
Esas dos funciones:
GenServer.init_arg. Como sugiere el nombre, este argumento se pasa al callback init/1.Iniciar un servidor llamando a GenServer.start/3 o GenServer.start_link/3 invocará el callback init/1 de forma bloqueante. El valor devuelto por init/1 determina si el servidor puede iniciarse correctamente.
El callback init/1 normalmente devuelve uno de estos valores:
{:ok, state}. El servidor iniciará su bucle de recepción usando state como estado inicial. state puede ser de cualquier tipo.{:stop, reason}. reason puede ser de cualquier tipo. El servidor no iniciará su bucle de recepción. El proceso finalizará con el motivo indicado.También hay posibilidades más avanzadas que no veremos ahora.
Si el bucle de recepción del servidor se inicia, las funciones GenServer.start/3 y GenServer.start_link/3 devuelven una tupla {:ok, pid}. En caso contrario, devuelven {:error, reason}
handle_call/3Se puede enviar un mensaje que requiere una respuesta a un proceso servidor con GenServer.call/2. Esta función espera el pid de un proceso servidor en ejecución como primer argumento y el mensaje como segundo argumento. El mensaje puede ser de cualquier tipo.
El callback handle_call/3 se encarga de gestionar y responder a los mensajes síncronos. Recibe tres argumentos:
message: el valor pasado como segundo argumento a GenServer.call/2.from: el pid del proceso que llama a GenServer.call/2. La mayoría de las veces se puede ignorar este argumento.state: el estado actual del servidor. Recuerda que su valor inicial se estableció en el callback init/1.El callback handle_call/3 normalmente devuelve una tupla de 3 elementos {:reply, reply, state}. Esto significa que el segundo elemento de la tupla, una reply que puede ser de cualquier tipo, se enviará de vuelta al llamador. El tercer elemento de la tupla, state, es el nuevo estado del servidor tras gestionar este mensaje.
También hay posibilidades más avanzadas que no veremos ahora.
Para memorizar qué hace este callback por su nombre, piensa en «llamar» a alguien por teléfono.
Si esa persona está disponible, recibirás una respuesta de inmediato (de forma síncrona).
handle_cast/2Se puede enviar un mensaje que no requiere una respuesta a un proceso servidor con GenServer.cast/2. Sus argumentos son idénticos a los de GenServer.call/2.
El callback handle_cast/2 se encarga de gestionar esos mensajes. Recibe dos argumentos, message y state, que son los mismos que en el callback handle_call/3 (excepto from).
El callback handle_cast/2 normalmente devuelve una tupla de 2 elementos {:noreply, state}.
También hay posibilidades más avanzadas que no veremos ahora.
Para memorizar qué hace este callback por su nombre, recuerda que «to cast» también significa «lanzar».
Si lanzas un mensaje en una botella al mar, no esperas recibir una respuesta de inmediato, ni quizá nunca.
call o cast?Casi siempre usa call, incluso si tu código cliente no necesita la respuesta del servidor.
Usar call espera la respuesta, lo que sirve como mecanismo de contrapresión (para evitar que los clientes envíen demasiados mensajes a la vez). Recibir una respuesta del servidor es además la única forma de asegurarte de que el servidor recibió y gestionó el mensaje del cliente.
handle_info/2Los mensajes también pueden acabar en el buzón del servidor por medios distintos a llamar a GenServer.call/2 o GenServer.cast/2, por ejemplo llamando a la función simple send/2.
Para gestionar esos mensajes, usa el callback handle_info/2. Este callback funciona exactamente igual que handle_cast/2.
El comportamiento GenServer proporciona una implementación que captura todos los casos de handle_info/2, la cual registra errores sobre mensajes inesperados. Si sobrescribes esa implementación predeterminada, asegúrate de incluir siempre tu propia implementación que capture todos los casos. Si lo olvidas, el servidor fallará si recibe un mensaje inesperado.
El valor devuelto por cada uno de los cuatro callbacks descritos anteriormente se puede ampliar con un elemento más en la tupla, un timeout. Por ejemplo, en lugar de devolver {:ok, state} desde init/1, devuelve {:ok, state, timeout}.
El timeout se puede usar para detectar la ausencia de mensajes en el buzón durante un periodo concreto. Si el servidor devuelve un timeout desde uno de sus callbacks y han transcurrido los milisegundos especificados sin que llegue ningún mensaje, se llama a handle_info/2 con :timeout como primer argumento.
La máquina básica de reparto de números se vendía muy bien, pero algunos usuarios se quejaban de que le faltaban características avanzadas en comparación con otros modelos disponibles en el mercado.
El fabricante escuchó los comentarios de los usuarios y decidió lanzar un modelo de lujo con más características, y una vez más te encargaron escribir el software de esta máquina.
Las nuevas características que se añadieron al modelo de lujo son:
La lógica de negocio de la máquina ya la implementó tu compañero y se encuentra en el módulo TakeANumberDeluxe.State. Ahora tu tarea es encapsularla en un GenServer.
Usa el comportamiento GenServer en el módulo TakeANumberDeluxe.
Implementa la función start_link/1 y el callback de GenServer necesario.
El argumento que se pasa a start_link/1 es una lista de palabras clave. Contiene las claves :min_number y :max_number. Los valores de esas claves hay que pasarlos a la función TakeANumberDeluxe.State.new/2.
Si TakeANumberDeluxe.State.new/2 devuelve una tupla {:ok, state}, la máquina debe arrancar, usando el estado devuelto como su estado. Si, en cambio, devuelve una tupla {:error, error}, la máquina debe detenerse, indicando el error devuelto como motivo de la parada.
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}
Es posible que hayas notado que la función TakeANumberDeluxe.State.new/2 también acepta un tercer argumento opcional, auto_shutdown_timeout. Lo usaremos en el último paso de este ejercicio.
Implementa la función report_state/1 y el callback de GenServer necesario. La máquina debe responder a quien la llama con su estado actual.
{: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 función queue_new_number/1 y el callback de GenServer necesario.
Debería llamar a la función TakeANumberDeluxe.State.queue_new_number/1 con el estado actual de la máquina.
Si TakeANumberDeluxe.State.queue_new_number/1 devuelve una tupla {:ok, new_number, new_state}, la máquina debe responder a quien la llama con el número nuevo y establecer el estado nuevo como su estado. Si, en cambio, devuelve una tupla {:error, error}, la máquina debe responder a quien la llama con el error y no cambiar su estado.
{: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 función serve_next_queued_number/2 y el callback de GenServer necesario.
Debería llamar a la función TakeANumberDeluxe.State.serve_next_queued_number/2 con el estado actual de la máquina y su segundo argumento opcional, priority_number.
Si TakeANumberDeluxe.State.serve_next_queued_number/2 devuelve una tupla {:ok, next_number, new_state}, la máquina debe responder a quien la llama con el número siguiente y establecer el estado nuevo como su estado. Si, en cambio, devuelve una tupla {:error, error}, la máquina debe responder a quien la llama con el error y no cambiar su estado.
{: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 función reset_state/1 y el callback de GenServer necesario.
Debería llamar a la función TakeANumberDeluxe.State.new/2 para crear un estado nuevo usando el min_number y el max_number del estado actual. La máquina debe establecer el estado nuevo como su estado. No debe responder a quien la llama.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.reset_state(machine)
# => :ok
Modifica el arranque de la máquina. Debe leer el valor de la clave :auto_shutdown_timeout en la lista de palabras clave que se pasa como init_arg y pasarlo como tercer argumento a TakeANumberDeluxe.State.new/3. Usa el valor predeterminado :infinity si no se proporcionó :auto_shutdown_timeout.
Modifica el reinicio del estado de la máquina para que también pase auto_shutdown_timeout a TakeANumberDeluxe.State.new/3.
Modifica los valores devueltos de todos los callbacks implementados (init/1 y todos los callbacks handle_*) para establecer un tiempo de espera. Usa el valor de la clave :auto_shutdown_timeout del estado actual de la máquina. No añadas el tiempo de espera al valor devuelto {:stop, reason} de init/1: los tiempos de espera solo se aplican después de que el servidor haya iniciado su bucle de recepción.
Implementa un callback de GenServer para gestionar el mensaje :timeout que se enviará a la máquina si no recibe ningún otro mensaje dentro del tiempo de espera indicado. Debe terminar el proceso con el motivo :normal.
Asegúrate de gestionar también los mensajes inesperados ignorándolos.
{: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
Regístrate en Exercism para aprender y dominar Elixir con 58 conceptos168 ejercicios y mentoría humana real, todo gratis.