Исходный код Антипаттерны, связанные с процессами
В данном документе описаны потенциальные антипаттерны, связанные с процессами и абстракциями, основанными на процессах.
Организация кода по процессам
Проблема
Этот антипаттерн относится к коду, который необоснованно организован по процессам. Сам процесс не является антипаттерном, но он должен использоваться только для моделирования свойств выполнения (таких как конкурентность, доступ к общим ресурсам, изоляция ошибок и т. д.). Когда вы используете процесс для организации кода, это может создавать узкие места в системе.
Пример
Пример этого антипаттерна, показанный ниже, — это модуль, реализующий арифметические операции (такие как 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 захватывается внутри запущенной функции. Затем функция извлекает поле 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
© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.18.1/process-anti-patterns.html