Треки
/
Elixir
Elixir
/
Вправи
/
Візьми номер Deluxe
Візьми номер Deluxe

Візьми номер Deluxe

Навчальна вправа

Вступ

GenServer

GenServer (узагальнений сервер) - це поведінка, яка абстрагує типові взаємодії клієнт-сервер між процесами Elixir.

Пригадаймо цикл отримання повідомлень, про який ми дізналися, коли вивчали процеси? Поведінка GenServer надає абстракції для реалізації таких циклів і для обміну повідомленнями з процесом, який виконує такий цикл. Вона спрощує збереження стану та виконання асинхронного коду.

Note

Зауважте, що назва GenServer багатозначна. Нею також описують модуль, який використовує поведінку GenServer, а також процес, запущений з модуля, який використовує поведінку GenServer.

Поведінка GenServer визначає один обовʼязковий колбек, init/1, і кілька цікавих необовʼязкових колбеків: handle_call/3, handle_cast/2 та handle_info/3. Клієнти, які використовують GenServer, не повинні викликати ці колбеки напряму. Натомість модуль GenServer надає функції, які клієнти можуть використовувати для звʼязку з процесом GenServer.

Часто один модуль визначає і клієнтський API, тобто набір функцій, які інші частини застосунку Elixir можуть викликати для звʼязку з цим процесом GenServer, і реалізації серверних колбеків, які містять логіку цього GenServer.

Розгляньмо спочатку простий приклад GenServer, а потім зʼясуймо, що означає кожен колбек.

Приклад

Це приклад сервера, який може відповідати на повторювані розпитування набридливих пасажирів під час довгої поїздки, а точніше на запитання: «are we there yet?». Він рахує, скільки разів це запитання вже поставили, і повертає дедалі роздратованіші відповіді.

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

Сервер можна запустити, викликавши GenServer.start/3 або GenServer.start_link/3. Про різницю між цими функціями ми дізналися в концепції звʼязків.

Ці дві функції:

  • приймають модуль, який реалізує поведінку GenServer, як перший аргумент;
  • приймають будь-що як другий аргумент, який називається init_arg. Як видно з назви, цей аргумент передається в колбек init/1;
  • приймають необовʼязковий третій аргумент із розширеними опціями запуску процесу, які ми зараз не розглядатимемо.

Запуск сервера викликом GenServer.start/3 або GenServer.start_link/3 викликає колбек init/1 у блокуючий спосіб. Повернене значення init/1 визначає, чи сервер зможе успішно запуститися.

Колбек init/1 зазвичай повертає одне з таких значень:

  • {:ok, state}. Сервер почне свій цикл отримання повідомлень, використовуючи state як початковий стан. state може бути будь-якого типу.
  • {:stop, reason}. reason може бути будь-якого типу. Сервер не почне свій цикл отримання повідомлень. Процес завершиться із зазначеної причини.

Є також складніші можливості, які ми зараз не розглядатимемо.

Якщо цикл отримання повідомлень сервера запускається, функції GenServer.start/3 і GenServer.start_link/3 повертають кортеж {:ok, pid}. Інакше вони повертають {:error, reason}

handle_call/3

Повідомлення, яке потребує відповіді, можна надіслати процесу сервера за допомогою GenServer.call/2. Ця функція приймає pid запущеного процесу сервера як перший аргумент і повідомлення як другий аргумент. Повідомлення може бути будь-якого типу.

Колбек handle_call/3 відповідає за обробку синхронних повідомлень і відповіді на них. Він приймає три аргументи:

  1. message - значення, передане другим аргументом у GenServer.call/2.
  2. from - pid процесу, який викликає GenServer.call/2. Найчастіше цей аргумент можна ігнорувати.
  3. state - поточний стан сервера. Пригадаймо, що його початкове значення задано в колбеку init/1.

Колбек handle_call/3 зазвичай повертає кортеж із 3 елементів {:reply, reply, state}. Це означає, що другий елемент кортежу, reply, який може бути будь-якого типу, буде надіслано назад тому, хто викликав. Третій елемент кортежу, state, - це новий стан сервера після обробки цього повідомлення.

