Мои страшные эксперименты с Erlang и gen_server

11 сентября 2012

Помните, я как-то писал про язык программирования Erlang и говорил, что на нем с легкостью пишутся распределенные приложения, а эти приложения в свою очередь можно обновлять, не останавливая их выполнения? Недавно я познакомиться с этими возможностями поближе, о чем и хотел бы вам поведать.

1. Что такое поведения OTP?

Многие задачи в программировании решаютсяп схожим образом. Допустим, вы решили с нуля написать некий TCP-сервер, используя только сокеты. Наверняка вы напишите кучу шаблонного кода, который когда-то уже был написан другими программистами (например, классическую комбинацию из вызовов bind, listen, accept и fork). Скорее всего вы допустите массу типичных ошибок (забудете, что send и recv могут передать меньше байт, чем указано в третьем аргументе, или не напишите обработчик сигнала SIGPIPE).

Open Telecom Platform представляет различные шаблоны поведения процессов Erlang — наблюдатель (supervisor), сервер (gen_server), конечный автомат (gen_fsm), обработчик событий (gen_event). Используя поведения OTP, вы пишите меньше кода и абстрагируетесь от деталей реализации вашего приложения. Также вы уменьшаете свои шансы на допущение ошибки, поскольку поведения OTP обкатывались десятилетиями.

2. Пример использования поведения gen_server

Лучший способ понять, что такое поведения OTP — это попробовать их на практике. Давайте напишем на Erlang простенькое хранилище ключ-значение аля Memcached. Поскольку мы пишем сервер, решить задачу нам поможет поведение gen_server.

-module(key_value).
-behaviour(gen_server).
-define(VERSION, 0.01).

% Simple gen_server
% (c) Alexandr Alexeev 2012 | http://eax.me/

-export([start/0, set/2, get/1, version/0]).
-export([init/1, handle_call/3, handle_cast/2,
         handle_info/2, terminate/2, code_change/3]).

% vvvv API vvvv

start() ->
  gen_server:start_link({global, ?MODULE}, ?MODULE, [], []).

set(Key, Value) ->
  gen_server:call({global, ?MODULE}, { set, Key, Value }).

get(Key) ->
  gen_server:call({global, ?MODULE}, { get, Key }).

version() ->
  gen_server:call({global, ?MODULE}, { version }).

% vvvv Gen Server Implementation vvvv

init([]) ->
  State = dict:new(),
  {ok, State}.

handle_call({ set, Key, Value }, _From, State) ->
  NewState = dict:store(Key, Value, State),
  { reply, ok, NewState };

handle_call({ get, Key }, _From, State) ->
  Resp = dict:find(Key, State),
  { reply, Resp, State };

handle_call({ version }, _From, State) ->
  { reply, ?VERSION, State };

handle_call(_Message, _From, State) ->
  { reply, invalid_command, State }.

handle_cast(_Message, State) -> { noreply, State }.
handle_info(_Message, State) -> { noreply, State }.
terminate(_Reason, _State) -> ok.
code_change(_OldVersion, State, _Extra) -> { ok, State }.

Присмотримся к коду повнимательнее.

-behaviour(gen_server).

Здесь говориться использовать поведение gen_server.

-export([start/0, set/2, get/1, version/0]).

В этом списке представлены экспортируемые функции, которые должны быть использованы в клиентском коде. Функция start/0 запускает сервер на текущем узле. Функции set/2, get/1 и version/0 предназначены для отправки запросов на сервер клиентами. Функция set/2 присваивает значение ключу, get/1 получает значение по ключу, а version/0 получает номер версии сервера.

