В первой статье мы познакомились с Эрлангом и фреймворком n2o. В этой части мы продолжим делать наш блог:
  • добавим авторизацию через фейсбук, для этого будем из клиента вызывать функции на сервере;
  • будем сохранять комментарии и посты в NoSQL базе;
  • развернем наш блог на DigitalOcean и замерим производительность (спойлер — 1300 запросов в секунду).


Код из статей https://github.com/denys-potapov/n2o-blog-example, готовый проект можно посмотреть по адресу http://46.101.118.21:8001/.



Файлы конфигурации


Для авторизации нам надо где-то хранить facebook_app_id, в Эрланг приложениях конфигурация хранится в sys.config, добавим туда наш facebook_app_id
[{n2o, [
    {port,8001},
    {route,routes},
    {log_modules,sample}
]},
{sample, [
     {facebook_app_id, "631083680327759"}
]}
].

Теперь мы можем получить с значение в application:get_env(sample, facebook_app_id, "")

Вызов серверного кода


Для авторизации через социальные сети в n2o проектах есть библиотека avz, которая поддерживает Twitter, Google, Facebook, Github и Microsoft авторизацию. Но, avz требует определенную схему в БД, которой у нас пока нет, а поэтому мы релизуем авторизацию самостоятельно.

Функция wf:wire(#api{name=login}) позволяет привязывает вызов функции login на клиенте к событию
api_event(login, Response, Term) на сервере.

Добавим файл login.erl:
-module(login).
-compile(export_all).
-include_lib("n2o/include/wf.hrl").
-include_lib("nitro/include/nitro.hrl").
-include_lib("records.hrl").

main() -> 
    wf:wire(#api{name=login}),
    #dtl{file="login", bindings=[{app_id, application:get_env(sample, facebook_app_id, "")}]}.

api_event(login, Response, Term) ->
    {Props} = jsone:decode(list_to_binary(Response)),
    User = binary_to_list(proplists:get_value(<<"name">>, Props)),
    wf:user(User),
    wf:redirect("/").


В функции main/0 мы объявляем событие login, которое потом обрабатываем в api_event. Мы декодируем json строку, авторизируем пользователя и направляем его на главную страницу. В priv/templates/login.html код который скопирован с образца на facebook, в котором главная магия в вызове login(response).
priv/templates/login.html
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}

<h1>Login</h1>
<p id="status"></p>
<button id="login" class="btn btn-primary" onclick="onLoginClick();">
    Login with facebook
</button>

<script>
 
  // This is called with the results from from FB.getLoginStatus().
  function statusChangeCallback(response) {
    console.log('statusChangeCallback');
    if (response.status === 'connected') {
      // Logged into your app and Facebook.
      FB.api('/me', function(response) {
        login(response);
      });
    } else if (response.status === 'not_authorized') {
      document.getElementById('status').innerHTML = 'Please log ' +
        'into this app.';
    } else {
      document.getElementById('status').innerHTML = 'Please log ' +
        'into Facebook.';
    }
  }

  window.fbAsyncInit = function() {
    FB.init({
      appId      : '{{ app_id }}',
      cookie     : true,
      version    : 'v2.2' // use version 2.2
    });
    FB.getLoginStatus(function(response) {
      statusChangeCallback(response);
    });
  };

  // Load the SDK asynchronously
  (function(d, s, id) {
    var js, fjs = d.getElementsByTagName(s)[0];
    if (d.getElementById(id)) return;
    js = d.createElement(s); js.id = id;
    js.src = "//connect.facebook.net/en_US/sdk.js";
    fjs.parentNode.insertBefore(js, fjs);
  }(document, 'script', 'facebook-jssdk'));

  function onLoginClick() {
    FB.login(function(response) {
      statusChangeCallback(response);
    }, {scope: 'public_profile,email'});<source lang="html">
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}

<h1>Login</h1>
<p id="status"></p>
<button id="login" class="btn btn-primary" onclick="onLoginClick();">
    Login with facebook
</button>

<script>
 
  // This is called with the results from from FB.getLoginStatus().
  function statusChangeCallback(response) {
    console.log('statusChangeCallback');
    if (response.status === 'connected') {
      // Logged into your app and Facebook.
      FB.api('/me', function(response) {
        login(response);
      });
    } else if (response.status === 'not_authorized') {
      document.getElementById('status').innerHTML = 'Please log ' +
        'into this app.';
    } else {
      document.getElementById('status').innerHTML = 'Please log ' +
        'into Facebook.';
    }
  }

  window.fbAsyncInit = function() {
    FB.init({
      appId      : '{{ app_id }}',
      cookie     : true,
      version    : 'v2.2' // use version 2.2
    });
    FB.getLoginStatus(function(response) {
      statusChangeCallback(response);
    });
  };

  // Load the SDK asynchronously
  (function(d, s, id) {
    var js, fjs = d.getElementsByTagName(s)[0];
    if (d.getElementById(id)) return;
    js = d.createElement(s); js.id = id;
    js.src = "//connect.facebook.net/en_US/sdk.js";
    fjs.parentNode.insertBefore(js, fjs);
  }(document, 'script', 'facebook-jssdk'));

  function onLoginClick() {
    FB.login(function(response) {
      statusChangeCallback(response);
    }, {scope: 'public_profile,email'});
  };
</script>
{% endblock %}



Обновление компонентов на клиенте


Теперь мы попробуем с сервера обновить компонент на клиенте. Для этого мы на главной (index.erl) сделаем хеадер, на котором будет кнопка выхода. Хеадер будет обновляться после того, как данные сессии очищены:
buttons() ->
    case wf:user() of
        undefined -> #li{body=#link{body = "Login", url="/login"}};
        _ -> [
                #p{class=["navbar-text"], body="Hello, " ++ wf:user()},
                #li{body=#link{body = "New post", url="/new"}},
                #li{body=#link{body = "Logout", postback=logout}}
        ] end.

header() ->
    #ul{id=header, class=["nav", "navbar-nav", "navbar-right"], body = buttons()}.
        

main() -> #dtl{file="index", bindings=[{posts, posts()}, {header, header()}]}.

event(logout) ->
    wf:user(undefined),
    wf:update(header, header()).


В событии event(logout) мы очищаем данные сессии и обновляем компонент.

База данных и зависимости



Для доступа к базе мы будем использовать kvs. kvs позволяет хранить связанные списки и поддерживает разные бекенды (Mnesia, Riak, KAI, Redis, MongoDB). Дальше в примере я буду использовать mnesia, потому что она идет в комплекте поставки и ее не надо настраивать.

Зависимости в Эрланг проектах лежат в файле rebar.config, добавляем туда kvs:
{kvs,    ".*", {git, "git://github.com/synrc/kvs",          {tag, "2.9"}   }}


В sys.config укажем какой бекенд и какую схему мы используем. Схема нужна только для mnesia, для других бекендов она не нужна.
{kvs, 
    {dba,store_mnesia},
    {schema,[sample]}
]}


