Распределение OTP
Мы можем запускать наши приложения Elixir на множестве различных узлов, распределенных по одному или по нескольким хостам. Elixir позволяет нам взаимодействовать между этими узлами с помощью нескольких различных механизмов, которые мы опишем в этом уроке.
Связь между узлами
Elixir работает на виртуальной машине Erlang, что означает, что у него есть доступ к мощным функциям дистрибутива Erlang.
Распределенная система Erlang состоит из нескольких систем выполнения Erlang, взаимодействующих друг с другом. Каждая такая система выполнения называется узлом.
Узел - это любая система выполнения Erlang, которой присвоено имя.
Мы можем запустить узел, открыв iex
сеанс и присвоив ему имя:
iex --sname alex@localhost
iex(alex@localhost)>
Давайте откроем другой узел в другом окне терминала:
iex --sname kate@localhost
iex(kate@localhost)>
Эти два узла могут отправлять сообщения друг другу с помощью Node.spawn_link/2
.
Связь через Node.spawn_link/2
Эта функция принимает два аргумента:
- Название узла, к которому вы хотите подключиться
- Функция, которая должна выполняться удаленным процессом, запущенным на этом узле
Она устанавливает соединение с удаленным узлом и выполняет переданную функцию на этом узле, возвращая PID связанного процесса.
Давайте определим модуль, Kate
с функцией, которая выводит в консоль приветствие от ‘Kate’:
iex(kate@localhost)> defmodule Kate do
...(kate@localhost)> def say_name do
...(kate@localhost)> IO.puts "Hi, my name is Kate"
...(kate@localhost)> end
...(kate@localhost)> end
Отправка сообщения
Теперь мы можем использовать Node.spawn_link/2
, узел alex
запрашивает у узла kate
вызов функции say_name/0
:
iex(alex@localhost)> Node.spawn_link(:kate@localhost, fn -> Kate.say_name end)
Hi, my name is Kate
#PID<10507.132.0>
Примечание по вводу-выводу и узлам
Обратите внимание, что Kate.say_name/0
хоть и выполняется на удаленном узле, но именно локальный или вызывающий узел получает выходные данные IO.puts
.
Это потому что локальный узел является лидером группы.
Виртуальная машина Erlang управляет вводом-выводом через процессы, это позволяет нам выполнять задачи ввода-вывода, такие как IO.puts
, на распределенных узлах.
Этими распределенными процессами ввода-вывода управляет лидер группы.
Лидер группы всегда является узлом, который запускает процесс.
Итак, поскольку наш узел alex
- это тот узел, с которого мы вызывали spawn_link/2
, то этот узел и является лидером группы, поэтому выходные данные IO.puts
будут направлены в стандартный поток ввода-вывода этого узла.
Ответ на сообщение
А что если мы захотим, чтобы узел получающий сообщение, отправил некоторый ответ отправителю? Мы можем использовать простую receive/1
и send/3
настройку, чтобы добиться именно этого.
Наш узел alex
создаст ссылку на узел kate
и предоставит узлу kate
анонимную функцию для выполнения. Эта анонимная функция будет прослушивать получение определенного сообщения в виде кортежа, описывающего сообщение и PID узла alex
. Узел kate
отреагирует на это сообщение, отправив ответное сообщение на PID узла alex
:
iex(alex@localhost)> pid = Node.spawn_link :kate@localhost, fn ->
...(alex@localhost)> receive do
...(alex@localhost)> {:hi, alex_node_pid} -> send alex_node_pid, :sup?
...(alex@localhost)> end
...(alex@localhost)> end
#PID<10467.112.0>
iex(alex@localhost)> pid
#PID<10467.112.0>
iex(alex@localhost)> send(pid, {:hi, self()})
{:hi, #PID<0.106.0>}
iex(alex@localhost)> flush()
:sup?
:ok
Примечание об обмене данными между узлами в разных сетях
Если вы хотите отправлять сообщения между узлами в разных сетях, нам нужно запустить именованные узлы с общим файлом cookie:
iex --sname alex@localhost --cookie secret_token
iex --sname kate@localhost --cookie secret_token
Только узлы, запущенные с одним и тем же cookie
, смогут успешно подключаться друг к другу.
Ограничения Node.spawn_link/2
Хотя Node.spawn_link/2
иллюстрирует связь между узлами и способ передачи сообщений между ними, это не совсем правильный выбор для приложения, которое будет работать с распределенными узлами.
Node.spawn_link/2
порождает изолированные процессы, то есть процессы, которые не контролируются.
Если бы только существовал способ порождать контролируемые, асинхронные процессы на всех узлах…
Распределенные задачи
Распределенные задачи позволяют нам порождать контролируемые задачи на разных узлах.
Мы построим простое приложение-супервизор, которое использует распределенные задачи для того, чтобы пользователи могли общаться друг с другом через iex
-сессию на распределенных узлах.
Генерируем приложение-супервизор
Создайте свое приложение:
mix new chat --sup
Добавление Task.Supervisor в древо супервизора приложения
Task.Supervisor
динамически контролирует задачи.
Он запускается без дочерних задач, часто под своим собственным супервизором, и в дальнейшем может быть использован для контроля любого количества задач.
Мы добавим Task.Supervisor
в древо детей нашего приложения и назовем его Chat.TaskSupervisor
.
# lib/chat/application.ex
defmodule Chat.Application do
@moduledoc false
use Application
def start(_type, _args) do
children = [
{Task.Supervisor, name: Chat.TaskSupervisor}
]
opts = [strategy: :one_for_one, name: Chat.Supervisor]
Supervisor.start_link(children, opts)
end
end
Теперь мы знаем, что где бы ни запускалось наше приложение на данном узле, Chat.Supervisor
будет запущен и готов контролировать выполнение задач.
Отправка сообщений с помощью Task.Supervisor
Мы запускаем контролируемые задачи с помощью функции Task.Supervisor.async/5
.
Эта функция должна принимать четыре аргумента:
-
Супервизор, который мы хотим использовать для контроля над задачей.Его можно передать в виде кортежа
{SupervisorName, remote_node_name}
, чтобы контролировать задачу на удаленном узле. - Имя модуля, на котором мы хотим выполнить функцию
- Имя функции, которую мы хотим выполнить
- Любые аргументы, которые необходимо передать этой функции
Вы можете передать пятый, необязательный аргумент, описывающий опции выключения. Здесь мы не будем об этом беспокоиться.
Наше приложение Chat довольно простое.
Оно отправляет сообщения на удаленные узлы, а удаленные узлы отвечают на эти сообщения путем IO.puts
-передачи их в STDOUT удаленного узла.
Сначала определим функцию Chat.receive_message/1
, которую будет выполнять наша контролируемая задача на удаленном узле.
# lib/chat.ex
defmodule Chat do
def receive_message(message) do
IO.puts message
end
end
Далее давайте научим модуль Chat
отправлять сообщение на удаленный узел с помощью контролируемой задачи.
Мы определим метод Chat.send_message/2
, который будет выполнять этот процесс:
# lib/chat.ex
defmodule Chat do
...
def send_message(recipient, message) do
spawn_task(__MODULE__, :receive_message, recipient, [message])
end
def spawn_task(module, fun, recipient, args) do
recipient
|> remote_supervisor()
|> Task.Supervisor.async(module, fun, args)
|> Task.await()
end
defp remote_supervisor(recipient) do
{Chat.TaskSupervisor, recipient}
end
end
Давайте посмотрим на это в действии.
В одном окне терминала запустите наше приложение чата в сессии с именем iex
.
iex --sname alex@localhost -S mix
Откройте другое окно терминала, чтобы запустить приложение на другом узле с другим именем:
iex --sname kate@localhost -S mix
Теперь из узла alex
мы можем отправить сообщение узлу kate
:
iex(alex@localhost)> Chat.send_message(:kate@localhost, "hi")
:ok
Переключитесь на окно kate
и вы увидите сообщение:
iex(kate@localhost)> hi
Узел kate
может отвечать узлу alex
:
iex(kate@localhost)> hi
Chat.send_message(:alex@localhost, "how are you?")
:ok
iex(kate@localhost)>
И он появится в сессии alex
узла iex
:
iex(alex@localhost)> how are you?
Давайте вернемся к нашему коду и разберем, что здесь происходит.
У нас есть функция Chat.send_message/2
, которая принимает имя удаленного узла, на котором мы хотим запустить наши контролируемые задачи и сообщение, которое мы хотим отправить этому узлу.
Эта функция вызывает нашу функцию spawn_task/4
, которая запускает асинхронную задачу на удаленном узле с заданным именем, контролируемую Chat.TaskSupervisor
на этом удаленном узле.
Мы знаем, что на этом узле запущен супервизор задач с именем Chat.TaskSupervisor
, потому что на этом узле также запущен экземпляр нашего приложения Chat, а Chat.TaskSupervisor
запускается как часть дерева наблюдения приложения Chat.
Мы указываем Chat.TaskSupervisor
контролировать задачу, которая выполняет функцию Chat.receive_message
с аргументом в виде сообщения, которое было передано в spawn_task/4
из send_message/2
.
Итак, Chat.receive_message(«hi»)
вызывается на удаленном узле kate
, в результате чего сообщение «hi»
будет выведено в поток STDOUT этого узла.
В данном случае, поскольку задача контролируется на удаленном узле, то этот узел является лидером группы для этого процесса ввода-вывода.
Ответы на сообщения с удаленных узлов
Давайте сделаем наше приложение Chat немного умнее.
Пока что любое количество пользователей может запустить приложение в именованной сессии iex
и начать общаться.
Но, допустим есть белая собака, средних размеров по имени Моэби, которая не хочет оставаться в стороне.
Моэби хочет участвовать в приложении «Chat», но к сожалению он не умеет печатать, потому что он - собака.
Поэтому мы научим наш модуль Chat
отвечать на все сообщения, отправленные на узел с именем moebi@localhost
от имени Моэби.
Что бы вы ни сказали Моэби, он ответит «chicken?»
, потому что его единственное истинное желание - съесть курицу.
Мы определим другую версию нашей функции send_message/2
, которая будет соответствовать шаблону в аргументе recipient
.
Если получатель - :moebi@locahost
, то мы
-
Получаем имя текущего узла с помощью
Node.self()
. -
Передайте имя текущего узла, т.е. отправителя, новой функции
receive_message_for_moebi/2
, чтобы мы могли обратно отправить сообщение этому узлу.
# lib/chat.ex
...
def send_message(:moebi@localhost, message) do
spawn_task(__MODULE__, :receive_message_for_moebi, :moebi@localhost, [message, Node.self()])
end
Далее мы определим функцию receive_message_for_moebi/2
, которая вызывает IO.puts
для вывода сообщение в поток STDOUT узла moebi
и отправляет сообщение обратно отправителю:
# lib/chat.ex
...
def receive_message_for_moebi(message, from) do
IO.puts message
send_message(from, "chicken?")
end
Вызывая send_message/2
с именем узла, отправившего исходное сообщение (“sender node”), мы сообщаем удаленному узлу, чтобы он снова запустил контролируемую задачу на узле-отправителе.
Давайте посмотрим на это в действии. В трех разных окнах терминала откройте три разных именованных узла:
iex --sname alex@localhost -S mix
iex --sname kate@localhost -S mix
iex --sname moebi@localhost -S mix
Пусть alex
отправит сообщение moebi
:
iex(alex@localhost)> Chat.send_message(:moebi@localhost, "hi")
chicken?
:ok
Мы видим, что узел alex
получил ответ «chicken?»
.
Если мы откроем узел kate
, то увидим, что никакого сообщения получено не было, так как ни alex
, ни moebi
не отправляли его ей (извини kate
).
А если мы откроем окно терминала узла moebi
, то увидим сообщение, которое отправил узел alex
:
iex(moebi@localhost)> hi
Тестирование распределенного кода
Давайте начнем с написания простого теста для нашей функции send_message
.
# test/chat_test.exs
defmodule ChatTest do
use ExUnit.Case, async: true
doctest Chat
test "send_message" do
assert Chat.send_message(:moebi@localhost, "hi") == :ok
end
end
Если мы запустим наши тесты через mix test
, то увидим, что они падают со следующей ошибкой:
** (exit) exited in: GenServer.call({Chat.TaskSupervisor, :moebi@localhost}, {:start_task, [#PID<0.158.0>, :monitor, {:sophie@localhost, #PID<0.158.0>}, {Chat, :receive_message_for_moebi, ["hi", :sophie@localhost]}], :temporary, nil}, :infinity)
** (EXIT) no connection to moebi@localhost
Эта ошибка вполне логична - мы не можем подключиться к узлу с именем moebi@localhost
, потому что такого узла не существует.
Мы можем починить этот тест, выполнив несколько шагов:
-
Откройте другое окно терминала и запустите именованный узел:
iex --sname moebi@localhost -S mix
-
Запустите тесты в первом терминале через именованный узел, который запускает тесты mix в сессии
iex
:iex --sname sophie@localhost -S mix test
.
Приходится делать много ручных манипуляций и это точно нельзя считать автоматизированным процессом тестирования.
Здесь можно использовать два разных подхода:
- Условно исключить тесты, которым требуются распределенные узлы, если необходимый узел не запущен.
- Настроить наше приложение так, чтобы оно не порождало задачи на удаленных узлах в тестовой среде.
Давайте рассмотрим первый подход.
Условное исключение тестов через тэги
Мы добавим тег ExUnit
к этому тесту:
# test/chat_test.exs
defmodule ChatTest do
use ExUnit.Case, async: true
doctest Chat
@tag :distributed
test "send_message" do
assert Chat.send_message(:moebi@localhost, "hi") == :ok
end
end
Если мы добавим некоторую условную логику в файл test_helper.exs
, так чтобы исключить тесты с тегами, если они не выполняются на именованном узле.
# test/test_helper.exs
exclude =
if Node.alive?, do: [], else: [distributed: true]
ExUnit.start(exclude: exclude)
Мы проверяем, жив ли узел, т.е. является ли узел частью распределенной системы с помощью Node.alive?
.
Если нет, мы можем сказать ExUnit
пропустить все тесты с тегом distributed: true
.
В противном случае мы скажем ExUnit
не исключать никаких тестов.
Теперь, если мы запустим старый добрый mix test
, то увидим:
mix test
Excluding tags: [distributed: true]
Finished in 0.02 seconds
1 test, 0 failures, 1 excluded
Если мы хотим запустить наши распределенные тесты, нам просто нужно проделать шаги, описанные в предыдущем разделе: запустить узел moebi@localhost
и запустить тесты в именованном узле через iex
.
Давайте рассмотрим другой подход к тестированию - настройку приложения на разное поведение в разных средах.
Конфигурация приложения для конкретной среды
Часть нашего кода, которая сообщает Task.Supervisor
о запуске контролируемой задачи на удаленном узле, находится здесь:
# app/chat.ex
def spawn_task(module, fun, recipient, args) do
recipient
|> remote_supervisor()
|> Task.Supervisor.async(module, fun, args)
|> Task.await()
end
defp remote_supervisor(recipient) do
{Chat.TaskSupervisor, recipient}
end
Task.Supervisor.async/5
принимает в качестве первого аргумента супервизор, который мы хотим использовать.
Если мы передадим кортеж {SupervisorName, location}
, он запустит данный супервизор на данном удаленном узле.
Однако если передать первым аргументом имя супервизора Task.Supervisor
, то он будет использовать этот супервизор для локального управления задачей.
Давайте сделаем функцию remote_supervisor/1
настраиваемой в зависимости от окружения.
В среде разработки она будет возвращать {Chat.TaskSupervisor, recipient}
, а в тестовой среде - Chat.TaskSupervisor
.
Мы сделаем это с помощью переменных приложения.
Создайте файл config/dev.exs
и добавьте в него:
# config/dev.exs
import Config
config :chat, remote_supervisor: fn(recipient) -> {Chat.TaskSupervisor, recipient} end
Создайте файл config/test.exs
и добавьте в него:
# config/test.exs
import Config
config :chat, remote_supervisor: fn(_recipient) -> Chat.TaskSupervisor end
Не забудьте раскомментировать эту строку в файле config/config.exs
:
import Config
import_config "#{config_env()}.exs"
Наконец, мы обновим нашу функцию Chat.remote_supervisor/1
, так чтобы она искала и использовала функцию, хранящуюся в нашей новой переменной приложения:
# lib/chat.ex
defp remote_supervisor(recipient) do
Application.get_env(:chat, :remote_supervisor).(recipient)
end
Заключение
Встроенные возможности Elixir по распределению, которыми он обладает благодаря силе Erlang VM, - одна из особенностей, которая делает его таким мощным инструментом. Мы можем представить себе, как можно использовать способность Elixir работать с распределенными вычислениями для выполнения параллельных фоновых заданий, поддержки высокопроизводительных приложений, выполнения дорогостоящих операций - да что угодно.
В этом уроке мы познакомились с концепцией распределения в Elixir и получим инструменты, необходимые для создания распределенных приложений. Используя контролируемые задачи, вы можете отправлять сообщения между различными узлами распределенного приложения.
Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!