gen_event — обобщённый шаблон для создания акторов — диспетчеров событий.
К этому универсальному актору — диспетчеру событий — можно прикреплять произвольное количество обработчиков событий. Эти события добавляются диспетчеру и удаляются динамически. Данные обработчики событий не являются самостоятельными акторами. Каждый прикреплённый обработчик получает свою копию приходящего в актор события (сообщения).
Если акторы, созданные на основе gen_server или gen_statem, умеют посылать сообщения сами себе (по таймауту), то обработчики событий так не умеют. Это сужает круг применения диспетчеров событий, потому что без таймаутов актор не может быть субъектом инициативы.
Можно, конечно, попробовать самому смастерить нужный таймаут. Например, в обработчика можно поместить следующую строку:
spawn(fun() -> timer:sleep(10_000), gen_event:notify(parrot, {timeout, hello}) end),
На время (10 секунд) будет запущен новый актор, который в конце своей работы пошлёт диспетчеру сообщение {timeout, hello}. Тут важно не запутаться.
Каждый обработчик событий может получить личный синхронный вызов (сall), поэтому при желании можно использовать gen_event для создания акторов-серверов.
Диспетчера удобно рассматривать как универсального обработчика приходящих событий (сообщений). Универсальность здесь означает и то, что могут приходить какие-то неопределённые сообщения, и то, что мы заранее не знаем, как следует классифицировать сообщения, каким акторам их переадресовать.
Переадресовывать другим акторам, конечно, не обязательно. Многие вещи можно сделать прямо в обработчиках событий, прикреплённых к диспетчеру. Например, в обработчике можно реализовать журналирование сообщений.
Рассмотрим пример. События (сообщения) приходят к нам из нашего веб-сервера. Мы желаем корректно обработать все события, но дело в том, что веб-сервер может получать извне очень разные сообщения. Не надо исключать, что и какие-то бандиты из интернета специально хотят навредить нашему серверу. Как можно поступить в такой ситуации? Мы запускаем диспетчера, и он все непонятные ему запросы просто журналирует. Мы потом систематически просматриваем журнал, выискиваем случаи, которые следовало бы обработать, решаем, как это сделать корректно, делаем обработчика такого события, прикрепляем его к диспетчеру, и далее некоторая часть приходящих сообщений от веб-сервера обрабатывается нужным нам образом, синхронно или асинхронно (вызовом нужной функции или передачей другому (компетентному) актору).
В нашем примере таких обработчиков потом может накопиться очень много. Причём одни сообщения будут обрабатываться только одним обработчиком, а другие — несколькими. Добавление (и удаление) обработчиков происходит динамически, и это весьма удобно и безопасно, потому что мы лишь слегка правим код. Подобную обработку (диспетчеризацию) можно реализовать с помощью gen_server и gen_statem, однако там код сосредоточен в одном колбеке, и поэтому случайно можно нарушить бизнес-логику актора и не заметить этого.
В gen_server и gen_statem предусмотрена горячая замена кода. Если мы желаем поменять бизнес-логику актора, просто компилируем и загружаем новую версию модуля. В gen_event не всегда для изменения бизнес-логики надо снова компилировать и загружать новую версию. Иногда бывает достаточно открепить или прикрепить обработчик, если его модуль уже скомпилирован. Опять же, можно открепить обработчик, а потом снова его прикрепить, но уже с новыми параметрами (в т.ч. фунтермом).
Можно сказать, что акторы, основанные на gen_server и gen_statem, это монолитные акторы. Пусть даже в модуле используется много разных функций, каждая из которых хорошо продумана, есть риск, что при увеличении кода мы получим нарушенную бизнес-логику. Причём чем больше код, тем больше риск и тем меньше хочется заново править модуль. Особо неприятно, когда эти нарушения не выливаются в исключения, поэтому могут очень долго быть скрытыми от нашего внимания.
Про акторы, основанные на gen_event, можно сказать, что это не монолитные акторы. Каждый обработчик определяется своим модулем, обычно небольшим. Когда мы пишем данный модуль, для нас важно лишь его содержание и то, какие сообщения будут приходить. Поэтому нет такой зависимости, что чем больше код актора, тем больше риск нарушить бизнес-логику. Тут даже есть некоторый парадокс: хотя gen_event беднее возможностями, с его помощью можно создавать гигантские акторы.
Тут надо сделать оговорку. Весь диспетчер выполняется синхронно, одним актором. В один момент времени может работать только один обработчик. Пока он работает, другие ничего поделать не могут. Если, например, мы засунем в обработчик timer:sleep(10_000), весь актор заснёт на эти 10 секунд. Если в этот промежуток придут новые сообщения, они не будут сразу обработаны — только когда таймер закончится.
При желании, например, всю бизнес-логику нашего сайта можно уместить в один актор, созданный на основе gen_event. Хотя удобнее всё же совмещать gen_event со, скажем, gen_statem, если мы желаем, например, контролировать состояние пользователя. На каждого пользователя мы можем завести его личный актор — машину состояний — и транслировать из диспетчера сообщения в эту машину.
Поскольку идея диспетчера состоит в том, что он может принимать самые разные сообщения, имеет смысл в каждом обработчике предусмотреть кляузу для непонятных сообщений. Кстати, если не будет найдена кляуза для сообщения (или будет другая ошибка), рухнет только обработчик (хотя он и не актор), но не весь актор-диспетчер.
При желании можно добавить колбек terminate(Args, State), который будет выполняться в обработчике, когда он открепляется или когда весь диспетчер останавливается. Это полезно, например, когда надо сохранить состояние до следующего запуска обработчика. Если используем ETS, при выходе её можно сохранить в DETS, а потом снова при запуске обработчика в init предусмотреть превращение DETS в ETS.
Для начала поупражняться в gen_event можно в оболочке. Создадим сначала актора — диспетчера событий.
1> gen_event:start({local, parrot}).
{ok,<0.85.0>}
Аргумент у start означает, что мы создаём диспетчера с локальным именем parrot. Вместо local можно было бы задать global, и тогда этот диспетчер был бы доступен во всём кластере нод.
Теперь создадим парочку обработчиков событий. Каждый создаётся в виде колбек-модуля (но это не делает его самостоятельным актором, как это реализуется в gen_server и gen_statem).
-module(handler1).
-behaviour(gen_event).
-export([init/1, handle_event/2, handle_call/2]).
init(_Args) ->
io:format("*** ~p: inited.~n", [?MODULE]),
{ok, 0}.
handle_event(Event, N) ->
io:format("*** ~p: some event:~p~n",[?MODULE, Event]),
{ok, N}.
handle_call(_Request, N) ->
Reply = N,
{ok, Reply, N}.
Второй модуль выглядит аналогично, только вместо handler1 имеет название handler2. Когда какое-то событие (сообщение) поступает диспетчеру, он даёт возможность всем обработчикам, в настоящее время прикрученным к нему, обработать это событие. Вот это мы и желаем увидеть — как одно событие будет обработано дважды.
Итак, мы уже запустили в оболочке диспетчера событий. Теперь добавим ему первый обработчик.
2> gen_event:add_handler(parrot, handler1, []).
*** handler1: inited.
ok
Квадратные скобки — это аргумент, который передаётся при создании обработчика. Этот аргумент будет обработан функцией handler1:init/1.
Мы пока не будем добавлять второго обработчика. Вместо этого попробуем послать диспетчеру сообщение.
3> gen_event:notify(parrot, "Good news!").
*** handler1: some event:"Good news!"
ok
Как мы видим, диспетчер передал сообщение обработчику, а именно функции handler1:handle_event/2. Эта функция и дала нам обратную связь. Теперь добавим второго обработчика и снова пошлём сообщение диспетчеру.
4> gen_event:add_handler(parrot, handler2, []).
*** handler2: inited.
ok
5> gen_event:notify(parrot, "Bad news!").
*** handler2: some event:"Bad news!"
ok
*** handler1: some event:"Bad news!"
Мы динамически добавили второго обработчика, и теперь диспетчер при получении сообщения разошлёт его двум обработчикам. Такое сообщение можно мыслить как циркуляр, рассылаемый начальством всем подчинённым учреждениям. Можно добавить сколько угодно обработчиков, и все они получат сообщение. В некоторых ситуациях это реально полезно.
Также можно динамически удалять обработчиков.
6> gen_event:delete_handler(parrot, handler1, []).
ok
7> gen_event:notify(parrot, "Amazing news!").
*** handler2: some event:"Amazing news!"
ok
Один обработчик был удалён, и он уже не участвовал в обработке сообщения “Amazing news!”.
Функции init и handle_event должны возвращать кортеж, в котором первый элемент это атом ok, а второй — это как раз передаваемое состояние.
Могут быть разные случаи, когда при обработке сообщения следует опираться на состояние, то есть на некоторые обстоятельства, которые заранее (при написании программы) неизвестны. Например, в функции init мы можем открыть какой-то файл и получить файловый дескриптор, и тогда функция init, как и функция handle_event, каждый раз будут возвращать кортеж {ok, Fd}, где Fd — это как раз файловый дескриптор. Это удобно для логирования сообщений — каждый раз handle_event будет записывать в этот открытый заранее файл какой-то текст. При желании она может закрыть этот файл, открыть другой, вернуть {ok, Fd2}, и тогда при следующем вызове текст будет писаться в новый файл.
Можно использовать передаваемое состояние, например, как счётчик событий. Исправим немного функцию handle_event в обоих колбэк-модулях.
handle_event(Event, N) ->
io:format("*** ~p: some event (~p):~p~n",[?MODULE, N, Event]),
{ok, N+1}.
Теперь мы будем наблюдать, как от вызова к вызову меняется счётчик сообщений.
1> gen_event:start({local, parrot}).
{ok,<0.85.0>}
2> gen_event:add_handler(parrot, handler1, []).
*** handler1: inited.
ok
3> gen_event:add_handler(parrot, handler2, []).
*** handler2: inited.
ok
4> gen_event:notify(parrot, "Awesome news!").
*** handler2: some event (0):"Awesome news!"
ok
*** handler1: some event (0):"Awesome news!"
5> gen_event:notify(parrot, "Interest news!").
*** handler2: some event (1):"Interest news!"
ok
*** handler1: some event (1):"Interest news!"
6> gen_event:notify(parrot, "Happy news!").
*** handler2: some event (2):"Happy news!"
ok
*** handler1: some event (2):"Happy news!"
Конечно, передаваемое состояние касается только одного обработчика. То есть у разных обработчиков разные состояния.
У модулей handler1 и handler2 есть ещё один колбэк — handle_call/2. Он нужен для прямых синхронных вызовов к данному обработчику. Этот вызов совершается с помощью gen_event:call/4.
29> N = gen_event:call(parrot, handler2, test, 1000).
22
parrot это имя нашего диспетчера событий, handler2 — имя обработчика, кому совершаем индивидуальный вызов, test — сообщение для него, 1000 — таймаут в миллисекундах. Соответствующий колбэк обрабатывает этот вызов:
handle_call(_Request, N) ->
Reply = N,
{ok, Reply, N}.
Первый аргумент это сообщение-запрос (в нашем случае атом test). Второй аргумент — состояние, передаваемое от сообщения к сообщению. handle_event и handle_call получают и передают общее состояние дальше. В нашем случае это счётчик полученных сообщений (notify).
Колбэк возвращает кортеж из трёх элементов, второй из них это собственно ответ на вызов. В нашем случае это число 22. Этот вызов синхронный, поэтому мы в оболочке можем сразу присвоить результат функции какой-нибудь переменной.
h(gen_event).
Copyright © 2025 Алексей Карманов