Схему описывается функцией metainfo/0 в sample.erl:
metainfo() ->
    #schema{name=sample,tables=[
        #table{name=id_seq,fields=record_info(fields,id_seq),keys=[thing]},
        #table{name=post,fields=record_info(fields,post)}
    ]}.

Мы указываем, что у нас есть две таблицы: post, которая содержит записи типа post, и id_seq, в которой kvs хранит значения автоинкремента.

Тут же в sample.erl в функции init/1 добавляем подключение к kvs.
init([])   -> 
	case cowboy:start_http(http,3,port(),env()) of
        {ok, _}   -> ok;
        {error,_} -> halt(abort,[]) end, sup(),
    kvs:join().


Теперь если мы перезапустим приложения, мы должны увидеть наши таблицы.
2> kvs:dir().
[{table,post},{table,id_seq},{table,schema}]


Чтение и запись


В модуле /src/new.erl у нас будет одно событие event(post), которое записывает пост в БД функцией kvs:put/1:
-module(new).
-compile(export_all).
-include_lib("n2o/include/wf.hrl").
-include_lib("nitro/include/nitro.hrl").
-include_lib("records.hrl").

main() ->
    case wf:user() of
        undefined -> wf:header(<<"Location">>, wf:to_binary("/login")), wf:state(status,302), [];
        _ -> #dtl{file="new", bindings=[{button, #button{id=send, class=["btn", "btn-primary"], body="Add post",postback=post,source=[title,text]} }]} end.

