24 августа 2011

Erlang. Billing server.

Волею судьбы делал простую систему билинга на erlang'е. Я уже неоднократно восторгался этим языком и даже переводил некоторые вводные статьи. Сегодня же я познакомлю вас с небольшой веб-инфраструктурой сложившейся вокруг данного языка. Предполагается, что читатель знаком с языком erlang, знание принципов OTP необязательно. В статье слегка затронется поведение gen_server.

Git репозитарий с исходным кодом доступен по адресу: http://github.com/filonenko-mikhail/erlbilling



Итак задание:

  • Создать простейшую базу пользовательских счетов вида со столбцами account, balance и базу транзакций по этим счетам.
  • Транзакции идентифицируются guid'ами.
  • Транзакции бывают двух видов: завершенные и незавершенные.
  • Незавершенную транзакцию можно завершить или удалить.
  • С завершенной транзакцией ничего нельзя сделать.
  • Предоставить http ui для просмотра/редактирования базы данных.
  • Предоставить SOAP сервис со следующими методами:
    • создать незавершенную транзакцию
    • создать завершенную транзакцию
    • подтвердить транзакцию
    • удалить транзакцию
    • пополнить счет
Ну и самое сладенькое: скорость работы должна быть высокой. Хотя об этом мы будем думать в последнюю очередь, если вообще будем.

После некоторых поисков я пришел к выводу, что soap возможен только с помощью yaws. Yaws (yet another web server) - это вебсервер написанный на эрланге и имеющий модуль для поддержки soap. Мы могли бы конечно воспользоваться библиотекой erlsoap или даже парсить/генерировать xml вручную, но кроме soap нам необходим вебинтерфейс.

Данные будем хранить во "встроенной" базе данных mnesia. Она представляет из себя хранилище ключ/значение, и входит в базовую поставку erlang'а. Для более хитрого доступа используется некоторый механизм/язык. Ниже переведены наиболее интересные возможности:
  • Реляционнообъектная модель данных хорошо подходит для телекоммуникационных систем.
  • Специально разработанный язык запросов QLC.
  • Персистентность. Таблицы могут находится на диске или в памяти.
  • Репликация. Таблицы могут копироваться на несколько узлов.
  • Атомарность. Ряд операций манипуляций данными в таблицах могут быть объединены в одну транзакцию.
  • Абстрагирование от хранилища. Программы могут быть написаны без знания о фактическом местонахождении данных.
  • Очень-очень быстрый поиск данных.
  • Реконфигурация СУБД во время выполнения без остановки системы.
Посмотрим, что из этого может получиться.

Компоненты, которые нам понадобятся:
Для генерации страниц используется встравание erlang кода в html код страницы. Например:

<h1>
Foobar</h1>
<erl>
 
 out(Arg) ->
    {html, "Funky Stuff"}.
 </erl>
 
 <h1>
Baz</h1>

Наш код будет состоять из нескольких модулей:

  • billingserver (gen_server) - ядро
  • uuid - генератор guid'ов
  • soapbilling - обработчики soap сообщений

и заголовочных файлов:
  • tables.hrl - структура БД
  • billingserver.hrl - структура SOAP запросов/ответов. Данный файл будет сгенерирован на основе WSDL файла. 

Кроме того проект будет содержать папку www для хранения вебинтерфейса. Для каждой страницы мы создадим *.yaws файл с логикой наполнения страницы.

SOAP сервис будет содержать WSDL файл, который будет описывать функции нашего вебсервиса (не путать с вебсервером). Данный файл мы расположим в папке www для доступа к нему SOAP клиентов.

Структура проекта


.
├── deps # зависимости проекта
│   ├── erlsom # xml парсер/генератор
│   └── yaws #вебсервер
├── log/ # логи сервера
├── tmp/ # временная папка вебсервера 
├── billingserver.erl # Ядро проекта, основные функции работы с базой данных
├── tables.hrl # Структура БД
├── billingserver.hrl # Структура SOAP XML в терминах erlang записей (record)
├── soapbilling.erl # Обертка для обработки SOAP запросов
├── test.erl # Тестирование с замерами времени исполнения запросов
├── uuid.erl # простая генерация guid'ов
└── www # вебинтерфейс
    ├── billingserver.wsdl # WSDL для SOAP клиентов
    ├── soapbilling.yaws # SOAP вебсервис
    ├── index.yaws # Оглавление
    ├── accountlist.yaws # Список всех счетов
    ├── addaccount.yaws # Добавление счета
    ├── showaccount.yaws # Отображение информации о счете
    ├── canceltransaction.yaws # Удаление неподтвержденной транзакции
    ├── chargeamount.yaws # Создание подтвержденной транзакции
    ├── confirmtransaction.yaws # Подтверждение транзакции
    ├── refillamount.yaws # Пополнение счета
    ├── removeaccount.yaws # Удаление счета
    ├── reserveamount.yaws # Создание неподтвержденной транзакции
    ├── transactionlist.yaws # Список всех транзакций
    ├── debug.yaws # ТЕСТИРОВАНИЕ. Отладочный вывод текущего запроса
    ├── addmanyaccounts.yaws # ТЕСТИРОВАНИЕ. Добавление большого количество счетов
    └── addmanytransactions.yaws # ТЕСТИРОВАНИЕ. Генерация большого количества случайных транзакций

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

