02 марта 2011

Clojure Web REPL

Сегодня мы постараемся сделать web приложение предоставляющее repl к самой себе. Ну мы могли бы просто внедрить swank и подключаться к нему через slime. Но slime на клиентской машине может быть недоступен, а web броузер доступен почти всегда, и даже, хм, в телефоне.


Ну что? Раз на коньках мне сегодня покататься не пришлось, начнем.

Простой Clojure Web сервер с помощью Ring


Создание проекта


Создайте новый проект с помощью leiningen.

lein new webrepl
cd webrepl


Что мы видим? Файл проекта являющийся clojure программой. Папку src содержащую наш исходный код. Папку test, которую мы пока трогать не будем.
Давайте откроем файл проекта в emacs. Я это делаю так:

(emacs --daemon &)
emacsclient -n project.clj
# Можно и по-простому
emacs project.clj &


Нам понадобяться следующие пакеты:
  # "ring":https://github.com/mmcgrana/ring (только ring-core и ring-jetty-adapter)
  # "swank-clojure":https://github.com/technomancy/swank-clojure (для разработки)

Отредактируйте project.clj, добавив новые зависимости.

(defproject webrepl "1.0.0-SNAPSHOT"
  :description "FIXME: write"
  :dependencies [[org.clojure/clojure "1.2.0"]
                  [org.clojure/clojure-contrib "1.2.0"]
                  [ring/ring-core "0.3.6"]
                  [ring/ring-jetty-adapter "0.3.6"]]
  :dev-dependencies [[swank-clojure "1.3.0-SNAPSHOT"]])


Если Вы меня послушались, то у Вас в Emacs уже давно стоит elein. Просто не выходя из редактора: M+x elein-deps RET.
Иначе:

lein deps


Ну вот все готово к программированию. Вы когда-нибудь делали это также быстро?

h2. Запуск сервера

Откройте файл src/webrepl/core.clj
Добавьте загрузку модуля ring:

(ns webrepl.core
  (:use ring.adapter.jetty))


Как нам теперь сделать сайт? Все очень-очень просто. Мы должны определить функцию, которая принимает параметр request и возвращает некоторый response.
Request в Ring представлен следующей структурой:

h4. "Request Map":https://github.com/mmcgrana/ring/blob/master/SPEC#L34

Запрос содержит следующие ключи:

* :server-port (Required, Integer)
  Порт сервера

* :server-name (Required, String)
  Имя или IP адрес сервера

* :remote-addr (Required, String)
  IP адрес клиента или последнего из прокси-серверов.

* :uri (Required, String)
  URI запроса. Должен начинаться с "/".

* :query-string (Optional, String)
  Строка запроса, если он был.

* :scheme (Required, Keyword)
  Протокол, должен быть или :http или :https

* :request-method (Required, Keyword)
  HTTP метода, должен быть
  ** :get
  ** :head
  ** :options
  ** :put
  ** :post
  ** :delete

* :content-type (Optional, String)
  MIME-тип содержимого запроса, если известен.

* :content-length (Optional, Integer)
  Длина содержимого запроса, если известно.

* :character-encoding (Optional, String)
  Кодировка содержимого запроса, если известно.

* :headers (Required, IPersistentMap)
  Clojure словарь. Ключи - строковые идентификаторы заголовков. Значения - строковые значения идентификаторов заголовков.

* :body (Optional, InputStream)
  Входящий поток содержимого запроса, если он был представлен.

h4. "Response Map":https://github.com/mmcgrana/ring/blob/master/SPEC#L89

Ответ должен ббыть представлен Clojure словарем со следующими ключами и значениями:

* :status (Required, Integer)
  Код HTTP ответа, должен быть больше или равен 100.

* :headers  (Required, IPersistentMap)
  Clojure словарь содержащий иденификаторы HTTP заголовка и их значения. То же что и в запросе.

* :body (Optional, {String, ISeq, File, InputStream})
  Содержимое ответа может быть представлено обычной строкой, последовательностью, файлом или потоком.
  ** String:
    Отсылается как-есть.
  ** ISeq:
    Каждый елемент последовательности отсылается клиенту как строка.
  ** File:
    Содержимое из указанного места отсылается клиенту. Сервер может использовать более оптимизированные методы посылки, когда они возможны.
  ** InputStream:
    Содержимое заполняется из потока и отсылается клиенту. Когда поток заканчивается, он закрывается.

