Обобщённый сервер

Обобщённый сервер (ген-сервер) — обобщённый шаблон, позволяющий минимальными усилиями реализовать клиент-серверные отношения между акторами. То же: generic server, gen_server.

Ген-сервер — продукт обобщающей оптимизации. Область его действия — конкурентное и распределённое программирование. Он задаёт клиент-серверные отношения между акторами, которые работают конкурентно и, возможно, распределённо (на разных компьютерах).

Клиент-серверные отношения — частный случай возможных отношений между акторами, которые в широком смысле есть всего лишь свободная передача сообщений между акторами. Пример: от А1 к А2, от А2 к А3, от А3 к А1 и А4 и т.д.

Свободная передача сообщений между акторами

При клиент-серверных отношениях между двумя акторами происходит распределение ролей: один становится клиентом, а другой сервером. Один актор (клиент) может сделать запрос к другому актору (серверу). Тот на этот запрос должен послать ответ. При этом как запрос, так и ответ — те же самые сообщения.

Клиент-серверные отношения

Клиент-серверные отношения широко используются в конкуретном и распределённом программировании. Клиент-серверные отношения мало ограничивают нас в построении систем со сложной архитектурой. Клиент-серверные отношения это отношения в паре акторов. Любой сервер в других отношениях может выступить как клиент.

Клиент-серверные отношения в сложной системе

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

В Эрланге обобщённый сервер реализован в модуле gen_server. Как и в других подобных случаях, в этом модуле собраны функции, с помощью которых мы можем запустить свой сервер, делать обращения к нему, но технические особенности реализации которых нам знать совсем не обязательно.

Специализированная часть сервера (полезная нагрузка, ценная для нас) задаётся с помощью функций обратного вызова (колбек-функций). При запуске своего сервера мы в качестве аргумента передаём имя модуля (колбек-модуля), который и содержит данные функции. Чтобы не запутаться, лучше, конечно, следовать принципу: один актор — один модуль.

Колбек-модуль должен реализовывать поведение gen_server. Чтобы узнать, какие колбеки обязательны, можно создать модуль, состоящий всего из двух строк:

-module(kopilka).
-behaviour(gen_server).

При компиляции получим три предупреждения о том, каких функций не хватает:

Другие возможные колбеки:

Начинать сервер, конечно, надо с задумки. В данное время задумка у нас несложная. Мы реализуем виртуальную копилку. Сервер будет принимать вызовы двух типов: положить монету в копилку, выдать статистику (сколько всего монет в копилке, какова их общая стоимость). Чтобы хранить эту информацию, у сервера должно быть меняющееся состояние. Реализуем его в виде списка чисел: 1 будет означать 1 копейку, 2 — 2 копейки, 100 — 1 рубль.

-module(kopilka).
-behaviour(gen_server).
-export([start_link/0, init/1, handle_call/3, handle_cast/2]).

start_link() ->
    gen_server:start_link({local,kopilka}, kopilka, [], []).

init(_Args) ->
    {ok, []}.

handle_call({vklad,N}, _From, Spisok) ->
    Valid = sets:from_list([1,2,3,5,10,15,20,50,100]),
    Proverka = sets:is_element(N, Valid),
    if
        not Proverka -> {reply, {error,"Это не монета."}, Spisok};
        true -> {reply, ok, [N | Spisok]}
    end.

handle_cast(proverka, Spisok) ->
    Sum = lists:sum(Spisok),
    io:format("Всего в копилке ~p монет общей стоимостью ~p руб. ~p коп.~n",
              [length(Spisok), Sum div 100, Sum rem 100]),
    {noreply, Spisok}.

С помощью такого небольшого количества кода нам удалось написать свой сервер на основе ген-сервера. В нашем коде нет, например bang-оператора (!) для посылки сообщений от одного актора к другому, нет блока receive ... end для разбора сообщений из почтового ящика, нет регистрации актора, нет цикла loop. Всё это — неспецифичные для сервера вещи, они спрятаны от нашего взора в модуле gen_server. Осталось только специфическое.

Можно запускать ген-сервер вне древа надзора, но с древом всё-таки как-то интереснее. Поэтому я использовал функцию gen_server:start_link, а не gen_server:start. Первый аргумент — {local,kopilka} — определяет имя актора (атом kopilka) в системе. Все другие акторы системы на данной ноде могут обращаться к нашему серверу по этому имени. Если бы мы хотели, чтобы можно было обращаться из всего кластера нод, тогда бы был атом global. Второй аргумент — колбек-модуль (kopilka). Третье — терм аргументов, которые можно передать в init/1 при создании сервера. В нашем случае они не используются и привязываются к _Args. Четвёртый аргумент — дополнительные опции при создании ген-сервера.

Сначала может показаться, что init/1 вообще ничего не делает, но это не так. Она, как и положено ей как колбеку, возвращает {ok, Состояние}. В нашем случае состояние — пустой список. В дальнейшем в этот список будут добавляться монеты (а точнее целые числа).

