13 августа 2011

Common Lisp. Restas. Maxima.

 So, lesson is, if you're going to start a Lisp company, don't make Lisp the product, make it your secret weapon. (c)jimbokun

Некоторое время назад решал много контрольных по математике. Однотипные задачи, просто разные варианты условий. Давайте рассмотрим такой вопрос: можно ли это автоматизировать, и поставить решение задач по математике на поток.
Начнем с системы символьных вычислений. Выбор пал на Maxima, которая имеет такую же длинную историю, как и Emacs.
Начало было положено проектом Macsyma в 1968 году и maclisp-ом, lisp-ом, который для этой системы и был разработан. В результате "санта-барбары" с владениями, лицензиями в распоряжении open source сообщества появился такая замечательная программа как Maxima, доступная на большинстве реализаций common lisp-a.
С этим проектом кстати в некоторой степени связан закат такой компании как symbolics, которая всячески производила лисп-машины в прямом понимании этого слова. Небольшая оценка заката здесь от Dan Weinreb. Dan Weinreb лиспу конечно не изменил и в итоге перебрался в ITA Software.
Но эта целая отдельная история, которая впрочем достаточно сильно повлияла на существующую программисткую реальность.


Установка Maxima


Для archlinux.

sudo pacman -S maxima

Взаимодействие с программой осуществляется через командную строку в виде уже всем известного repl-a.
Кроме того существует несколько реализаций графических оболочек для нее.
Пример работы программы:

michael@filonenko emacs maxima
Maxima 5.24.0 http://maxima.sourceforge.net
using Lisp SBCL 1.0.50
Distributed under the GNU Public License. See the file COPYING.
Dedicated to the memory of William Schelter.
The function bug_report() provides bug reporting information.
(%i1) 1 + 1 + 1/2 + 1/3 + 1/4 + 1/5 + 1/6 + 1/7;
                                      503
(%o1)                                 ---
                                      140
(%i2) x+x^3 = 0;
                                   3
(%o2)                             x  + x = 0
(%i3) solve(%i2,x);    
(%o3)                      [x = - %i, x = %i, x = 0]
(%i4) x^y^z + q^w^e^(r - t^(1/(y - i))) = 0;
                                            1
                                          -----
                                          y - i
                                     r - t
                              z     e
                             y     w
(%o4)                       x   + q             = 0
(%i5) 

В данном случае командой является математическое выражение в классической нотации. Для преобразований/вычислений выражений используются функции, аргументами которых и являются выражения.
Кроме того в процессе работы автоматически создаются переменные хранящие значения каждого ввода/вывода. Например %i1 ссылается на первую введенную команду, а %o3 на результат выполнения команды solve.
Гораздно понятнее и подробнее о взаимодействии с программой можно прочесть здесь http://maxima.sourceforge.net/ru/documentation.html.

Мы же займемся построением web-оболочки для данной программы. Принцип прост и заключается в предоставлении удаленной консоли с запущенным процессом maxima через браузер с помощью ajax запросов для обновления контента без перезагрузки страницы.

В качестве реализации web сервера выберем hunchentoot, для более автоматизированной с ним работы установим restas, а для межпроцессного взаимодействия типа "перенаправления stdin/out" воспользуемся пакетом external-program.
Почему я предлагаю разделить web сервер и процесс maxima, хотя обе программы работают в cl-машине? Просто так проще, по крайней мере на первых порах.

Принцип

+----------------+          +----------------+          +----------------+
| MAXIMA process |          |Web application |          |Web browser     |
| with TeX or    | stdout   |                |  http    |Ajax            |
| MathML output  | =======> |                | =======> |MathML or TeX   |
|                | <======= |                | <======= |renderer        |
|                | stdin    |                |          |----------------|
|----------------|          |----------------|          |----------------|
+----------------+          +----------------+          +----------------+

Установка зависимостей


Для common lisp существует прекрасный менджер пакетов Quicklisp, которым мы и воспользуемся.

$ curl -O http://beta.quicklisp.org/quicklisp.lisp
$ sbcl --load quicklisp.lisp
* (quicklisp-quickstart:install)
* (ql:add-to-init-file)

* (ql:quickload "restas")
* (ql:quickload "restas-directory-publisher") # потребуется для предоставления статического контента (javascript, css)
* (ql:quickload "closure-template")           # движок шаблонов
* (ql:quickload "external-program")

