Агент

В этой главе мы создадим модуль с именем KV.Bucket, который будет отвечать за хранение основных значений таким образом, что позволит им быть прочитанными и измененными другими процессами.

Если вы пропустили «Руководство для начинающих» или читали его давно, обязательно перечитайте главу «Процессы». Мы будем использовать ее в качестве отправной точки.

Проблема состояния

Эликсир – это иммутабельный язык, в котором нет ничего совместно используемого по умолчанию. Если мы хотим сохранять состояние, создавать корзины, записывать и читать значения из нескольких мест, существует два основных подхода в Эликсире:

Мы уже говорили о процессах, а ETS изучим далее в этом руководстве. Когда дело доходит до процессов, редко используются собственные реализации, вместо этого берутся абстракцим из Эликсира и OTP:

  • Модуль Agent – простая обертка вокруг состояния;
  • Модуль GenServer – инкапсулирующий состояние «универсальный сервер». Он обеспечивает синхронные и асинхронные вызовы, поддерживает горячую замену кода и многое другое;
  • Модуль Task – асинхронные единицы вычислений, которые позволяют запускать несколько процессов и извлекать результаты в последующем.

Мы рассмотрим большинство этих абстракций в этом руководстве. Имейте в виду, что все они реализованы на основе процессов, используя базовые возможности, предоставляемые виртуальной машиной, такие как send, receive, spawn и link.

Агенты

Модуль Agent представляет собой простую обертку вокруг состояния. Если все, что вы хотите от процесса, чтобы он сохранял состояние, агенты отлично подойдут для этого. Давайте запустим IEx-сессию внутри проекта:

$ iex -S mix

И немного поиграем с агентами:

