Pull to refresh

Chicago Boss, Erlang для маленьких

Бытует мнение, что Erlang отлично подходит для веба (и это правда), но
для небольших сайтов его использование неоправдано. Так ли это на самом деле?

Вместо вступления или «А так уж он хорош?»


image«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
  • автоматический выбор языка
  • … и достаточно.

Небольшой скриншот того, что мы получим в итоге:
stopthedoomsday

Выбор фреймворка


Веб-фреймворков для эрланга не сказать, чтобы много. Но они есть. При беглом просмотре документации Chicago Boss, он показался мне наиболее подходящим для небольшого веб-приложения.

Установка


Я приведу пример установки для macos.
  1. устанавливаем пакетный менеджер Homebrew:
    ruby -e "$(curl -fsSkL raw.github.com/mxcl/homebrew/go)"
    
  2. устанавливаем Erlang:
    brew install erlang
    
  3. устанавливаем git:
    brew install git
  4. подготавливаем директории:
    cd ~
    mkdir erlang && cd erlang
  5. ставим Chicago Boss:
    git clone https://github.com/evanmiller/ChicagoBoss.git
    cd ChicagoBoss
    make
  6. устанавливаем mysql:
    brew install mysql
    
  7. устанавливаем 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:
    -module(human, [Id, Identity, Provider, Hits::integer() ]).
    -compile([export_all]).
    
    Id — автоинкрементное поле, первичный ключ, Identity — идентификатор пользователя, Provider — сервер openid, Hits — количество кликов.
    После этого мы сможем работать с моделью вот так:
    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.
Tags:
Hubs:
You can’t comment this publication because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author’s username will be hidden by an alias.