В предыдущей главе мы использовали агентов для представления корзин. В первой главе мы указали имя каждой корзины, поэтому сейчас можно сделать следующее:
CREATE shopping
OK
PUT shopping milk 1
OK
GET shopping milk
1
OK
Поскольку агенты – это процессы, каждая корзина имеет свой идентификатор процесса, но не имеет названия. Мы узнали о регистрации имени в главе «Процессы», и вы могли бы решить эту проблему с помощью такой регистрации. Например, можно создать корзину так:
iex> Agent.start_link(fn -> %{} end, name: :shopping)
{:ok, #PID<0.43.0>}
iex> KV.Bucket.put(:shopping, "milk", 1)
:ok
iex> KV.Bucket.get(:shopping, "milk")
1
Тем не менее, это ужасная идея! Имена процессов в Эликсире должны быть атомами. Это означает, что нужно преобразовать название корзины (часто получаемое из внешнего источника) в атомы. Ни в коем случае нельзя преобразовывать пользовательский ввод в атомы. Сборщик мусора не собирает атомы. Генерация атомов из пользовательского ввода может привести к исчерпанию системной памяти!
На практике, скорее всего, вы достигнете ограничения виртуальной машины Эрланга на максимальное число атомов раньше, чем исчерпаете память. Вместо того, чтобы злоупотреблять встроенной возможностью регистрации названий, давайте создадим свой собственный реестр процессов который будет связывать название корзины с процессом корзины.
Реестр должен гарантировать, что словарь всегда находится в актуальном состоянии. Например, если один из процессов падает из-за ошибки, реестр должен заметить это изменение и избежать обслуживания устаревших сущностей. Принято говорить, что реестр в Эликсире должен мониторить каждую корзину.
Мы будем использовать модуль GenServer
для создания реестра процессов, который может мониторить процессы корзин. Модуль GenServer
обеспечивает надежную промышленную функциональность для создания серверов на Эликсире и OTP
.
Наш первый GenServer
Модуль GenServer
состоит из двух частей: клиентского API и серверных колбэков. Вы можете либо объединить обе части в один модуль, либо разделить их на клиентский и серверный модули. Клиент и сервер работают в отдельных процессах, при этом клиент передает сообщения обратно на сервер, и принимает сообщения при вызове его функций. Здесь мы объединим серверные колбэки и клиентский API в одном модуле.
Создайте новый файл lib/kv/registry.ex
со следующим содержимым:
defmodule KV.Registry do
use GenServer
## Client API
@doc """
Запускает реестр.
"""
def start_link(opts) do
GenServer.start_link(__MODULE__, :ok, opts)
end
@doc """
Ищет идентификатор процесса корзины для `name`, сохраненного в `server`.
Возвращает `{:ok, pid}`, если корзина существует, иначе `:error`.
"""
def lookup(server, name) do
GenServer.call(server, {:lookup, name})
end
@doc """
Обеспечивает наличие корзины, связанной с указанным `name` в `server`.
"""
def create(server, name) do
GenServer.cast(server, {:create, name})
end
## Серверные колбэки
def init(:ok) do
{:ok, %{}}
end
def handle_call({:lookup, name}, _from, names) do
{:reply, Map.fetch(names, name), names}
end
def handle_cast({:create, name}, names) do
if Map.has_key?(names, name) do
{:noreply, names}
else
{:ok, bucket} = KV.Bucket.start_link([])
{:noreply, Map.put(names, name, bucket)}
end
end
end
Первая функция start_link/3
запускает GenServer
и передает ему три аргумента:
-
Модуль, в котором реализованы серверные колбэки, в нашем случае
__MODULE__
– значение текущего модуля; -
Аргументы инициализации, в нашем случае – атом
:ok
; -
Список опций, которые могут использоваться для определения, например, имени сервера. По умолчанию представляет пустой список. Настроим это позже.
Есть два типа запросов, которые можно отправлять в GenServer
: call
и cast
.
Запрос типа call
– синхронный, и сервер должен ответить на него. Запрос типа cast
– асинхронный, и сервер на них не отвечает.
Следующие две функции: lookup/2
и create/2
, ответственны за отправку этих запросов к серверу. В нашем случае, мы воспользовались {:lookup, name}
и {:create, name}
соответственно. Запросы зачастую описываются в виде таких кортежей, чтобы иметь возможность разместить несколько «аргументов» в одном слоте. Как правило, заправшиваемое действие указывается первым элементом кортежа, а оставшиеся элементы являются аргументами. Обратите внимание, что запросы должны соответствовать первым аргументам функций handle_call/3
или handle_cast/2
.
С клиентским API закончили. На стороне сервера мы можем реализовать различные колбэки, чтобы гарантировать инициализацию сервера, его отключение и обработку запросов. Эти колбэки являются необязательными, и сейчас мы реализовали только нужные нам.
Первый – колбэк init/1
. Он принимает на вход второй аргумент из функции GenServer.start_link/3
и возвращает кортеж {:ok, state}
, где состояние представляет собой новый словарь. Уже можно заметить, что API модуля GenServer
делает разделение на клиентскую и серверную части более очевидным. Функция start_link/3
выполняется на клиенте, а функция init/1
– это соответствующий колбэк, который выполняется на сервере.
Для запросов типа call/2
реализуется колбэк handle_call/3
, который принимает request
, процесс, отправивший запрос (_from
) и текущее состояние сервера (names
). Колбэк handle_call/3
возвращает кортеж в формате {:reply, reply, new_state}
. Первый элемент кортежа, :reply
, показывает, что сервер должен отправлять ответ обратно клиенту. Второй элемент, reply
, будет отправлен клиенту, а третий элемент, new_state
, представляет собой новое состояние сервера.
Для запросов типа cast/2
реализуется колбэк handle_cast/2
, который принимает request
и текущее состояние сервера (names
). Колбэк handle_cast/2
возвращает кортеж в формате {:noreply, new_state}
. Обратите внимание, что в реальном приложении мы бы, наверное, реализовали колбэк для запроса :create
с синхронным вызовом вместо асинхронного. Сейчас мы просто иллюстрируем, как реализовать асинхронный вызов.
Есть и другие форматы кортежей для функций handle_call/3
и handle_cast/2
, которые могут возвращать колбэки. Так же есть и другие колбэки, такие как terminate/2
и code_change/3
, которые можно было бы реализовать. Вы можете изучить полную документацию модуля GenServer
, чтобы узнать о них больше.
Теперь давайте напишем несколько тестов, чтобы гарантировать правильную работу GenServer
.
Тестирование GenServer
Тестирование GenServer
не сильно отличается от тестирования агента Agent
. Мы создадим сервер в колбэке setup
и будем использовать его во время наших тестов. Создайте файл test/kv/registry_test.exs
со следующим содержимым:
defmodule KV.RegistryTest do
use ExUnit.Case, async: true
setup do
{:ok, registry} = start_supervised KV.Registry
%{registry: registry}
end
test "spawns buckets", %{registry: registry} do
assert KV.Registry.lookup(registry, "shopping") == :error
KV.Registry.create(registry, "shopping")
assert {:ok, bucket} = KV.Registry.lookup(registry, "shopping")
KV.Bucket.put(bucket, "milk", 1)
assert KV.Bucket.get(bucket, "milk") == 1
end
end
Наш тест должен сразу же выполниться без ошибок!
Напомним, что благодаря функции start_supervised
, модуль ExUnit
следит за завершением процесса реестра после выполнения каждого теста. Если необходимость остановить GenServer
– это часть логики приложения, можно использовать функцию GenServer.stop/1
:
## Клиентский API
@doc """
Остановка реестра.
"""
def stop(server) do
GenServer.stop(server)
end
Необходимость мониторинга
Наш реестр почти готов. Единственный оставшийся вопрос заключается в том, что реестр может устареть, если процесс корзины остановится или упадет. Давайте добавим тест в KV.RegistryTest
, который выявляет эту ошибку:
test "removes buckets on exit", %{registry: registry} do
KV.Registry.create(registry, "shopping")
{:ok, bucket} = KV.Registry.lookup(registry, "shopping")
Agent.stop(bucket)
assert KV.Registry.lookup(registry, "shopping") == :error
end
Этот тест упадет, так как имя корзины остается в реестре даже после остановки процесса корзины.
Для исправления этой ошибки, необходимо научить реестр мониторить каждый процесс корзины, который он создает. Как только мы установим мониторинг, реестр будет получать уведомления при каждом завершении работы процесса корзины, позволяя подчистить реестр.
Давайте поиграем с мониторингом в консоли, выполнив команду iex ‐S mix
:
iex> {:ok, pid} = KV.Bucket.start_link
{:ok, #PID<0.66.0>}
iex> Process.monitor(pid)
#Reference<0.0.0.551>
iex> Agent.stop(pid)
:ok
iex> flush()
{:DOWN, #Reference<0.0.0.551>, :process, #PID<0.66.0>, :normal}
Заметьте, что функция Process.monitor(pid)
возвращает уникальную ссылку, которая позволяет сопоставлять ей входящие сообщения процесса. После остановки агента, можно посмотреть все входящией сообщения при помощи функции flush/0
. Обратите внимание, что пришло сообщение :DOWN
с той же самой ссылкой на процесс корзины, что вернул монитор. Отметим, что процесс корзины завершен с причиной :normal
.
Давайте переопределим серверные колбэки для исправления этого бага, и перезапустим тест. Сначала, разделим состояние GenServer
на два словаря: один будет содержать соответствие name ‐> pid
, а другой – ref ‐> name
. Затем добавим мониторинг процесса корзины в функцию handle_cast/2
, а также напишем колбэк handle_info/2
для обработки сообщений мониторинга. Полная реализация всех серверных колбэков показана ниже:
## Серверные колбэки
def init(:ok) do
names = %{}
refs = %{}
{:ok, {names, refs}}
end
def handle_call({:lookup, name}, _from, {names, _} = state) do
{:reply, Map.fetch(names, name), state}
end
def handle_cast({:create, name}, {names, refs}) do
if Map.has_key?(names, name) do
{:noreply, {names, refs}}
else
{:ok, pid} = KV.Bucket.start_link([])
ref = Process.monitor(pid)
refs = Map.put(refs, ref, name)
names = Map.put(names, name, pid)
{:noreply, {names, refs}}
end
end
def handle_info({:DOWN, ref, :process, _pid, _reason}, {names, refs}) do
{name, refs} = Map.pop(refs, ref)
names = Map.delete(names, name)
{:noreply, {names, refs}}
end
def handle_info(_msg, state) do
{:noreply, state}
end
Обратите внимание, что мы смогли значительно изменить реализацию сервера без изменения какого-либо клиентского API. Это одно из преимуществ явного разделения сервера и клиента.
Наконец, в отличие от других колбэков, мы определили условие «поймать все» для функции handle_info/2
, которая отбрасывает любые неизвестные сообщения. Чтобы понять зачем это нужно, перейдем к следующему разделу.
Какой колбэк выбрать?
До сих пор мы пользовались тремя колбэками: handle_call/3
, handle_cast/2
и handle_info/2
. Вот что мы должны учитывать при принятии решения, когда следует использовать каждый:
-
Колбэк
handle_call/3
должен быть использован для синхронных запросов. Он является выбором по умолчанию, поскольку ожидание ответа сервера является полезным механизмом противодавления. -
Колбэк
handle_cast/2
должен быть использован для асинхронных запросов, когда не нужно заботиться об ответе. Запрос типаcast
не дает гарантии получения запроса сервером. По этой причине его следует использовать с осторожностью. Например, функцияcreate/2
, которую мы определили в этой главе, должна использовать функциюcall/2
. Мы использовали функциюcast/2
для обучающих целей. -
Колбэк
handle_info/2
должен использоваться для всех других сообщений, которые не передаются через функцииGenServer.call/2
илиGenServer.cast/2
, включая отправку обычных сообщения функциейsend/2
. Мониторинг сообщений:DOWN
– как раз такой пример.
Поскольку любое сообщение, в том числе отправленное через функцию send/2
, попадают в колбэк handle_info/2
, есть шанс, что на сервер будут приходить непредвиденные сообщения. Поэтому, если мы не определим условие «поймать все», нужного перехватчика не будет найдено, и эти сообщения могут уронить реестр. Нам не нужно беспокоиться о таких случаях для функций handle_call/3
и handle_cast/2
. Запросы типов call
и cast
выполняются только через API GenServer
, так что неизвестные сообщения, скорее всего, ошибка разработчика.
Чтобы помочь разработчикам запомнить различия между запросами типов call
, case
и info
, поддерживаемые возвращаемые значения и т. д., Бенджамин Тан Вэй Хао подготовил отличную шпаргалку для модуля GenServer
.
Мониторы или ссылки?
Мы узнали о ссылках в главе «Процессы». Теперь, с завершением работы над реестром, вам может быть интересно: когда следует использовать мониторы, а когда ссылки?
Ссылки двунаправлены. Если вы связываете два процесса, и один из них выйдет из строя, другая сторона тоже выйдет из строя (если только не перехватываются сигналы выхода). Монитор является однонаправленным: процесс мониторинга будет только получать уведомления о контролируемом. Другими словами: используйте ссылки, когда вы хотите получить связанные падения, и мониторы, когда нужно лишь получать информацию о падениях, выходах и т. п.
Возвращаясь к нашей реализации колбэка handle_cast/2
, вы можете видеть, что реестр является как связующим, так и мониторящим процессы корзин:
{:ok, pid} = KV.Bucket.start_link
ref = Process.monitor(pid)
Это плохая идея, т. к. реестр не должен падать при падении корзины! Обычно, мы избегаем прямого создания новых процессов, вместо этого делегируя эту ответственность супервизорам. Как мы увидим в следующей главе, супервизоры полагаются на ссылки. Это объясняет, почему API на основе ссылок (например, функции spawn_link
, start_link
т. д.) настолько распространены в Эликсире и OTP
.