Percursos
/
Elixir
Elixir
/
Exercícios
/
Take-A-Number Deluxe
Take-A-Number Deluxe

Take-A-Number Deluxe

Exercício de aprendizagem

Introdução

GenServer

GenServer (servidor genérico) é um comportamento que abstrai interações comuns cliente-servidor entre processos de Elixir.

Lembras-te do ciclo de receção de quando aprendemos sobre processos? O comportamento GenServer fornece abstrações para implementar esses ciclos e para trocar mensagens com um processo que corre esse ciclo. Torna mais fácil manter o estado e executar código assíncrono.

Note

Atenção que o nome GenServer está carregado. Também é usado para descrever um módulo que usa o comportamento GenServer, bem como um processo que foi iniciado a partir de um módulo que usa o comportamento GenServer.

O comportamento GenServer define um callback obrigatório, init/1, e alguns callbacks opcionais interessantes: handle_call/3, handle_cast/2 e handle_info/3. Os clientes que usam um GenServer não devem chamar esses callbacks diretamente. Em vez disso, o módulo GenServer fornece funções que os clientes podem usar para comunicar com um processo GenServer.

Muitas vezes, um único módulo define tanto uma API de cliente, um conjunto de funções que outras partes da tua aplicação Elixir podem chamar para comunicar com este processo GenServer, como implementações de callbacks de servidor, que contêm a lógica deste GenServer.

Vamos ver primeiro um exemplo simples de um GenServer e depois aprender o que significa cada callback.

Exemplo

Este é um exemplo de servidor que consegue responder às inquisições repetitivas de passageiros irritantes durante uma longa viagem de carro, mais exatamente à pergunta: "já chegámos?". Mantém o registo de quantas vezes esta pergunta foi feita e devolve respostas cada vez mais 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

Um servidor pode ser iniciado chamando GenServer.start/3 ou GenServer.start_link/3. Aprendemos sobre a diferença entre essas funções no conceito de links.

Estas duas funções:

  • Aceitam um módulo que implementa o comportamento GenServer como primeiro argumento.
  • Aceitam qualquer coisa como segundo argumento, chamado init_arg. Como o nome sugere, este argumento é passado ao callback init/1.
  • Aceitam um terceiro argumento opcional com opções avançadas para correr o processo, que não vamos abordar agora.

Iniciar um servidor chamando GenServer.start/3 ou GenServer.start_link/3 invoca o callback init/1 de forma bloqueante. O valor devolvido de init/1 determina se o servidor pode ser iniciado com sucesso.

O callback init/1 devolve normalmente um destes valores:

  • {:ok, state}. O servidor inicia o seu ciclo de receção usando state como estado inicial. state pode ser de qualquer tipo.
  • {:stop, reason}. reason pode ser de qualquer tipo. O servidor não inicia o seu ciclo de receção. O processo termina com o motivo indicado.

Existem também possibilidades mais avançadas que não vamos abordar agora.

Se o ciclo de receção do servidor arrancar, as funções GenServer.start/3 e GenServer.start_link/3 devolvem um tuplo {:ok, pid}. Caso contrário, devolvem {:error, reason}.

handle_call/3

Uma mensagem que exige uma resposta pode ser enviada a um processo servidor com GenServer.call/2. Esta função espera o pid de um processo servidor em execução como primeiro argumento, e a mensagem como segundo argumento. A mensagem pode ser de qualquer tipo.

O callback handle_call/3 é responsável por tratar e responder a mensagens síncronas. Recebe três argumentos:

  1. message - o valor passado como segundo argumento a GenServer.call/2.
  2. from - o pid do processo que chama GenServer.call/2. Na maioria dos casos, este argumento pode ser ignorado.
  3. state - o estado atual do servidor. Lembra-te de que o seu valor inicial foi definido no callback init/1.

