Воюем со скобками в дереве супервизоров

В Эликсире версии 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.

Тут же возникнут две основные проблемы:

  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, определённая ранее, не переопределяйте её в коде вручную!

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

Чтобы передать данные функции 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: ...

# Да вы шутите?

Кажется, что всё очень просто? Не спешите радоваться…

Где ещё в 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 в вашем модуле неизвестна (на момент создания спецификации).

В связи с этим необходимо какое-то средство, чтобы задать количество аргументов динамически, будь то один аргумент, или два, или 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)

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

  1. Словарь, представляющий собой саму спецификацию потомка, описанную в одном из предыдущих примеров:
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 реализована кое-как, можно воспользоваться данным подходом и адаптировать её для своего дерева супервизоров.

  1. Кортеж с модулем в качестве первого элемента и стартовый аргумент в качестве второго:
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 и and init/1. Предыдущий подход, при котором количество аргументов start_link было переменным, а init имела всего один, показал свою неэффективность. Больным местом, как вы отметили в своей статье, теперь является супервизор :simple_one_for_one, и мы обратим внимание на эту проблему при разработке Эликсира версии 1.6. Надеюсь, тогда изжившие себя методы постепенно канут в небытие, и рабочий процесс пойдёт как по маслу».

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.