Є також складніші можливості, які ми зараз не розглядатимемо.

Note

Щоб запамʼятати за назвою, що робить цей колбек, уявімо, ніби ми «телефонуємо» комусь.

Якщо ця людина доступна, ми одразу отримаємо відповідь (синхронно).

handle_cast/2

Повідомлення, яке не потребує відповіді, можна надіслати процесу сервера за допомогою GenServer.cast/2. Його аргументи ідентичні аргументам GenServer.call/2.

Колбек handle_cast/2 відповідає за обробку таких повідомлень. Він приймає два аргументи, message і state, які є тими самими аргументами, що й у колбеку handle_call/3 (крім from).

Колбек handle_cast/2 зазвичай повертає кортеж із 2 елементів {:noreply, state}.

Є також складніші можливості, які ми зараз не розглядатимемо.

Note

Щоб запамʼятати за назвою, що робить цей колбек, пригадаймо, що «to cast» також означає «кидати».

Якщо ми кинемо пляшку з повідомленням у море, то не чекаємо на відповідь одразу, а можливо, й ніколи.

Використовувати call чи cast?

Майже завжди варто використовувати call, навіть якщо клієнтському коду не потрібна відповідь від сервера.

Використання call чекає на відповідь, що слугує механізмом зворотного тиску (щоб клієнти не надсилали забагато повідомлень одночасно). Отримання відповіді від сервера - це також єдиний спосіб переконатися, що сервер отримав і обробив повідомлення клієнта.

handle_info/2

Повідомлення також можуть опинятися в поштовій скриньці сервера не лише через виклик GenServer.call/2 чи GenServer.cast/2, а й, наприклад, через виклик звичайної функції send/2.

Щоб обробляти такі повідомлення, використовуймо колбек handle_info/2. Цей колбек працює точно так само, як handle_cast/2.

Поведінка GenServer надає універсальну реалізацію handle_info/2, яка записує в журнал помилки про неочікувані повідомлення. Якщо ми перевизначимо цю типову реалізацію, варто завжди додавати власну реалізацію, яка перехоплює все. Якщо ми забудемо, сервер аварійно завершиться, коли отримає неочікуване повідомлення.

Тайм-аути

Повернене значення кожного з чотирьох колбеків, описаних вище, можна доповнити ще одним елементом кортежу - тайм-аутом. Наприклад, замість повернення {:ok, state} з init/1 повернімо {:ok, state, timeout}.

Тайм-аут можна використовувати, щоб виявити відсутність повідомлень у поштовій скриньці протягом певного періоду. Якщо сервер повертає тайм-аут з одного зі своїх колбеків і вказана кількість мілісекунд минає без надходження повідомлень, викликається handle_info/2 з :timeout як першим аргументом.

Вказівки

Базова машина Take-A-Number продавалася дуже добре, але деякі користувачі скаржилися на брак розширених можливостей порівняно з іншими моделями, доступними на ринку.

Виробник дослухався до відгуків користувачів і вирішив випустити модель делюкс із більшою кількістю можливостей, і нам знову доручили написати програмне забезпечення для цієї машини.

Серед нових можливостей, доданих до моделі делюкс:

  • Відстеження номерів, які зараз стоять у черзі.
  • Встановлення мінімального й максимального номера. Це дозволить використовувати кілька машин делюкс Take-A-Number, щоб ставити в чергу відвідувачів різних відділів однієї установи, і розрізняти відділи за діапазоном номерів.
  • Дозвіл певним номерам оминати чергу, щоб надавати пріоритетне обслуговування вагітним жінкам і людям похилого віку.
  • Автоматичне вимкнення, яке не дає випадково залишити машину ввімкненою на цілі вихідні й марно витрачати енергію.

Бізнес-логіку машини вже реалізував колега, і її можна знайти в модулі TakeANumberDeluxe.State. Тепер наше завдання - обгорнути її в GenServer.

1. Запустіть машину

Використайте поведінку GenServer у модулі TakeANumberDeluxe.

