Ерланген для веб-розробки (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,
версія : '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,
версія : '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 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$ в місяць. Теж саме у вигляді графіка:



Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.