При разработке на Erlang часто приходится собирать зависимости из разных источников, следить за их нужными версиями, создавать OTP-релизы для распространения проектов. Дела достаточно рутинные и неприятные. Для того, чтобы разработка меньше доставляла неприятных моментов, компанией Basho был создан очень удобный инструмент — Rebar. В этой статье я постараюсь раскрыть преимущества от его использования на реальном примере с использованием сторонних зависимостей и созданием конфигурируемых OTP-релизов.
Скачать Rebar можно по адресу http://hg.basho.com/rebar/downloads/rebar. Это один единственный файл, вмещающий в себя несколько beam-модулей. Его надо разместить в любом удобном месте, входящем в путь поиск исполняемых файлов PATH, например, ~/bin/
или /usr/local/bin/
.
Приступим к проекту.
Для начала создаём директорию, в которой будет находиться наш проект (gpt
) и переходим в неё:
$ mkdir gpt && cd gpt
В ней создадим поддиректорию, где будем сохранять исходные файлы непосредственно нашего приложения:
$ mkdir -p apps/gpt && cd apps/gpt
Делаем скелет приложения:
$ rebar create-app appid=gpt
Параметр appid определяет имя нашего приложения и соответственно префикс исходных файлов.
$ ls -1 src
gpt_app.erl
gpt.app.src
gpt_sup.erl
Добавляем в заготовку для .app-файла (src/gpt.app.src
) описание приложения и зависимость от gproc:
{description, "GProc tutorial"},
...
{applications,
[
kernel,
stdlib,
gproc % <--- Приложение зависит от gproc
]},
...
Возвращаемся назад в директорию верхнего уровня, где хранится наш проект, создаём в ней поддиректорию rel
и переходим туда:
$ cd ../../
$ mkdir rel && cd rel
В rel будут лежать файлы, необходимые для создания релиза — всего того, что требуется для запуска проекта, все его runtime-зависимости.
С помощью rebar
создадим заготовку для ноды, передав её имя в параметре nodeid
:
$ rebar create-node nodeid=gptnode
Редактируем файл reltool.config
:
...
{lib_dirs, ["../deps", "../apps"]}, % <--- В этих директориях reltool будет искать зависимости и наше приложение
{rel, "gptnode", "1",
[
kernel,
stdlib,
sasl,
gproc, % <--- Приложение gproc
gpt % <--- Наше приложение
]},
...
Далее можно отредактировать файл files/vm.args
, изменив, допустим, имя ноды:
-name [email protected]
на
-sname gptnode@localhost
Вернёмся в директорию верхнего уровня:
$ cd ../
и создадим файл rebar.config
со следующим содержанием:
%% Здесь будут лежать зависимости
{deps_dir, ["deps"]}.
%% Поддиректории, в которые rebar должен заглядывать
{sub_dirs, ["rel", "apps/gpt"]}.
%% Опции компилятора
{erl_opts, [debug_info, fail_on_warning]}.
%% Список зависимостей
%% В директорию gproc будет клонирована ветка master соответствующего git-репозитория.
{deps,
[
{gproc, ".*", {git, "http://github.com/esl/gproc.git", "master"}}
]}.
Теперь у нас всё готово для создания релиза. Выполним несколько команд rebar
(вывод команд опущен):
$ rebar get-deps
$ rebar compile
$ rebar generate
Команда get-deps
скачивает зависимости. В нашем случае, это приложение gproc. Команда compile
, очевидно, вызывает компиляцию всех исходных файлов, а generate
создаёт релиз.
Директорию rel/gptnode
можно смело перемещать на другие хосты (разумеется, при условии бинарной совместимости, так как релиз включает в себя и виртуальную машину Erlang). После создания релиза запускаем то, что получилось:
(cd rel/gptnode && sh bin/gptnode console)
Убедимся в том, что все нужные приложения запущены:
(gptnode@localhost)1> application:which_applications().
[{sasl,"SASL CXC 138 11","2.1.9.2"},
{gpt,"GProc tutorial","1"},
{gproc,"GPROC","0.01"},
{stdlib,"ERTS CXC 138 10","1.17.2"},
{kernel,"ERTS CXC 138 10","2.14.2"}]
Нас интересуют gpt и gproc. Как видно, они в этом списке присутствуют.
Итак, с rebar
разобрались, научились создавать простенький проект и работать с ним. Приступим к gproc.
Как известно, приложения в Erlang, как правило, состоят из множества процессов, которые обмениваются сообщениями.
Чтобы процессы знали, кому какое сообщение посылать, необходимо иметь регистратор, преобразующий какие-то координаты в идентификатор процесса. По умолчанию Erlang/OTP предоставляет регистрацию процессов под именем-атомом. Это расточительно, так как атомы не собираются сборщиком мусора, и, создавшись один раз, живут до завершения работы всей ноды, что обязательно приведёт к исчерпанию всей памяти при необходимости регистрировать процессы под уникальными именами. К тому же подобный подход неудобен, так как пришлось бы преобразовывать разные термы в атом, сочинять для этого какие-то правила, кроме того, процесс можно зарегистрировать только под одним именем. Регистрация процессов под именем-атомом с помощью функции erlang:register/2
допустима лишь для небольшого числа долгоживущих процессов, имя которых не должно меняться, аналог — глобальные переменные в императивных языках программирования.
Для обхода этих ограничений зачастую используется следующая схема:
- запускается процесс-регистратор, создающий ets-таблицу и являющийся её владельцем;
- при запуске процессов, нуждающихся в регистрации, они посылают регистратору сообщение, содержащее координаты для регистрации (любой erlang-терм) и свой идентификатор;
- регистратор записывает это сопоставление в ets-таблицу и включает мониторинг процесса с помощью
erlang:monitor/2
; - регистрируемый процесс при завершении либо явно посылает сообщение о своей дерегистрации, либо регистратор получает сообщение
'DOWN'
при падении этого процесса, после чего удаляет его запись из ets-таблицы;
Схема эта очень часто используется, почти в каждом приложении есть своя реализация, со своими особенностями и багами. Разумеется, что возникает естественное желание заменить этот регистратор на что-то единственное. И решение проблемы пришло в виде разработчика Ulf Wiger и его приложения gproc (https://github.com/esl/gproc).
С API приложения можно ознакомиться на странице https://github.com/esl/gproc/blob/master/doc/gproc.md.
Рассмотрим простейший случай — локальная (на текущей ноде) регистрация процесса по произвольному терму.
Исходный код для примеров можно взять здесь: http://github.com/Zert/gproc-tutorial.git
Код процессов, которые мы будем регистрировать через gproc, находится в файле gpt_proc.erl
. В gpt_sup.erl
находится код супервизора этой группы процессов. При вызове функции gpt_sup:start_worker/1
будет запускаться наш процесс и регистрироваться под тем именем, которое передаётся в функцию в качестве единственного аргумента. В данном случае, это число.
Запустили ноду, используя вышеупомянутую команду, и выполнили в ней серию запусков процессов с разными идентификаторами:
(gptnode@localhost)1> [gpt_sup:start_worker(Id) || Id <- lists:seq(1,3)].
(gpt_proc:29) Start process: 1
(gpt_proc:29) Start process: 2
(gpt_proc:29) Start process: 3
[{ok,<0.61.0>},{ok,<0.62.0>},{ok,<0.63.0>}]
Вызов функции gproc:add_local_name(Name)
регистрирует процесс, её вызывающий, под именем Name
(эта функция является просто обёрткой над gproc:reg({n,l,Name})
, где n
— name
, l
— local
). После этого функция gproc:lookup_local_name(Name)
будет возвращать идентификатор процесса.
Теперь скажем одному из процессов, чтобы он начал ждать запуска и регистрации процесса с именем 4. Код, отвечающий за это:
handle_info({await, Id},
#state{id = MyId} = State) ->
gproc:await({n, l, Id}),
?DBG("MyId: ~p.~nNewId: ~p.", [MyId, Id]),
{noreply, State};
Здесь функция gproc:await/1
вызывается с аргументом, имеющим следующий вид: {n, l, Id}
. Почему-то она не имеет обёртки, ну да ладно.
(gptnode@localhost)2> gproc:lookup_local_name(1) ! {await, 4}.
{await,4}
Запустив процесс с идентификатором 4, увидим сначала сообщение от него, а затем от первого ждущего процесса:
(gptnode@localhost)3> gpt_sup:start_worker(4).
(gpt_proc:29) Start process: 4
(gpt_proc:45) MyId: 1.
NewId: 4.
{ok,<0.66.0>}
Сделаем остановку процесса по приёму сообщения stop
:
handle_info(stop, State) ->
{stop, normal, State};
и остановим его:
(gptnode@localhost)4> gproc:lookup_local_name(1) ! stop.
stop
После этого процесс автоматически удаляется из базы данных регистратора:
(gptnode@localhost)5> gproc:lookup_local_name(1).
undefined
Общеизвестно, что Erlang является дико распределённым. Под этим подразумевается прозрачный обмен сообщениями между узлами, другими словами, имея идентификатор процесса, можно послать ему сообщение, не зная, на каком узле он находится. Локальная регистрация процесса с помощью gproc позволяет сопоставить произвольный терм идентификатору процесса в пределах одной ерланг-ноды, при этом на другой ноде нельзя получить значение идентификатора, используя этот терм.
Для того, чтобы любая нода в кластере имела возможность регистрировать свои процессы так, чтобы они были доступны и с других нод, имеется глобальная регистрация. GProc реализует вызов gproc:add_global_name/1
, позволяющий осуществлять это действие. Рассмотрим на примере.
Сначала соорудим две ноды, объединённые в кластер, и rebar
нам в этом поможет, так как имеет возможность создавать конфигурационные файлы по заданному шаблону. При создании кластера необходимо учесть следующие детали:
- Установить одинаковые значения cookie у узлов
- Задать им разные имена
- Передать соответствующие параметры обязательному приложению
kernel
на каждом узле - При использовании GProc, передать ему желаемые роли узлов
Первые два пункта устанавливаются в файле files/vm.args
:
## Name of the node
-sname {{node}}
## Cookie for distributed erlang
-setcookie gptnode
Здесь {{node}}
— плейсхолдер, который будет заполняться при создании релиза. Параметр виртуальной машины -setcookie
устанавливает значение cookie для этой ноды, в кластере у всех нод эти значения должны быть одинаковыми.
Вторые два пункта устанавливаются в файле files/app.config
. Здесь тоже будут использоваться плейсхолдеры:
%% GProc
{gproc, {{ gproc_params }} },
%% Kernel
{kernel, {{ kernel_params }} },
Для заполнения плейсхолдеров укажем в файле reltool.config
, что предыдущие два файла надо обрабатывать как шаблоны:
{template, "files/app.config", "etc/app.config"},
{template, "files/vm.args", "etc/vm.args"}
Создаём два конфигурационных файла, по одному на каждую ноду: vars/dev1_vars.config
и vars/dev2_vars.config
. В файле dev1_vars.config
будут находиться следующие значения плейсхолдеров:
%% etc/app.config
{gproc_params,
[
{gproc_dist, {['gpt1@localhost'],
[{workers, ['gpt2@localhost']}]}}
]}.
{kernel_params,
[
{sync_nodes_mandatory, ['gpt2@localhost']},
{sync_nodes_timeout, 15000}
]}.
%% etc/vm.args
{node, "gpt1@localhost"}.
Для файла dev2_vars.config
параметры sync_nodes_mandatory
и node
поменяются местами. Разберём их подробнее.
Параметр gproc_dist
относится к приложению gproc, он является кортежом из двух списков. Первый список — узлы, которые способны становиться лидером (master), второй список содержит key-value кортежи, нам пока достаточно лишь одного ключа — workers
, который задаёт список нод, являющихся простыми участниками кластера (slave).
К приложению kernel относятся два параметра. Первый, sync_nodes_mandatory
— список нод, которые обязаны присутствовать в кластере. Второй, sync_nodes_timeout
— время в миллисекундах, которое каждый из узлов будет ожидать появления узлов из предыдущего списка. Если в течение этого времени ноды не появились, то нода остановится. Сделаем его значение 15 секунд, дабы успеть запустить их оба руками.
Значение node
будет записано в параметры запуска виртуальной машины, это её имя.
Теперь создадим два релиза с помощью следующего правила из Makefile:
dev1 dev2:
mkdir -p dev
(cd rel && rebar generate target_dir=../dev/$@ overlay_vars=vars/$@_vars.config)
Переходим в директорию dev/dev1
, запускаем второе окно терминала (или создаём новое окно в screen), переходим в директорию
dev/dev2. Запускаем в каждом шелле
./bin/gptnode console`. Посмотрим список доступных нод в первом ерланговском шелле:
(gpt1@localhost)1> nodes().
[gpt2@localhost]
Видим, что вторая нода нормально запустилась и подключилась к кластеру. Чтобы долго не мудрить, зарегистрируем глобально процесс текущего шелла под каким-нибудь термом:
(gpt1@localhost)2> gproc:add_global_name({shell, 1}).
true
В другом окне попробуем запросить идентификатор процесса по этому терму:
(gpt2@localhost)2> gproc:lookup_global_name({shell, 1}).
<3358.70.0>
Как видим, успешно. Послав этому процессу сообщение, мы сможем получить его на первой ноде:
(gpt2@localhost)3> gproc:lookup_global_name({shell, 1}) ! {the,message}.
message
Читаем его на первой ноде командой flush()
:
(gpt1@localhost)3> flush().
Shell got {the,message}
ok
На этом всё. Статью начал писать для себя, так как документация по rebar очень скудная и постоянно забывается после очередного использования. Попутно начал использовать gproc, и чтобы два раза не вставать, разместил всё в одной статье.