iex> {:ok, agent} = Agent.start_link fn -> [] end
{:ok, #PID<0.57.0>}

iex> Agent.update(agent, fn list -> ["eggs" | list] end)
:ok

iex> Agent.get(agent, fn list -> list end)
["eggs"]

iex> Agent.stop(agent)
:ok

Мы запустили агента с исходным состоянием пустого списка. Затем обновили состояние агента, добавив новый элемент в голову списка. Второй аргумент функции Agent.update/3 – это функция, принимающая текущее состояние агента в качестве входных данных и возвращающая новое состояние. Далее мы получаем весь список. Второй аргумент функции Agent.get/3 – это функция, принимающая состояние в качестве входных данных и возвращающая значение, которое и вернет сама функция Agent.get/3. Как только мы закончили работу с агентом, можно вызвать Agent.stop/3 для остановки процесса агента.

Давайте реализуем модуль KV.Bucket через агента. Прежде чем приступить к выполнению, давайте сначала напишем несколько тестов. Создайте файл test/kv/bucket_test.exs (напоминаем расширение .exs) со следующим содержимым:

defmodule KV.BucketTest do
  use ExUnit.Case, async: true

  test "stores values by key" do
    {:ok, bucket} = start_supervised KV.Bucket
    assert KV.Bucket.get(bucket, "milk") == nil

    KV.Bucket.put(bucket, "milk", 3)
    assert KV.Bucket.get(bucket, "milk") == 3
  end
end

Первый тест запускает новое приложение KV.Bucket через функцию start_supervised и выполняет на нем операции get/2 и put/3 с проверкой ожидаемых результатов. Нам не надо явно останавливать агента, потому что он связан с процессом тестирования через start_supervised, и агент выключается автоматически после окончания теста.

Также обратите внимание на параметр async: true, передаваемый в модуль ExUnit.Case. Эта опция разрешает выполнение тестов параллельно с другими асинхронными тестами при помощи использования нескольких ядер процессора. Это крайне полезно для ускорения тестов. Однако, параметр :async должен быть установлен только в случае, если тест не предполагает изменение каких-либо глобальных значений. Например, если тест требует записи данных в файловую систему, регистрации процессов, или доступ к базе данных, сохраните его синхронно (опустите параметр :async), чтобы избежать состояния гонки между тестами.

Наш новый тест должен упасть независимо от включенного или выключенного параметра async: true, передаваемого в модуль ExUnit.Case, т. к. функциональность не реализована в тестируемом модуле.

Чтобы поправить падающий тест, давайте создадим файл lib/kv/bucket.ex с описанным ниже содержимым. Попробуйте написать реализацию модуля KV.Bucket через агенты самостоятельно, прежде чем заглядывать в представленный ниже код.

defmodule KV.Bucket do
  use Agent
  
  @doc """
  Стартует новую корзину.
  """
  def start_link do
    Agent.start_link(fn -> %{} end)
  end

  @doc """
  Получает значение `key` из `bucket`.
  """
  def get(bucket, key) do
    Agent.get(bucket, &Map.get(&1, key))
  end

  @doc """
  Кладет в `bucket` значение `value` для ключа `key`.
  """
  def put(bucket, key, value) do
    Agent.update(bucket, &Map.put(&1, key, value))
  end
end

Первый шаг этой реализации – вызов use Agent. Благодаря этому, определится функция child_spec/1, включающая конкретные шаги для старта процесса.

Затем определяем функцию start_link/1, которая эффективно стартует агента. Функция start_link/1 всегда принимает список опций, но пока не будем этим пользоваться. Затем переходим к вызову функции Agent.start_link/1, которая принимает анонимную функцию, возвращающую начальное состояние агента.

Для хранения ключей и значений внутри агента используется словарь. Об операторе захвата & вы можете почитать в главе «Модули и функции».

Теперь, когда модуль KV.Bucket определен, тесты должны пройти! Проверьте это самостоятельно, выполнив команду mix test.

Настройка тестов через колбэки ExUnit

Прежде чем двигаться дальше и добавлять новые возможности в модуль KV.Bucket, давайте поговорим о колбэках ExUnit. Как можно ожидать, всем тестам модуля KV.Bucket потребуется, чтобы агент корзины был запущен. К счастью, модуль ExUnit поддерживает колбэки, которые позволяют пропускать такие повторяющиеся задачи.

Давайте перепишем тест, используя колбэки:

defmodule KV.BucketTest do
  use ExUnit.Case, async: true

  setup do
    {:ok, bucket} = start_supervised(KV.Bucket)
    %{bucket: bucket}
  end

  test "stores values by key", %{bucket: bucket} do
    assert KV.Bucket.get(bucket, "milk") == nil

    KV.Bucket.put(bucket, "milk", 3)
    assert KV.Bucket.get(bucket, "milk") == 3
  end
end

Сначала мы определили настройку колбэков с помощью макроса setup/1. В нём колбэк выполняется перед каждым тестом, в том же процессе, что и сам тест.

Обратите внимание, что нам нужен механизм для передачи идентификатора процесса корзины bucket из колбэка в сам тест. Это делается с помощью тестового контекста. Когда мы возвращаем словарь %{bucket: bucket} из колбэка, модуль ExUnit объединит этот словарь с тестовым контекстом. Так как тестовый контекст представляет собой словарь, мы можем достать из него переменную bucket через сопоставление с образцом и передать ее в тест:

test "stores values by key", %{bucket: bucket} do
  # теперь `bucket` – это корзина из блока настроек
end

Прочитать больше о библиотеке ExUnit можно в документации модуля ExUnit.Case, а о колбэках в документации модуля ExUnit.Callbacks.

Другие действия агента

Получать значение и обновлять состояние агента позволяет вызов функции Agent.get_and_update/2. Давайте реализуем функцию KV.Bucket.delete/2, которая удаляет ключ из корзины, возвращая его текущее значение:

@doc """
Удаляет `key` из `bucket`.

Возвращает текущее значение `key`, если `key` существует.
"""
def delete(bucket, key) do
  Agent.get_and_update(bucket, &Map.pop(&1, key))
end

Теперь ваша очередь написать тест на код выше! Кроме того, обязательно изучите документацию к модулю Agent, чтобы узнать об агентах больше.

Клиент/сервер в агентах

Прежде чем мы перейдем к следующей главе, давайте обсудим клиент-серверную раздвоенность агентов. Для начала расширим функцию delete/2:

def delete(bucket, key) do
  Agent.get_and_update(bucket, fn dict ->
    Map.pop(dict, key)
  end)
end

Все, что мы передали агенту внутри функции происходит в процессе агента. В данном случае, поскольку процесс агента получает и отвечает на наши сообщения, мы говорим о процессе агента на сервере. Все, что за пределами функции происходит на клиенте.

Это различие имеет важное значение. Если есть дорогостоящие действия, вы должны рассмотреть, где лучше выполнить эти действия, на клиенте или на сервере. Например:

def delete(bucket, key) do
  Process.sleep(1000) # отправит клиента в сон
  Agent.get_and_update(bucket, fn dict ->
    Process.sleep(1000) # отправит сервер в сон
    Map.pop(dict, key)
  end)
end

Когда длительное действие выполняется на сервере, все остальные запросы на этом сервере будут ждать, пока действие закончится и могут отправить клиентов в таймаут.

В следующей главе будет исследован модуль GenServer, в котором разделение между клиентами и серверами ещё более значимо.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.