Эрланг для веб-разработки (2) -> БД и деплой;

от автора


В первой статье мы познакомились с Эрлангом и фреймворком 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$ в месяц. Тоже самое в виде графика:

ссылка на оригинал статьи http://habrahabr.ru/post/274107/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *