Источник Управление простым состоянием с помощью агентов
В этой главе мы узнаем, как сохранять и обмениваться состоянием между несколькими сущностями. Если у вас есть опыт программирования, вы можете подумать о глобальных переменных, но модель, которую мы изучим здесь, отличается. В последующих главах мы обобщим концепции, представленные здесь.
Если вы пропустили руководство Начало работы или прочли его давно, обязательно перечитайте главу Процессы. Мы будем использовать ее в качестве отправной точки.
Проблемы с (изменяемым) состоянием
Elixir — это язык с неизменяемым состоянием, где по умолчанию ничего не делится. Если мы хотим обмениваться информацией, которую можно читать и изменять из нескольких мест, у нас есть два основных варианта в Elixir:
- Использование процессов и передачи сообщений
- ETS (Хранилище терминов Erlang)
Мы рассмотрели процессы в руководстве Начало работы. ETS (Хранилище терминов Erlang) — это новая тема, которую мы изучим в последующих главах. Однако в случае с процессами мы редко создаем их самостоятельно, вместо этого мы используем абстракции, доступные в Elixir и OTP:
-
Agent— Простые оболочки вокруг состояния. -
GenServer— «Общие серверы» (процессы), которые инкапсулируют состояние, предоставляют синхронные и асинхронные вызовы, поддерживают перезагрузку кода и многое другое. -
Task— Асинхронные единицы вычислений, которые позволяют запускать процесс и потенциально получать его результат в более позднее время.
Мы изучим эти абстракции по мере продвижения. Имейте в виду, что все они реализованы поверх процессов с использованием основных функций виртуальной машины, таких как send/2, receive/1, spawn/1 и Process.link/1.
Здесь мы будем использовать агентов и создадим модуль под названием KV.Bucket, отвечающий за хранение наших записей ключ-значение таким образом, чтобы их могли читать и изменять другие процессы.
Агенты 101
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 для завершения процесса агента.
Функция Agent.update/3 принимает в качестве второго аргумента любую функцию, которая получает один аргумент и возвращает значение:
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.338.0>}
iex> Agent.update(agent, fn _list -> 123 end)
:ok
iex> Agent.update(agent, fn content -> %{a: content} end)
:ok
iex> Agent.update(agent, fn content -> [12 | [content]] end)
:ok
iex> Agent.update(agent, fn list -> [:nop | list] end)
:ok
iex> Agent.get(agent, fn content -> content end)
[:nop, 12, %{a: 123}]
Как видите, мы можем изменять состояние агента любым способом. Поэтому, скорее всего, нам не захочется обращаться к API агентов в разных местах нашего кода. Вместо этого мы хотим инкапсулировать всю функциональность, связанную с агентами, в одном модуле, который мы назовём KV.Bucket. Прежде чем реализовать его, давайте напишем некоторые тесты, которые нарисуют API, экспонируемый нашим модулем.
Создайте файл в test/kv/bucket_test.exs (помните об расширении .exs), содержащий:
defmodule KV.BucketTest do
use ExUnit.Case, async: true
test "stores values by key" do
{:ok, bucket} = KV.Bucket.start_link([])
assert KV.Bucket.get(bucket, "milk") == nil
KV.Bucket.put(bucket, "milk", 3)
assert KV.Bucket.get(bucket, "milk") == 3
end
end
use ExUnit.Case отвечает за настройку нашего модуля для тестирования и импортирует множество функциональных возможностей, связанных с тестированием, таких как макрос test/2.
Наш первый тест запускает новый KV.Bucket вызовом start_link/1 и передачей пустого списка опций. Затем мы выполним некоторые get/2 и put/3 операции, подтверждая результат.
Обратите также внимание на опцию async: true переданную ExUnit.Case. Эта опция позволяет запускать тестовый случай параллельно с другими тестовыми случаями :async с использованием нескольких ядер нашего компьютера. Это чрезвычайно полезно для ускорения нашего набора тестов. Однако :async необходимо устанавливать только в том случае, если тестовый случай не зависит от глобальных значений или не изменяет их. Например, если тест требует записи в файловую систему или доступа к базе данных, сохраните его синхронным (опустите опцию :async), чтобы избежать гонок между тестами.
Асинхронно или нет, наш новый тест, очевидно, должен завершиться ошибкой, так как ни одна из функциональных возможностей не реализована в тестируемом модуле:
** (UndefinedFunctionError) function KV.Bucket.start_link/1 is undefined (module KV.Bucket is not available)
Чтобы исправить не проходящий тест, давайте создадим файл в lib/kv/bucket.ex с приведенным ниже содержимым. Попробуйте самостоятельно реализовать модуль KV.Bucket с использованием агентов, прежде чем ознакомиться с реализацией ниже.
defmodule KV.Bucket do
use Agent
@doc """
Starts a new bucket.
"""
def start_link(_opts) do
Agent.start_link(fn -> %{} end)
end
@doc """
Gets a value from the `bucket` by `key`.
"""
def get(bucket, key) do
Agent.get(bucket, &Map.get(&1, key))
end
@doc """
Puts the `value` for the given `key` in the `bucket`.
"""
def put(bucket, key, value) do
Agent.update(bucket, &Map.put(&1, key, value))
end
end
Первым шагом в нашей реализации является вызов use Agent. Большая часть функциональности, которую мы будем изучать, например, GenServer и Supervisor, следуют этому шаблону. Для всех них вызов use генерирует функцию child_spec/1 с конфигурацией по умолчанию, что будет полезно, когда мы начнем контролировать процессы в главе 4.
Затем мы определяем функцию start_link/1 которая фактически запустит агента. Принято определять функцию start_link/1 которая всегда принимает список опций. Сейчас мы не планируем использовать какие-либо опции, но возможно мы будем использовать их позже. Затем мы приступаем к вызову Agent.start_link/1, который получает анонимную функцию, возвращающую начальное состояние агента.
Мы храним карту внутри агента для хранения наших ключей и значений. Получение и добавление значений в карту выполняется с помощью API агента и оператора захвата &, представленного в руководстве по началу работы. Агент передает свое состояние анонимной функции через аргумент &1 при вызове Agent.get/2 и Agent.update/2.
Теперь, когда модуль KV.Bucket определен, наш тест должен пройти! Вы можете попробовать самостоятельно, запустив: mix test.
Настройка тестов с помощью вызовов ExUnit
Прежде чем продолжить и добавить больше функций к KV.Bucket, давайте поговорим о вызовах ExUnit. Как можно предположить, все тесты KV.Bucket потребуют, чтобы агент-хранилище был запущен. К счастью, ExUnit поддерживает вызовы, которые позволяют нам избежать таких повторяющихся задач.
Давайте перепишем тестовый случай, используя вызовы:
defmodule KV.BucketTest do
use ExUnit.Case, async: true
setup do
{:ok, bucket} = KV.Bucket.start_link([])
%{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. Макрос setup/1 определяет вызов, который выполняется перед каждым тестом, в том же процессе, что и сам тест.
Обратите внимание, что нам нужен механизм для передачи bucket PID из вызова в тест. Мы делаем это с помощью контекста теста. Когда мы возвращаем %{bucket: bucket} из вызова, ExUnit объединит эту карту в контекст теста. Поскольку контекст теста сам по себе является картой, мы можем сопоставить шаблон bucket из него, обеспечивая доступ к bucket внутри теста:
test "stores values by key", %{bucket: bucket} do
# `bucket` is now the bucket from the setup block
end
Вы можете узнать больше о случаях ExUnit в ExUnit.Case документации модуля и больше о вызовах в ExUnit.Callbacks.
Другие действия агента
Помимо получения значения и обновления состояния агента, агенты позволяют получить значение и обновить состояние агента в одном вызове функции с помощью Agent.get_and_update/2. Давайте реализуем функцию KV.Bucket.delete/2 которая удаляет ключ из хранилища, возвращая его текущее значение:
@doc """ Deletes `key` from `bucket`. Returns the current value of `key`, if `key` exists. """ 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 map ->
Map.pop(map, key)
end)
end
Все, что находится внутри функции, переданной агенту, происходит в процессе агента. В этом случае, так как процесс агента — это тот, кто получает и отвечает на наши сообщения, мы говорим, что процесс агента — сервер. Все, что происходит вне функции, происходит на клиенте.
Это различие важно. Если существуют дорогостоящие действия, которые необходимо выполнить, вы должны подумать, будет ли лучше выполнить эти действия на клиенте или на сервере. Например:
def delete(bucket, key) do
Process.sleep(1000) # puts client to sleep
Agent.get_and_update(bucket, fn map ->
Process.sleep(1000) # puts server to sleep
Map.pop(map, key)
end)
end
Когда длительное действие выполняется на сервере, все другие запросы к этому конкретному серверу будут ждать, пока действие не будет выполнено, что может привести к сбою некоторых клиентов.
В следующей главе мы рассмотрим GenServer, где разделение между клиентами и серверами становится более очевидным.
© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.18.1/agents.html