GenServer (узагальнений сервер) - це поведінка, яка абстрагує типові взаємодії клієнт-сервер між процесами Elixir.
Пригадаймо цикл отримання повідомлень, про який ми дізналися, коли вивчали процеси? Поведінка GenServer надає абстракції для реалізації таких циклів і для обміну повідомленнями з процесом, який виконує такий цикл. Вона спрощує збереження стану та виконання асинхронного коду.
Зауважте, що назва 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 відповідає за обробку синхронних повідомлень і відповіді на них. Він приймає три аргументи:
message - значення, передане другим аргументом у GenServer.call/2.from - pid процесу, який викликає GenServer.call/2. Найчастіше цей аргумент можна ігнорувати.state - поточний стан сервера. Пригадаймо, що його початкове значення задано в колбеку init/1.Колбек handle_call/3 зазвичай повертає кортеж із 3 елементів {:reply, reply, state}. Це означає, що другий елемент кортежу, reply, який може бути будь-якого типу, буде надіслано назад тому, хто викликав. Третій елемент кортежу, state, - це новий стан сервера після обробки цього повідомлення.
Є також складніші можливості, які ми зараз не розглядатимемо.
Щоб запамʼятати за назвою, що робить цей колбек, уявімо, ніби ми «телефонуємо» комусь.
Якщо ця людина доступна, ми одразу отримаємо відповідь (синхронно).
handle_cast/2Повідомлення, яке не потребує відповіді, можна надіслати процесу сервера за допомогою GenServer.cast/2. Його аргументи ідентичні аргументам GenServer.call/2.
Колбек handle_cast/2 відповідає за обробку таких повідомлень. Він приймає два аргументи, message і state, які є тими самими аргументами, що й у колбеку handle_call/3 (крім from).
Колбек handle_cast/2 зазвичай повертає кортеж із 2 елементів {:noreply, state}.
Є також складніші можливості, які ми зараз не розглядатимемо.
Щоб запамʼятати за назвою, що робить цей колбек, пригадаймо, що «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 продавалася дуже добре, але деякі користувачі скаржилися на брак розширених можливостей порівняно з іншими моделями, доступними на ринку.
Виробник дослухався до відгуків користувачів і вирішив випустити модель делюкс із більшою кількістю можливостей, і нам знову доручили написати програмне забезпечення для цієї машини.
Серед нових можливостей, доданих до моделі делюкс:
Бізнес-логіку машини вже реалізував колега, і її можна знайти в модулі TakeANumberDeluxe.State. Тепер наше завдання - обгорнути її в GenServer.
Використайте поведінку 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. Ми використаємо його в останньому кроці цієї вправи.
Реалізуйте функцію 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,
# }
Реалізуйте функцію 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}
Реалізуйте функцію 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}
Реалізуйте функцію 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
Змініть запуск машини. Вона повинна прочитати значення за ключем :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
Зареєструйтеся на Exercism, щоб вивчати й опановувати Elixir, а також 58 концепцій168 вправ та справжнє наставництво від людей, і все це безкоштовно.