event(post) ->
    Id = kvs:next_id("post",1),
    Post = #post{id=Id,author=wf:user(),title=wf:q(title),text=wf:q(text)},
    kvs:put(Post),
    wf:redirect("/post?id=" ++ wf:to_list(Id)).


/priv/templates/new.html
{% extends "base.html" %}
{% block title %}New Post{% endblock %}
{% block content %}
<h1>Add new post</h1>
<h3>Title</h3>
<input id="title" class="form-control">
<h3>Body</h3>
<textarea id="text" maxlength="1000" class="form-control" rows=10>
    
</textarea>
{{ button }}
{% endblock %}



Теперь в файле post.erl заменим функцию получения поста, если пост не найден выдаем 404 ошибку.
main() ->
    case kvs:get(post, post_id()) of
        {ok, Post} -> #dtl{file="post", bindings=[
            {title, wf:html_encode(Post#post.title)},
            {text, wf:html_encode(Post#post.text)},
            {author, wf:html_encode(Post#post.author)},
            {comments, comments()}]};
        _ -> wf:state(status,404), "Post not found" end.


В модуле главной страницы index.erl получаем все посты вызовом kvs:all(post):
posts() -> [
    #panel{body=[
        #h2{body = #link{body = wf:html_encode(P#post.title), url = "/post?id=" ++ wf:to_list(P#post.id)}},
        #p{body = wf:html_encode(P#post.text)}
      ]} || P <- kvs:all(post)].


Контейнеры и итераторы



Для хранения связанных списков в kvs используется концепция контейнеров и итераторов. Итератор хранит указатели двусвязных списков, а контейнер хранит указатели на голову и хвост списка.

Обновим наши записи в records.hrl добавим итератор комментарий и контейнер пост:
-record(post, {?CONTAINER, title, text, author}).
-record(comment, {?ITERATOR(post), text, author}).


Обновляем схему:
metainfo() ->
    #schema{name=sample,tables=[
        #table{name=id_seq,fields=record_info(fields,id_seq),keys=[thing]},
        #table{name=post,container=true,fields=record_info(fields,post)},
        #table{name=comment,container=post,fields=record_info(fields,comment)}
    ]}.


Пересоздаем схему базы данных:
2> kvs:destroy().
ok
3> kvs:join().
ok


В модуле post.erl обновляем логику комментариев:
comments() ->
    case wf:user() of
        undefined -> #link{body = "Login to add comment", url="/login"};
        _ -> [
                #textarea{id=comment, class=["form-control"], rows=3},
                  #button{id=send, class=["btn", "btn-default"], body="Post comment",postback=comment,source=[comment]}
        ] end.

event(init) ->
    [event({client,Comment}) || Comment <- kvs:entries(kvs:get(post, post_id()),comment,undefined) ],
    wf:reg({post, post_id()});

event(comment) ->
    Comment = #comment{id=kvs:next_id("comment",1),author=wf:user(),feed_id=post_id(),text=wf:q(comment)},
    kvs:add(Comment),
    wf:send({post, post_id()}, {client, Comment});

event({client, Comment}) ->
	wf:insert_bottom(comments,
		#blockquote{body = [
			#p{body = wf:html_encode(Comment#comment.text)},
			#footer{body = wf:html_encode(Comment#comment.author)}
		]}).


В функции comments(), мы проверяем автризирован ли пользователь. В event(init) мы выбираем все комментарии, которые относяться к данному посту и передаем их в событии event({client, Comment}), то есть комментарии у нас загружаються после загрузки страницы.

В событии event(comment) мы не только выводим комментарий, но и сохраняем его в БД.

Создание своих элементов


Для постраничной навигации мы добавим в DSL свой элемент pagination. В файле /apps/sample/include/elements.hrl добавим запись, в которой укажем какой модуль отвечает за отображение этого элемента:
-include_lib("nitro/include/nitro.hrl").

-record(pagination, {?ELEMENT_BASE(element_pagination), active, count, url}).


Сам модуль вывода element_pagination.erl довольно прост:
-module(element_pagination).
-compile(export_all).
-include_lib("nitro/include/nitro.hrl").
-include_lib("elements.hrl").