Ядро


Ядро представляет из себя несколько функций в модуле billingserver с префиксом bs.
Рассмотрим их:

Запуск, инициализация


Инициализация осуществляется функциями bs_start. В первый раз для создания базы данных необходимо вызывать функцию bs_start(init). В остальных случаях bs_start().
Вот как выглядят эти функции:

deinit() ->
    mnesia:delete_schema([node()]).

init_tables() ->
    mnesia:create_table(account,
                        [{disc_copies, [node()]},
                         {attributes, record_info(fields,account)}]),
    mnesia:create_table(transaction,
                        [{disc_copies, [node()]},
                         {attributes, record_info(fields,transaction)}]),
    mnesia:add_table_copy(account, node(), disc_copies).

mnesia_init_env() ->
    application:set_env(mnesia, dump_log_write_threshold, 60000),
    application:set_env(mnesia, dc_dump_limit, 40).

initschema() ->
    mnesia:create_schema([node()]),
    mnesia:change_config(extra_db_nodes, [node()]),
    mnesia:start(),
    init_tables().
bs_start(all) ->
    code:add_path("./deps/erlsom/ebin"),
    code:add_path("./deps/yaws/ebin"),
    code:add_path("."),
    Docroot = "www",
    Logdir = "log",
    Tmpdir = "tmp",
    yaws:start_embedded(Docroot,[{port,8081},
                                 {servername,"localhost"},
                                 {dir_listings, true},
                                 {listen,{0,0,0,0}},
                                 {flags,[{auth_log,false},{access_log,false}]}],
                        [{enable_soap,true},   % <== THIS WILL ENABLE SOAP IN A YAWS SERVER!!
                         {trace, false},
                         {tmpdir,Tmpdir},{logdir,Logdir},
                         {flags,[{tty_trace, false}, {copy_errlog, true}]}]),
    yaws_soap_lib:write_hrl("www/billingserver.wsdl", "billingserver.hrl"),
    yaws_soap_srv:setup({soapbilling, handler}, "www/billingserver.wsdl");
bs_start(init) ->
    deinit(),
    mnesia_init_env(),
    initschema(),
    bs_start(all).
bs_start() ->
    mnesia_init_env(),
    mnesia:start(),
    bs_start(all).


Функции выполняют несколько действий:
  • создание схемы mnesia
  • запуск mnesia
  • генерация hrl файла из wsdl
  • запуск вебсервера в контексте текущего проекта
Первоначальная инициализация БД заключается в:
создании схемы на узле erlang: mnesia:create_schema/1;
запуске приложения: mnesia:start/0;
создании таблиц:  mnesia:create_table/2.

Перед инициализацией мы удаляем предыдущую схему функцией mnesia:delete_schema/1.

При создании таблицы список столбцов мы получаем из записи посредством функции record_info/2, возвращающей некоторую информацию о записи.

Структура БД:


