Статьи

Erlang: клиент / сервер


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

Как они справляются с параллельными запросами?

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

Суть в том, что каждый процесс строго содержит свои структуры данных, поэтому два разных процесса, запущенных одновременно, никогда не могут создать условие гонки, например, в общем списке. Даже при обслуживании нескольких клиентов один процесс действует как фасад, который получает все запросы.

Хватит, покажи нам код!

Давайте создадим очень простую гостевую книгу: люди могут оставлять на ней сообщения, которые должны следовать порядку вставки. Люди могут прочитать последнее сообщение в любое время (для простоты в этом примере); на самом деле мы создаем стек только для вставки, в терминах информатики.

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

client_server_test() ->
    Server = new_book(),
    ok = new_post(Server, "Hello, world"),
    ok = new_post(Server, "Greetings"),
    Post = last_post(Server),
    ?assertEqual("Greetings", Post).

Сервер содержит pid нового процесса, а new_post / 2 и last_post / 1 являются примитивами, вызываемыми клиентом, которые отправляют запрос на сервер и ожидают ответа. Ответ нормально для пустых операций, в то время как это строка для last_post / 1. Мы можем различать переменные параметры и атомы, если не используем последние.

Вот как работают эти примитивы:

new_post(Server, Text) ->
    call(Server, Text).

last_post(Server) ->
    call(Server, last).

call(Server, Request) ->
    Server ! {self(), request, Request},
    receive
        {reply, Reply} -> Reply
    end.

Я следую предложению книги программирования Erlang о извлечении запросов к функции call / 2. То же самое можно сделать для ответов сервера (показано далее в этой статье).

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

Как я и ожидал, серверная часть обрабатывает один запрос за раз:

new_book() ->
    spawn(fun() -> book() end).

book() ->
    book([]).

book(Posts) ->
    receive
    {Sender, request, last} ->
            Sender ! {reply, head(Posts)},
            NewPosts = Posts;
        {Sender, request, NewPost} ->
            Sender ! {reply, ok},
            NewPosts = lists:append([NewPost], Posts)
    end,
    book(NewPosts).

head([]) -> 'No messages yet.';
head([Head|_Tail]) -> Head.

book / 1 получает одно сообщение, воздействует на него и перезапускает цикл. Я хочу, чтобы вы заметили некоторые особые различия с императивным программированием.

Цикл while (true) реализуется посредством оптимизации хвостовых вызовов: книга вызовов book / 1 (NewPosts), когда она обработала сообщение, и эти вызовы могут продолжаться бесконечно, не исчерпывая стек .

Структуры данных являются неизменными : мы должны создать переменную NewPosts, если мы хотим добавить что-то в список, представляющий состояние сервера.

В императивном обработчике «нового сообщения» мы выполняем синхронизированную операцию над сообщениями и только затем отвечаем « ОК» . Здесь у нас есть свобода работать с сообщениями столько, сколько мы хотим, потому что никакое новое сообщение не будет доставлено процессу, пока мы не запросим его с получением . В некотором смысле, по умолчанию уже выполняется синхронизация на объекте, представляющем процесс: очень трудно ввести условие гонки в коде сервера в этих конструкциях.

Обратите также внимание на соединение идентификаторов сервера и примитивов с идентификатором в качестве первого аргумента. Это похоже на наличие объекта Server: эту двойственность между объектами и функциями очень часто можно увидеть в Erlang (например, в модуле списков ), если у вас есть привычка думать о мире в терминах объекта.

Выводы

Я оставляю команду для остановки сервера в качестве упражнения для читателя; Вы можете начать с кода в репозитории Github для этой серии .

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