avatar

Немного о сетевом движке на Erlang

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

В данном уроке речь пойдет не о полноценном сетевом движке, а лишь о простой попытке «пощупать» язык и его возможности. Этого будет вполне достаточно, чтобы понять, использовать ли Erlang в своем проекте или поискать что-нибудь другое. Поэтому буду опускать многие моменты, связанные с языком программирования. Для тех же, кто заинтересован в его изучении советую книги Programming Erlang: Software for a Concurrent World и Erlang and OTP in Action. Так же если вы не способны потратить 5 или 10 минут на изучение исходного кода, то эта статья не для вас.

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

Подготовка


Установка Erlang
Когда я в первый раз столкнулся с разработкой под Erlang, то у меня были небольшие затруднения с установкой, вызванные не знанием *unix на котором она проивозводилась. Вот небольшой туториал как поставить Erlang на Debian 6 Squezee.

Rebar
Rebar — это инструмент для управления зависимостями, компиляции и тестирования Erlang приложений и релизов. Более подробно почитать о нем можно здесь. Так же по этой ссылке можно узнать как установить этот полезный инструмент.

В бой!

Создадим папку для проекта и каркас сервера.
mkdir myserver && cd myserver
rebar create-app appid=myserver

Команда create-app создаст три «основных» файла.

Теперь в корневой директории проекта создадим файл rebar.config. Внутри него будет всего одна строчка, которая означает, что наш проект использует фреймворк Ranch, который будет отвечать за работу с сетью.
{deps, [
    {ranch, ".*", {git, "git://github.com/extend/ranch.git", {branch, "master"}}}
]}.

Сейчас структура приложения должна выглядеть так:


Пришла пора писать код.

Откройте в любом текстовом редакторе файл myserver_app.erl. В метод start/2 надо добавить строчки для запуска Ranch.
AcceptorCount = 100,
Port = 8080,
{ok, _} = ranch:start_listener(myserver_pool, AcceptorCount, ranch_tcp, [{port, Port}], myserver_sup, [])

Так же для удобства запуска, добавим метод start/0.
start() ->
    application:start(ranch),
    application:start(myserver).

Файл myserver_app.erl полностью
-module(myserver_app).

-behaviour(application).

-export([start/0, start/2, stop/1]).

start() ->
    application:start(ranch),
    application:start(myserver).

start(_StartType, _StartArgs) ->
    AcceptorCount = 100,
    Port = 8080,
    {ok, _} = ranch:start_listener(myserver_pool, AcceptorCount,
        ranch_tcp, [{port, Port}], myserver_sup, []),
    myserver_sup:start_link().

stop(_State) ->
    ok.


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

Теперь откройте файл myserver_sup.erl. Измените метод init/1, чтобы он выглядел так
init([]) ->
    AChild = {myserver_connection_server, {myserver_connection_server, start_link, []},
        temporary, 2000, worker, [myserver_connection_server]},
    Children = [AChild],
    RestartStrategy = {simple_one_for_one, 10, 10},
    {ok, {RestartStrategy, Children}}.

И добавьте метод start_link/4.
start_link(Ref, Socket, Transport, Opts) ->
    supervisor:start_child(?MODULE, [Ref, Socket, Transport, Opts]).

Файл myserver_sup.erl полностью
-module(myserver_sup).

-behaviour(supervisor).

-export([start_link/0, start_link/4]).
-export([init/1]).

start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

start_link(Ref, Socket, Transport, Opts) ->
    supervisor:start_child(?MODULE, [Ref, Socket, Transport, Opts]).

init([]) ->
    AChild = {myserver_connection_server, {myserver_connection_server, start_link, []},
        temporary, 2000, worker, [myserver_connection_server]},
    Children = [AChild],
    RestartStrategy = {simple_one_for_one, 10, 10},
    {ok, {RestartStrategy, Children}}.


Создайте новый файл myserver_connection_server.erl и поместите его в папку src. Он будет отвечать за работу с каждым конкретным соединением. Фактически каждый процесс myserver_connection_server — это пользователь в онлайне. Он достаточно объемный (по сравнению с другими), поэтому приведу сразу весь исходный код.