handle_call при вызове получает три аргумента: запрос (произвольный терм), обратный адрес, текущее состояние сервера. Его мы привязали к переменной Spisok, ведь это список монет. В теле функции мы создаём множество Valid, в котором все допустимые для обозначения монет термы. Если мы введём 7, 20.0 или ruble, копилка это не примет. Колбек handle_call должен вернуть кортеж {reply, Ответ, Новое_состояние}. В блоке if ... end у нас две кляузы для двух случаев. Если это не монета, возвращается ответ — сообщение об ошибке, состояние при этом не меняется. Во втором случае возвращается ok и состояние меняется (добавлена новая монета).

Я решил, что добавлять монеты буду синхронно (call), а проверять состояние копилки — асинхронно (cast). Синхронный вызов функции должен закончиться как вызов обычной функции, то есть возвратом некоторого терма. Асинхронный вызов ничем таким не должен закончиться: мы просто передаём сообщение, которое как-то влияет или не влияет на текущее состояние. В нашем примере просто будет выведено сообщение в оболочку с помощью io:format.

handle_cast получает два аргумента: запрос и текущее состояние сервера. Для удобства я сразу суммирую номинальную стоимость монет и получаю Sum. Далее выводится текстовое сообщение. handle_cast должна вернуть кортеж {noreply, Новое_состояние}, но состояние у нас не меняется, поэтому мы возвращаем исходный список монет.

Проверим работу сервера.

$ erl +pc unicode
1> kopilka:start_link().
{ok,<0.85.0>}
2> gen_server:cast(kopilka, proverka).
Всего в копилке 0 монет общей стоимостью 0 руб. 0 коп.
ok
3> gen_server:call(kopilka, {vklad,5}).
ok
4> gen_server:call(kopilka, {vklad,15}).
ok
5> gen_server:call(kopilka, {vklad,7}).
{error,"Это не монета."}
6> gen_server:call(kopilka, {vklad,10}).
ok
7> gen_server:call(kopilka, {vklad,20}).
ok
8> gen_server:call(kopilka, {vklad,20.0}).
{error,"Это не монета."}
9> gen_server:call(kopilka, {vklad,100}).
ok
10> gen_server:call(kopilka, {vklad,ruble}).
{error,"Это не монета."}
11> gen_server:cast(kopilka, proverka).
Всего в копилке 5 монет общей стоимостью 1 руб. 50 коп.
ok

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

И вот, мы в столь короткий срок реализовали актор-сервер, вполне законченный и работоспособный. Конечно, вы можете заметить, что в handle_call и handle_cast не предусмотрена обработка случайных сообщений. И действительно, мы можем в качестве сообщения передать случайно (или нарочно) не {vklad,10} а просто 10, не proverka, а proverit. В обоих случаях возникнет исключительная ситуация, потому что программа не знает, как ей поступить в таком случае. Актор-сервер рухнет, и если у него нет надзирателя, не поднимется снова.

Однако дальнейшее развитие сервера-копилки зависит от общего контекста. Например, мы будем заносить новые данные через веб-интерфейс. Из него в любом случае будет посылаться кортеж вида {vklad,N}, поэтому если мы в этом уверены, можно не делать дополнительную (ненужную) проверку. В мире Эрланга не принято усложнять код без особой нужды разными проверками.

Может ли сервер быть клиентом?

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

Это легко понять на нашем примере с копилкой. Допустим, мы решили, что с каждым новым вкладом в копилку надо посылать уведомление об этом по email. Мы делаем ещё один сервер, принимающий такие сообщения и передающий собственно email. А внутри handle_call нашей копилки вставляем вызов, что-то вроде gen_server:call(email, "Получено 50 копеек."). На этот вызов сервер с именем email нам ответит, например, “ok”.

Однако в данном примере наш сервер kopilka выглядит всего лишь как передаточное звено, ведь инициатива события исходит извне (в данном случае от человека, добавляющего новые монеты). Если монеты не будут добавляться, ничего происходить не будет. А что, если мы желаем, чтобы инициатива рождалась и внутри сервера kopilka? Например, мы хотим, чтобы kopilka, если ею не пользовались больше суток, напоминала нам посредством email, что пора бы сделать новый вклад.

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

loop(Spisok) ->
    check_and_email(),
    receive
        ...
    after 10_000 -> ?MODULE:loop(Spisok)
    end.

То есть у нас была бы бесконечная loop с таймаутом (10 секунд). Если 10 секунд вообще ничего не будет происходить (не будет новых входящих сообщений), тогда loop запускается снова. Там в начале стоит функция check_and_email, которая что-то проверяет и посылает или не посылает email в зависимости от обстоятельств.

Если мы желаем, чтобы сервер проявлял собственную инициативу (запускал новую цепочку событий), нам не обойтись без таймаута. В ген-сервере таймаут реализован следующим образом. handle_call может возвращать кортеж, состоящий не из трёх элементов, а из четырёх, handle_cast — не из двух, а из трёх. Четвёртым и третьим соответственно элементом добавляется целое число — таймаут в миллисекундах.