Реалізуйте функцію start_link/1 і відповідний колбек GenServer.

У start_link/1 передають список ключових слів. Він містить ключі :min_number і :max_number. Значення за цими ключами потрібно передати у функцію TakeANumberDeluxe.State.new/2.

Якщо TakeANumberDeluxe.State.new/2 повертає кортеж {:ok, state}, машина повинна запуститися, використавши повернений стан як свій стан. Якщо ж він повертає кортеж {:error, error}, машина повинна зупинитися, а повернена помилка стане причиною зупинки.

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}

Можна помітити, що функція TakeANumberDeluxe.State.new/2 також приймає необовʼязковий третій аргумент, auto_shutdown_timeout. Ми використаємо його в останньому кроці цієї вправи.

2. Повідомте стан машини

Реалізуйте функцію report_state/1 і відповідний колбек GenServer. Машина повинна відповісти тому, хто викликав, своїм поточним станом.

{: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. Поставте нові номери в чергу

Реалізуйте функцію queue_new_number/1 і відповідний колбек GenServer.

Вона повинна викликати функцію TakeANumberDeluxe.State.queue_new_number/1 з поточним станом машини.

Якщо TakeANumberDeluxe.State.queue_new_number/1 повертає кортеж {:ok, new_number, new_state}, машина повинна відповісти тому, хто викликав, новим номером і зробити новий стан своїм станом. Якщо ж він повертає кортеж {:error, error}, машина повинна відповісти тому, хто викликав, помилкою і не змінювати свій стан.

{: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. Обслужіть наступний номер із черги

Реалізуйте функцію serve_next_queued_number/2 і відповідний колбек GenServer.

Вона повинна викликати функцію TakeANumberDeluxe.State.serve_next_queued_number/2 з поточним станом машини та її другим необовʼязковим аргументом, priority_number.

Якщо TakeANumberDeluxe.State.serve_next_queued_number/2 повертає кортеж {:ok, next_number, new_state}, машина повинна відповісти тому, хто викликав, наступним номером і зробити новий стан своїм станом. Якщо ж він повертає кортеж {:error, error}, машина повинна відповісти тому, хто викликав, помилкою і не змінювати свій стан.

{: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. Скиньте стан

Реалізуйте функцію reset_state/1 і відповідний колбек GenServer.

Вона повинна викликати функцію TakeANumberDeluxe.State.new/2, щоб створити новий стан із min_number і max_number поточного стану. Машина повинна зробити новий стан своїм станом. Вона не повинна відповідати тому, хто викликав.

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

TakeANumberDeluxe.reset_state(machine)
# => :ok

6. Реалізуйте автоматичне вимкнення

Змініть запуск машини. Вона повинна прочитати значення за ключем :auto_shutdown_timeout у списку ключових слів, переданому як init_arg, і передати його третім аргументом у TakeANumberDeluxe.State.new/3. Якщо :auto_shutdown_timeout не задано, використайте типове значення :infinity.

Змініть скидання стану машини так, щоб воно також передавало auto_shutdown_timeout у TakeANumberDeluxe.State.new/3.

Змініть повернені значення всіх реалізованих колбеків (init/1 і всіх колбеків handle_*), щоб вони встановлювали тайм-аут. Використайте значення за ключем :auto_shutdown_timeout у поточному стані машини. Не додавайте тайм-аут до поверненого значення {:stop, reason} функції init/1: тайм-аути діють лише після того, як сервер запустить цикл отримання повідомлень.

Реалізуйте колбек GenServer, який оброблятиме повідомлення :timeout, що надійде машині, якщо протягом заданого тайм-ауту вона не отримає жодних інших повідомлень. Він повинен завершити процес із причиною :normal.

Подбайте також про будь-які несподівані повідомлення: просто ігноруйте їх.

{: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
Редагувати через GitHub Посилання відкривається в новому вікні або вкладці
Elixir Exercism

Час розпочати Візьми номер Deluxe?

Зареєструйтеся на Exercism, щоб вивчати й опановувати Elixir, а також 58 концепцій168 вправ та справжнє наставництво від людей, і все це безкоштовно.