Spec-Zone.ru › Elixir 1.17

Исходный код Управление простым состоянием с помощью агентов

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

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

Проблемы с (изменяемым) состоянием

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 объединит эту карту в контекст теста. Поскольку контекст теста сам по себе является картой, мы можем сопоставить шаблон для получения ёмкости, обеспечивая доступ к ёмкости внутри теста:

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 dict ->
    Map.pop(dict, key)
  end)
end

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

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

def delete(bucket, key) do
  Process.sleep(1000) # puts client to sleep
  Agent.get_and_update(bucket, fn dict ->
    Process.sleep(1000) # puts server to sleep
    Map.pop(dict, key)
  end)
end

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

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

← Предыдущая страница Введение в Mix
Следующая страница → Взаимодействие клиент-сервер с GenServer

Скачать версию ePub

Создано с помощью ExDoc (v0.34.1) для языка программирования Elixir

© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.17.2/agents.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API