Как написать веб-утилиту небольшого размера на Common Lisp
2013.06.06
Когда-то написал небольшую программку для сообщества игры Prime World. Она парсит таблицу рейтингов и составляет по ней линейные графики, плюс вычислят средний рейтинг (в рейтинге указываются первые 25 мест) по каждому персонажу. Персонажей 62, соответственно, линий на графике тоже 62. :)
Здесь напишу, как и из чего сделал и как оно работает.
Полный исходный код выложил к себе на GitHub.
Как работает
План был такой: приложение представляет собой одну HTML страницу, с одним файлом CSS и одним Javascript.
При загрузке страницы яваскрипт сразу делает AJAX-запрос на бэкэнд за данными.
Бэкэнд грабит исходную HTML-страницу, при помощи XPath выбирает значения рейтингов, имена игроков, места и имена персонажей. Затем кодирует это всё в JSON и отправляет скрипту обратно.
Скрипт отрисовывает полученные от бэкэнда рейтинги в виде графика при помощи библиотеки Highcharts. Дополнительно бэкэнд возвращает вычисленные средние значения рейтинга; они отображаются в виде таблицы, которая обрабатывается javascript библиотекой DataTables, чтобы добавить сортировку.
Между таблицей и графиком переключаемся кнопками.
Как я уже сказал, приложение достаточно маленькое.
В качестве недостатка можно упомянуть то, что каждый раз, когда приложение открывается, делается запрос на исходный сайт Нивала. Делать так, конечно, дурной тон, и стоит прикрутить какое-нибудь кэширование на стороне сервера. Хотя у них всё равно топ-25 не так часто обновляется, да и посещаемость никакая, так что для начала сойдёт.
Как собрано
Серверная часть основана на Hunchentoot, то есть, мы сами себе веб-сервер.
Вот все необходимые URL:
/
, по которому возвращается HTML страница приложения./data
, который AJAX endpoint, возвращающий JSON объект с данными/assets/scripts.js
, клиентский скрипт, который раскладывает данные из JSON в график и таблицу/assets/styles.css
, бумажка со стилями (с украшениями я не парился, в стилях только сокрытие некоторых элементов для инициализации интерфейса)./assets/loading.gif
, показываем эту картинку пока/data
грузится./favicon.ico
, иконка до кучи, честно стырил из пакета Html5 Boilerplate.
Хостимся на Heroku.
Извлечение исходных данных
Сам парсинг исходной веб-страницы полностью заключён в одном файле под названием dno.lisp
, и единственная функция, которая оттуда нужна публично, это send-data-from-origin-as-json
, которая генерирует строку в JSON формате, содержащую данные, нужные в scripts.js
для того, чтобы построить графики и таблицу.
Сам файл dno.lisp
можно посмотреть в репозитарии на гитхабе, я здесь его приводить не буду, слишком большой.
В итоге мы можем делать (load "dno.lisp")
и получать с этого функцию, которая даст нам JSON, который мы можем возвращать как результат AJAX запроса, то есть, то, что нужно для пункта 2.
Отдача статических файлов
В идеологии hunchentoot’а (если не углубляться в детали), инициализация приложения выглядит так:
-
Настраиваем все пути в приложении, вызывая
hunchentoot:create-folder-dispatcher-and-handler
,hunchentoot-create-static-file-dispatcher-and-handler
,hunchentoot:define-easy-handler
и т. п. Это как раз было упрощено при помощи хелперов в предыдущем пункте. -
Создаём экземпляр приложения заклинанием:
(hunchentoot:start (make-instance 'hunchentoot:easy-acceptor :port 4242)))
Для начала делаем три хелпера в отдельном файле, для того, чтобы добавление путей в Hunchentoot не было пыткой:
Файл helpers.lisp:
(defun publish-directory (uri dirname)
"Makes files in given <dirname> accessible under the URL prefix <uri>. <dirname> should be in relative directory path format, e. g. \"foo/bar/baz\""
(push (hunchentoot:create-folder-dispatcher-and-handler uri dirname)
hunchentoot:*dispatch-table*))
(defun publish-file (uri filename)
(push (hunchentoot:create-static-file-dispatcher-and-handler uri filename)
hunchentoot:*dispatch-table*))
(defmacro publish-ajax-endpoint (uri name params &body body)
"<name> is a name of handler function, <uri> is a relative URI for endpoint, <params> is a list of symbol names of expected params in request>"
`(hunchentoot:define-easy-handler (,name :uri ,uri) ,params
(setf (hunchentoot:content-type*) "application/json")
,@body))
Имея эти три хелпера, файл инициализации, фактически, будет иметь следующий вид:
Файл init.lisp:
;; Webroot
(publish-file "/" "webroot/index.html")
;; Favicon, just because
(publish-file "/favicon.ico" "webroot/favicon.ico")
;; Images, CSS and JS files referenced from index.html
(publish-directory "/assets/" "assets/")
;; AJAX endpoint to grab data to display in chart
(publish-ajax-endpoint "/data" data () (send-data-from-origin-as-json))
(defun run ()
"Launch Hunchentoot web server instance on default port"
(hunchentoot:start (make-instance 'hunchentoot:easy-acceptor :port 4242)))
Само содержимое файлов index.html
, scripts.js
и styles.css
можно посмотреть на гитхабе, здесь я описываю только то, как приложение собрано, а не как оно работает.
Мы не можем сделать (publish-directory "/" "webroot/")
, потому что мы хотим дефолтный хэндлер для пути /
.
Я завернул старт приложения в отдельную функцию (и не вызываю её сразу же), потому что так будет удобнее в дальнейшём.
Теперь мы можем делать так:
(load "dno.lisp")
(load "helpers.lisp")
(load "init.lisp")
(run)
После чего открываем браузер по адресу http://localhost:4242/ и наблюдаем готовое приложение, при условии, что в том же каталоге, что и эти два скрипта, находятся папки webroot
и assets
с соответствующими файлами в них.
Структурирование
Теперь запакуем всё в ASD. Вот моё определение приложения:
Файл dno.asd:
(asdf:defsystem #:dno
:serial t
:description "Presents ratings of players of Prime World as charts."
:author "Mark Safronov <hijarian@gmail.com>"
:license "Public Domain"
:depends-on (#:drakma
#:cl-ppcre
#:cl-libxml2
#:iterate
#:cl-json
#:hunchentoot)
:components ((:file "package")
(:module :src
:serial t
:components ((:file "helpers")
(:file "dno")
(:file "init")))))
Файл package.lisp:
(defpackage #:dno
(:export :run)
(:use #:cl
#:cl-user
#:hunchentoot))
Как видно, все три исходника на Common Lisp, описанные выше, были переложены в отдельный подкаталог src
.
Понятное дело, во всех файлах теперь первой строчкой идёт вызов (in-package :dno)
.
Заметьте, что в ASD прописаны библиотеки, от которых зависит приложение (cl-ppcre
, cl-libxml2
, iterate
и cl-json
используются только в dno.lisp
, на самом деле), однако сам пакет (package.lisp
) не подключает эти библиотеки.
Это просто дело вкуса: я предпочитаю использовать символы из чужих пакетов по их полному имени, не сокращая.
Так сразу понятно, в какую документацию лезть.
Теперь у нас такая структура файлов:
assets/
scripts.js
styles.css
webroot/
index.html
favicon.ico
src/
dno.lisp
init.lisp
helpers.lisp
dno.asd
package.lisp
Запуск на локальной машине
Так как у нас теперь определён ASD пакет, мы можем воспользоваться механизмами Quicklisp для того, чтобы максимально просто загружать приложение на локальной машине.
Если в рабочем каталоге Quicklisp в подкаталоге local-projects сделать символическую ссылку на каталог приложения, то можно будет подключать пакет приложения простым вызовом (ql:quickload :dno)
.
Таким образом, кладём в корневой каталог приложения следующий скрипт запуска:
Файл runner.lisp:
(in-package :cl-user)
(ql:quickload :dno)
(dno:run)
Теперь видно, зачем нужна отдельная функция run
, определённая в init.lisp
: для того, чтобы разделить загрузку самого приложения в рантайм и запуск приложения как веб-сервера.
Всё, готовое веб-приложение можно запустить из консоли, например, так:
$ cd path/to/dno/app
$ sbcl
* (load "runner.lisp")
Понятное дело, вместо SBCL может быть любой Лисп на ваш выбор.
Консоль становится неюзабельной после этого. Когда понадобится загасить приложение — нажатие Ctrl+D убивает приложение вместе с рантаймом лиспа.
Деплой на Heroku
Благодаря специальному buildpack’у для CL появилась возможность хостить SBCL + Hunchentoot приложения на Heroku.
Для этого нужно подготовить приложение следующим образом:
- Настраиваем у себя подключение к Heroku.
heroku create -s cedar --buildpack http://github.com/jsmpereira/heroku-buildpack-cl.git
. Запоминаем название проги, которое Heroku нам сгенерировало.heroku labs:enable user-env-compile -a myapp
, вместо myapp пишем название проги из п.2.heroku config:add CL_IMPL=sbcl
heroku config:add CL_WEBSERVER=hunchentoot
heroku config:add LANG=en_US.UTF-8
Теперь в корневом каталоге приложения нужно добавить следующий скрипт, который ожидает buildpack:
Файл heroku-setup.lisp
(in-package :cl-user)
(load (merge-pathnames *build-dir* "dno.asd"))
(ql:quickload :dno)
Крайне важно то, что этот скрипт не выполняет (dno:run)
, как это делает runner.lisp
, потому что buildpack сам запустит Hunchentoot, так что от init.lisp
требуется только настроить пути.
После того, как этот скрипт добавлен в репозитарий (имя heroku-setup.lisp
имеет значение), можно пушить: git push heroku master
, при условии, что п. 1 выполнен полностью.
Поздравляю
Всё, теперь прога готова, есть как возможность запустить на локальной машине, так и деплой сразу в Сеть (доменное имя тоже вполне приличное получается).
По тому же принципу можно строить любые другие простые веб-приложения, добавляя в init.lisp
определения для путей в приложении.
Конечно же, если прога сложная, то придётся делать какой-то кустарный роутер, а также скорее всего, не отдавать статичные HTML файлы, а генерировать страницы при помощи какого-нибудь шаблонизатора типа CL-WHO, CL-EMB или чего-то подобного. Яваскрипт и CSS тоже можно генерировать прямо из Common Lisp, для этого есть проекты Parenscript и css-lite соответственно.
Мне всё это не понадобилось, приложение достаточно простое чтобы сразу отдавать статичные ассеты и HTML.