В этой главе мы создадим модуль с именем KV.Bucket
, который будет отвечать за хранение основных значений таким образом, что позволит им быть прочитанными и измененными другими процессами.
Если вы пропустили «Руководство для начинающих» или читали его давно, обязательно перечитайте главу «Процессы». Мы будем использовать ее в качестве отправной точки.
Проблема состояния
Эликсир – это иммутабельный язык, в котором нет ничего совместно используемого по умолчанию. Если мы хотим сохранять состояние, создавать корзины, записывать и читать значения из нескольких мест, существует два основных подхода в Эликсире:
- Процессы;
- ETS (Erlang Term Storage).
Мы уже говорили о процессах, а 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
, в котором разделение между клиентами и серверами ещё более значимо.