Tracks
/
Elixir
Elixir
/
Ejercicios
/
Take-A-Number Deluxe
Take-A-Number Deluxe

Take-A-Number Deluxe

Ejercicio de aprendizaje

Introducción

GenServer

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.

Note

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.

Ejemplo

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

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 por ahora.

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/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 manejar 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.

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.

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

Note

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.

¿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 también la única forma de saber con certeza que el servidor recibió y manejó el mensaje del cliente.

handle_info/2

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

Tiempos de espera

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.

Instrucciones

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:

  • Llevar un registro de los números que están actualmente en la cola.
  • Establecer el número mínimo y máximo. Esto permitirá usar varias máquinas Take-A-Number de lujo para poner en cola a los clientes de diferentes 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 las mujeres embarazadas y a las personas mayores.
  • Apagado automático para evitar que la máquina se quede encendida todo el fin de semana por accidente y desperdicie energía.

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.

1. Iniciar 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 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.

2. Reportar el 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 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. Encolar números nuevos

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}

4. Atender el siguiente número en la cola

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}

5. Reiniciar el estado

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

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

¿Todo listo para empezar Take-A-Number Deluxe?

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