+--------------+                +--------------+
|account       |                |transaction   |
+--------------+ 1              +--------------+
|accountnumber | -----,    inf  |transactionid |
|balance       |      `-------->|accountnumber |
|              |                |amount        |
|              |                |finished      |
+--------------+                +--------------+

Данная связь один-ко-многим существует только у нас в голове. Какого-то механизма реализации связей mnesia не предоставляет.

Повторная инициализация БД требует только запуска приложения mnesia.
Функция mnesia_init_env содержит настройку базы данных для бОльшей нагрузки.

Инициализация вебсервера заключается в вызове функции yaws_api:embedded_start_conf/1,2,3,4
Далее мы используем  yaws_soap_lib для генерации записей исходя из wsdl файла.
Настраиваем url обработчик soap запросов.



Останов


bs_stop() ->
yaws:stop(),
mnesia:stop().

Останавливаем работу приложений mnesia, yaws.


Внутреннее API


Добавление счета


bs_add_account(AccountNumber) ->
    Account = #account{accountnumber=AccountNumber, balance=0},
    InsertFun = fun() ->
                        case mnesia:read({account, AccountNumber}) of
                            [_] ->
                                mnesia:abort(e_accountexists);
                            _ ->
                                mnesia:write(Account)
                        end
                end,
    case mnesia:transaction(InsertFun) of
        {atomic, Result} ->
            Result;
        {aborted, Reason} ->
            if Reason == e_accountexists ->
                    Reason;
               true ->
                    e_internalerror
            end
    end.

Реализация данной функции просто вставляет строку в таблицу account запись с номером счета AccountNumber и нулевым балансом.
Для вставки записей в таблицу служит функция mnesia:write/1. В параметре мы передаем запись для вставки и mnesia вставляет ее в одноименную таблицу. Данная функция должна быть вызвана внутри транзакции. Для создания транзакции используется функция mnesia:transaction/2. Первым аргументом она принимает другую функцию, которая и будет выполняться атомарно.
Однако, прежде чем добавить новый счет, мы проверяем его наличие и в положительном случае вызываем откат транзакции. Для чтения строки из таблицы по ключу, а ключом по умолчанию является первый столбец, вызываем функцию mnesia:read/2. Первый параметр имя таблицы, второй - ключ, по которому производится поиск.

Для отката транзакции мы в любой момент времени можем вызвать mnesia:abort/1. В параметре мы передаем ошибку.
В данном случае мы создаем функцию InsertFun, которая содержит запросы к базе данных. После на ее основе мы создаем транзакцию. Если в InsertFun что-то пошло не так, БД будет иметь иметь вид равно такой, как до вызова данной функции.


Состояние счета


bs_retreive_account(AccountNumber) ->
    RetrFun = fun() ->
                      mnesia:read({account, AccountNumber})
              end,
    mnesia:transaction(RetrFun).


Я думаю здесь все понятно.

Удаление счета


bs_remove_account(AccountNumber) ->
    DeleteFun = fun() ->
                        MatchHead = #transaction{transactionid='$1', accountnumber='$2',
                                                 _='_'},
                        GuardDel = {'==', '$2', AccountNumber},
                        Result = '$_',
                        ListsDel = mnesia:select(transaction,[{MatchHead, [GuardDel],
                                                               [Result]}]),
                        lists:foreach(fun(List) -> mnesia:delete_object(List) end, ListsDel),
                        mnesia:delete({account, AccountNumber})
                end,
    mnesia:activity(async_dirty, DeleteFun).

Данная функция удаляет счет, а также все транзакции с ним связанные. Для удаления записи используется функция mnesia:delete/1, принимающая кортеж с двумя элементами: имя таблицы, и значение ключа (первого столбца).
Для поиска всех строк удовлетворяющих условию, в нашем случае мы ищем транзакции с заданным accountnumber, есть функция mnesia:select/2. Использовать функцию с первого взгляда непросто, но и при более детальном рассмотрении легче не становиться:)
Первый аргумент функции - это таблица. В нашем случае transaction. Второй аргумент задает условия фильтрации, и какие столбцы вывести в результат. Полное описание грамматики для второго аргумента. Не совсем красивое описание звучит так:
MatchHead - это запись, где полям записи заданы псевдонимы.
GuardDel - это кортеж содержащий условия выбора записей. В нем используются псевдонимы, заданные в MatchHead.
Result - список псевдонимов для возврата из функции.
Далее мы проходимся по списку и удаляем все его элементы из БД с помощью mnesia:delete_object/1.

Функции для создания транзакции


add_transaction(AccountNumber, Amount, Finished) ->
    Transaction = #transaction{transactionid = uuid:uuid4(), accountnumber = AccountNumber, amount = Amount, finished = Finished},
    AddTranFun = fun() ->
                         case mnesia:wread({transaction, Transaction#transaction.transactionid}) of
                             [_] ->
                                 mnesia:abort(e_transactionexists);
                             _ ->
                                 case mnesia:wread({account, AccountNumber}) of
                                     [Acc] ->
                                         Balance = Amount + Acc#account.balance,
                                         if Balance < 0 ->
                                                % "Not enough money"
                                                 mnesia:abort(e_insufficientcredit);
                                            true ->
                                                 Acc2 = Acc#account{balance = Balance},
                                                 mnesia:write(Acc2),
                                                 mnesia:write(Transaction)
                                         end;
                                     _ ->
                                                %"No such account"
                                         mnesia:abort(e_invalidaccount)
                                 end
                         end
                 end,
    case mnesia:transaction(AddTranFun) of
        {atomic, _} ->
            Transaction#transaction.transactionid;
        {aborted, Reason} ->
            if Reason == e_invalidaccount ;
               Reason == e_insufficientcredit ->
                    Reason;
               true ->
                    e_internalerror
            end
    end.

bs_refill_amount(AccountNumber, Amount) ->
    add_transaction(AccountNumber, Amount, true).

bs_reserve_amount(AccountNumber, Amount) ->
    add_transaction(AccountNumber, -Amount, false).

bs_charge_amount(AccountNumber, Amount) ->
    add_transaction(AccountNumber, -Amount, true).

Я думаю здесь все просто. Для чтения мы используем mnesia:wread/1, которую можно расшифровать так: "читаем для последующей записи".

Подтверждение транзакции


bs_confirm_transaction(TransactionId) ->
    ConfTranFun = fun() ->
                         case mnesia:wread({transaction, TransactionId}) of
                             [Transaction] ->
                                 Finished = Transaction#transaction.finished,
                                 case Finished
                                 of true ->
                                                % "Wrong transaction status"
                                         mnesia:abort(e_wrongtransactionstatus);
                                    false ->
                                         Transaction2 = Transaction#transaction{finished = true},
                                         mnesia:write(Transaction2)
                                 end;
                             _ ->
                                                %"No such account"
                                 mnesia:abort(e_transactionnotfound)
                         end
                  end,
    case mnesia:transaction(ConfTranFun) of
        {atomic, _} ->
            e_success;
        {aborted, Reason} ->
            if Reason == e_transactionnotfound ;
               Reason == e_wrongtransactionstatus ->
                    Reason;
               true ->
                    e_internalerror
            end
    end.

Удаление транзакции


bs_cancel_transaction(TransactionId) ->
    CancTranFun = fun() ->
                          case mnesia:wread({transaction, TransactionId}) of
                              [Transaction] ->
                                  Finished = Transaction#transaction.finished,
                                  case Finished
                                  of false ->
                                          case mnesia:wread({account, Transaction#transaction.accountnumber}) of
                                              [Acc] ->
                                                % because we store negative value for outgoing transaction
                                                  Balance =  Acc#account.balance - Transaction#transaction.amount,
                                                  if Balance < 0 ->
                                                % "Not enough money"
                                                          mnesia:abort(e_insufficientcredit);
                                                     true ->
                                                          Acc2 = Acc#account{balance = Balance},
                                                          mnesia:write(Acc2)
                                                  end;
                                              _ ->
                                                %"No such account"
                                                  mnesia:abort(e_invalidaccount)
                                          end,
                                          mnesia:delete({transaction, Transaction#transaction.transactionid});
                                      true ->
                                                % "Wrong transaction status"
                                          mnesia:abort(e_wrongtransactionstatus)
                                              
                                  end;
                              _ ->
                                                %"No such account"
                                  mnesia:abort(e_transactionnotfound)
                          end
                  end,
    case mnesia:transaction(CancTranFun) of
        {atomic, _} ->
            e_success;
        {aborted, Reason} ->
            if Reason == e_wrongtransactionstatus ;
               Reason == e_transactionnotfound ->
                    Reason;
               true ->
                    e_internalerror
            end
    end.

Отображение всех счетов


bs_account_list() ->
    SelectFun = fun() ->
                        MatchHead = #account{accountnumber='$1', balance='$2'},
                        Guards = [],
                        Results = [['$1', '$2']],
                        mnesia:dirty_select(account,[{MatchHead, Guards, Results}])
                end,
    case mnesia:transaction(SelectFun) of
        {atomic, Result} -> Result;
        {aborted, Reason} -> Reason
    end.

Отображение всех транзакций


bs_transaction_list() ->
    SelectFun = fun() ->
                        MatchHead = #transaction{transactionid='$1',
                                                 accountnumber='$2', amount='$3',
                                                 finished='$4'},
                        Guards = [],
                        Results = [['$1', '$2', '$3', '$4']],
                        mnesia:dirty_select(transaction,[{MatchHead, Guards, Results}])
                end,
    case mnesia:transaction(SelectFun) of
        {atomic, Result} -> Result;
        {aborted, Reason} -> Reason
    end.

Функции для отображения транзакций для счета


transaction_list(AccountNumber, Finished) ->
    SelectFun = fun() ->
                        MatchHead = #transaction{transactionid='$1',
                                                 accountnumber='$2', amount='$3',
                                                 finished='$4'},
                        Guards = [{'==','$2',AccountNumber}, {'==', '$4', Finished}],
                        Results = [['$1', '$2', '$3', '$4']],
                        mnesia:dirty_select(transaction,[{MatchHead, Guards, Results}])
                end,
    case mnesia:transaction(SelectFun) of
        {atomic, Result} -> Result;
        {aborted, Reason} -> Reason
    end.

bs_unfinished_transaction_list(AccountNumber) ->
    transaction_list(AccountNumber, false).

bs_finished_transaction_list(AccountNumber) ->
    transaction_list(AccountNumber, true).

Реализация поведения gen_server


Для реализации поведения gen_server потребуется определить несколько дополнительных функций. Я использовал скелет gen_server взятый здесь. По сути я просто сделал обертки функций перечисленных ранее (с префиксом bs).


Инициализация


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

В первом случае функция инициализации получит пустой список в первом аргументе.
Во втором случае в функцию передается список с элементом init.

init([]) ->
    bs_start(),
    {ok, #state{}};
init([init]) ->
    bs_start(init),
    {ok, #state{}}.

Останов


handle_cast(stop, State) ->
    {stop, normal, State};
terminate(_Reason, _State) ->
    bs_stop(),
    ok.

Внешнее API


Внешнее api представляет собой обертку функций с префиксом bs функциями handle_call. Функции handle_call вызываются в блокирующем режиме, и мы используем их, а не handle_cast, так как все функции внутреннего API возвращают некоторый результат.

handle_call({add_account, AccountNumber}, _From, State) ->
  Reply = bs_add_account(AccountNumber),
  {reply, Reply, State};
handle_call({retreive_account, AccountNumber}, _From, State) ->
  Reply = bs_retreive_account(AccountNumber),
  {reply, Reply, State};
handle_call({remove_account, AccountNumber}, _From, State) ->
  Reply = bs_remove_account(AccountNumber),
  {reply, Reply, State};
........
handle_call(_Request, _From, State) ->
  Reply = ok,
  {reply, Reply, State}.

Да, метапрограммирования в erlang'е нет.

Синтаксический сахар для gen_server:call/2


Чтобы запустить billingserver в качестве gen_server'а, необходимо использовать функцию billingserver:start/0,1. Для того, чтобы вызывать функции сервера используется функция gen_server:call/2. Добавление аккаунта будет выглядеть так:

gen_server:call(?SERVER, {add_account, AccountNumber}).

Однако мне бы хотелось вызывать фунцкии просто по их именам, поэтому делаем еще обертки.

add_account(AccountNumber) ->
  gen_server:call(?SERVER, {add_account, AccountNumber}).
retreive_account(AccountNumber) ->
  gen_server:call(?SERVER, {retreive_account, AccountNumber}).
remove_account(AccountNumber) ->
...........
finished_transaction_list(AccountNumber) ->
  gen_server:call(?SERVER, {finished_transaction_list, AccountNumber}).

Теперь для добавления нового счета достаточно просто вызвать billingserver:add_account/1.
Ядро готово. Перейдем к реализации вебинтерфейса.

Вебинтерфейс


Логика вебинтерфейса содержиться в файлах www/*.yaws. Данные файлы являются html страницами со встроенным erlang кодом, который будет выполняться в момент запроса страницы.

Index


В данном файле создадим ссылки на функции билинговой системы.

<html>
  <h1>Functions</h1>
  <p>
    <a href="addaccount.yaws">Add account</a>
    <a href="removeaccount.yaws">Remove account</a>
    <a href="showaccount.yaws">Show account</a>
    <a href="refillamount.yaws">Refill amount</a>
  </p>

  <p>
    <a href="chargeamount.yaws">Charge amount</a>
  </p>

  <p>
    <a href="reserveamount.yaws">Reserve amount</a>
    <a href="confirmtransaction.yaws">Confirm transaction</a>
    <a href="canceltransaction.yaws">Cancel transaction</a>
  </p>

  <p>
    <i>
      <a href="addmanyaccounts.yaws">Add many accounts</a>
      <a href="addmanytransactions.yaws">Add many transactions</a>
    </i>
  </p>

  <p>
    <i>
      <a href="accountlist.yaws">Show list of accounts. May be slow</a>
    </i>
  </p>

  <p>
    <i>
      <a href="transactionlist.yaws">Show list of transactions. May be slow</a>
    </i>
  </p>

  <p>
    <i>
      <a href="debug.yaws">Debug information</a>
    </i>
  </p>
</html>

Отладочный вывод


<html>
<head>
<!-- n = seconds -->
<meta http-equiv="refresh" content="5" />
</head>
<h2> The Arg </h2>
<p>This page displays the Arg #argument structure
supplied to the out/1 function.
<erl>
out(A) ->
    Req = A#arg.req,
    H = yaws_api:reformat_header(A#arg.headers),
    AddLi = fun(Elem) ->
                    {Key, Value} = Elem,
                    {li, [], f("~w:~w", [Key, Value])}
            end,
    {ehtml,
     [{h4,[], "The headers passed to us were:"},
      {hr},
      {ol, [],lists:map(fun(S) -> {li,[], {p,[],S}} end,H)},
      {h4, [], "The request"},
      {ul,[],
       [{li,[], f("method: ~s", [Req#http_request.method])},
        {li,[], f("path: ~p",
                  [Req#http_request.path])},
        {li,[], f("version: ~p", [Req#http_request.version])}]},
      {hr},
      {h4, [], "Other items"},
      {ul,[],
       [{li,[], f("clisock from: ~p", [inet:peername(A#arg.clisock)])},
        {li,[], f("docroot: ~s",
                  [A#arg.docroot])},
        {li,[], f("fullpath: ~s",
                  [A#arg.fullpath])}]},
      {hr},
      {h4, [], "Parsed query data"},
      {pre,[], f("~p", [yaws_api:parse_query(A)])},
      {hr},
      {h4,[], "Parsed POST data "},
                                                %      {pre,[], f("~p", [yaws_api:parse_post(A)])},
      {hr},
      {h1, [], "Mnesia status"},
      {h4, [], "Sysinfo"},
      {p, [],
       {ul, [],
        lists:map(AddLi, mnesia:system_info(all))}}]}.
</erl>
<p>
<a href="index.yaws">Index</a>
</p>
</html>

Итак, для того, чтобы yaws смог отобразить страницу с erlang кодом, ему необходимо предоставить функцию out/1. Входной параметр представляет собой запись arg. Вы можете сами подробнее просмотреть структуру. В этом же проекте понадобится только метод запрос POST/GET и POST параметры. Результатом выполнения могут быть:
  • {html, DeepCharOrBinaryList}
  • {ehtml, ErlangTermStructure}
  • и т.д. 
В проекте будет использоваться только второй вариант. Это, как и в библиотеке cl-who, просто преобразование иерархической структуры данных хост-языка в язык html.

Например, ehtml:

{table, [{bgcolor, "tan"}],
  {tr, [],
    [{td, [{width, "70%"}], {p, [{class, "foo"}], "Hi there"}}]}}

будет выглядеть так:

<table bgcolor="tan">
   <tr>
      <td width="70%">
        <p class="foo"> Hi there </p>
      </td>
   </tr>
</table>

Об остальных результатах функции out/1 можно прочесть на странице проекта yaws.

Yaws определяет функцию f, которая является синонимом функции io_lib:format/2.

Список всех счетов


<html>
<h1>Accounts</h1>
<erl>
out(A) ->
    AddTd = fun(Val) ->
                    {td, [], integer_to_list(Val)}
            end,
    AddTr = fun(Val) ->
                    {tr, [], lists:map(AddTd, Val)}
            end,
    Accounts = billingserver:account_list(),
    {ehtml, [{table, [],
             lists:append([[{tr, [], [{td, [], "Account number"},
                                      {td, [], "Balance"}]}], 
                           lists:map(AddTr, Accounts)])},
            {p, [], {b, [], f("Total: ~w", [length(Accounts)])}}]}.
</erl>
<p><a href="index.yaws">Index</a></p>
</html>

Как видите, вся суть заключена в вызове billingserver:account_list(), которая возвращает список, который впоследствии преобразуется в ehtml строки и столбцы с помощью функций AddTr и AddTr.

В конце каждой страницы я предоставляю возможность перейти в главное меню.


Добавление счета


<html>
<h1>Add Account</h1>
<erl>
out(A) ->
    Req = A#arg.req,
    
    if
        Req#http_request.method == 'GET' ->
            {ehtml, {form, [{action, "addaccount.yaws"},{method, "post"}],
                     [{p, [], "Account identifier"},
                      {input, [{name, accountid}, {type, text}]},
                      {input, [{type, submit}]}]}};
        
        Req#http_request.method == 'POST' ->
            L = yaws_api:parse_post(A),
            [{"accountid", IdStr}|_] = L,
            {Id, _} = string:to_integer(IdStr),
            billingserver:add_account(Id),
            {ehtml, {p, [], IdStr}};
        true -> {ehtml, {p, [], "Error"}}
    end.
</erl>
<a href="index.yaws">Index</a>
</html>

Здесь мы в зависимости от метода запроса отдаем либо форму, либо результат выполнения billingserver:add_account/1 с параметрами полученными из формы, отправленной в первом случае.

Остальные страницы вебинтерфейса сделаны по аналогии.

SOAP


WSDL

Для soap сервиса нам необходим wsdl файл, который будет содержать список функций и их аргументов. В нашем случае он выглядит так:

<?xml version="1.0" encoding="utf-8"?>
<!--     xmlns:tns="http://localhost/billingserver" -->
<wsdl:definitions 
    targetNamespace="http://localhost:8081" 
    xmlns:tns="http://localhost/billingserver.wsdl"
    xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" 
    xmlns:tm="http://microsoft.com/wsdl/mime/textMatching/" 
    xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/" 
    xmlns:mime="http://schemas.xmlsoap.org/wsdl/mime/" 
    xmlns:s="http://www.w3.org/2001/XMLSchema" 
    xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">

  <wsdl:documentation xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">Simple billing written on Erlang. SOAP server is yaws.
  There are following functions:
  ReserveAmount(AccountNumber, Amount)
  ChargeAmount(AccountNumber, Amount)
  ConfirmTransaction(TransactionID)
  CancelTransaction(TransactionID)
  RefillAmount(AccountNumber, Amount)
  </wsdl:documentation>
  <wsdl:types>
   <s:schema elementFormDefault="qualified" targetNamespace="http://localhost:8081/billingserver.wsdl">

      <s:element name="ReserveAmount">
        <s:complexType>
          <s:sequence>
            <s:element minOccurs="1" maxOccurs="1" name="AccountNumber" type="s:int" />
            <s:element minOccurs="1" maxOccurs="1" name="Amount" type="s:int" />
          </s:sequence>
        </s:complexType>
      </s:element>

      <s:element name="ReserveAmountResponse">
        <s:complexType>
          <s:sequence>
            <s:element minOccurs="1" maxOccurs="1" name="Result" type="s:string"/>
          </s:sequence>
        </s:complexType>
      </s:element>

      ........................
  </wsdl:types>
  
  <wsdl:message name="ReserveAmountSoapIn">
    <wsdl:part name="parameters" element="ReserveAmount" />
  </wsdl:message>
  <wsdl:message name="ReserveAmountSoapOut">
    <wsdl:part name="parameters" element="ReserveAmountResponse" />
  </wsdl:message>

  ...................

  <wsdl:portType name="BillingServerSoap">

    <wsdl:operation name="ReserveAmount">
      <wsdl:documentation xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">Reserve amount in the user's account.
      Reserved amount is not available for other
      operations, but finally not be debited from the account. The function 
      return guid TransactionID or one of the following errors:
      E_INTERNALERROR</wsdl:documentation>
      <wsdl:input message="ReserveAmountSoapIn" />
      <wsdl:output message="ReserveAmountSoapOut" />
    </wsdl:operation>

    ..................

  </wsdl:portType>

  <wsdl:binding name="BillingServerSoap" type="BillingServerSoap">
    <soap:binding transport="http://schemas.xmlsoap.org/soap/http" />

    <wsdl:operation name="ReserveAmount">
      <soap:operation soapAction="http://localhost/ReserveAmount" style="document" />
      <wsdl:input>
        <soap:body use="literal" />
      </wsdl:input>
      <wsdl:output>
        <soap:body use="literal" />
      </wsdl:output>
    </wsdl:operation>

    .............

  </wsdl:binding>
  <wsdl:service name="BillingServer">
    <wsdl:documentation xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">Simple billing written on Erlang. SOAP server is yaws.</wsdl:documentation>  
    <wsdl:port name="BillingServerSoap" binding="BillingServerSoap">
      <soap:address location="http://localhost:8081/soapbilling.yaws" />
    </wsdl:port>
  </wsdl:service>
</wsdl:definitions>

Вообщем-то я немногое понял о soap'е. То, что сделано, опишу кратко и поверхностно.
Значение тега wsdl:documentation и так понятно - это документация.
Поддерево тега wsdl:types описывает имена и типы аргументов и возвращаемых значений функций. У нас будет использоваться только int для номера счета и суммы, и string для guid'а.
Список тегов wsdl:message описывают входящие и исходящие пакеты которые будут созданы для вызова и возврата результата функции.
Поддерево wsdl:portType объединяет входящие и исходящие пакеты предыущего шага в одну сущность - операцию. Операция это то же самое, что и функция. Вызвали с аргументами, получили результат.
Поддерево wsdl:binding описывает, с помощью какого транспорта какие операции могут выполняться. В нашем случае: эта вся конструкция будет работать через SOAP.
И в wsdl:service мы указываем, что за транспорты у нас есть, и на каких адресах они работают.
В двух словах о нашем случае: SOAP выполняет задачу удаленного вызова функций.

Именно из этого wsdl после строки:

yaws_soap_lib:write_hrl("www/billingserver.wsdl", "billingserver.hrl"),

получаются следующие erlang записи:

-record('p:ReserveAmount', {anyAttribs, 'AccountNumber', 'Amount'}).
-record('p:ReserveAmountResponse', {anyAttribs, 'Result'}).
.......

Обработчики


Во-первых в теге soap:address мы определили url обработчика SOAP location="http://localhost:8081/soapbilling.yaws"

Вот как выглядит soapbilling.yaws:

<erl>
out(A) ->
    yaws_rpc:handler_session(A, {soapbilling, handler}).
</erl>

Мы просто перенаправляем запрос в функцию yaws_rpc:handler_session/2. Для того, чтобы функция yaws_rpc:handler_session/2 знала куда дальше перенапавить запрос, мы связали сервиc soapbilling.yaws с обработчиком с помощью строки:

yaws_soap_srv:setup({soapbilling, handler}, "www/billingserver.wsdl");

Как видите, теперь управление перейдет функции soapbilling:handler/4.

Обработка запроса вылядит так:

handler(_Header,
        [#'p:ReserveAmount'{'AccountNumber' = AccountNumber, 'Amount' = Amount}],
        _Action, 
        _SessionValue) ->
    {ok, undefined, reserve_amount(AccountNumber, Amount)};

В результате вызывается функция soapbilling:reserve_amount/2, которая уже и вызывает долгожданную billingserver:reserve_amount/2, которая, как вы помните, тоже обертка, и преобразую полученное значение в структуру завершает выполнение.
Полученная структура внутри yaws преобразуется в xml и отдается soap клиенту.

reserve_amount(AccountNumber, Amount) ->
    Result = billingserver:reserve_amount(AccountNumber, Amount),
    Response = #'p:ReserveAmountResponse'{anyAttribs = [],
                                         'Result' = marshall2soap(Result)
                                        },
    
    [Response]. 

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

Запуск


Распаковка, компиляция


Распаковка, компиляция:

tar xzf erlbilling.tar.gz
# erlsom
cd erlbilling/deps/erlsom
rebar compile
# yaws
cd ../yaws
autoconf
./configure
make
cd ../..

Запуск


Впервые:

erl
c(billingserver).
 billingserver:start(init).
После:
erl
billingserver:start().

Останов


billingserver:stop().

Запуск сервера происходит на 0.0.0.0:8081. Веб интерфейс http://localhost:8081/ Для создания N аккаунтов служит форма. Форма создает от 1 до N аккаунтов с порядковыми идентификаторами. http://localhost:8081/addmanyaccounts.yaws

 

Тестирование

 

Тестирование с помощью erlang yaws_soap_client НА ЛОКАЛЬНОМ компьютере. Функция test содержит 4-5 траназакций. test(Count) выполняет транзакции для заданного количество аккаунтов. test_under_timer(AccCount, N) делает N замеров для test(AccCount).
erl
1> c(test).

2> test:test_under_timer(40, 100). % 100 замеров для test(40) (>160 транзакций).
Range: 779519 - 1017516 mics
Median: 810963 mics
Average: 828167 mics
810963
3>

21 комментарий:

  1. Большое спасибо, очень итересная статья!

    Единственное, что я бы хотел знать: изменяли ли вы как-нибудь конфигурацию YAWS'а (yaws.conf). Вроде бы, в статье об этом не сказано.

    ОтветитьУдалить
  2. Я в статье на акцентировал внимание, но yaws я использовал в embedded режиме, и параметры его работы передавал в стартовую функцию.

    yaws:start_embedded(Docroot,[{port,8081},
    {servername,"localhost"},
    ........

    ОтветитьУдалить
  3. ** exception exit: function_clause
    in function yaws_soap_lib:get_url_file/1
    called as yaws_soap_lib:get_url_file("www/billingserver.wsdl")
    in call from yaws_soap_lib:initModel2/5
    in call from yaws_soap_lib:write_hrl/2
    in call from billingserver:bs_start/1
    in call from billingserver:init/1
    in call from gen_server:init_it/6
    in call from proc_lib:init_p_do_apply/3

    Не подскажете, что делать?

    ОтветитьУдалить
  4. Ошибка произошла на этапе генерации hrl (erlang загловочный) из wsdl (описание soap сервисов). Возможно, файл не найден. Проверьте существование www/billingserver.wsdl. Если система windows, может быть нао поменять слеш в пути к данному файлу.

    ОтветитьУдалить
  5. С файлом все в порядке. Компилирую в убунте.Спасибо, что ответили :)

    ОтветитьУдалить
  6. Возможно, не запущен inets.
    erl
    inets:start().
    c(billingserver).
    billingserver:start(init).

    Скажите какая у вас версия yaws, у меня 4.7.1
    yaws:module_info().

    ОтветитьУдалить
  7. Тоже не помогло. Версия yaws 4.7.5.

    ОтветитьУдалить
  8. Скачал версию 4.7.5, проблема не воспроизвелась.

    возможно текущая папка для ерланга неверная? Запускаться следует из папки вместе с billingserver.erl, или использовать file:set_cwd/1.

    ОтветитьУдалить
  9. или может в функцию yaws:write_hrl/1 вписать полный путь с указанием протокола доступа file:///home/user/....?

    ОтветитьУдалить
  10. Папка для эрланга та. Сейчас попробую вписать полный путь.

    ОтветитьУдалить
  11. Опять тоже самое. Попробую заново поставить yaws, буду пробовать все варианты. Спасибо за помощь!

    ОтветитьУдалить
  12. Ага, прошу прощения что не сразу посмотрел тип ошибки:
    exit: function_clause
    http://www.erlang.org/doc/reference_manual/errors.html#id203337
    No matching function clause is found when evaluating a function call.

    Но я не могу понять, какую функцию он не находит.

    ОтветитьУдалить
  13. Я ставила erlsom, как написано тут: http://yaws.hyber.org/soap_intro.yaws

    При этом он ругался на команды chmod a+x configure; и sudo make install.

    Может быть дело в этом?

    ОтветитьУдалить
  14. erlsom у меня компилировался rebarом.
    tar xzf erlsom
    cd erlsom
    ./rebar compile

    ОтветитьУдалить
  15. Да-да, у меня так же

    ОтветитьУдалить
  16. Прошу прощения за перерыв, получился ли запуск?

    ОтветитьУдалить
  17. нет, все такая же ошибка. Убрала совсем yaws и uuid. Так все работает)

    ОтветитьУдалить
  18. Это хорошо, правда uuid там нужен иначе не из чего id транзакции генерировать.

    ОтветитьУдалить
  19. привет, прошу помощи. простой веб сервис, написан на Erlang, веб сервер yaws, не могу добавить ссылку на эту службу в visual studio (проект на С#). ошибка - "Ошибка специального инструмента: Не удается импортировать веб-службу или схему. Невозможно найти определение http://localhost:8082/serv.wsdl:ServSoap. Отсутствует описание службы с пространством имен http://localhost:8082/serv.wsdl."
    может быть кто-то подскажет что не так? спасибо.
    wsdl здесь можно посмотреть http://files.mail.ru/673A09AEDD784990951B7FB660E3BD66

    ОтветитьУдалить
    Ответы
    1. Предлагаю сгенерить простой веб-сервис (svc) силами WCF, и посмотреть различие в файлах wsdl.

      Удалить