Pull to refresh

Web-сервер на базе Cowboy

Reading time 10 min
Views 34K
Привет!
В этом туториале я планирую показать тем, кто еще не знаком с веб-сервером Cowboy, как им пользоваться. Для людей, которые имеют опыт работы с ним, данный туториал врядли будет интересен, а вот для тех, кто знает о Ковбое лишь по наслышке — welcome!

Что мы будем делать:
  1. Простейшая установка и запуск сервера
  2. Краткий обзор роутинга, обслуживание статики
  3. Шаблонизация с помощью ErlyDTL (Django Template Language для Erlang)


Для удобства работы нам потребуется rebar, установка нехитрая:
> git clone git://github.com/basho/rebar.git && cd rebar && ./bootstrap

Теперь у нас в директории появился исполняемый файл rebar — копируем (а лучше линкуем) его куда-нибудь в $PATH. Например:
> sudo ln -s `pwd`/rebar /usr/bin/rebar


And here we go!

Простейшая установка и запуск сервера


Для начала создадим директорию и скелет для нашего будущего приложения, в этом нам поможет rebar. Переходим куда-нибудь, где будем создавать приложение и выполняем следующую команду:
> mkdir webserver && cd webserver && rebar create-app appid=webserver

Команда rebar create-app appid=webserver создает скелет простейшего Erlang-приложения и теперь наша директория webserver должна выглядеть таким образом:

Следующее, что мы сделаем — добавим зависимость от Cowboy, Sync, Mimetypes и Erlydtl. Cowboy — наш web-сервер, Sync — утилита, которая позволит нам не перезагружать наш сервер при каждом изменении и будет сама перекомпилировать измененные модули при обновлении, Mimetypes — библиотека для определения соответствия расширения с mimetype (пригодится, когда будем заниматься отдачей статики), а Erlydtl — шаблонизатор. Создадим конфигурационный файл для rebar под названием rebar.config:
Содержимое rebar.config
{deps, [
	{cowboy, ".*", {git, "https://github.com/extend/cowboy.git", {branch, "master"}}},
	{sync, ".*", {git, "git://github.com/rustyio/sync.git", {branch, "master"}}},
	{mimetypes, ".*", {git, "git://github.com/spawngrid/mimetypes.git", {branch, "master"}}},
	{erlydtl, ".*", {git, "git://github.com/evanmiller/erlydtl.git", {branch, "master"}}}
]}.


Создадим файл src/webserver.erl, с помощью которого мы пока будем просто запускать и останавливать наш сервер:
Содержимое src/webserver.erl
-module(webserver).

%% API
-export([
	start/0,
	stop/0
]).

-define(APPS, [crypto, ranch, cowboy, webserver]).

%% ===================================================================
%% API functions
%% ===================================================================

start() ->
	ok = ensure_started(?APPS),
	ok = sync:go().

stop() ->
	sync:stop(),
	ok = stop_apps(lists:reverse(?APPS)).

%% ===================================================================
%% Internal functions
%% ===================================================================

ensure_started([]) -> ok;
ensure_started([App | Apps]) ->
	case application:start(App) of
		ok -> ensure_started(Apps);
		{error, {already_started, App}} -> ensure_started(Apps)
	end.

stop_apps([]) -> ok;
stop_apps([App | Apps]) ->
	application:stop(App),
	stop_apps(Apps).


Теперь вызов webserver:start() запустит по-очереди приложения crypto, ranch, cowboy, webserver и автообновление с помощью Sync, а webserver:stop остановит все запущенное в обратном порядке.
Каскад готов, пора уже переходить к Ковбою. Открываем webserver_app.erl и редактируем функцию start/2:
Функция webserver_app:start/2
start(_StartType, _StartArgs) ->
	Dispatch = cowboy_router:compile([
		{'_', [
			{"/", index_handler, []},
			{'_', notfound_handler, []}
		]}
	]),
	Port = 8008,
	{ok, _} = cowboy:start_http(http_listener, 100,
		[{port, Port}],
		[{env, [{dispatch, Dispatch}]}]
	),
	webserver_sup:start_link().


В правилах диспатчинга мы указали, что абсолютно все запросы кроме "/", которые будут приходить на сервер, мы будем обслуживать с помощью notfound_handler (будем отдавать 404 ошибку), а запросы к "/" будем обрабатывать с помощью index_handler. Значит, стоит их создать:
Содержимое src/index_handler.erl
-module(index_handler).
-behaviour(cowboy_http_handler).
%% Cowboy_http_handler callbacks
-export([
	init/3,
	handle/2,
	terminate/3
]).

