GenServer (servidor genérico) es un comportamiento que abstrae las interacciones comunes cliente-servidor entre procesos de Elixir.
¿Recuerdas el bucle de recepción de cuando aprendimos sobre procesos? El comportamiento GenServer proporciona abstracciones para implementar esos bucles y para intercambiar mensajes con un proceso que ejecuta un bucle así. Facilita mantener el estado y ejecutar código asíncrono.
Ten cuidado, porque el nombre GenServer tiene varios significados. 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 solo 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.
Primero veamos un ejemplo sencillo de un GenServer y después aprendamos qué significa cada callback.
Este es un servidor de ejemplo que puede responder a las repetitivas inquisiciones de pasajeros molestos durante un largo viaje por carretera, más exactamente a la pregunta: «¿ya llegamos?». Lleva la cuenta de cuántas veces se ha hecho esta 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 invoca el callback init/1 de forma bloqueante. El valor que devuelve init/1 determina si el servidor se puede iniciar correctamente.
Por lo general, el callback init/1 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 terminará con el motivo indicado.También hay posibilidades más avanzadas que no veremos por 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}. De lo 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 manejar 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.Por lo general, el callback handle_call/3 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 a quien hizo la llamada. El tercer elemento de la tupla, state, es el nuevo estado del servidor después de manejar este mensaje.
También hay posibilidades más avanzadas que no veremos por 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 manejar esos mensajes. Recibe dos argumentos, message y state, que son los mismos argumentos que en el callback handle_call/3 (excepto from).
Por lo general, el callback handle_cast/2 devuelve una tupla de 2 elementos {:noreply, state}.
También hay posibilidades más avanzadas que no veremos por ahora.
Para memorizar qué hace este callback por su nombre, recuerda que «to cast» también significa «to throw».
Si lanzas un mensaje en una botella al mar, no esperas recibir una respuesta de inmediato, o quizás 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 también la única forma de saber con certeza que el servidor recibió y manejó el mensaje del cliente.
handle_info/2Los mensajes también pueden llegar a la bandeja de entrada del servidor por medios distintos a llamar a GenServer.call/2 o GenServer.cast/2, por ejemplo llamando a la función básica send/2.
Para manejar esos mensajes, usa el callback handle_info/2. Este callback funciona exactamente igual que handle_cast/2.
El comportamiento GenServer proporciona una implementación comodín de handle_info/2 que registra los errores de los mensajes inesperados. Si sobrescribes esa implementación predeterminada, asegúrate de incluir siempre tu propia implementación comodín. Si lo olvidas, el servidor fallará si recibe un mensaje inesperado.
El valor que devuelve cada uno de los cuatro callbacks descritos arriba se puede ampliar con un elemento más de 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 específico. 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 Take-A-Number se vendía muy bien, pero algunos usuarios se quejaban de su falta de funcionalidades 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 funcionalidades, y una vez más te encargaron escribir el software de esta máquina.
Las nuevas funcionalidades que se agregaron al modelo de lujo incluyen:
La lógica de negocio de la máquina ya la implementó tu colega y se encuentra en el módulo TakeANumberDeluxe.State. Ahora tu tarea es envolverla 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 le pasa a start_link/1 es una lista de palabras clave. Contiene las claves :min_number y :max_number. Los valores de esas claves se deben pasar 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, usando el error devuelto como razón para detenerse.
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}
Quizás hayas notado que la función TakeANumberDeluxe.State.new/2 también recibe 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 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.
Debe 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 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 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.
Debe 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 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 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.
Debe 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 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 de retorno 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 en el estado actual de la máquina. No agregues el tiempo de espera al valor de retorno {: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 manejar el mensaje :timeout que se le enviará a la máquina si no recibe ningún otro mensaje dentro del tiempo de espera dado. Debe terminar el proceso con razón :normal.
Asegúrate de manejar también cualquier mensaje inesperado ignorándolo.
{: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.