Давайте наконец сделаем функцию, все-таки функциональным программированием занимаемся.
Вот она:

(defn application
  [request]
  {:status  200
    :headers {"Content-Type" "text/html"}
    :body "

Hello World

"})


Все просто. Вернули словарик с ключами :status :headers и :body.

А давайте теперь заведем переменную, которая будет содержать запуск сервера. Запуск сервера в свою очередь производиться функцией "run-jetty":http://mmcgrana.github.com/ring/adapter.jetty-api.html#ring.adapter.jetty/run-jetty.
Первый параметр функция-обработчик запросов. Второй - словарь с опциями.
Опции:
  :configurator   - Функция, которая будет вызвана с экземпляром объекта сервера.
  :port
  :host
  :join?          - Блокировать, по умолчанию true
  :ssl?           - Использовать SSL
  :ssl-port       - SSL порт, по умолчанию 443
  :keystore
  :key-password
  :truststore
  :trust-password

Вот как мы ею воспользуемся:

(defonce server-thread
  (run-jetty
    (var application)
    {:port 8080 :join? false}))


Специальная форма (var переменная) позволяет, нам менять переменную, в нашем случае application, и новое определение будет автоматически использовано при обработке следующего запроса.

Запускаем repl.
Для счастливых обладателей elein: M+x elein-swank RET. Для трудолюбивых:

lein swank


Затем уже в Emacs

M+x slime-connect RET RET


Перейдите в REPL и выполните:

; SLIME 2011-02-13
user> (use 'webrepl.core)
nil
user> (.start server-thread)
nil
user> 


Переменная server-thread глубоко внутри явы реализуется интерфейс callable, и мы с помощью формы (.имя-метода объект) вызываем метод start.

Теперь откройте "http://127.0.0.1:8080":http://127.0.0.1:8080 . Вуаля, первое web приложение готово. Однако мы на этом не остановимся.

Сделаем функцию отдельно от application, и последнюю сошлем на нее. Перейдите в буфер src/webrepl/core.clj:

(defn not-found
  [request]
  {:status 200
    :headers {"Content-Type" "text/html"}
    :body "

Page not found

"}) (defn application not-found)


Мы завели новую функцию not-found, она возвращает "Страница не найдена" и присвоили ее переменной application.

Нажмите C+c C+k. Это компиляция нашего webrepl.core. "http://127.0.0.1:8080":http://127.0.0.1:8080 обновите страницу в броузере. Ого, кажеться мы ничего не перезапускали, а поведение изменилось.

А давайте теперь сделаем middleware-функцию (затрудняюсь красиво перевести слово). Она будет в первом параметре принимать функцию-обработчик и возвращать тоже функцию-обрабочик, которая выполняет что-то промежуточное и вызывает обработчик переданный в первом параметре. На деле все выглядит просто.

(defn wrap-json-rpc
  [handler]
  (fn [request]
    (merge (handler request) {:body "

Hello from middleware

"}))) (def application (wrap-json-rpc not-found))


Здесь, внимание, вы пронаблюдали "замыкание". Анонимная функция "fn [request]" захватила переменную-аргумент "handler". Если говорить "паттернами", то функция wrap-json-rpc является фабрикой других функций на основе параметра handler.

Ну как обычно: C+c C+k. Смотрим результаты в броузере "http://127.0.0.1:8080":http://127.0.0.1:8080

Теперь давайте используем макрос ->. Протестируйте его в REPL.

user> (use 'clojure.walk)
nil
user> (macroexpand-all '(-> a b (c d)))
(c (b a) d)
user> (macroexpand-all '(-> handler wrapper1))
(wrapper1 handler)
user> (macroexpand-all '(-> handler wrapper1 wrapper2))
(wrapper2 (wrapper1 handler))
user> (macroexpand-all '(-> handler wrapper1 wrapper2 (wrapper3-params a b c) wrapper4))
(wrapper4 (wrapper3-params (wrapper2 (wrapper1 handler)) a b c))
user> 


clojure.walk модуль, который содержит функцию macroexpand-all, рекурсивно разворачивающую макросы.

Макрос -> берет каждый элемент списка и вставляет его как первый параметр для последующего элемента.

Подключите модуль ring.middleware.file.

(ns webrepl.core
  (:use ring.adapter.jetty)
  (:use ring.middleware.file))


Оберните свои обработчики так:

(def application
  (->
    not-found
    wrap-json-rpc
    (wrap-file "/" {:root "./public"} )))


Данная форма развернется в следующую форму:

(def application
  (wrap-file (wrap-json-rpc not-found) "/" {:root "./public"}))


Символ аpplication будет указывать на анонимную функцию, которую возвращает функция wrap-file. wrap-file первым параметром принимает обработчик, вторым URI, на котором необходимо оборачивать файлы. В опциях :root указывается: где в файловой системе располагаются файлы.

Создадим папку public в корне проекта, и там создадим какой-нибудь файл. Ну hello.txt с текстом:

I'm file.


По данной ссылке "http://127.0.0.1:8080/hello.txt":http://127.0.0.1:8080/hello.txt мы должны увидеть "I'm file".

Создание qooxdoo приложения


Но и на этом мы не остановимся. Скачайте себе последнюю версию qooxdoo. Это javascript фреймворк для построения сайтов прям, как десктоп приложений. Почему он, а не sencha, ui-jquire, yahoo gui? Потому что он похож на Qt, что обеспечивает мне быстрое понимание, свободен для коммерции, имеет виджеты таблица, дерево, и поддерживается стабильным сообществом.

Расположим qooxdoo на одном уровне с проектом.

cd ..
git clone --depth 1 https://github.com/qooxdoo/qooxdoo.git


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

(def application
  (->
    not-found
    wrap-json-rpc
    (wrap-file "/" {:root "./../qooxdoo"} )))


Откройте "http://127.0.0.1:8080/qooxdoo":http://127.0.0.1:8080/qooxdoo . Почитайте, скомпилируйте документацию, примеры по желанию. Вкратце скажу, что проект на qooxdoo надо создать с помощью пайтон скрипта, а затем компилировать в debug или release версию с помощью generate.py скрипта, который будет в самом проекте. Это конечно на первый взгляд неудобно, и на второй, и на третий взгляды мнение не меняется, но что делать.

cd qooxdoo/qooxdoo
./tool/bin/create-application.py -n webrepl -o application
cd application/webrepl


Ну вот мы и в qooxdoo проекте. Мы его создали командой create-application с параметрами -n имя проекта -o директория

.
|-- config.json -- Настройки сборки
|-- generate.py -- Скрипт для сборки
|-- Manifest.json -- Метаинформация о проекте
|-- readme.txt -- Ну как же без него
`-- source -- Вот здесь, что интересно, будут и исходный код и debug сборка
    |-- class -- Вот он исходный код
    |   `-- webrepl -- Как всегда пошли пространства имен в виде директорий
    |       |-- Application.js -- Класс webrepl.Application
    |       |-- simulation -- Не интересно
    |       |   `-- DemoSimulation.js -- Не интересно
    |       |-- test -- Не нужно, наш код идеален
    |       |   `-- DemoTest.js
    |       `-- theme -- Пока не интересно
    |           |-- Appearance.js
    |           |-- Color.js
    |           |-- Decoration.js
    |           |-- Font.js
    |           `-- Theme.js
    |-- index.html -- Debug версия сборки
    |-- resource -- Ресурсы
    |   `-- webrepl -- Наши
    |       `-- test.png
    |-- script -- Это загрузчик нашей javascript программы
    |   `-- webrepl.js
    `-- translation -- Ну и, конечно, локализация
        `-- readme.txt


Давайте сразу его настроим и доработаем.

В файле config.json в объект let добавьте поле QOOXDOO_URI равное "../../.." .
Теперь давайте создадим новый класс webrepl.MainWindow. Файл для него должен быть в папке source/class/webrepl и называться MainWindow.js.

qx.Class.define("webrepl.MainWindow", {
  extend: qx.ui.window.Window,
  construct: function() {
    this.base(arguments, "Web REPL");
  }
});


В файл Application.js добавьте создание и показ нашего окна. Я думаю догадаетесь куда.

var main = new webrepl.MainWindow();
main.open();


Компиляция qooxdoo проекта производится скриптов в корне проекта.

./generate.py source


Теперь откройте "http://127.0.0.1/qooxdoo/application/webrepl/source":http://127.0.0.1/qooxdoo/application/webrepl/source/

А теперь давайте думать, как нам соединить кнопочки из броузера на javascript с web приложением на clojure. Скомпилируем документацию по qooxdoo

cd qooxdoo/qooxdoo/framework/
./generate.py api


"http://localhost:8080/qooxdoo/framework/api/index.html#qx":http://localhost:8080/qooxdoo/framework/api/index.html#qx

Теперь мы вооружены до зубов. После некоторого изучения документации, я решил, что лучшим механизмом соединения GUI с web приложением будет RPC механизм.
На clojure мы сделаем некоторый набор функций, а на javascript мы будем их вызывать в зависимости от событий от пользователя. Транспортом для вызовов будет служить JSON-RPC. JSON формат очень прост, практичен для javascript, ну и вполне поддерживается в clojure.

Почитать по теме нужно следующие ссылки:
"json":http://en.wikipedia.org/wiki/JSON
"json-rpc-2.0":http://groups.google.com/group/json-rpc/web/json-rpc-2-0
"clojure-json":http://clojuredocs.org/clojure_contrib/clojure.contrib.json
"qooxdoo-json-rpc":http://localhost:8080/qooxdoo/framework/api/index.html#qx.io.remote.Rpc

Расскажу вкратце.
Запрос:

{
  "jsonrpc": "2.0",
  "id" : "идентификатор запроса",
  "service" : "имя-сервиса",
  "method" : "функция",
  "params" : ["param1", "param2"]
}


Оповещение:

{
  "jsonrpc": "2.0",
  "service" : "имя-сервиса",
  "method" : "функция",
  "params" : ["param1", "param2"]
}


Ответ:

{
  "id" : "идентификатор ответа должен быть такой же как и запроса",
  "result" : "результат, может быть простым типом, массивом или объектом.",
  или объект ошибки 
  "error" : {
    code: "код ошибки",
    message: "сообщение",
    data: "дополнительные данные"
  }
}


Итак давайте отредактируем MainWindow.js.

qx.Class.define("webrepl.MainWindow", {
  extend : qx.ui.window.Window,
  construct : function()
  {
    // Вызываем базовый конструктор
    this.base(arguments, "Web REPL");
    // Изменение размера окна
    this.setWidth(250);
    this.setHeight(300);
    // Добавляем форматирование по сетке
    var layout = new qx.ui.layout.Grid(0, 0);
    this.setLayout(layout);

    // Добавляем список
    this.list = new qx.ui.form.TextArea();
    this.add(this.list, {row: 0, column : 0, colSpan: 2});
    this.list.setValue("Welcome to Clojure Web REPL");

    // Текстовое поле для команды
    this.command = new qx.ui.form.TextArea();
    this.add(this.command, {row: 1, column: 0});
    this.command.setPlaceholder("Enter clojure code here");

    // кнопка «Post»
    var eval = new qx.ui.form.Button("eval");
    this.add(eval, {row: 1, column: 1});

    //Автозаполнение виджетами всего пространства
    layout.setRowFlex(0, 10);
    layout.setRowFlex(0, 1);
    layout.setColumnFlex(0, 1);
    
    // Подключаем слот _onEval к событию execute кнопки eval
    eval.addListener("execute", this._onEval, this);

    // Создаем shortcut ctrl+enter
    var evalkey = new qx.ui.core.Command("Ctrl+Enter");
    evalkey.addListener("execute", this._onEval, this);

    // Создаем объект для вызова удаленный функций из пространства имен clojure.core.
    this.rpc = new qx.io.remote.Rpc("/api/json", "clojure.core");

    // Подключаемся к сигналам
    this.rpc.addListener("completed", this._onCompleted, this);
    this.rpc.addListener("failed", this._onFailed, this);
  },
  members: {
    _onEval : function() {
      // Вызываем удаленную процедуру load-string с параметрами
      this.rpc.callAsyncListeners(true, "load-string", "(str *ns* \"=>\" \"" + this.command.getValue().replace(/"/g, "\\\"") + "\" \\newline " + this.command.getValue() + ")");
    },
    _onCompleted: function(event) {
      // Вызов завершился успешно
      this.command.setValue("");
      this.list.setValue(this.list.getValue() + "\n" + event.getData()["result"]);
    },

    _onFailed: function(event) {
      // Произошла ошибка во время вызова
      this.list.setValue(this.list.getValue() + "\nError:\n" + event.getData());
    }
  }
});


В данном случае мы обрабатываем нажатие на клавишу и отправляем на сервер по адресу /api/json все, что напечатал пользователь. Но структура пакета у нас получается хитрая.

{
  "jsonrpc": "2.0",
  "id" : "какое-то число",
  "service" : "clojure.core",
  "method" : "load-string",
  "params" : ["все, что напечатал пользователь"]
}


Теперь нам на сервере нужно обработать запрос по адресу /api/json, преобразовать содержимое запроса из JSON в структуры и типы Clojure, а затем в пространстве имен service выполнить method с параметрами params.

JSON RPC сервер на Clojure


Подключите модуль clojure.contrib.json. Для конвертации clojure map -> json мы будет использовать json-str, для json -> clojure map - read json

(ns webrepl.core
  (:use ring.adapter.jetty)
  (:use ring.middleware.file)
  (:use clojure.contrib.json))


Давайте начнем с определения того, что нам пришло в запросе и является ли это json-rpc запросом. Это будет выглядеть в виде такого предиката:

(defn json-rpc-request?
  [request]
  (and (= "application/json" (:content-type request)) (= "/api/json" (:uri request))))


Теперь давайте определим middleware-функцию для перехвата JSON.

(defn wrap-json-rpc
  [handler]
  (fn [request]
    (if-let [body (and (json-rpc-request? request) (:body request))]
      (json-response
        (try
          (let [bstr (slurp body)
                 json (read-json bstr)]
            (json-rpc-server json))
          (catch Exception e {:error {:origin 0 :code 0 :message (str e)}})))
      (handler request))))


Переводя на русский язык:

Если json-rpc-request? вернула true и request :body что-то содержит, то завести переменную body, указывающую на :body request.
    Попытаться
      Прочесть все что в body в переменную bstr
        Отпарсить json из bstr в json
        Вызвать rpc-handler с параметром json
    При ошибке вернуть {:error {:origin 0 :code 0 :message "exception" }}
Иначе вызвать handler с параметрами request.


Давайте определим функцию json-response. Она не сложная:

(defn json-response
  [json]
  {:status 200
    :headers {"Content-Type" "application/json"}
    :body (json-str json)})


А теперь самое вкусное. Функция, которая вызывает то, что просит front-end.

(defn json-rpc-server
  [json]
  (let [{:keys [id service method params]} json]
    {:id id
      :result (apply (resolve (symbol service method)) params)}))


(let [{:keys [id service method params]} json]
Это строка деструктуризации Clojure словаря. Из структуры json в переменные копируются значения одноименных ключей. Подробнее "здесь":http://clojure.org/special_forms . Если перефразировать:
(let [id (:id json) service (:service json) method (:method json) params (:params json)])

Далее мы возвращаем структуру, в качестве значения ключа result которой выступает результат такой формы (apply (resolve (symbol service method)) params). Для того чтобы лучше понять выполните в repl следующее:

user> (symbol "+")
+
user> (symbol "clojure.core" "+")
clojure.core/+
user> (resolve (symbol "clojure.core" "+"))
#'clojure.core/+
user> (apply (resolve (symbol "clojure.core" "+")) [1 2 3])
6
user> (symbol "clojure.core" "load-string")
clojure.core/load-string
user> (resolve (symbol "clojure.core" "load-string"))
#'clojure.core/load-string
user> (apply (resolve (symbol "clojure.core" "load-string")) ["(+ 1 2 3)"])
6
user>


Функция symbol возвращает символ для заданных пространства имен и идентификатора.
resolve возвращает переменную или класс, на который указывает символ.
apply вызывает функцию с заданными параметрами.

Ну вот теперь выполните C+c C+k для буфера Emacs и скомпилируйте qooxdoo проект.

Вот так выглядит наш простой web repl.

Комментариев нет:

Отправить комментарий