init({tcp, http}, Req, _Opts) ->
	{ok, Req, undefined_state}.

handle(Req, State) ->
	Body = <<"<h1>It works!</h1>">>,
	{ok, Req2} = cowboy_req:reply(200, [], Body, Req),
	{ok, Req2, State}.

terminate(_Reason, _Req, _State) ->
	ok.


Содержимое src/notfound_handler.erl
-module(notfound_handler).
-behaviour(cowboy_http_handler).
%% Cowboy_http_handler callbacks
-export([
	init/3,
	handle/2,
	terminate/3
]).

init({tcp, http}, Req, _Opts) ->
	{ok, Req, undefined_state}.

handle(Req, State) ->
	Body = <<"<h1>404 Page Not Found</h1>">>,
	{ok, Req2} = cowboy_req:reply(404, [], Body, Req),
	{ok, Req2, State}.

terminate(_Reason, _Req, _State) ->
	ok.


Вот и все — мы создали простейший веб-сервер, который умеет обрабатывать запросы на localhost:8008 и localhost:8008/WHATEVER. Теперь осталось скомпилировать и запустить веб-сервер:
> rebar get-deps
> rebar compile
> erl -pa ebin deps/*/ebin -s webserver

rebar get-deps подтянет зависимости из конфига, rebar compile скомпилирует код, а erl -pa ebin deps/*/ebin -s webserver запустит сам сервер. Кстати, самое время создать простенький Makefile для облегчения выполнения вышеперечисленных операций:
Содержимое Makefile
REBAR = `which rebar`

all: deps compile

deps:
	@( $(REBAR) get-deps )

compile: clean
	@( $(REBAR) compile )

clean:
	@( $(REBAR) clean )

