Spec-Zone.ru › Elixir 1.16

Источник Антипаттерны, связанные с процессами

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

Организация кода по процессам

Проблема

Этот антипаттерн относится к коду, необоснованно организованному по процессам. Сам процесс не представляет собой антипаттерн, но он должен использоваться только для моделирования свойств выполнения (например, конкурентности, доступа к общим ресурсам, изоляции ошибок и т. д.). Когда вы используете процесс для организации кода, это может привести к созданию узких мест в системе.

Пример

Примером этого антипаттерна, как показано ниже, является модуль, реализующий арифметические операции (например, add и subtract) с помощью процесса GenServer. Если количество вызовов этому единственному процессу растёт, такая организация кода может нанести ущерб производительности системы, став узким местом.

defmodule Calculator do
  @moduledoc """
  Calculator that performs basic arithmetic operations.

  This code is unnecessarily organized in a GenServer process.
  """

  use GenServer

  def add(a, b, pid) do
    GenServer.call(pid, {:add, a, b})
  end

  def subtract(a, b, pid) do
    GenServer.call(pid, {:subtract, a, b})
  end

  @impl GenServer
  def init(init_arg) do
    {:ok, init_arg}
  end

  @impl GenServer
  def handle_call({:add, a, b}, _from, state) do
    {:reply, a + b, state}
  end

  def handle_call({:subtract, a, b}, _from, state) do
    {:reply, a - b, state}
  end
end
iex> {:ok, pid} = GenServer.start_link(Calculator, :init)
{:ok, #PID<0.132.0>}
iex> Calculator.add(1, 5, pid)
6
iex> Calculator.subtract(2, 3, pid)
-1

Рефакторинг

В Elixir, как показано далее, организация кода должна выполняться только с помощью модулей и функций. По возможности, библиотека не должна навязывать пользователям конкретное поведение (такое как распараллеливание). Лучше делегировать это решение по поведению разработчикам клиентов, тем самым увеличивая потенциальную возможность повторного использования кода библиотеки.

defmodule Calculator do
  def add(a, b) do
    a + b
  end

  def subtract(a, b) do
    a - b
  end
end
iex> Calculator.add(1, 5)
6
iex> Calculator.subtract(2, 3)
-1

Разбросанные интерфейсы процессов

Проблема

В Elixir использование Agent, GenServer или любой другой абстракции процесса само по себе не является антипаттерном. Однако, когда ответственность за непосредственное взаимодействие с процессом распределяется по всей системе, это может стать проблематичным. Данная плохая практика может увеличить сложность сопровождения кода и сделать его более подверженным ошибкам.

Пример

Следующий код призван проиллюстрировать этот антипаттерн. Ответственность за взаимодействие непосредственно с Agent распределена по четырём различным модулям (A, B, C, и D).

defmodule A do
  def update(process) do
    # Some other code...
    Agent.update(process, fn _list -> 123 end)
  end
end
defmodule B do
  def update(process) do
    # Some other code...
    Agent.update(process, fn content -> %{a: content} end)
  end
end
defmodule C do
  def update(process) do
    # Some other code...
    Agent.update(process, fn content -> [:atom_value | content] end)
  end
end
defmodule D do
  def get(process) do
    # Some other code...
    Agent.get(process, fn content -> content end)
  end
end

Это распределение ответственности может привести к дублированию кода и усложнению его сопровождения. Кроме того, из-за отсутствия контроля над форматом общих данных могут передаваться сложные составные данные. Такая свобода в выборе формата данных опасна и может привести к появлению ошибок у разработчиков.

# start an agent with initial state of an empty list
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.135.0>}

# many data formats (for example, List, Map, Integer, Atom) are
# combined through direct access spread across the entire system
iex> A.update(agent)
iex> B.update(agent)
iex> C.update(agent)

# state of shared information
iex> D.get(agent)
[:atom_value, %{a: 123}]

Для GenServer и других поведений этот антипаттерн проявится, когда вызовы GenServer.call/3 и GenServer.cast/2 будут разбросаны по нескольким модулям вместо того, чтобы инкапсулировать все взаимодействие с GenServer в одном месте.

