Бытует мнение, что Erlang отлично подходит для веба (и это правда), но
для небольших сайтов его использование неоправдано. Так ли это на самом деле?
«Erlang — is a programming language used to build massively scalable soft real-time systems with requirements on high availability.» — © http://www.erlang.org
Язык для создания высоконагрузочных, масштабируемых, сверхнадежных систем. Звучит неплохо, именно такими и должны быть веб-сайты.
По сути Erlang/OTP это скорее платформа, чем просто язык программирования.
Вот ее ключевые части и особенности:
Эрланг разивается уже более 15 лет, был спроектирован инженерами, успешно используется для решения широкого круга задач. Будем считать, что он действительно хорош.
Итак, для больших и сложных систем эрланг подходит. Но что, если нам нужен всего лишь сайт и даже не «портал», а визитка или небольшой онлайн-магазин? Можно взять php, python или руби, но конец света не загорами, поэтому будем пробовать странное и напишем сайт на эрланге.
Ведь эрланг — довольно специфичный язык, функциональный, в нем нет ООП и, поговаривают, есть проблемы с юникодом, скоростью обработки строк и с числодробильными задачами. Да и бизнес-логику на нем писать будет как минимум непривычно. Каждому инструменту своя задача, давайте все же сайты писать на питоне, php, java, clojure, ruby, а для эрланга оставим:
В конце концов «it's worth a try».
Простой, одностраничный,бессмысленный, но милый сайт. Постараемся при малом объеме затронуть побольше функциональности.
«Если ты веришь, что нажатие этой кнопки поможет остановить конец света — то жми ее!». Про это и будет сайт:
Небольшой скриншот того, что мы получим в итоге:
Веб-фреймворков для эрланга не сказать, чтобы много. Но они есть. При беглом просмотре документации Chicago Boss, он показался мне наиболее подходящим для небольшого веб-приложения.
Я приведу пример установки для macos.
для небольших сайтов его использование неоправдано. Так ли это на самом деле?
Вместо вступления или «А так уж он хорош?»
«Erlang — is a programming language used to build massively scalable soft real-time systems with requirements on high availability.» — © http://www.erlang.org
Язык для создания высоконагрузочных, масштабируемых, сверхнадежных систем. Звучит неплохо, именно такими и должны быть веб-сайты.
По сути Erlang/OTP это скорее платформа, чем просто язык программирования.
Вот ее ключевые части и особенности:
- BEAM (Bogdans' Erlang Abstract Machine) — довольно шустрая виртуальная машина (запущенный экземпляр виртуальной машины называется «нода») со сборщиком мусора. Портирована на многие платформы, имеет открытый исходный код, реализована на С. Так же существует компилятор в нативный код HiPE.
- OTP (Open Telecom Platform) — стандартная библиотека Erlang, одновременно и фреймворк, и набор Design Patterns. Во-многом именно благодаря OTP эрланг обладает столь неординарными возможностями.
- Interoperability — часть вычислительных процессов можно выносить за пределы виртуальной машины и реализовывать на классических языках вроде С или python.
- Парадигма — эрланг — функциональный (не чистый) язык с необычным, но достаточно простым синтаксисом. Иммутабельность данных, паттерн-матчинг, замыкания, функции высшего порядка и прочие прелести функциональных языков присутствуют в должном количестве.
- Параллельность — эрланг (как язык, так и виртуальная машина) изначально проектировался с упором на использование параллельных вычислений и возможность по максимуму задействовать возможности многоядерных процессоров.
- Процессная модель — любой код выполняется в своем процессе, но, в отличии от процессов операционной системы или потоков в классических языках программирования, в эрланге реализованы легковесные процессы или «акторы». Легковесные процессы изолированы, параллельны, дешевы (десятки тысяч процессов — совершенно нормально для эрланговской ноды), общаются друг с другом посредством посылки сообщений, могут создавать другие процессы и следить за ними.
- Горячее обновление кода — эрланг позволяет обновлять код части системы (модуль), не останавливая ноду целиком. При грамотном использовании этой возможности обновление всей системы происходит незаметно для конечного пользователя.
- Распределенность — эрланговское приложение представляет собой некоторое количество легковесных процессов, запущенных на ноде (экземпляре виртуальной машины), общающихся между собой посредством посылки сообщений. При этом посылка сообщения процессу, находящемуся на другой ноде (которая в свою очередь может быть запущена на другом сервере) ничем не отличается от посылки сообщения процессу в своей виртуальной машине.
- Отказоустойчивость — людям свойственно ошибаться, люди не могут предугадать все-все-все возможные сценарии выполнения кода или гарантировать ожидаемое поведение внешних систем, в конце-концов законы Мура никто не отменял. Пэтому в эрланге не принято бороться с окружающим миром, вместо этого проповедуются принципы «let it crash» и «happy case», мониторинг процессов и распределенность (дублирование) систем. Кроме того свой вклад вносят иммутабельность данных, изолированность процессов, классические юнит- и интеграционные тесты, автоматическое управление памятью и пр. штуки, которыми никого не удивишь.
- Масштабируемость — учитывая вышеуказанные особенности проекты на эрланге гораздо проще спроектировать таким образом, что «добавление» нового ядра в процессор или парочки серверов с запущенными нодами увеличит производительность системы чуть ли не линейно.
- Встроенный инструментарий — Erlang поставляется с набором полезных приложений, среди них интерактивная консоль erl, распределенная база данных Mnesia, графические мониторы нод и процессов appmon, observer, etop, графический дебаггер debugger, статический анализатор кода dialyzer и др.
Эрланг разивается уже более 15 лет, был спроектирован инженерами, успешно используется для решения широкого круга задач. Будем считать, что он действительно хорош.
Пишем сайт
Итак, для больших и сложных систем эрланг подходит. Но что, если нам нужен всего лишь сайт и даже не «портал», а визитка или небольшой онлайн-магазин? Можно взять php, python или руби, но конец света не загорами, поэтому будем пробовать странное и напишем сайт на эрланге.
Подумай еще разок!
Ведь эрланг — довольно специфичный язык, функциональный, в нем нет ООП и, поговаривают, есть проблемы с юникодом, скоростью обработки строк и с числодробильными задачами. Да и бизнес-логику на нем писать будет как минимум непривычно. Каждому инструменту своя задача, давайте все же сайты писать на питоне, php, java, clojure, ruby, а для эрланга оставим:
- обработку большого количества одновременных долгоживущих подлючений без хитрой бизнес-логики
- использование в качестве брокера очереди сообщений
- отдачи статики (хотя нет, nginx — наше все)
В конце концов «it's worth a try».
Что будем писать
Простой, одностраничный,
«Если ты веришь, что нажатие этой кнопки поможет остановить конец света — то жми ее!». Про это и будет сайт:
- счетчик кликов, отражающий кол-во нажатий на кнопку в real-time (long polling),
- возможность авторизоваться через openid
- автоматический выбор языка
- … и достаточно.
Небольшой скриншот того, что мы получим в итоге:
Выбор фреймворка
Веб-фреймворков для эрланга не сказать, чтобы много. Но они есть. При беглом просмотре документации Chicago Boss, он показался мне наиболее подходящим для небольшого веб-приложения.
Установка
Я приведу пример установки для macos.
- устанавливаем пакетный менеджер Homebrew:
ruby -e "$(curl -fsSkL raw.github.com/mxcl/homebrew/go)"
- устанавливаем Erlang:
brew install erlang
- устанавливаем git:
brew install git
- подготавливаем директории:
cd ~ mkdir erlang && cd erlang
- ставим Chicago Boss:
git clone https://github.com/evanmiller/ChicagoBoss.git cd ChicagoBoss make
- устанавливаем mysql:
brew install mysql
- устанавливаем memcached:
brew install memcached
Пишем код
Chicago Boss — MVC веб-фреймворк, в составе которого есть:
- простой ORM BossDb на базе параметризированных модулей. Автоматическое кэширование результатов выборок в memcached. В качестве драйвера могут использоваться как NoSql базы, так и реляционные. Реализует Relational ActiveRecord паттерн.
- MVC. Роутинг на базе рэгэкспов, контроллеры, экшены, шаблоны (Django templates).
- работа с сессиями и параметрами запроса.
- (еще в нем есть система событий от ORM, очередь сообщений tiny_mq, работа с почтой и тестирование).
Создание каркаса приложения в Chicago Boss выполняется так:
make app PROJECT=stopthedoomsday
После этого мы получим следующую структуру папок:
|-stopthedoomsday: |---log: |---ebin: |---priv: |------static: |------init: |------lang: |------stopthedoomsday.routes |---include: |---src: |------view: |------controller |------model |------lib |------mail |------test |------websocket |---boss.config |---init.sh
Запустить проект можно так:
cd ~/erlang/stopthedoomsday ./init-dev.sh
После чего можно открыватьhttp://localhost:8001
.
База данных
Хранить мы будем информацию о зарегистрированных пользователях и количестве их кликов.
Создадим файл модели src/model/human.erl:
Id — автоинкрементное поле, первичный ключ, Identity — идентификатор пользователя, Provider — сервер openid, Hits — количество кликов.-module(human, [Id, Identity, Provider, Hits::integer() ]). -compile([export_all]).
После этого мы сможем работать с моделью вот так:
NewUser = human:new(0,"first user","manual",1), % создаем модель {ok,{human,UserId,_,_,_}} = NewUser:save(), % и сохраняем в базе User0 = boss_db:find( "human-0" ), % получаем модель по ключу Human1 = User0:set(hits, User0:hits() + Count1 ), % помним про имутабельность Human1:save().
В модель можно добавлять свои функции, счетчики (не пользовался, вроде как каждый счетчик это запись в таблице counters), указывать связи с другими моделями (belongs_to(model), -has({model, 1}), -has({model, 14}), -has({model, many}) ), устанавливать хуки на создание, удаление, обновление, валидацию.
В целом вполне себе ActiveRecord.
На данный момент Chicago Boss позволяет работать с Tokyo Tyrant, Mnesia, MongoDB, Riak, MySQL, PostgreSQL, а так же с mock-базой, которая живет в оперативке и удобна для тестирования.
Модуль работы с базой boss_db довольно простой, предоставляет методы для поиска моделей, манипулирования счетчиками, выполнения raw-команд базы данных, транзакций и валидации моделей.
Создадим еще одну модель, будем хранить в ней временные данные.
Файл src/model/info.erl:
-module(info, [Id, Val]). -compile([export_all]).
Теперь можно создать базу mysql:
Названия таблиц нужно указывать во множественном числе.CREATE DATABASE `stopthedoomsday`; USE `stopthedoomsday`; CREATE TABLE `humen` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `identity` varchar(500) NOT NULL, `provider` varchar(500) NOT NULL, `hits` int(10) unsigned DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) ); CREATE TABLE `infos` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `val` int(11) DEFAULT NULL, PRIMARY KEY (`id`) );
Конфиг
Конфигурация приложения хранится в файле stopthedoomsday/boss.config:
{assume_locale, "en"}, % дефолтный язык приложения {cache_adapter, memcached_bin}, % используем memcached {cache_exp_time, 86400}, % время жизни кэша {db_host, "localhost"}, % подключение к базе {db_port, 3306}, {db_adapter, mysql}, {db_username, "stopthedoomsdayuser"}, {db_password, ""}, {db_database, "stopthedoomsday_db"}, {db_cache_enable, true}, % кэшировать результаты выборок {session_adapter, cache}, % сессии хранить в memcached { tinymq, [ {max_age, 300} % время жизни канала очереди сообщений tiny_mq ]},
Роутинг
Соответствия входящих урлов и ответственных за их обработку контроллеров живут в файлеpriv/stopthedoomsday.routes
:
{404, [{controller, "main"}, {action, "index"}]}. {"/", [{controller, "main"}, {action, "index"}]}. {"/ru", [{controller, "main"}, {action, "ru"}]}. {"/en", [{controller, "main"}, {action, "en"}]}. {"/updates/(\\d+)", [{controller, "main"}, {action, "updates"}, {t, '$1'} ]}. {"/hit", [{controller, "main"}, {action, "hit"}]}.
В рутах можно использовать регулярки:
(Однако заставить работать регулярные выражения для выбора контроллера и экшна мне не удалось.){"/(some|any)/(\\w+)/(?<route_id>\\d+)", [{application, some_app}, {controller, '$1'}, {action, '$2'}, {id, '$route_id'}]}.
Итак у нас будет один контроллер main и пять экшнов:
- index — главная (и единственная) страница сайта
- ru — показывать сайт на русском языке, хранить настройки языка в сессии
- en — показывать сайт на английском языке, хранить настройки языка в сессии
- updates — получение данных о текущем количестве людей и кликов, long polling
- hit — отправка на сервер количества кликов пользователя
Контроллеры
В эрланге нет неймспейсов, все названия модулей равны, так что для каждого модуля приложения принято добавлять уникальный префикс.
Контроллер у нас один src/controller/stopthedoomsday_main_controller.erl:
-module(stopthedoomsday_main_controller, [Req, SessionID]). -compile([export_all]). %% http://localhost:8001/ index('GET', [])-> {{Year,Month,Day},_} = calendar:now_to_datetime( erlang:now() ), DaysLeft = 21 - Day, % количество дней, оставшихся до конца света HumanCount = boss_db:count(human), % кол-во зарегистрированных пользователей Info = boss_db:find("info-1"), % ActiveRecord запись, таблица infos, id = 1 HitsCount = Info:val(), % получаем поле `val`, в нем храним сумированное кол-во кликов пользователей % получаем ID авторизованного пользователя SessionUser = boss_session:get_session_data(SessionID, userid), if (SessionUser == undefined) -> % аноним User = undefined, YourHits = undefined; true -> % авторизован, получаем имя и количество кликов пользователя UserRec = boss_db:find( SessionUser ), {human,_,User,_,YourHits} = UserRec end, {ok, [ % для рендеринга будет использоваться вьюшка src/view/main/index.html { user, User}, % переменные, которые будут переданы в шаблон { yourHits, YourHits}, { hitsCount, HitsCount}, { humanCount, HumanCount}], [{"Content-Language", boss_session:get_session_data(SessionID, locale)}] % можно изменять заголовки ответа }; % если запрос пришел через метод POST - значит его инициировала loginza % нужно сначала авторизовать пользователя index('POST', []) -> Token = Req:post_param("token"), if ( Token == undefined ) -> error; true -> WidgetId = "00000", ApiSignature = md5_hex( Token ++ "539d00a6360a00502b95acdd10a516b8" ), {ok, {_,_,Info} } = httpc:request("http://loginza.ru/api/authinfo?token=" ++ Token ++ "&id=" ++ WidgetId ++ "&sig=" ++ ApiSignature), Res = case mochijson:decode(Info) of {struct, [ {"error_type", ErrType} | _ ] } -> ok; {struct, [ {"identity", Identity}, {"provider", Provider} | _ ]} -> % логинза дала добро, пользователь авторизовался через openid User = boss_db:find_first(human, [ { identity, 'equals', Identity } ]), case User of % если пользователь уже регистрировался, обновляем сессию {human,UserId,_,_,_} -> boss_session:set_session_data(SessionID, userid, UserId); _Else -> % создаем нового пользователя NewUser = human:new(id,Identity,Provider,1), {ok,{human,UserId,_,_,_}} = NewUser:save(), boss_session:set_session_data(SessionID, userid, UserId) end; _Else -> ok end end, index('GET', []). %% http://localhost:8001/en en('GET', []) -> % сохраняем язык в сессии и редиректим на главную boss_session:set_session_data(SessionID, locale, "en"), {redirect, [{action, "index"}]}. %% http://localhost:8001/ru ru('GET', []) -> boss_session:set_session_data(SessionID, locale, "ru"), {redirect, [{action, "index"}]}. %% http://localhost:8001/updates/0 %% описание ниже updates('GET', [T]) -> {ok, Timestamp, Messages} = boss_mq:pull("updates", list_to_integer(T), 300), {json, [{timestamp, Timestamp}, {messages, [ {dummy, 0} ] ++ Messages }]}. %% http://localhost:8001/hit %% пользователь кликнул по кнопке hit('POST', []) -> Count = list_to_integer(Req:post_param("count")), SessionUser = boss_session:get_session_data(SessionID, userid), if (SessionUser == undefined) -> % аноним {output, "not logined"}; true -> % авторизован UserRec = boss_db:find( SessionUser ), {human,_,User,_,YourHits} = UserRec, % обновляем значение счетчика в базе Human1 = UserRec:set(hits, UserRec:hits() + Count ), Human1:save(), {output, "ok"}; end. %% INTERNAL md5_hex(S) -> Md5_bin = erlang:md5(S), Md5_list = binary_to_list(Md5_bin), lists:flatten(list_to_hex(Md5_list)). list_to_hex(L) -> lists:map(fun(X) -> int_to_hex(X) end, L). int_to_hex(N) when N < 256 -> [hex(N div 16), hex(N rem 16)]. hex(N) when N < 10 -> $0+N; hex(N) when N >= 10, N < 16 -> $a + (N-10).
Экшн index('GET', []) вытаскивает количество кликов и зарегистрированных людей из базы, и, если пользователь авторизован, количество его кликов.
У нас включено использование memcached, так что вызов boss_db:count(human) будет по-возможности брать закэшированное значение.
Результаты raw-sql запросов не кэшируются, именно поэтому мы ввели дополнительную модель info. Хотя можно было бы написать и так:
{data,{mysql_result,_,[[HitsCount]],_,_,_}} = boss_db:execute("select sum(hits) as hits from humen")
Экшн index('POST', []) сначала проверяет информацию о пользователе, переданную сервисом loginza, при необходимости создает пользователя, а затем вызывает index('GET', []) для дальнейшей обработки.
Экшн updates('GET', [T]) -> возвращает json массив сообщений из канала «updates».
Параметр T содержит timestamp, начиная с которого мы хотим получить сообщения, находящиеся в канале. Если timestamp нулевой — получаем все сообщения. Количество сообщений в канале может накапливаться, по истечении таймаута старые сообщения удаляются из канала.
boss_mq:pull(Channel, Since, Timeout), — возвращает содержимое очереди сообщений канала Channel, начиная с Since. Если очередь сообщений пуста — вызывающий процесс блокируется, пока в нее не поступит сообщение, либо не сработает таймаут. Я советую всегда использовать таймаут, потому что канал очереди сообщний tiny_mq представляет собой эрланговский процесс со временем жизни, который мы указали в настройках проекта ({max_age, 300}) и если за этот промежуток времени в канал не поступит ни одного сообщения — процесс канала уничтожается и процесс, подписавшийся на получение сообщений канала никогда их не получит и будет висеть в памяти бесконечно долго.
Экшн hit('POST', []) -> инкрементирует количество кликов для авторизованного пользователя.
Все достаточно прямолинейно. Остается неясным как сообщения попадают в канал «updates» очереди сообщений и где пересчитывается значение суммы кликов всех пользователей.
Инициализация
При запуске приложения Chicago Boss выполняет функцию init/0 всех модулей из папки priv/init.
Вот пример файла priv/src/stopthedoomsday_00_cache.erl, в котором мы инициализируем начальное значение суммированного количества кликов пользователей:
-module(stopthedoomsday_00_cache). -export([init/0, stop/1]). init() -> application:start(inets), {data,{mysql_result,_,[[HitsCount]],_,_,_}} = boss_db:execute("select sum(hits) as hits from humen"), Info = info:new(1,0), Info1 = Info:set(val, HitsCount), Info1:save(), {ok, []}. stop(ListOfWatchIDs) -> ok.
Очередь сообщений и события
Chicago Boss позволяет подписываться на события от ORM — изменение, удаление, обновление записей (или конкретной записи). Этой возможностью мы и воспользуемся — подпишемся на создание в базе нового пользователя и на изменение поля hits.
Подпись на события происходит при инициализации приложения, файл priv/init/stopthedoomsday_01_news.erl:
-module(stopthedoomsday_01_news). -export([init/0, stop/1]). init() -> %% добавляем обзервера, следящего за изменением поля hits у всех записей human W1 = boss_news:watch("human-*.hits", fun (Event, EventInfo) -> %% пересчитываем сумму кликов всех пользователей %% вызов raw-команд базы данных не кэшируется {data,{mysql_result,_,[[HitsCount]],_,_,_}} = boss_db:execute("select sum(hits) as hits from humen"), %% обновляем значение нашей вспомогательной модели info %% дальнейшее обращение к ней уже будет кэшироваться Info = boss_db:find("info-1"), Info1 = Info:set(val, HitsCount), Info1:save(), %% и посылаем в канал "updates" сообщение с обновленным значением количества кликов boss_mq:push("updates", { hitsCount, HitsCount} ) end), %% этот коллбэк будет вызываться для всех событий от ORM, связанных с моделью human %% создание + удаление %% в этом случае мы посылаем в очередь сообщений не только новое количество кликов, но и новое количество зарегистрированных пользователей W2 = boss_news:watch("humen", fun (_, EventInfo) -> {data,{mysql_result,_,[[HitsCount]],_,_,_}} = boss_db:execute("select sum(hits) as hits from humen"), Info = boss_db:find("info-1"), Info1 = Info:set(val, HitsCount), Info1:save(), boss_mq:push("updates", { hitsCount, HitsCount } ), HumanCount = boss_db:count(human), boss_mq:push("updates", { humanCount, HumanCount} ) end), {ok, []}. stop(ListOfWatchIDs) -> lists:map(fun boss_news:cancel_watch/1, ListOfWatchIDs).
View
Здесь ничего особо интересного нет. Вьюшки используют ErlyDTL — реализацию джанговских шаблонов. Можно писать свои фильтры и тэги.
Функция long-polling'a: посылаем на сервер запрос "/updates/0". Получаем текущие значения из очереди сообщений и таймстемп нашего последнего обращения. Затем посылаем запрос "/updates/timestamp" и выставляем большой таймаут, сервер получив такой запрос блокируется вызвовом boss_mq:pull, как только в очередь поступит сообщение — сервер отвечает клиенту, клиент обновляет информацию на странице и вновь посылает запрос "/updates/timestamp" с уже обновленным timestamp:
var poll = function() { $.ajax({ type: "GET", url: "/updates/" + lastMessageTimestamp, async: true, cache: false, timeout: 1*60*1000, success: function(data) { updateTimestamp(data.timestamp); var obj = data.messages; if( obj.humanCount ) { counter_humans.goToNumber( obj.humanCount ); } if( obj.hitsCount ) { counter_hits.goToNumber( obj.hitsCount ); } }, error: function(XMLHttpRequest, textStatus, errorThrown) { handleError(textStatus, errorThrown); }, complete: function(jqXHR, textStatus) { if( textStatus != 'success') setTimeout( poll, 5000 ); else poll(); } }); };
При клике на кнопку проверяем, авторизован ли пользователь, и, в завимимости от этого, либо показываем слой авторизации, либо отсылаем на сервер количество кликов $.post("/hit", {count:1}).
Запуск
В продакшн-режиме сервер запускается командой
Для обновления кода без остановки сервера:./init.sh start
./rebar compile ./init.sh reload
Выводы
Написание такого сайта у меня заняло примерно один день вместе с изучением основ синтаксиса эрланга и чтением документации Chicago Boss. В целом разработка на CB ничуть не сложнее (а порой даже проще), чем на других MVC веб-фреймворках (естественно мы сейчас не рассматриваем взрослые сайты). Так что если вы давно хотели «пощупать» эрланг, но решение задачек из книг не впечатляет — напишите сайт!
P.S. Результат можно посмотреть по адресу http://stopthedoomsday.com. Он будет работать еще примерно неделю. Исходники скоро выложу на github.