Если ничего не будет происходить N секунд, заданных в качестве таймаута, то по прошествии этого времени, наш сервер получит сообщение на свой почтовый ящик (не call и не cast) — просто атом timeout. Наш сервер kopilka создан на основе ген-сервера, поэтому эти сообщения автоматически вызывают колбек, а именно handle_info/2. В нашем примере это может выглядеть так:

handle_info(timeout, Spisok) ->
    io:format("Давно не поступало новых монет!~n"),
    check_and_email(),
    {noreply, Spisok, 24*60*60*1000};

На вход этот колбек получает атом timeout в качестве сообщения и состояние Spisok. Вернуть можно {noreply, Состояние} или, как в данном случае, {noreply, Состояние, Таймаут}, чтобы снова запустить таймер, чтобы через сутки снова получить напоминание.

Важно твёрдо понимать, в каких именно случаях следует запускать таймер. В коде сервера kopilka, который мы запускали выше, есть колбек handle_call с двумя кляузами (и соответственно с двумя выходами) и handle_cast с одной кляузой. В каких из этих трёх случаев имеет смысл запускать таймер?

Про что мы даём обратную связь? Про то, что давно не поступало новых монет. Значит, логично будет только после этого события запускать таймер, а то иначе получится, что деньги не поступают (мы просто проверяем баланс) или поступают неверные монеты, но это всё равно засчитывается за событие, от которого и будет отсчёт 24 часа. Поэтому мы лишь в handle_call исправим одну строку:

% true -> {reply, ok, [N | Spisok]} % было
true -> {reply, ok, [N | Spisok], 24*60*60*1000} % стало

Мы научились ставить таймаут — на случай, когда входящие события могут быть крайне редки, настолько, что в нужное время сервер не проявит нужную инициативу. Однако есть и обратная опасность. Ценная для нас функция check_and_email вызывается только по таймауту, но ведь таймаута может и не быть вовсе, если входящие сообщения просто бомбардируют наш сервер. Они так часто приходят, что таймер не успевает срабатывать.

Для нашего примера с копилкой это пока не критично, даже наоборот — хорошо. Монеты поступают исправно, и это здорово, можно и не запускать check_and_email. Однако может быть так, что нам всё же надо раз в час, например, обращаться к какому-то ещё серверу. Допустим, мы желаем получать с сервера get_image новое изображение копилки. Если мы будем обращаться к нему только по таймауту, есть вероятность, что одно и то же изображение будет слишком долго висеть на нашем сайте, потому что сервер kopilka получает сообщения слишком часто.

Выход из этой ситуации один — вставить функцию get_image во все кляузы всех колбеков: handle_call, handle_cast и handle_info. Внутри этой функции можно сделать дополнительную проверку на время, чтобы выяснить, когда последний раз мы обращались к серверу get_image, чтобы не обращаться к этому серверу слишком часто, ведь это может вызвать очень сильную нагрузку на компьютер.

Работать с таймаутом всегда надо аккуратно, продумывая разные негативные сценарии. Другой пример — вот мы чуть выше выставили таймаут в сутки (24*60*60*1000). А что будет, если посреди этих суток мы перезапускаем Систему? Может быть и так, что мы все неделю перезапускаем её ежедневно, потому что ведутся какие-то работы, и при каждом перезапуске таймаут будет обнуляться, поэтому всю неделю важного для нас события можем не получить.

Тут есть как минимум два выхода. Можно продумать остановку поднадзорного актора — добавить колбек terminate/2. При остановке нашего актора-сервера будет вызываться эта функция. В ней можно предусмотреть сохранение состояния таймаута, чтобы при следующем запуске через init выставить нужный таймаут. Другой вариант — не делать такие длинные таймауты, использовать короткие, но каждый раз проверять, как давно мы совершали нужное действие. Это тоже придётся запоминать и вспоминать с помощью terminate и init.

Монеты

gen_server и gen_statem

Хотя и заявлено, что gen_server нужен для создания акторов-серверов, из этого не следует, что этот обобщённый шаблон используется только для вызовов с обратной связью. Как мы видели выше, gen_server умеет принимать и обрабатывать cast-сообщения, а также прочие сообщения, приходящие на почтовый ящик. От актора, созданного на основе gen_server, совсем не требуется отвечать на эти сообщения. Поэтому на основе gen_server можно построить любой актор. gen_server — универсальный шаблон для создания акторов.

Другим таким универсальным шаблоном является gen_statem. Там тоже можно обрабатывать cast- и call-вызовы, а также прочие сообщения, приходящие на п/я. Этот шаблон строится вокруг машины состояний (state machine), и это позволяет удобно реализовывать даже самую сложную бизнес-логику. Там есть удобные таймауты (целых три вида), что опять же очень важно для создания акторов с собственной инициативой. Там можно удобно группировать вызовы — в зависимости от состояния.

gen_statem устроен немного сложнее, и на нём удобно делать более сложные вещи. gen_server зато чуть быстрее (на 1 миллисекунду) обрабатывает запросы. Его имеет смысл применять для создания более-менее простых акторов (например, отдающих по запросу те или иные ресурсы).

Документация

h(gen_server)

h(gen_statem)


Copyright © 2025 Алексей Карманов