O callback handle_call/3 devolve normalmente um tuplo de 3 elementos, {:reply, reply, state}. Isto significa que o segundo elemento do tuplo, um reply que pode ser de qualquer tipo, é enviado de volta para quem chamou. O terceiro elemento do tuplo, state, é o novo estado do servidor depois de tratar esta mensagem.

Existem também possibilidades mais avançadas que não vamos abordar agora.

Note

Para memorizares o que este callback faz pelo seu nome, pensa nele como "chamar" alguém ao telefone.

Se essa pessoa estiver disponível, recebes uma resposta imediatamente (de forma síncrona).

handle_cast/2

Uma mensagem que não exige uma resposta pode ser enviada a um processo servidor com GenServer.cast/2. Os seus argumentos são idênticos aos de GenServer.call/2.

O callback handle_cast/2 é responsável por tratar essas mensagens. Recebe dois argumentos, message e state, que são os mesmos que no callback handle_call/3 (exceto from).

O callback handle_cast/2 devolve normalmente um tuplo de 2 elementos, {:noreply, state}.

Existem também possibilidades mais avançadas que não vamos abordar agora.

Note

Para memorizares o que este callback faz pelo seu nome, lembra-te de que "to cast" também significa "atirar".

Se atirares uma mensagem numa garrafa para o mar, não esperas receber uma resposta imediatamente, ou talvez nunca.

Devo usar call ou cast?

Usa quase sempre call, mesmo que o teu código de cliente não precise da resposta do servidor.

Usar call espera pela resposta, o que serve como mecanismo de contrapressão (para impedir que os clientes enviem demasiadas mensagens de uma vez). Receber uma resposta do servidor é também a única forma de teres a certeza de que o servidor recebeu e tratou a mensagem do cliente.

handle_info/2

As mensagens também podem acabar na caixa de entrada do servidor por meios que não sejam chamar GenServer.call/2 ou GenServer.cast/2, por exemplo chamando a função simples send/2.

Para tratar essas mensagens, usa o callback handle_info/2. Este callback funciona exatamente da mesma forma que o handle_cast/2.

O comportamento GenServer fornece uma implementação abrangente de handle_info/2 que regista erros sobre mensagens inesperadas. Se substituíres essa implementação predefinida, certifica-te de que incluis sempre a tua própria implementação abrangente. Se te esqueceres, o servidor vai rebentar se receber uma mensagem inesperada.

Tempos limite

O valor devolvido de cada um dos quatro callbacks descritos acima pode ser estendido com mais um elemento do tuplo, um tempo limite. Por exemplo, em vez de devolveres {:ok, state} de init/1, devolve {:ok, state, timeout}.

O tempo limite pode ser usado para detetar a ausência de mensagens na caixa de correio durante um período específico. Se o servidor devolver um tempo limite de um dos seus callbacks, e tiverem passado os milissegundos especificados sem chegar nenhuma mensagem, handle_info/2 é chamado com :timeout como primeiro argumento.

Instruções

A máquina Take-A-Number básica estava a vender muito bem, mas alguns utilizadores queixavam-se da falta de funcionalidades avançadas em comparação com outros modelos disponíveis no mercado.

O fabricante ouviu o feedback dos utilizadores e decidiu lançar um modelo de luxo com mais funcionalidades, e, mais uma vez, coube-te a tarefa de escrever o software desta máquina.

As novas funcionalidades do modelo de luxo incluem:

  • Manter o registo dos números que estão atualmente em fila.
  • Definir o número mínimo e máximo. Isto permite usar várias máquinas Take-A-Number de luxo para colocar clientes em fila para departamentos diferentes da mesma instalação, e distinguir os departamentos pelo intervalo de números.
  • Permitir que determinados números saltem a fila, para dar um atendimento prioritário a grávidas e a idosos.
  • Encerramento automático, para evitar deixar a máquina ligada durante todo o fim de semana e desperdiçar energia.

A lógica de negócio da máquina já foi implementada pelo teu colega e encontra-se no módulo TakeANumberDeluxe.State. Agora, a tua tarefa é envolvê-la num GenServer.