run:
	@( erl -pa ebin deps/*/ebin -s webserver )

.PHONY: all deps compile clean run


Теперь компилировать проект можно будет вызовом make, а запускать вызовом make run
После того, как сервер был запущен, можно перейти сначала на localhost:8008, а затем на localhost:8008/whatever и убедиться, что сервер работает ожидаемо, отдавая «It works» на первый запрос и «404 Page Not Found» на второй

Краткий обзор роутинга, обслуживание статики


Роутинг в Ковбое не сказать, что самый удобный, однако вполне сносный — основные фишки вроде передачи параметров в URL и валидация этих параметров доступны. Пока у нас в правилах диспатчинга есть лишь два роута:
{"/", index_handler, []},
{'_', notfound_handler, []}

Которые находится внутри другого, определяющего, для какого хоста мы будем использовать вложенные. Подробнее об этом и о роутинге в целом можно почитать здесь: github.com/extend/cowboy/blob/master/guide/routing.md а здесь я уточню лишь что атом '_' означает, что роут будет матчить запросы к абсолютно всем адресам, notfound_handler — имя модуля, который будет обрабатывать заматченные запросы, а [] — список доп. параметров, передаваемых модулю
Хранить статику мы будем в директории priv в поддиректориях priv/css priv/js, priv/img и матчить ее будем по следующим правилам:
/css/WHATEVER -> /priv/css/WHATEVER
/js/WHATEVER  -> /priv/js/WHATEVER
/img/WHATEVER -> priv/img/WHATEVER

Для этого добавим 3 роута соответственно:
Dispatch = cowboy_router:compile([
	{'_', [
		{"/css/[...]", cowboy_static, [
			{directory, {priv_dir, webserver, [<<"css">>]}},
			{mimetypes, {fun mimetypes:path_to_mimes/2, default}}
		]},
		{"/js/[...]", cowboy_static, [
			{directory, {priv_dir, webserver, [<<"js">>]}},
			{mimetypes, {fun mimetypes:path_to_mimes/2, default}}
		]},
		{"/img/[...]", cowboy_static, [
			{directory, {priv_dir, webserver, [<<"img">>]}},
			{mimetypes, {fun mimetypes:path_to_mimes/2, default}}
		]},
		{"/", index_handler, []},
		{'_', notfound_handler, []}
	]}
]).

функция mimetypes:path_to_mimes/2 отвечает за отдачу верного mimetype по расширению файла.
Легко можно заметить, что предыдущие 3 роута почти полностью копируют друг друга за мелкими исключениями, давайте вынесем генерацию роута для статики в функцию и заменим ей роуты:
Static = fun(Filetype) ->
	{lists:append(["/", Filetype, "/[...]"]), cowboy_static, [
		{directory, {priv_dir, webserver, [list_to_binary(Filetype)]}},
		{mimetypes, {fun mimetypes:path_to_mimes/2, default}}
	]}
end,
Dispatch = cowboy_router:compile([
	{'_', [
		Static("css"),
		Static("js"),
		Static("img"),
		{"/", index_handler, []},
		{'_', notfound_handler, []}
	]}
]).

Теперь, чтобы новые правила диспатчинга вступили в силу, нам нужно либо перезагрузить сервер, либо воспользоваться функцией cowboy:set_env/3
Первое — неспортивно, да и перезагружать сервер на каждый чих в правилах роутинга замучаешься, поэтому добавим функцию для обновления роутинга в нашем файле webserver, чтобы можно было в консоли вызвать webserver:update_routing(). И, чтобы функция webserver:update_routing/0 знала о новых роутах — вынесем их определение в отдельную функцию. В итоге файл webserver_app.erl примет следующий вид:
Содержимое src/webserver_app.erl
-module(webserver_app).
-behaviour(application).

%% Application callbacks
-export([
	start/2,
	stop/1
]).

%% API
-export([dispatch_rules/0]).

%% ===================================================================
%% API functions
%% ===================================================================

dispatch_rules() ->
	Static = fun(Filetype) ->
		{lists:append(["/", Filetype, "/[...]"]), cowboy_static, [
			{directory, {priv_dir, webserver, [list_to_binary(Filetype)]}},
			{mimetypes, {fun mimetypes:path_to_mimes/2, default}}
		]}
	end,
	cowboy_router:compile([
		{'_', [
			Static("css"),
			Static("js"),
			Static("img"),
			{"/", index_handler, []},
			{'_', notfound_handler, []}
		]}
	]).

%% ===================================================================
%% Application callbacks
%% ===================================================================

start(_StartType, _StartArgs) ->
	Dispatch = dispatch_rules(),
	Port = 8008,
	{ok, _} = cowboy:start_http(http_listener, 100,
		[{port, Port}],
		[{env, [{dispatch, Dispatch}]}]
	),
	webserver_sup:start_link().

stop(_State) ->
	ok.


Теперь добавим функцию update_routing в модуль webserver.erl:
Функция webserver:update_routes/0
update_routes() ->
	Routes = webserver_app:dispatch_rules(),
	cowboy:set_env(http_listener, dispatch, Routes).


И не забудьте добавить функцию в аттрибут -export(), после чего он станет выглядеть так:
%% API
-export([
	start/0,
	stop/0,
	update_routes/0
]).

выполняем в консоли webserver:update_routes()., создаем директории для статики
> mkdir priv && cd priv && mkdir css js img

и кладем туда какие-нибудь соответствующие файлы, после чего можно проверить, что они отдаются, как и предполагалось, по адресу localhost:8008/PATH/FILE

Шаблонизация с помощью ErlyDTL (Django Template Language для Erlang)


Evan Miller, автор небезызвестного web-фреймворка Chicago Boss под Erlang, портировал Django Template Language (https://docs.djangoproject.com/en/dev/topics/templates/) на Erlang и получилось это, откровенно говоря, довольно круто. Собственно, именно этот шаблонизатор я бы и порекомендовал к использованию в ваших будущих проектах — альтернатив лучше я пока не видел.
Создаем новую директорию webserver/tpl и сохраняем туда три шаблона:

Содержимое tpl/layout.dtl
<!DOCTYPE html>
<html>
<head>
	<title>Webserver</title>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>


Содержимое tpl/index.dtl
{% extends "layout.dtl" %}
{% block content %}
<h1>Hello, {{ username | default : "stranger" }}!</h1>
{% endblock %}


Содержимое tpl/404.dtl
{% extends "layout.dtl" %}
{% block content %}
<h1>URL <span style="color:red;">{{ url }}</span> does not exists.</h1>
{% endblock %}



Чтобы использовать шаблоны, их нужно скомпилировать. Делается это с помощью erlydtl:compile/3 следующим образом:
ok = erlydtl:compile("tpl/layout.dtl", "layout_tpl", []),
ok = erlydtl:compile("tpl/index.dtl", "index_tpl", []),
ok = erlydtl:compile("tpl/404.dtl", "404_tpl", []).

Последний аргумент — список опций для компиляции шаблона, прочитать о которых подробнее можно здесь: github.com/evanmiller/erlydtl
Чтобы руками не компилировать все шаблоны каждый раз при изменении, создадим функции в модуле webserver, которые будут заниматься перекомпиляцией:
Функции для перекомпиляции шаблонов
c_tpl() ->
	c_tpl([]).

c_tpl(Opts) ->
	c_tpl(filelib:wildcard("tpl/*.dtl"), Opts).

c_tpl([], _Opts) -> ok;
c_tpl([File | Files], Opts) ->
	ok = erlydtl:compile(File, re:replace(filename:basename(File), ".dtl", "_tpl", [global, {return, list}]), Opts),
	c_tpl(Files, Opts).


и экспортируем их:
%% API
-export([
	start/0,
	stop/0,
	update_routes/0,
	c_tpl/0, c_tpl/1, c_tpl/2
]).

c_tpl/0 будет перекомпилировать все шаблоны из директории tpl без опций, c_tpl/1 будет делать то же самое, только с заданными опциями, а c_tpl/2 будет перекомпилировать заданные файлы с заданными опциями. Давайте скомпилируем все шаблоны выполнив в консоли Эрланга webserver:c_tpl().
Также обновим наш rebar.config, чтобы при компиляции он также компилировал и шаблоны (спасибо за подсказку egobrain):
Обновленный rebar.config
{plugins,[rebar_erlydtl_compiler]}.

{deps, [
    {cowboy, ".*", {git, "https://github.com/extend/cowboy.git", {branch, "master"}}},
    {sync, ".*", {git, "git://github.com/rustyio/sync.git", {branch, "master"}}},
    {mimetypes, ".*", {git, "git://github.com/spawngrid/mimetypes.git", {branch, "master"}}},
    {erlydtl, ".*", {git, "git://github.com/evanmiller/erlydtl.git", {branch, "master"}}}
]}.

{erlydtl_opts,[
    {compiler_options, [debug_info]},
    [
        {doc_root, "tpl"},
        {out_dir, "ebin"},
        {source_ext, ".dtl"},
        {module_ext, "_tpl"}
    ]
]}.


К сожалению, у меня не вышло простыми средствами заставить Sync подхватывать изменения в шаблонах, погляжу в его код чуть позже, поэтому функции для перекомпиляции по-прежнему оставил в модуле.

Теперь редактируем наши хендлеры, чтобы они отдавали ответом скомпилированные шаблоны, а также передаем в шаблоны нужные переменные:

Содержимое src/index_handler.erl
-module(index_handler).
-behaviour(cowboy_http_handler).
%% Cowboy_http_handler callbacks
-export([
	init/3,
	handle/2,
	terminate/3
]).

init({tcp, http}, Req, _Opts) ->
	{ok, Req, undefined_state}.

handle(Req, State) ->
	{Username, Req2} = cowboy_req:qs_val(<<"username">>, Req, "stranger"),
	{ok, HTML} = index_tpl:render([{username, Username}]),
	{ok, Req3} = cowboy_req:reply(200, [], HTML, Req2),
	{ok, Req3, State}.

terminate(_Reason, _Req, _State) ->
	ok.


Содержимое src/notfound_handler.erl
-module(notfound_handler).
-behaviour(cowboy_http_handler).
%% Cowboy_http_handler callbacks
-export([
	init/3,
	handle/2,
	terminate/3
]).

init({tcp, http}, Req, _Opts) ->
	{ok, Req, undefined_state}.

handle(Req, State) ->
	{URL, Req2} = cowboy_req:url(Req),
	{ok, HTML} = '404_tpl':render([{url, URL}]),
	{ok, Req3} = cowboy_req:reply(404, [], HTML, Req2),
	{ok, Req3, State}.

terminate(_Reason, _Req, _State) ->
	ok.


Вот, собственно, и все. Открываем localhost:8008/?username=world или localhost:8008/qweqweasdasd и радуемся, что все работает ровно так, как мы ожидали.

Полный код проекта можно найти здесь: github.com/chvanikoff/webserver

На этом я завершаю свой рассказ, а в следующей статье расскажу о том, как добавить поддержку мультиязычности в наше написанное сегодня приложение. Вопросы, комментарии, замечания приветствуются ;)
Tags:
Hubs:
+32
Comments 41
Comments Comments 41

Articles