Rutas
/
Elixir
Elixir
/
Ejercicios
/
Toma un número Deluxe
Toma un número Deluxe

Toma un número Deluxe

Ejercicio de aprendizaje

Introducción

GenServer

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.

Note

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.

Ejemplo

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

Callbacks

init/1

Un 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:

  • Aceptan como primer argumento un módulo que implementa el comportamiento GenServer.
  • Aceptan cualquier cosa como segundo argumento, llamado init_arg. Como sugiere el nombre, este argumento se pasa al callback init/1.
  • Aceptan un tercer argumento opcional con opciones avanzadas para ejecutar el proceso, que no veremos ahora.

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/3

Se 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:

  1. message: el valor pasado como segundo argumento a GenServer.call/2.
  2. from: el pid del proceso que llama a GenServer.call/2. La mayoría de las veces se puede ignorar este argumento.
  3. 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.

Note

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/2

Se 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.

Note

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.

¿Debería usar 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/2

Los 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.

Timeouts

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.

Instrucciones

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:

  • Llevar la cuenta de los números que están actualmente en cola.
  • Establecer el número mínimo y el máximo. Esto permitirá usar varias máquinas de reparto de números de lujo para poner en cola a clientes de distintos departamentos de la misma instalación, y distinguir los departamentos por el rango de números.
  • Permitir que ciertos números se salten la cola para dar un servicio prioritario a mujeres embarazadas y personas mayores.
  • Apagado automático para evitar dejar la máquina encendida todo el fin de semana por accidente y desperdiciar energía.

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.

1. Arrancar la máquina

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.

2. Informar del estado de la máquina

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,
#    }

3. Poner números nuevos en cola

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}

4. Servir el siguiente número en cola

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}

5. Reiniciar el estado

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

6. Implementar el apagado automático

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
Editar en GitHub El enlace se abre en una ventana o pestaña nueva
Elixir Exercism

¿Listo para empezar Toma un número Deluxe?

Regístrate en Exercism para aprender y dominar Elixir con 58 conceptos168 ejercicios y mentoría humana real, todo gratis.