1. Iniciar a máquina

Usa o comportamento GenServer no módulo TakeANumberDeluxe.

Implementa a função start_link/1 e o callback GenServer necessário.

O argumento passado a start_link/1 é uma keyword list. Contém as chaves :min_number e :max_number. Os valores dessas chaves têm de ser passados à função TakeANumberDeluxe.State.new/2.

Se TakeANumberDeluxe.State.new/2 devolver um tuplo {:ok, state}, a máquina deve iniciar-se, usando o estado devolvido como seu estado. Se, em vez disso, devolver um tuplo {:error, error}, a máquina deve parar, indicando o erro devolvido como motivo da paragem.

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}

Talvez tenhas reparado que a função TakeANumberDeluxe.State.new/2 também recebe um terceiro argumento opcional, auto_shutdown_timeout. Vamos usá-lo no último passo deste exercício.

2. Comunicar o estado da máquina

Implementa a função report_state/1 e o callback GenServer necessário. A máquina deve responder a quem a invoca com o seu estado atual.

{: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. Colocar novos números na fila

Implementa a função queue_new_number/1 e o callback GenServer necessário.

Deve chamar a função TakeANumberDeluxe.State.queue_new_number/1 com o estado atual da máquina.

Se TakeANumberDeluxe.State.queue_new_number/1 devolver um tuplo {:ok, new_number, new_state}, a máquina deve responder a quem a invoca com o novo número e definir o novo estado como seu estado. Se, em vez disso, devolver um tuplo {:error, error}, a máquina deve responder a quem a invoca com o erro e não alterar o seu 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 o próximo número da fila

Implementa a função serve_next_queued_number/2 e o callback GenServer necessário.

Deve chamar a função TakeANumberDeluxe.State.serve_next_queued_number/2 com o estado atual da máquina e o seu segundo argumento opcional, priority_number.

Se TakeANumberDeluxe.State.serve_next_queued_number/2 devolver um tuplo {:ok, next_number, new_state}, a máquina deve responder a quem a invoca com o número seguinte e definir o novo estado como seu estado. Se, em vez disso, devolver um tuplo {:error, error}, a máquina deve responder a quem a invoca com o erro e não alterar o seu 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 o estado

Implementa a função reset_state/1 e o callback GenServer necessário.

Deve chamar a função TakeANumberDeluxe.State.new/2 para criar um novo estado a partir do min_number e do max_number do estado atual. A máquina deve definir o novo estado como seu estado. Não deve responder a quem a invoca.

{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)

TakeANumberDeluxe.reset_state(machine)
# => :ok

6. Implementar o encerramento automático

Modifica o arranque da máquina. Deve ler o valor da chave :auto_shutdown_timeout na keyword list passada como init_arg e passá-lo como terceiro argumento a TakeANumberDeluxe.State.new/3. Usa o valor predefinido :infinity se :auto_shutdown_timeout não tiver sido indicado.

Modifica o reinício do estado da máquina para também passar auto_shutdown_timeout a TakeANumberDeluxe.State.new/3.

Modifica os valores devolvidos por todos os callbacks implementados (init/1 e todos os callbacks handle_*) para definirem um timeout. Usa o valor da chave :auto_shutdown_timeout do estado atual da máquina. Não adiciones o timeout ao valor devolvido {:stop, reason} de init/1: os timeouts só se aplicam depois de o servidor ter iniciado o seu ciclo de receção.

Implementa um callback GenServer para tratar a mensagem :timeout que será enviada à máquina se esta não receber outras mensagens dentro do timeout indicado. Deve terminar o processo com o motivo :normal.

Certifica-te também de que tratas quaisquer mensagens inesperadas, ignorando-as.

{: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 via GitHub A ligação abre numa nova janela ou separador
Elixir Exercism

Estás pronto para começar Take-A-Number Deluxe?

Inscreve-te no Exercism para aprenderes e dominares Elixir com 58 conceitos168 exercícios, e mentoria humana real, tudo grátis.