Воюем со скобками в дереве супервизоров
В Эликсире версии 1.5 был представлен новый подход к управлению дочерними процессами, что сделало модуль Supervisor.Spec
устаревшим. Все методы объявления дерева супервизоров были подвержены значительным изменениям, и сейчас самое время разобраться в процессе передачи аргументов – от супервизора, находящегося на самой вершине, до простых воркеров или GenServer
.
Постановка задачи
При объявлении аргументов в цепочке Supervisor → GenServer
можно столкнуться с некоторыми проблемами. Например, заглянув в документацию поведения Supervisor
, можно увидеть следующее:
Stack.child_spec([:hello])
#=> %{
id: Stack,
start: {Stack, :start_link, [[:hello]]},
restart: :permanent,
shutdown: 5000,
type: :worker
}
Что здесь вообще происходит? Почему атом :hello
помещён в одинарные скобки в первой строке, а в четвёртой уже в двойные?
Как же определить, сколько скобок должно быть в тот или иной раз? Например, как должен выглядять атом :ok
в модуле GenServer
– так :ok
, или так [:ok]
, или так [[:ok]]
? Как сопоставлять это значение в функциях start_link
, chils_spec
и init
? Давайте же разберёмся с этими «чудесами» вместе.
Арность функции init/1
– единица
Пожалуй, начнём с самых азов, а именно с функции, в которой сразу известно, какой аргумент передавать и что с ним потом делать. Единственная функция, подходящая под данное описание, – это init/1
из модуля GenServer
.
Как правило, при передаче аргументов происходит инициализация состояния воркера:
def init(_something_) do
state = ...
# некоторое преобразование документов
{:ok, state}
end
Основное правило, которое при этом нужно помнить:
Арность функции
init/1
– единица!
Если необходимо больше одного аргумента, придётся обернуть их в какую-нибудь структуру данных, чтобы их можно было передать в функцию с арностью 1.
Тут же возникнут две основные проблемы:
- У вас есть лишь один аргумент (например, нужно передать
true
или:ok
):
def init([true]) do
{:ok, %{}}
end
Вам не кажется, что скобки здесь ни к чему? В них явно нет никакого смысла, так что просто откажитесь от них!
Есть аргументы, уже обёрнутые в кортеж, словарь или список:
map = %{name: "Joe", age: 21}
tuple = {"Joe", 21}
list = ["Joe", 21]
И вы пытаетесь обернуть их в скобки?
def init([%{name: "Joe", age: 21}]) do
{:ok, %{name: "Joe", age: 21}}
end
def init([{"Joe", 21}]) do
{:ok, {"Joe", 21}}
end
def init([["Joe", 21]]) do
{:ok, ["Joe", 21]}
end
Отличная идея… Даёшь тысячи ненужных скобок!
Ещё кое-что: благодаря use GenServer
определение функции init\1
выглядит следующим образом:
def init(state) do
{:ok, state}
end
Говоря прямо, есть только две причины добавлять единственный аргумент в скобки:
-
вы хотите получить состояние
GenServer
в виде списка с одним элементом после инициализации; -
вы не хотите переопределять функцию
init/1
.
И в заключении:
-
если в вашем модуле, использующем
GenServer
, не определена функцияinit/1
, то вы знаете, что делать. -
если ваша функция
init/1
выглядит так же, какinit/1
, определённая ранее, не переопределяйте её в коде вручную!
Не стоит использовать скобки только потому, что вы увидели их в документации. Возможно, там был приведён неудачный пример.
Функция GenServer.start_link
. Вы уже знаете, что делать
Чтобы передать данные функции init/1
модуля GenServer
следует запустить сервер с помощью функции GenServer.start_link/3
. Вот её спецификация:
start_link(module: atom, args: any, options: Keyword.t) :: on_start
Всё просто: есть только одна позиция для аргументов, которые затем передадутся в функцию init/1
, арность которой равна единице. Вам даже не придётся ничего искать, вы и так знаете, что делать!
Например:
# Так хорошо
GenServer.start_link(__MODULE__, true)
def init(true), do: ...
GenServer.start_link(__MODULE__, %{name: "Joe", age: 21})
def init(%{name: "Joe", age: 21}), do: ...
GenServer.start_link(__MODULE__, {"Joe", 21})
def init({"Joe", 21}), do: ...
GenServer.start_link(__MODULE__, ["Joe", 21])
def init(["Joe", 21]), do: ...
# Никогда так не делайте!
GenServer.start_link(__MODULE__, [true])
def init(true), do: ...
** (FunctionClauseError) no function clause matching
GenServer.start_link(__MODULE__, [true])
def init([true]), do: ...
# 4 ненужных скобки...
GenServer.start_link(__MODULE__, [{"Joe", 21}])
def init({"Joe", 21}), do: ...
** (FunctionClauseError) no function clause matching
GenServer.start_link(__MODULE__, [["Joe", 21]])
def init([["Joe", 21]]), do: ...
# Да вы шутите?
Кажется, что всё очень просто? Не спешите радоваться…
Функция _MODULE_.start_link
. Что с ней так?
Где ещё в GenServer
вызывать GenServer.start_link/3
, как ни в своей собственной функции start_link
.
Для начала давайте посмотрим, какие функции добавятся в модуль после вызова макроса use GenServer
:
iex> defmodule Foo, do: use GenServer
iex> Foo.__info__(:functions)
[child_spec: 1, code_change: 3, handle_call: 3, handle_cast: 2, handle_info: 2, init: 1, terminate: 2]
Как видно, что среди перечисленных функций не оказалось функции start_link
, поэтому определим её сами.
Ну наконец-то можно поиграться с арностью и задать функции start_link
столько аргументов, сколько захочется!
# Просто пример - пните разработчика, который напишет это в продакшн
def start_link(_a,_b,_c, ... _z) do # И до 255 - максимальная арность в Эрланге
GenServer.start_link(__MODULE__, true)
end
А теперь вызовем функцию start_link/255
:
{:ok, pid} = MyGenServer.start_link(1,2,3,4,...255)
Как вы думаете, в каких случаях при определении start_link
реально может понадобиться больше одного аргумента? Не будем философствовать на темы «Кому это вообще нужно?» и «Что это: паттерн это или антипаттерн?», а просто приведём несколько примеров:
- задание динамического имени:
def start_link(init_arg, name) do
GenServer.start_link(__MODULE__, init_arg, name: name)
end
- обращение к модулю, определяющему колбеки для вашего
GenServer
:
def start_link(dynamic_module_name, init_arg) do
GenServer.start_link(dynamic_module_name, init_arg)
end
- какие-либо действия с
opts
:
def start_link(init_arg, opts) do
GenServer.start_link(__MODULE__, init_arg, opts)
end
Как вы понимаете, нельзя заранее определить, какую арность будет иметь функция start_link
. Здесь-то и начинаются все проблемы. Это и есть портал в скобочный ад…
Спецификации потомков
Прежде чем объявить скобкам войну, хорошо бы вооружиться знаниями о спецификации потомков. Откровенно говоря, спецификации потомков впервые появились ещё до версии 1.5
. Но, по правде, до сих пор не было подходящих инструментов для работы с ними, если только вы не фанат метапрограммирования и похожих вещей.
Спецификации потомков используются супервизором для понимания как:
-
запускать дочерние процессы;
-
перезапускать дочерние процессы.
В свою очередь у супервизора также есть свои спецификации, которые диктуют ему:
-
когда перезапускать дочерние процессы (стратегия перезапуска);
-
с какой частотой это делать, пока процесс ещё жив.
Обратимся к примеру спецификации потомка для вышеописанного модуля Foo
с функцией start_link/255
:
%{
id: Foo,
start: {Foo, :start_link, [1, 2, 3, ..., 255]},
restart: :permanent,
shutdown: 5000,
type: :worker
}
Все параметры описаны здесь, так что нет смысла перечислять их снова.
Видите эти непонятные три пары скобок внутри кортежа start
? Сразу же хочется спросить:
-
Нужны ли они здесь?
-
Можно ли их опустить, имея только один аргумент?
Не хотелось бы вас расстраивать, но в данном случае скобки выполняют особые задачи, следовательно, выкинуть их не получится.
Попробуем разобраться во всём по порядку.
Арность функции start_link
неизвестна
К первой паре претензий нет: арность функции start_link
в вашем модуле неизвестна (на момент создания спецификации).
В связи с этим необходимо какое-то средство, чтобы задать количество аргументов динамически, будь то один аргумент, или два, или 255. Здесь-то и пригодится хорошо известный в Эрланге подход…
Функция Kernel.apply
Обратимся к спецификации Kernel.apply/3
:
apply(module: atom, function_name: atom, args: [any]) :: any
Примеры:
iex> Enum.reverse([1, 2, 3])
[3, 2, 1]
iex> apply(Enum, :reverse, [[1, 2, 3]])
[3, 2, 1]
Как видите, функция apply/3
может только догадываться, сколько аргументов вы хотите ей передать. Поэтому необходимо представить аргументы в виде списка, и, даже если аргумент всего один, обернуть его в скобки. На этом отдельно заостряется внимание в спецификации функции apply/3
.
Хуже того, имея всего один аргумент в виде списка (как в приведённом примере), придётся смириться с двойными скобками…
Что ж, вернёмся к спецификацииям потомков. Возьмём кортеж, следующий после ключевого слова start
, и передадим его элементы в качестве аргументов функции Kernel.apply
:
iex> Kernel.apply(Foo, :start_link, [1, 2, 3, ... 255])
# это то же самое, что и...
iex> Foo.start_link(1, 2, 3, ..., 255)
Вы это видите? Спецификация потомка никак не изменяет ситуацию: кортеж просто копируется и вставляется в Kernel.apply
. Для этого и нужны скобки, что тут скажешь.
На минутку вспомним самый первый пример:
Stack.child_spec([:hello])
#=> %{
id: Stack,
start: {Stack, :start_link, [[:hello]]},
restart: :permanent,
shutdown: 5000,
type: :worker
}
Как можно заметить, Stack.start_link/1
на входе ожидает получить один аргумент, и этот аргумент – список. Вот почему :hello
нужно поместить в двойные скобки.
Подведём итоги одним мнемоническим правилом:
Если функция передаётся по своему имени, то можно убрать одну пару скобок!
Эти скобки нужны для передачи аргументов в виде списка, так что такая нотация опустит скобки и передаст эти аргументы, как в вызове функции.
Функция child_spec/1
. Динамическая точка в воркерах
Помните те функции, что появляются после вызова use GenServer
?
iex> defmodule Foo, do: use GenServer
iex> Foo.__info__(:functions)
[child_spec: 1, code_change: 3, handle_call: 3, handle_cast: 2, handle_info: 2, init: 1, terminate: 2]
Среди этого списка есть кое-что новенькое, чего не было ни в Эрланге, ни в Эликсире версии 1.4 и более низких версиях, – child_spec/1
.
Заглянем в её исходный код, чтобы понять, какие задачи она выполняет:
spec = [
id: opts[:id] || __MODULE__,
start: Macro.escape(opts[:start]) || quote(do: {__MODULE__, :start_link, [arg]}),
restart: opts[:restart] || :permanent,
shutdown: opts[:shutdown] || 5000,
type: :worker
]
@doc false
def child_spec(arg) do
%{unquote_splicing(spec)}
end
Всё довольно просто. Ожидается передача некоторых аргументов в выражение use GenServer
, чтобы как-то изменить отношение спецификации потомка. Можно, например, сделать так:
use GenServer, restart: :transient
Это переопределит стратегию перезапуска.
Единственное место, вызывающее интерес, – это кортеж {MODULE, :start_link, [arg]}
после ключевого слова key
в спецификации потомка. Аргумент, который передаётся функции child_spec/1
, помещён в скобки и уже подготовлен для передачи в start_link/1
, определённой в том же модуле.
Как известно, эти скобки будут опущены, значит, вызов будет выглядеть так:
MyGenServer.start_link(arg)
Очевидно, функцию child_spec/1
можно переопределить, но только не забывайте, что её арность равна единице. Таким образом, для того, чтобы задать что-либо динамически, необходимо обернуть данные в структуру:
def child_spec({id, {_module, _fun, args} = start, restart, shutdown}) do
%{
id: id,
...
}
end
# или
def child_spec([id, {_module, _fun, args} = start, restart, shutdown]) do
%{
id: id
...
}
end
# или (эта функция не имеет смысла, но для примера... )
def child_spec(%{id: id, start: {_module, _fun, args} = start, restart: restart, shutdown: shutdown} = spec) do
spec
end
# Никогда так не делайте!
def child_spec(id, {_module, _fun, args} = start, restart, shutdown) do
%{
id: id
...
}
end
# Все будут пытаться вызвать функцию `YourModule.child_spec/1`,
# но вы определили её как `YourModule.child_spec/4`.
# Ваша функция не переопределит функцию `child_spec/1`.
Вот мы и подошли к последней битве…
Учим супервизора вызывать воркеры
Запуск серверов GenServer с помощью start_link
прямо в коде – не лучшая идея. Существует отличный OTP
-фреймворк, побуждающий запускать все процессы в дереве супервизоров. Выглядит он как обычный список воркеров:
Supervisor.start_link([
_worker1_,
_worker2_,
...
_workerN_
], opts)
Воркеры могут быть определены тремя различными способами, но каждый из них в итоге приводит к спецификации потомка. Рассмотрим каждый из них по очереди.
- Словарь, представляющий собой саму спецификацию потомка, описанную в одном из предыдущих примеров:
Supervisor.start_link([
%{
id: "id",
start: {MyModule, :start_link, [true]},
restart: :transient,
shutdown: 500,
type: worker
}
], opts)
Здесь супервизор даже не соприкасается с функцией YourModule.child_spec/1
, а запускает контролируемый процесс напрямую из спецификации. Так что даже если вам приглянётся библиотека в Хексе, но child_spec/1
главного GenServer
реализована кое-как, можно воспользоваться данным подходом и адаптировать её для своего дерева супервизоров.
- Кортеж с модулем в качестве первого элемента и стартовый аргумент в качестве второго:
Supervisor.start_link([
{MyModule, true}
], opts)
При таком способе супервизор извлечёт спецификацию потомка из модуля MyModule
. Вы же помните, что child_spec/1
имеет арность 1? Как раз тот случай, когда это может пригодиться:
{MyModule, true}
# внутри процесса инициализации супервизора превращается в
MyModule.child_spec(true)
# Плохой пример
{MyModule, [true]}
# внутри процесса инициализации супервизора превращается в
MyModule.child_spec([true])
# и вероятно вы ожидали не этого...
Модуль:
Supervisor.start_link([
MyModule
], opts)
В данном случае это эквивалентно передаче {MyModule, []}
:
MyModule
# внутри процесса инициализации супервизора превращается в
MyModule.child_spec([])
Функция Supervisor.start_child
. Ещё один скользкий момент
Есть и ещё один способ запустить воркер супервизором – функция start_child/2
.
Изучим её спецификацию:
start_child(
supervisor: supervisor,
child_spec_or_args: :supervisor.child_spec | [term]
) :: on_start_child
Видите переменную с очень любопытным именем – child_spec_or_args
? Значит ли это, что потомок может быть запущен как с помощью спецификации, так и с помощью аргументов?
Вовсе нет! Второй аргумент функции start_child/2
будет зависеть от стратегии надзора:
-
:simple_one_for_one
– передаём аргументы; -
любая другая стратегия – передаём спецификацию потомка.
Давайте разбираться.
НЕ :simple_one_for_one
Определение спецификации потомка происходит одновременно с запуском воркера. Поэтому просто передаём спецификацию, и новый воркер запускается!
:simple_one_for_one
При использовании стратегии :simple_one_for_one
, определение спецификации потомка происходит одновременно с инициализацией супервизора, но потомки запускаются динамически. Тут могут возникнуть проблемы.
Во время инициализации супервизора пока неизвестно, какие аргументы понадобятся для будущей работы различных воркеров. Но мы можем передать эти аргументы в виде списка с помощью функции start_child/2
! Связывая два списка (list1++list2)
, она добавит их к уже существующим аргументам в определённой ранее спецификации потомка:
%{
id: "id",
start: {MyModule, :start_link, [true]},
restart: :transient,
shutdown: 500,
type: worker
}
# Если запускать потомка не нужно, необходимо переопределить
# стартовую часть и передать 0 аргументов:
spec = Supervisor.child_spec(MyModule, start: {MyModule, :start_link, []})
# После этой манипуляции, спецификация потомка внутри переменной `spec` будет:
%{
id: "id",
start: {MyModule, :start_link, []},
restart: :transient,
shutdown: 500,
type: worker
}
# Затем запускаем супервизор:
{:ok, pid} = Supervisor.start_link([spec], strategy: :simple_one_for_one)
# Воркер не запустится, потому что функция `start_link` с нулём аргументов не определена
# Теперь запускаем воркер динамически:
Supervisor.start_child(pid, [true]) # здесь ставим скобки, поскольку это список.
# Нужно, чтобы спецификация потомка была:
%{
id: "id",
start: {MyModule, :start_link, []++[true]}, # or simply [true]
restart: :transient,
shutdown: 500,
type: worker
}
# И новый воркер запустится.
Примеры кода
Подводя итоги, понаблюдаем за полным циклом передачи аргументов в различных условиях.
Простейший случай: самый обычный GenServer без какого-либо полезного состояния. Давайте напишем только самый необходимый минимум кода:
# В супервизоре:
Supervisor.start_link([SimpleModule], opts)
# Функция `child_spec` будет вызвана следующим образом:
SimpleModule.child_spec([])
# Я слишком ленив, чтобы переопределять `child_spec`, так что спецификация будет:
%{
id: SimpleModule,
start: {SimpleModule, :start_link, [[]]}, # double brackets, but you already know why!
restart: :permanent,
shutdown: 5000,
type: :worker
}
# Используя эту спецификацию потомка, супервизор запустит сервер следующим образом:
SimpleModule.start_link([])
# Я слишком ленив, чтобы думать об аргументах, так что решил пропустить их напрямую в `init`
# через функцию `start_link`, которую определил так:
def start_link(arg), do: GenServer.start_link(__MODULE__, arg)
# Больше не нужно переопределять `init`, так что вызовем её так:
SimpleModule.init([])
# Сервер запустится с состоянием:
{:ok, []}
Допустим, вам не нравится имя start_link
, и вы заменяете его на star_blink
:
# В супервизоре:
Supervisor.start_link([AstronomyModule], opts)
# Функция `child_spec` будет вызвана так:
AstronomyModule.child_spec([])
# Необходимо переопределить функцию `child_spec`, чтобы сообщить супервизору, как запустить мой модуль:
def child_spec(arg) do
%{
id: __MODULE__,
start: {__MODULE__, :star_blink, [arg]},
restart: :permanent,
shutdown: 5000,
type: :worker
}
end
# Эта спецификация возвращает:
%{
id: AstronomyModule,
start: {AstronomyModule, :star_blink, [[]]}, # как в предыдущем примере
restart: :permanent,
shutdown: 5000,
type: :worker
}
# Используя эту спецификацию потомка, супервизор запустит сервер следующим образом:
AstronomyModule.star_blink([])
# Пропускаем аргумент в функцию `init` через `star_blink`:
def star_blink(arg), do: GenServer.start_link(__MODULE__, arg)
# Функция `init` будет вызвана так:
AstronomyModule.init([])
# и запустит сервер с состоянием:
{:ok, []}
У вас есть аргумент-список с одним элементом, но вы не читали эту статью и решили добавить ещё одну пару скобок, ну чтобы наверняка:
# В супервизоре:
Supervisor.start_link([
{BracketsModule, [[:element]]}
], opts)
# Функция `child_spec` будет вызвана так:
BracketsModule.child_spec([[:element]])
# Эта спецификация возвращает:
%{
id: BracketsModule,
start: {BracketsModule, :start_link, [[[:element]]]}, # OH SHI~
restart: :permanent,
shutdown: 5000,
type: :worker
}
# Используя эту спецификацию потомка, супервизор запустит сервер следующим образом:
BracketsModule.start_link([[:element]])
# Пропускаем аргумент в функцию `init` через `star_blink`:
def start_link(arg), do: GenServer.start_link(__MODULE__, arg)
# Функция `init` будет вызвана так:
AstronomyModule.init([[:element]])
# и запустит сервер с состоянием:
{:ok, [[:element]]}
Естественно, вам не хотелось бы иметь список в списке в качестве состояния. Проблему можно решить несколькими способами:
# Единственный верный способ - изменить корень (он же супервизор)
Supervisor.start_link([
{BracketsModule, [:element]}
], opts)
# Никогда так не делайте!
# Переопределяем функцию `child_spec`:
def child_spec([arg]) do # пытаемся сопоставить скобки
%{
id: __MODULE__,
start: {__MODULE__, :star_blink, [arg]},
restart: :permanent,
shutdown: 5000,
type: :worker
}
end
# Переопределяем функцию `start_link`
def start_link([arg]), do: GenServer.start_link(__MODULE__, arg)
# Или переопределяем функцию `init`
def init([arg]), do: {:ok, arg}}
Заключение
Как видите, битва со скобками оказалась не такой уж и страшной! Теперь вам не придётся перезапускать свои программы из-за глупых ошибок со скобками в супервизорах, функциях spec
, start_link
и init
.
Напоследок пару советов, которые могут помочь вам и вашей команде сделать код чище.
-
При построении кода двигайтесь от воркеров к супервизорам, словно от веточек к корням. Ведь именно воркерам поручено выполнять основную работу, они – главная часть программы. Данная статья – некий обзор всего необходимого для правильного запуска воркеров независимо от того, как они были определены. Не пытайтесь «переупаковать» данные в функциях
init
иstart_link
, а передавайте их напрямую из супервизора. -
Возьмите на вооружение принцип KISS – не усложняйте код. К примеру, не переопределяйте
init
вGenServer
, когда можно передать начальное состояние с помощью функцииstart_link
. Не переопределяйте также и child_spec, если требуется создать простойGenServer
-воркер. Лучше сделать это через макрос using, используя аргументы. -
Основное внимание следует уделить функции
child_spec
. Даже если ваш код – уникальное произведение искусства, старайтесь, во-первых, реализоватьinit
иstart_link
как можно более стандартно и, во-вторых, не использовать спецификации потомков непосредственно вSupervisor.init
. Лучше поместить всю логику по запуску воркера вchild_spec
.
Жозе Валим: «Одна из причин, почему в Эликсире версии 1.5 была представлена новая спецификация потомков, – привести в порядок функции
start_link/1
и andinit/1
. Предыдущий подход, при котором количество аргументовstart_link
было переменным, аinit
имела всего один, показал свою неэффективность. Больным местом, как вы отметили в своей статье, теперь является супервизор:simple_one_for_one
, и мы обратим внимание на эту проблему при разработке Эликсира версии 1.6. Надеюсь, тогда изжившие себя методы постепенно канут в небытие, и рабочий процесс пойдёт как по маслу».