link(Class, Body, Url) -> #li{class=[Class], body=#link{body=Body, url=Url}}.
disabled(Body) -> link("disabled", Body, "#").

left_arrow(#pagination{active = 1}) -> disabled("«");
left_arrow(#pagination{active = Active, url = Url}) ->
    link("", "«", Url ++ wf:to_list(Active - 1)).

right_arrow(#pagination{active = Count, count = Count}) -> disabled("»");
right_arrow(#pagination{active = Active, url = Url}) ->
    link("", "»",  Url ++ wf:to_list(Active + 1)).

left(0, P) -> [left_arrow(P)];
left(I, P) ->
    S = wf:to_list(I),
    left(I - 1, P) ++ [link("", S, P#pagination.url ++ S)].

right(I, P = #pagination{count = Count}) when I > Count -> [right_arrow(P)];
right(I, P) ->
    S = wf:to_list(I),
    [link("", S, P#pagination.url ++ S) | right(I + 1, P)].

render_element(P = #pagination{}) ->
    wf:render(#nav{body=#ul{class=["pagination"], body=[
        left(P#pagination.active - 1, P),
        link("active", wf:to_list(P#pagination.active), "#"),
        right(P#pagination.active + 1, P)
    ]}}).


Как делать нельзя


Kvs создан для хранения связанных списков, и поэтому плохо подходит для постраничной навигации.

Резкий комментарий автора kvs о постраничной навигации в современном вебе



Но, для чистоты эксперимента, мы добавим постраничную навигацию. Добавим контейнер feed, в котором будем хранить посты
-record(feed, {?CONTAINER}).
-record(post, {?ITERATOR(feed), title, text, author}).
-record(comment, {?ITERATOR(feed), text, author}).

И обновим схему:
metainfo() ->
    #schema{name=sample,tables=[
        #table{name=id_seq,fields=record_info(fields,id_seq),keys=[thing]},
        #table{name=feed,container=true,fields=record_info(fields,feed)},
        #table{name=post,container=feed,fields=record_info(fields,post)},
        #table{name=comment,container=feed,fields=record_info(fields,comment)}
    ]}.

Комментарии мы будем хранить в контейнере feed вида {post, post_id()}:
Comment = #comment{id=kvs:next_id("comment",1),author=wf:user(),feed_id={post, post_id()},text=wf:q(comment)},

И будем получать комментарии из этого контейнера:
[event({client,Comment}) || Comment <- kvs:entries(kvs:get(feed, {post, post_id()}),comment,undefined) ];


Огранизуем постраничный вывод на главной странице. Еще раз отмечу, что kvs плохо подходит для постраничной навигации, и этот код просто демонстрация того, как применения несоответствующих инструментов приводит к запутыванию кода:
-define(POST_PER_PAGE, 3).

page() ->
    case wf:q(<<"page">>) of
        undefined -> 1;
        Page      -> wf:to_integer(Page)
    end.

pages() ->
    Pages = kvs:count(post) div ?POST_PER_PAGE,
    case kvs:count(post) rem ?POST_PER_PAGE of
        0 -> Pages;
        _ -> Pages + 1
    end.

posts() -> [
    #panel{body=[
        #h2{body = #link{body = wf:html_encode(P#post.title), url = "/post?id=" ++ wf:to_list(P#post.id)}},
        #p{body = wf:html_encode(P#post.author)}
      ]} || P <- lists:reverse(kvs:traversal(post, kvs:count(post) - (page() - 1) * ?POST_PER_PAGE, ?POST_PER_PAGE, #iterator.prev))].


Деплой и производительность


Mad позволяет создавать бандл — один файл, в котором храниться код и все необходимые для приложения файлы (шаблоны, статика). Создадим и зальем на удаленный сервер:
mad deps compile plan bundle sample
scp sample root@46.101.117.36:/var/www/sample/

Установим на удаленном сервере Эрланг и запустим наше приложение:
wget https://packages.erlang-solutions.com/erlang/esl-erlang/FLAVOUR_1_general/esl-erlang_18.0-1~ubuntu~trusty_amd64.deb
dpkg -i esl-erlang_18.0-1~ubuntu~trusty_amd64.deb
escript sample


Для тестирования производительности я создал самый маленький дроплет на DigitalOcean (512 MB Memory / 20 GB Disk). Для теста мы сделаем 20 тысяч запросов, по 50 параллельно:

root@ubuntu-1gb-fra1-01:~# ab -l -n 20000 -c 50 -g gnuplot.dat http://46.101.118.21:8001/
...
Concurrency Level:      50
Time taken for tests:   15.131 seconds
Complete requests:      20000
Failed requests:        0
Total transferred:      78279988 bytes
HTML transferred:       76399988 bytes
Requests per second:    1321.80 [#/sec] (mean)
Time per request:       37.827 [ms] (mean)
Time per request:       0.757 [ms] (mean, across all concurrent requests)
Transfer rate:          5052.26 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       9
Processing:     9   37   4.9     37      65
Waiting:        9   37   4.9     37      65
Total:         11   38   4.9     37      65

Percentage of the requests served within a certain time (ms)
  50%     37
  66%     38
  75%     39
  80%     40
  90%     44
  95%     47
  98%     53
  99%     56
 100%     65 (longest request)


Сервер обрабатывал около 1300 запросов в секунду, 95% запросов выполнено за меньше чем 50 мс, что очень неплохо для хостинга за 5$ в месяц. Тоже самое в виде графика:

Комментарии (12)


  1. kreon
    28.12.2015 09:29
    +1

    Сервер обрабатывал около 1300 запросов в секунду, 95% запросов выполнено за меньше чем 50 мс, что очень неплохо для хостинга за 5$ в месяц.
    Главный аргумент за использование Эрланга на проде. Надо теперь на yaws + jquery переписать и посмотреть сколько будет RPS.
    Но, как показывает практика, большинству PM'ов проще выбить сервер с x2 RAM и CPU и оставить все на [java/c#/etc], чем нанять erl-разработчиков.

    Вот поэтому у меня на эрланге полтора проекта для себя.


    1. mag2000
      28.12.2015 11:17

      Надо теперь на yaws + jquery переписать

      серьёзно?


      1. kreon
        28.12.2015 12:18
        -1

        AFAIK yaws таки быстрее cowboy'я :)


        1. erlyvideo
          28.12.2015 13:28

          нет =)

          Ковбой исчезает на фоне любой бизнес-логики, так что намерять что явс быстрее попросту нечем. Это если он быстрее.


  1. michael_vostrikov
    28.12.2015 09:31
    +1

    Логика, верстка, и работа с БД в одном файле. В PHP за такое давно по рукам бьют.


    event(logout) ->
        wf:user(undefined)
    ...
    buttons() ->
        case wf:user() of
            undefined 
    

    Я ошибаюсь, или это обычное императивное программирование с изменением переменной?

    Еще раз отмечу, что kvs плохо подходит для постраничной навигации, и этот код просто демонстрация того, как применения несоответствующих инструментов приводит к запутыванию кода
    А как все-таки сделать правильно, чтобы код не был запутанным? Спрашиваю не с целью потроллить, мне правда интересно


    1. PlatinumThinker
      28.12.2015 10:11

      бесконечные скроллы?


    1. begemot_sun
      28.12.2015 10:22

      Я ошибаюсь, или это обычное императивное программирование с изменением переменной?

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


      1. Ogra
        28.12.2015 10:49
        +4

        n2o поощряет грязный трюк с глобальными переменными.
        Посмотрел исходники n2o — ни -spec/-type, ни edoc, ни комментариев, -compile(export_all) повсюду. Не понравилось.


        1. mag2000
          28.12.2015 11:31

          Вам ехать или шашечки?
          1. https://github.com/synrc/n2o/blob/master/include/api.hrl
          2. зачем Вам коменты для одной тысячи строк кода? эрланг код и так достаточно понятен. дока тут http://synrc.com/apps/n2o/
          3. ничего плохого в export_all нет


    1. Ogra
      28.12.2015 10:22

      Я ошибаюсь, или это обычное императивное программирование с изменением переменной?
      Нет, это общение с потоком-синглтоном =)
      Ошибся, смотрите правильный ответ выше.


    1. PatapSmile
      28.12.2015 12:06

      Использовать бесконечный скролл. В kvs легко выбрать 10 следующих записей, или 10 предыдущих.


    1. erlyvideo
      28.12.2015 13:29
      +3

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