myserver_connection_server.erl
-module(myserver_connection_server).

-behaviour(gen_server).
-behaviour(ranch_protocol).

-export([stop/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-export([start_link/4]).

-define(SERVER, ?MODULE).

-record(state, {type = undefined, ref, socket, transport, opts}).

stop() ->
    gen_server:cast(self(), stop).

start_link(Ref, Socket, Transport, Opts) ->
    gen_server:start_link(?MODULE, [Ref, Socket, Transport, Opts], []).

init(Args) ->
    [Ref, Socket, Transport, Opts] = Args,
    {ok, #state{type = accept_client, ref = Ref, socket = Socket, transport = Transport, opts = Opts}, 0}.

handle_call(Request, _From, State) ->
    io:format("Unknown handle_call request in module '~p'. Request = ~w~n", [?MODULE, Request]),
    Reply = ok,
    {reply, Reply, State}.

handle_cast(Request, State) ->
    case Request of
        stop ->
            {stop, normal, State};
        _ ->
            io:format("Unknown handle_cast request in module '~p'. Request = ~w~n", [?MODULE, Request]),
            {noreply, State}
    end.

handle_info(Info, State) ->
    Socket = State#state.socket,
    Transport = State#state.transport,
    {OK, Closed, Error} = Transport:messages(),
    {RemoteIP, RemotePort} = get_ip_and_port(Transport, Socket),
    case Info of
        timeout ->
            Type = State#state.type,
            case Type of
                accept_client ->
                    Ref = State#state.ref,
                    ok = ranch:accept_ack(Ref),
                    ok = Transport:setopts(Socket, [{active, once}]),
                    io:format(">>> Client did connect ~w:~w ('~w'). ~n", [RemoteIP, RemotePort, self()]),
                    send_data_to_client(State, [<<60, 60, 60, " Hello! Your name is ">>, erlang:pid_to_list(self()), <<"\r\n">>]),
                    NewState = State#state{type = undefined},
                    {noreply, NewState};
                _ ->
                    io:format("Unknown handle_info #state.type in module '~p'. #state.type = ~w~n", [?MODULE, Type]),
                    {noreply, State}
            end;
        {OK, Socket, Data} ->
            ok = Transport:setopts(Socket, [{active, once}]),
            io:format(">>> Data received from ~w:~w ('~w'). Data = ~p~n", [RemoteIP, RemotePort, self(), Data]),
            parse_data(Data),
            {noreply, State};
        {Closed, Socket} ->
            io:format(">>> Client '~w' did disconnected.~n", [self()]),
            {stop, normal, State};
        {Error, Socket, Reason} ->
            io:format(">>> Error happend. Client ~w:~w ('~w'). Reason = ~p~n", [RemoteIP, RemotePort, self(), Reason]),
            ok = Transport:setopts(Socket, [{active, once}]),
            {noreply, State};
        _ ->
            io:format("Unknown handle_info info. Info = ~w~n", [Info]),
            {noreply, State}
    end.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

send_data_to_client(State, Packet) ->
    Socket = State#state.socket,
    Transport = State#state.transport,
    case Transport:send(Socket, Packet) of
        ok ->
            ok;
        Error ->
            {RemoteIP, RemotePort} = get_ip_and_port(Transport, Socket),
            case Error of
                {error, closed} ->
                    io:format(">>> Client '~w' did disconnected.~n", [self()]),
                    stop();
                _ ->
                    io:format("Unknown error send data to ~w:~w ('~w'). Error = ~w. Data = ~p~n", [RemoteIP, RemotePort, self(), Error, Packet])
            end,
            ok
    end.

get_ip_and_port(Transport, Socket) ->
    PeerName = Transport:peername(Socket),
    {RemoteIP, RemotePort} = case PeerName of
                                 {ok, {Address, Port}} ->
                                     {Address, Port};
                                 _ ->
                                     {unknown_ip, unknown_port}
                             end,
    {RemoteIP, RemotePort}.
   
parse_data(Data) ->
    F = fun() ->
        <<Type:8, CommandCode:16, Parameters/binary>> = Data,
        {Type, CommandCode, Parameters}
    end,
    try F() of
        {Type, CommandCode, Parameters} ->
            io:format(">>> Get request of type '~p'. Command code '~p'. Parameters '~p'~n", [Type, CommandCode, Parameters]),
            ok
    catch
        _:_ ->
            ok
    end,
    ok.


Перед финальным шагом немного расскажу что тут происходит.

Когда к серверу подключается пользователь, то вызывается метод start_link/4 модуля myserver_sup. Этот супервизор создает gen_server для каждого пользователя. Т.е. мы можем асинхронно обрабатывать все запросы и отвечать на них.

Метод init/1 в модуле myserver_connection_server синхронный. Чтобы не блокировать процессы, метод возвращает кортеж {ok, State, Timeout}, где Timeout — это число милисекунд, через которое вызовется метод handle_info/2. При этом параметр Info будет равен атому timeout. Именно в нем и производим дальнейшую инициализацию. Принимаем клиента (ok = ranch:accept_ack(Ref)) и отправляем ему сообщение (send_data_to_client(...)).

Самое интересное происходит в методе parse_data/1. С помощью pattern matching с легкостью распарсиваются пришедшие от клиента данные. Должен заметить, что тут мы используем свой протокол обмена данными (очень простой). Т.е. каждое сообщение имеет формат
Type: 1 байт, CommandCode: 2 байта, Parameters: остальное

Type может служить каким-нибудь флагом. CommandCode выступает в качестве названия метода API.

О всей мощи bit syntax можно почитать тут или тут.

Теперь осталось создать только make файл, чтобы было удобно запускать приложение. Он должен располагаться в корне приложения.

Makefile
REBAR = `which rebar`
REBAR_GET_DEPS = $(REBAR) get-deps
REBAR_UPDATE_DEPS = $(REBAR) update-deps
REBAR_COMPILE = $(REBAR) compile
REBAR_CLEAN = $(REBAR) clean

all: deps compile

deps:
	@( $(REBAR_GET_DEPS) )

update:
	@( $(REBAR_CLEAN) $(REBAR_UPDATE_DEPS) $(REBAR_COMPILE) )

compile: clean
	@( $(REBAR_COMPILE))

clean:
	@( $(REBAR_CLEAN) )

run:
	@(erl -pa ebin deps/*/ebin \
	      -s application start myserver)

.PHONY: all deps update compile clean run


Теперь выполняем
make && make run

и подключаемся к серверу
telnet localhost 8080
+2
0
2144
  • 2 комментария

    avatar
    Опыт — это, конечно, хорошо, это правильно, но статья на мой взгляд сыровата.

    Для новичков она непонятна. Возникают вопросы, наподобие таких: почему выбран именно erlang, в чём его преимущества перед тем же apache/php или nodejs? Зачем использовать Ranch, какие ещё есть фреймворки и почему выбор пал именно на него?

    Для тех, кто в теме, она тоже принесёт мало пользы — они это уже и так знают.

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

    Статью лайкнул, жду ещё =)
    avatar
    почему выбран именно erlang, в чём его преимущества перед тем же apache/php или nodejs?
    PHP плох. Мне не нравится. Не буду вступать в холивар, но для большого проекта этот язык не стал бы использовать.
    Nodejs немного щупал. Не в восторге от его callback ада. Плюс нет автоматической поддержки нескольких ядер из коробки (возможно уже есть. смотрел давно).
    Erlang выбрал, потому что давно хотел выучить функциональный язык. Плюс полная распределенность, отказоустойчивость и… легкость :) Вообще, как уже писал, статья не для тех кто не может при желании нагуглить такую информацию.

    Для тех, кто в теме, она тоже принесёт мало пользы — они это уже и так знают.
    Erlang имеет одну особенность. От новичка до достаточно продвинутого программиста на этом языке проходит очень мало времени. Так что эта статья для новичков, любящих покопаться. Не люблю статьи, в которых все можно копипастить от а до я.

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