Рефакторинг

Вместо того, чтобы распределять прямой доступ к абстракции процесса, такой как Agent, по многим местам кода, лучше переработать этот код, централизовав ответственность за взаимодействие с процессом в одном модуле. Это улучшает сопровождаемость, удаляя дублированный код; также это позволяет ограничить принимаемый формат общих данных, уменьшая вероятность ошибок. Как показано ниже, модуль Foo.Bucket централизует ответственность за взаимодействие с Agent. Любое другое место в коде, которому нужен доступ к общим данным, должно теперь делегировать это действие модулю Foo.Bucket. Кроме того, Foo.Bucket теперь допускает обмен данными только в формате Map.

defmodule Foo.Bucket do
  use Agent

  def start_link(_opts) do
    Agent.start_link(fn -> %{} end)
  end

  def get(bucket, key) do
    Agent.get(bucket, &Map.get(&1, key))
  end

  def put(bucket, key, value) do
    Agent.update(bucket, &Map.put(&1, key, value))
  end
end

Ниже приведены примеры делегирования доступа к общим данным (предоставляемым Agent) модулю Foo.Bucket.

# start an agent through `Foo.Bucket`
iex> {:ok, bucket} = Foo.Bucket.start_link(%{})
{:ok, #PID<0.114.0>}

# add shared values to the keys `milk` and `beer`
iex> Foo.Bucket.put(bucket, "milk", 3)
iex> Foo.Bucket.put(bucket, "beer", 7)

# access shared data of specific keys
iex> Foo.Bucket.get(bucket, "beer")
7
iex> Foo.Bucket.get(bucket, "milk")
3

Дополнительные замечания

Этот антипаттерн ранее был известен как навязчивое использование агентов.

Передача ненужных данных

Проблема

Отправка сообщения процессу может быть дорогостоящей операцией, если сообщение достаточно велико. Это связано с тем, что сообщение полностью копируется в принимающий процесс, что может быть ресурсоёмким по ЦП и памяти. Это связано с архитектурой Erlang «ничего не делится», где каждый процесс имеет собственную память, что упрощает и ускоряет сборку мусора.

Это более очевидно при использовании send/2, GenServer.call/3 или исходных данных в GenServer.start_link/3. Заметим, что это также происходит при использовании spawn/1, Task.async/1, Task.async_stream/3 и так далее. Здесь это менее заметно, так как анонимная функция, переданная этим функциям, захватывает переменные, к которым она обращается, и все захваченные переменные будут скопированы. Тем самым вы можете случайно отправить процессу больше данных, чем вам фактически нужно.

Пример

Представьте, что вы реализуете простую систему отчётности об IP-адресах, которые отправляли запросы вашему приложению. Вы хотите сделать это асинхронно и не блокировать обработку, поэтому вы решаете использовать spawn/1. Может показаться разумным передать всё подключение, так как нам могут понадобиться дополнительные данные позже. Однако передача подключения приводит к копированию большого количества ненужных данных, таких как тело запроса, параметры и т. д.

# log_request_ip send the ip to some external service
spawn(fn -> log_request_ip(conn) end)

Эта проблема также возникает при доступе только к необходимым частям:

spawn(fn -> log_request_ip(conn.remote_ip) end)

Это всё равно скопирует весь conn, так как переменная conn захватывается внутри функции, создаваемой с помощью `spawn`. Функция затем извлекает поле remote_ip, но только после того, как весь conn был скопирован.

send/2 и API GenServer также основаны на передаче сообщений. В примере ниже conn снова копируется в базовый GenServer:

GenServer.cast(pid, {:report_ip_address, conn})

Рефакторинг

Для решения этой проблемы есть много вариантов:

  • Ограничьте передаваемые данные абсолютным минимумом, а не отправляйте всю структуру. Например, не отправляйте всю структуру conn если вам нужны только несколько полей.

  • Если процессу, которому вы отправляете данные, они нужны только ему, рассмотрите возможность загрузки данных процессом вместо передачи.

  • Некоторые абстракции, например, :persistent_term, позволяют обмениваться данными между процессами, при условии, что такие данные меняются редко.

В нашем случае разумной стратегией является ограничение входных данных. Если нам сейчас нужен только IP-адрес, то давайте работаем только с ним и убедимся, что мы передаём только IP-адрес в замыкание, как показано ниже:

ip_address = conn.remote_ip
spawn(fn -> log_request_ip(ip_address) end)

Или в случае GenServer:

GenServer.cast(pid, {:report_ip_address, conn.remote_ip})

Ненаблюдаемые процессы

Проблема

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

Пример

Следующий пример кода демонстрирует библиотеку, отвечающую за поддержание числового Counter через процесс GenServer вне дерева наблюдения. Клиент (один процесс на каждый счётчик) может создавать несколько счётчиков одновременно, что делает эти ненаблюдаемые процессы сложными для управления. Это может привести к проблемам с инициализацией, перезапуском и завершением системы.

defmodule Counter do
  @moduledoc """
  Global counter implemented through a GenServer process.
  """

  use GenServer

  @doc "Starts a counter process."
  def start_link(opts \\ []) do
    initial_value = Keyword.get(opts, :initial_value, 0)
    name = Keyword.get(opts, :name, __MODULE__)
    GenServer.start(__MODULE__, initial_value, name: name)
  end

  @doc "Gets the current value of the given counter."
  def get(pid_name \\ __MODULE__) do
    GenServer.call(pid_name, :get)
  end

  @doc "Bumps the value of the given counter."
  def bump(pid_name \\ __MODULE__, value) do
    GenServer.call(pid_name, {:bump, value})
  end

  @impl true
  def init(counter) do
    {:ok, counter}
  end

  @impl true
  def handle_call(:get, _from, counter) do
    {:reply, counter, counter}
  end

  def handle_call({:bump, value}, _from, counter) do
    {:reply, counter, counter + value}
  end
end
iex> Counter.start_link()
{:ok, #PID<0.115.0>}
iex> Counter.get()
0
iex> Counter.start_link(initial_value: 15, name: :other_counter)
{:ok, #PID<0.120.0>}
iex> Counter.get(:other_counter)
15
iex> Counter.bump(:other_counter, -3)
12
iex> Counter.bump(Counter, 7)
7

Рефакторинг

Чтобы гарантировать, что клиенты библиотеки имеют полный контроль над своими системами, независимо от количества используемых процессов и срока службы каждого из них, все процессы должны запускаться внутри дерева наблюдения. Как показано ниже, в этом коде используется Supervisor в качестве дерева наблюдения. При запуске этого приложения Elixir также запускаются два разных счётчика (Counter и :other_counter) как дочерние процессы Supervisor с именем App.Supervisor. Один инициализируется значением 0, другой — 15. С помощью этого дерева наблюдения можно управлять жизненным циклом всех дочерних процессов (остановка или перезапуск каждого из них), улучшая видимость всего приложения.

defmodule SupervisedProcess.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # With the default values for counter and name
      Counter,
      # With custom values for counter, name, and a custom ID
      Supervisor.child_spec(
        {Counter, name: :other_counter, initial_value: 15},
        id: :other_counter
      )
    ]

    Supervisor.start_link(children, strategy: :one_for_one, name: App.Supervisor)
  end
end
iex> Supervisor.count_children(App.Supervisor)
%{active: 2, specs: 2, supervisors: 0, workers: 2}
iex> Counter.get(Counter)
0
iex> Counter.get(:other_counter)
15
iex> Counter.bump(Counter, 7)
7
iex> Supervisor.terminate_child(App.Supervisor, Counter)
iex> Supervisor.count_children(App.Supervisor) # Only one active child
%{active: 1, specs: 2, supervisors: 0, workers: 2}
iex> Counter.get(Counter) # The process was terminated
** (EXIT) no process: the process is not alive...
iex> Supervisor.restart_child(App.Supervisor, Counter)
iex> Counter.get(Counter) # After the restart, this process can be used again
0
← Предыдущая страница Антипаттерны, связанные с проектированием
Следующая страница → Антипаттерны метапрограммирования

Скачать версию 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/process-anti-patterns.html

Spec-Zone.ru

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