-export([init/1, handle_call/3, ...).

Тут перечислены callback’и, используемые поведением gen_server. Пользователь не должен вызывать эти функции напрямую.

start() ->
   gen_server:start_link({global, ?MODULE}, ?MODULE, [], []).

Запуск сервера осуществляется с помощью функции gen_server:start_link/4. Во время запуска будет произведен вызов функции init/1:

init([]) ->
   State = dict:new(),
   {ok, State}.

Задача этой функции — создать и вернуть начальное состояние сервера. Состояние нашего сервера представляет собой обычный словарь.

set(Key, Value) ->
   gen_server:call({global, ?MODULE}, { set, Key, Value }).

Отправка запросов на сервер осуществляется с помощью функции gen_server:call/2. При получении нового запроса будет вызвана функция handle_call:

handle_call({ set, Key, Value }, _From, State) ->
   NewState = dict:store(Key, Value, State),
   { reply, ok, NewState };

...

Используя сопоставление с образцом, функция определяет, какой именно запрос был получен. Функция должна вернуть ответ, который будет послан клиенту (в данном случае это атом ok) и новое состояние сервера (NewState).

Функции get/1 и version/0 устроены аналогично функции set/2. Функции handle_cast/2, handle_info/2, terminate/2 и code_change/3 в приведенном примере ничего особенного не делают. Они были объявлены только для того, чтобы избавиться от предупреждений во время компиляции сервера. Описание этих callback’ов вы найдете в документации по gen_server.

3. Собираем и тестируем

Создадим новый каталог key_value. Все дальнейшие операции будут производиться в нем. Приведенный выше код сохраним в файле src/key_value.erl. Также нам понадобится пустой каталог ebin и файл Emakefile следующего содержания:

{'src/key_value.erl', [{outdir, 'ebin'}]}.

Собираем сервер:

erl -make

Запускаем сервер:

$ erl -pa ebin -sname key_value
> l(key_value).
{ok,key_value}
> key_value:start().
{ok,<0.45.0>}

В другом окне терминала говорим:

$ erl -pa ebin -sname client
> net_kernel:connect_node(key_value@eeepc).
true
> erlang:nodes().
[key_value@eeepc]
> l(key_value).
{ok,key_value}
> key_value:set(qwerty, 123).
ok
> q().

Возвращаемся к первому окну:

> key_value:get(qwerty).
{ok,123}
> q().
ok

Можете попробовать запустить несколько клиентов и убедиться, что они успешно обмениваются данными через наш сервер.

4. Взаимодействие с сервером по сети

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

В целях безопасности всем взаимодействующим узлам Erlang должно быть присвоено одинаковое значение cookie. Cookie представляет собой обычную строку. Не зная cookie, используемый на некотором узле, нельзя послать ему сообщение. Точнее, послать можно, но оно будет проигнорировано. Присвоить значение cookie можно либо с помощью ключа -setcookie программы erl, либо прописав его в файл ~/.erlang.cookie. Чтобы не светить cookie в списке процессов, рекомендуется использовать второй вариант.

Каждый узел Erlang должен иметь имя. Имена бывают двух типов — короткие и длинные. Так задаются короткие имена:

erl -sname key_value ...

А так — длинные:

erl -name key_value@123.45.67.89 ...

В зависимости от типа имени соединение с узлом устанавливается одним из следующих двух способов:

> net_kernel:connect_node(key_value@eeepc).
> net_kernel:connect_node('key_value@123.45.67.89').

Чтобы узлы могли взаимодействовать друг с другом, они должны иметь одинаковые типы имен. То есть, узел с коротким именем может взаимодействовать только с узлами, также имеющими короткие имена.

Короткие имена более удобны, но они зависят от DNS и содержимого файла hosts. Длинные имена не так удобны, но в некоторых ситуациях предпочтительнее использовать их. Например, с их помощью вы можете установить соединение с узлом, находящимся за NAT. В этом случае в имени узла, находящегося за NAT, должен использоваться IP шлюза, а на самом шлюзе должен быть настроен port forwarding для EPMD.

Erlang Port Mapper Daemon (EPMD) по умолчанию слушает порт 4369. Каждый из узлов Erlang прослушивает некоторый порт. При запуске он посылает локальному EPMD уведомление со своим именем и номером порта. При получении сообщений от узлов, работающих на других машинах, EPMD пересылает эти сообщения своим локальным узлам.

Учитывая все вышесказанное, запустите сервер на одной машине, после чего пошлите ему несколько запросов с другой. Если с первого раза не получится, попробуйте следующее. Убейте EPMD на машине, где запущен сервер и перезапустите его в отладочном режиме.

epmd -kill
epmd -d

Не забудьте перезапустить сервер. Теперь на стороне клиента скажите:

> net:ping('key_value@192.168.2.210').
pong

Ответ pong означает, что все ОК. Если функция вернула pang, значит что-то не так. Отладочный вывод EPMD поможет вам диагностировать проблему. Если в выводе ничего нет, значит EPMD не получает никаких сообщений и проблема вовсе не в Erlang с его кукисами и именами. С помощью утилит netcat и telnet проверьте, что машины вообще могут обмениваться данными по сети.

После окончания отладки перезапустите EPMD:

epmd -daemon

Напишите скрипт для запуска сервера примерно такого содержания:

#!/bin/sh

erl -noshell -pa ebin -name key_value@192.168.2.210 \
  -run key_value start

Остановите и вновь запустите сервер.

5. Горячее обновление кода

Представьте, что мы с вами выкатили наше key-value хранилище в бой. Прошло несколько месяцев и нам захотелось добавить в него новый функционал (например, удаление ключей). Но вот незадача — если остановить сервер хотя бы на секунду, зависящие от него процессы не смогут нормально работать, потому что сервер будет недоступен. Кроме того, мы потеряем все наши данные, поскольку они хранятся в оперативной памяти. Значит, придется либо смириться с потерей, либо потратить дополнительное время на восстановление данных из резервной копии.

К счастью, в Erlang предусмотрена возможность обновления кода «на лету». Другими словами, мы сможем обновить код сервера, не останавливая его. Для наглядности выполним несколько запросов к серверу:

> key_value:version().
0.01
> key_value:set(qwerty, 123).
ok
> key_value:get(qwerty).
{ok,123}

Теперь зайдем (например, по SSH) на машину, где запущен сервер. Найдем файл key_value.erl и обновим в нем макрос VERSION:

-define(VERSION, 0.02).

Сохраняем изменения, но код не пересобираем. Вместо этого устанавливаем соединение с узлом, на котором крутится наш сервер:

erl -name admin@192.168.2.211 -remsh key_value@192.168.2.210

… и выполняем на этом узле команду:

> make:all([load]).
Recompile: src/key_value
up_to_date

Только не наберите по привычке «q().», это остановит сервер! Вместо этого нажмите Ctr+G, а затем Q.

Теперь, если все было сделано правильно, на стороне клиента должна получиться следующая картина:

> key_value:get(qwerty).
{ok,123}
> key_value:version().  
0.02

Смотрите! Сервер обновился и данные никуда не делись!

Вот как это работает. Виртуальная машина Erlang умеет хранить в памяти две версии одного и того же модуля. Когда мы обновляем модуль, версия кода, которая раньше была в памяти, помечается как старая, а только что загруженный код помечается как текущий. Выполнение процесса происходит в контексте старого кода до тех пор, пока одна из функций модуля не будет вызвана по полному имени (то есть, module:function, а не просто function). В этот момент происходит переключение контекста на текущий код.

При следующем обновлении модуля старый код уничтожается, текущий код помечается как старый, а только что загруженный код помечается как текущий, после чего все происходит по той же схеме, что и раньше. Если во время обновления модуля какой-то процесс все еще работает со старым кодом, он завершается.

На практике обновление кода может оказаться не настолько простой задачей, как в приведенном случае. Например, при обновлении модуля может измениться представление некоторых данных. Кроме того, иногда требуется не обновить модуль, а наоборот — откатить его к более ранней версии. Но эти вопросы выходят за рамки данной заметки.

6. Вопросы читателям

Еще раз отмечу, что я далеко не гуру Ерланга. Например, про то, как пользоваться Emakefile и как правильно производить обновление кода я совсем недавно узнал в рассылке erlang-russian. Так что, если вы видите в этой заметке неточности или откровенную отсебятину, пожалуйста, сообщите об этом в комментариях.

Мне бы очень хотелось узнать, как правильно обновить представление состояния моего gen_server. Насколько я понимаю, для этого предназначена функция code_change/3. Согласно документации, для ее вызова требуется обновить релиз модуля, а вот с релизами я пока работать не умею. Нет ли среди читателей кого-нибудь, кто может поделиться ссылкой на рабочий пример или годный туториал по этой теме?

Дополнение: См также заметку Создание GUI приложений с помощью wxErlang.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.