Spec-Zone.ru › Elixir 1.16

Source Управление простым состоянием с агентами

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

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

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

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

  • Использование процессов и передачи сообщений
  • ETS (Erlang Term Storage)

В руководстве Начало работы мы рассмотрели процессы. ETS (Erlang Term Storage) — это новая тема, которую мы изучим в последующих главах. Однако, когда дело доходит до процессов, мы редко делаем их своими руками, вместо этого мы используем доступные в 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 callback

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

Давайте перепишем тестовый случай, используя callback:

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

Мы сначала определили callback настройки с помощью макроса setup/1. Макрос setup/1 определяет callback, который выполняется перед каждым тестом в том же процессе, что и сам тест.

Обратите внимание, что нам нужен механизм для передачи PID bucket от callback к тесту. Мы делаем это, используя контекст теста. Когда мы возвращаем %{bucket: bucket} из callback, ExUnit объединит эту карту в контекст теста. Поскольку сам контекст теста является картой, мы можем выполнить совпадение шаблона с бакетом, обеспечив доступ к бакету внутри теста:

test "stores values by key", %{bucket: bucket} do
  # `bucket` is now the bucket from the setup block
end

Вы можете узнать больше о случаях ExUnit в документации модуля ExUnit.Case и больше о callback в 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.32.2) для языка программирования Elixir

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

Spec-Zone.ru

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