Создание простого web приложения


Начнем с распределения файлов проекта:

.
├── README                  
├── restmax.asd             # Обозначим пакет.
└── src                     
    ├── defmodule.lisp      # Декларация restas модуля
    ├── index.tmpl          # closure template для страницы приложения
    ├── routes.lisp         # Обработчики http запросов, запуск/перенаправление stdin/out maxima
    └── static              # Статический контент для страниц
        ├── css             # Стили
        │   └── style.css   #
        └── script          # 
            ├── jquery.js   # 
            └── ready.js    # Ввод/вывод через Ajax 

Создадим asdf пакет, в нем мы перечислим зависимости проекта и файлы с исходными кодами.

restmax.asd


(defsystem restmax
  :depends-on (#:restas #:external-program #:closure-template #:restas-directory-publisher)
  :components ((:module "src"
                :components ((:file "defmodule")
                             (:file "routes" :depends-on ("defmodule"))))))

defmodule.lisp


Создадим restas модуль для web приложения, настроим некоторые переменные влияющие на его работу.

(restas:define-module #:restmax
  (:use #:cl))

(in-package #:restmax)

Обозначаем путь, по которому находится файл шаблона страницы.

(defparameter *template-path*
  (merge-pathnames "src/index.tmpl"
                   (asdf:component-pathname (asdf:find-system '#:restmax))))

Компилируем данный файл. В процессе компиляции будет создан модуль указанный в index.tmpl {namespace}. После чего для каждого {template *} будет создана одноименная функция принимающая plist для подстановки значений и генерирующая контент.

(closure-template:compile-template :common-lisp-backend
                                   *template-path*)

Предоставляем доступ к статическим ресурсам приложения с помощью модуля restas-directory-publisher. Это осуществляется функцией restas:mount-submodule, которая позволяет иерархически организовывать модули web приложения и настраивать их для работы в контексте сабмодуля.

(restas:mount-submodule :restmax (#:restas.directory-publisher)
  (restas.directory-publisher:*directory* (merge-pathnames 
                                           "src/static/"
                                           (asdf:component-pathname (asdf:find-system '#:restmax))))
  (restas.directory-publisher:*autoindex* nil))

index.tmpl


Декларируем пространство имен. На его основе в cl-машине будет создан package.

{namespace restmax.view}

Составляем "оберточный" шаблон, определяющий окончательный вид страницы.

{template finalizePage}
      < link href="css/style.css" rel="stylesheet" type="text/css"> </link >
      <script src="script/jquery.js" type="text/javascript">
</script>
      {if $title}
        <title>{$title}</title>
      {/if}
      <script src="script/ready.js" type="text/javascript">
</script>
      {$content |noAutoescape}
{/template}

Доступ к генерации контента в соответствие с данным шаблоном будет осуществляться с помощью функции (restmax.view:finalizePage arg), где arg может иметь вид (list :title text :content text).

Определяем шаблон для пути index.html. Называем его таким же символом, что и обработчик пути index.html - index.

{template index}
  <div id="output">
</div>
<form id="inputform">
<div>
<textarea cols="40" name="input" rows="8">
</textarea>
    </div>
</form>
<div class="clickable" id="button">
Submit
</div>
{/template}

routes.lisp


Декларируем обработчик пути index.html

(restas:define-route index ("index.html")
  (list :title "Index"))

Теперь я постараюсь подробно и понятно описать процесс генерации страницы.
Возвращаемый plist отображается дженерик функцией (restas.render-object *default-render-method* data). Мы заводим свой класс отвечающий за отрисовку страницы:

(defclass drawer () ())

И устанавливаем его в качестве "отрисовщика".

(setf *default-render-method* (make-instance 'drawer))

Теперь реализуем метод для заданного "отрисовщика" и данных возвращаемых из обработчика пути.

(defmethod restas:render-object ((drawer drawer) (data list))
  (let ((content (render-route-data drawer
                                    data
                                    (restas:route-symbol restas:*route*))))
    (finalize-page drawer
                   (list :content content
                         :title (getf data :title)))))

Как вы заметили мы преобразуем данные еще двумя функциями render-route-data и finalize-page. render-route-data вызывается с текущим "отрисовщиком", данными от обработчика пути и символом текущего обработчика пути. Затем finalize-page вызывается для приведения страницы в окончательный вид.

Декларируем методы render-route-data и finalize-page.

(defgeneric finalize-page (drawer data)
  (:documentation "Create result page"))

(defgeneric render-route-data (drawer data route)
  (:documentation "Render page for current route"))

(defmethod finalize-page ((drawer drawer) (data list))
  (restmax.view:finalize-page data))

(defmethod render-route-data ((drawer drawer) (data list) route)
  (funcall (find-symbol (symbol-name route)
                        '#:restmax.view)
           data))

В методе finalize-page мы вызываем функцию сгенерированную ранее с помощью closure-template из index.tmpl restmax.view:finalize-page. Данный метод/шаблон формирует окончательный вид страницы.

В методе render-route-data мы вызываем метод restmax.view:$current-route$, где $current-route$ является символом текущего обработчика пути.

Фух, вроде ничего непонятно. Давайте по-другому.
1. Вот браузер послал запрос localhost:8080/index.html.
2. Срабатывает (index)
3. Возвращает data = (list :title "Index")
4. Срабатывает (restas:render-object drawer data)
5. Срабатывает (render-route-data drawer data index). index получается исходя из выражения (restas:route-symbol restas:*route*) - это текущий обработчик пути.
6. Срабатывает (restmax.view:index (:list :title "Index")). Этот метод генерируется из index.tmpl
7. content = то что получилось на шаге 6.
8. Срабатывает (finalize-page drawer content)
9. Срабатывает (restmax.view:finalize-page content) Этот метод генерируется из index.tmpl
10. Полученная строка отправляется обратно браузеру.

Теперь давайте определим маршрут который будет перенаправлять принятые данные в stdin maxima.

(restas:define-route execute ("execute"
                              :method :post)
  (when (not (hunchentoot:session-value :process))
    (hunchentoot:start-session)
    (setf (hunchentoot:session-value :process) (external-program:start "maxima" nil :input :stream :output :stream :error :stream)))
  (let* ((*standard-output* (external-program:process-input-stream (hunchentoot:session-value :process)))
         (*standard-input* (external-program:process-output-stream (hunchentoot:session-value :process)))
         (ajax-input (hunchentoot:post-parameter "input")))
    (write-line ajax-input)
    (finish-output)
    ;; Waiting a result
    ;; Hmm, bad style? 
    (sleep 0.2)
    (read-all)))

При определении мы задали тип запроса POST, кроме того запрос должен содержать параметр input.
Вначале проверяем была ли создана сессия. Если нет создаем ее, запускаем процесс maxima и сохраняем его идентификатор в сессии. Запуск процесса параллельно данному осуществляется функцией external-program:start.
Перенаправляем ввод/вывод, записываем принятые данные из запроса, обязательно дожидаемся записи (finish-output), и возвращаем браузеру результат (read-all).


Функция чтения stdout read-all выглядит так:

(defun read-all (&optional (input-stream *standard-input*))
  "Read all data from stream"
  (do ((output (make-array 0
                           :element-type 'character
                           :fill-pointer 0
                           :adjustable t)))
      ((not (listen input-stream)) output)
    (vector-push-extend (read-char input-stream) output)))

Теперь при удалении сессии необходимо завершить процесс maxima. Для этого служит hook-функция удаления сессии.

(defun session-remove-hook (session)
  "Hook for removing session event"
  (external-program:signal-process (hunchentoot:session-value :process session) :quit))

(setf hunchentoot:*session-removal-hook* #'session-remove-hook)

Теперь давайте займемся программированием клиентской части приложения. Скачайте и разместите jQuery в папку src/static/script.

ready.js


Содержит обработчик нажатия на клавишу посылки команды.

$(document).ready(function(){
  $('#button').click(function()
                     {
                       var input = $("#inputform textarea[name=input]").val();

                       var lastChar = input.charAt(input.length - 1);
                       // команда должна заканчиваться символом ; или $
                       if (lastChar == ";" || lastChar == "$")
                       {
                         // Добавляем на страницу введенное выражение                    
                         $("<pre />", {text:input}).appendTo("#output");
                         $.post(
                           "execute"
                           // Удобная штука сериализации всей формы в одну строку
                           , $("#inputform").serialize()
                           // Обработчик Ajax ответа
                           , function (data, textStatus) {
                             // Добавляем результат вычисления на страницу
                             $("<pre />", {text:data}).appendTo("#output");
                           }
                           , "text");
                       }
                     });
});

style.css

Кроме того немного настроим стили отображения.

#output {
    width: 700px;
}
.clickable {
    cursor:pointer;
}

Запуск


Для запуска воспользуемся в reple следующими командами:

* (push #p"~/путь/к/папке/restmax/" asdf:*central-registry*)
* (ql:quickload "restmax")
* (restas:start :restmax :port 8080)

Кстати мы могли бы не закачивать зависимости проекта ранее, так как Quicklisp сделал бы это автоматически на
этапе загрузки restmax.

Для просмотра результата перейдите по адресу http://localhost:8080/index.html

Решение системы линейных уравнений (СЛАУ)


Функция для решения системы линейных уравнений: linsolve
В качестве аргументов принимает два списка. Первый список содержит уравнения. Второй список переменных, относительно которых производить решение.

Использовать очень просто:




Осталось только вместо a,b,c подставить необходимые значения, или вместо x, y, z.

Заключение

В результате проведенной работы, конечно, остались открытыми некоторые вопросы, как то:

  • красивое отображение формул
  • вывод графиков
  • построение документов и встраивание в него символьных вычислений

но лиха беда начало. Существующие gui оболочки уже решили это вопросы, и можно будет воспользоваться их наработками.

Я хотел бы обратить ваше внимание на то, что процесс построения пусть простейшего, но web приложения не составляет большого труда на common lispе. Для этого нам потребовалось всего несколько шагов:

  1. определить серверную часть обработки маршрутов
  2. написать шаблоны и логику работы клиентской части
  3. ну и собственно запустить это все
API, который предоставляется пакетом restas очень лаконичен и прост в изучении даже десктоп-программисту. Он предоставляет всего лишь в общей сумме ~ 30 функций и переменных, а на нем уже написаны такие приложения как ресурс lisper.ru и блог archimag.lisper.ru.

Исходный код можно скачать здесь
Пожелания и критика приветствуются всячески.

7 комментариев:

  1. Да круто, что и говорить. Манипуляции с drawer и создание специализированных методов новичкам может показаться сложной (а зря, всё очень просто на самом деле, кроме того это даёт очень серьёзную гибкость для реальных веб-приложений), поэтому возможно стоило обойтись как-то без этого.

    ОтветитьУдалить
  2. Но это единственная сложная часть приложения:) Без нее не было бы смысла начинать повествование. Хотя я конечно позабыл углубиться в hunchentoot:session и в external-program, но там все проще простого.

    ОтветитьУдалить
  3. Ох, круто. Отличный туториал. Хорошо, что пишешь, а тов в Russian Lisp Planet очень редко последнее время записи появляются.

    Но есть пара замечаний, чтобы сделать урок ещё лучше:
    1) (setf *default-render-method* (make-instance 'drawer))
    по моему можно воспользоваться просто символом и eql-специализатором, а не целым пустым классом. Хотя, конечно, вопрос, что будет понятнее новичку.
    2) (defgeneric finalize-page (drawer data) ... )
    (defgeneric render-route-data (drawer data route) ... )
    Не совсем понятно, зачем городить огород из обобщённых функций, если тут можно спокойно обойтись простыми функциями. Да и параметр drawer нигде не используется.
    3)
    > 4. Срабатывает (defmethod restas:render-object drawer data)
    Срабатывает не defmethod, a (restas:render-object drawer data) определённый для нашего drawer.
    И вообще в списке из 10 пунктов непонятно, когда какая функция прекращает рабтать, лучше сделать его вложенным.


    Но это так, брюзжание. Отличный урок, пиши ещё. :)

    ОтветитьУдалить
  4. 1) Я просто архимаговский код использовал, чтобы и самому понятнее стало, и другие получили два туториала об одной теме.
    2) См. 1
    3) Спасибо, поправил.

    Спасибо, уже запланировал продолжение.

    ОтветитьУдалить
  5. И ещё по ссылке ничего не качается.

    ОтветитьУдалить
  6. Ага, гуглодокс поумолчанию приватно хранит данные.

    ОтветитьУдалить
  7. > Не совсем понятно, зачем городить огород
    > из обобщённых функций

    Смысл обобщённых функций в том, что при использовании как подключаемого модуля можно определить custom-drawer, наследующий от drawer, переопределить для него эти функции и связать объект этого класса с *default-render-method*. Это даёт более простую возможность настройки способа отображения.

    ОтветитьУдалить