воскресенье, 8 января 2012 г.

CRUD на Clojure средствами Noir


Давно собирался рассказать немного о Noir – классном веб-фреймворке для Clojure. Наконец, у меня появилась возможность написать большой туториал по применению Noir, что я с удовольствием и сделаю :-)

Есть ли вообще необходимость в самоучителе по Noir? Не очень существенная, если честно. На сайте фреймворка (webnoir.org) более чем приличная документация: раздел для быстрого старта, несколько обучалок по использованию Noir и даже индекс API. Мой туториал, возможно, будет вам полезен, если вы, зная Clojure и разобравшись более-менее с Noir, все еще не очень представляете себе архитектуру своего будущего приложения.

А задуматься над архитектурой придется. Noir очень сильно отличается от всех известных мне фреймворков. При своем минимализме он оставляет выбор конкретных библиотек за пользователем – такая свобода может привести к неверному их использованию. Насколько этот выбор широк можете судить хотя бы по статье с обзором разных компонентов для каждого из уровней веб-стека на clojure.

Давайте посмотрим, на что похоже приложение Noir. Если вы проследовали инструкциям из Getting Started на сайте webnoir.org, то у вас уже установлен leiningen-плагин для Noir. Создадим новый проект командой lein noir new optimalist. У него очень простая структура.
resources – содержит каталог public со статическим контентом (css, javasript, images);
src – каталог с исходниками;
test – тесты;
project.clj – описание проекта и версий подключаемых библиотек.

Исходники содержаться в двух каталогах src/optimalist/views и src/optimalist/models. Никаких вопросов не вызывает? А как насчет src/optimalist/controllers? Такого каталога нету, т. к. самописных контроллеров здесь не предусмотрено. Это, конечно, у многих может вызвать разрыв шаблона: как вообще программировать MVC без C!

Автор Noir Крис Гранжер очень подробно обосновал свое представление архитектуры. Исторически сложилось, что бизнес-логику обычно помещали в контроллер. Но со временем её все больше и больше выносили в отдельные компоненты, контроллер стал совсем тоненьким и, откровенно говоря, ненужным. Функции маршрутизации берет на себя фреймворк, так что в Noir (да и в других современных веб-фреймворках) строгой необходимости в отдельных контроллерах попросту нету. Единственное, что всё еще делает контроллер – это указывает, какую страницу рендерить в ответ на запрос пользователя. Раз уж код контроллера такой маленький, то Крис Гранжер попросту решил перенести функции контроллера на уровень View.

Если сравнивать такой подход с другими фреймворками, то, пожалуй, Крис – один из первых, кто открыто решился заявить о ненужности контроллеров. Например, в практике программирования Ruby on Rails всё еще считается правильным всю бизнес логику вынести на уровень модели, а контроллеры сделать маленькими и легковесными. Это очень упрощает тестирование. Но это половинчатый подход: выбросили бы контроллеры да и дело с концом! Скажем, в JSF2.0 примерно так и обстоят дела. Вся бизнес-логика находится в Java-бинах, вызываются они с уровня View напрямую, минуя слой контроллера. Маршруты привязываются к страницам обычно централизованно в специальном файле конфигурации.

Поскольку каталог models пустой, давайте взглянем повнимательнее на содержимое каталога views.

В файле common.clj создается специальная функция layout. Она задает общий шаблон страницы, заголовки и структуру html-файла.

В файле welcome.clj при помощи defpage задается маршрут «/welcome» и функция, которая сгенерирует ответ на запрос пользователя по этому маршруту. Как видно из кода, функция вернет строку приветствия. Шаблон html-страницы создается при помощи библиотеки Hiccup. Она позволяет писать html-код прямо на языке clojure при помощи родных для clojure структур данных: векторов и мапов.

Hiccup очень удобен тем, что позволяет производить любые манипуляции с шаблоном прямо при помощи Clojure. Заметьте, такой подход вообще не встречается среди мейнстримных фреймворков. Что в ASP.NET, что в J2EE для разметки страницы применяется свой собственный DSL, построенный на базе XHTML. Как, например, сделать повторяющийся фрагмент страницы на JSF2.0, на которой еще есть экшены? Правильно, с применением ломика и какой-то матери. В ASP.NET с этим, вроде, несколько проще, но подход такой же: xml-теги. Ну и скажите мне, как программисту Java, почему я не могу использовать Java для изменения шаблона веб-страницы? Как ни крути, но даже Java... хотя нет, уж лучше этот долбаный XML, чем веб-страницы на Java...

Короче, в Noir используется hiccup, а hiccup позволяет писать html-страницы на clojure в декларативном стиле.

Вручную hiccup-код имеет смысл писать только для страниц очень небольшого размера. Если вам досталась от дизайнера монстрозная страница в десятки килобайт кода – конечно, такое поделие переписывать на hiccup не стоит. В этом вам поможет clj-tagsoup. Просто вызовите его функцию parse с указанием html-страницы и сохраните полученный hiccup-код.

Почти все руководства о веб-фреймворках в виде первого учебного приложения предлагают свой вариант блога. Давайте и мы сделаем свой простой блог при помощи Noir. Переименуем welcome.clj в article_pages.clj (а также namespace) и напишем код первой страницы блога – списка записей. Еще я немного изменил список импортируемых библиотек.
(ns optimalist.views.article_pages
  (:require [optimalist.views.common :as common]
            [noir.response :as resp])
  (:use [noir.core]
        [hiccup.core]))

(defpage articles "/articles" []
         (common/layout
           [:p "List of articles"]))
Обратите внимание, что маршрут «/articles» – именованный. В будущем я смогу получить его при помощи (url-for articles).

Запустите веб-приложение командой lein run и откройте его по ссылке localhost:8080/articles. Разумеется, вы увидите текст «List of articles». Но пока что еще не работает корневой маршрут: localhost:8080/. Давайте добавим для него определение маршрута.
(defpage "/" []
  (resp/redirect (url-for articles)))
Как видите, здесь объявляется неименованный маршрут «/», и вместо ответа на него сервер редиректит на страницу articles.

Следующая проблема: как вывести список каких-либо элементов? Поскольку код шаблона мы пишем на clojure, то воспользуемся функцией for:
(defpage articles "/articles" []
  (let [data [{:id 1, :title "First title", :body "First body"}
              {:id 2, :title "Second title", :body "Second body"}
              {:id 3, :title "Third title", :body "Third body"}]]
         (common/layout
          [:p "List of articles" [:br] [:br]
           (for [item data]
             [:div
              (:title item)
              [:br]
              (:body item)
              [:br][:br]])])))

Пока что все достаточно просто, не так ли? Страница articles покажет нам следующий текст:

List of articles
First title
First body

Second title
Second body

Third title
Third body

Теперь данные нужно перенести в отдельный файл. Создадим в каталоге models файл article_model.clj, добавим туда код:
(ns optimalist.models.article_model)

(def *data*
  (ref
   [{:id "1", :title "First article", :body "Lorem ipsum dolor sit amet."}
   {:id "2", :title "Second article", :body "Lorem ipsum dolor sit amet."}
   {:id "3", :title "Third article", :body "Lorem ipsum dolor sit amet. "}
   {:id "4", :title "Fourth article", :body "Lorem ipsum dolor sit amet."}]))

(defn fetch-list []
  @*data*)
Воспользуемся этой моделью. Чтобы импортировать модель, добавим в секцию require такую строку: [optimalist.models.article_model :as article-model]; а код функции articles перепишем таким образом:
(defpage articles "/articles" []
  (common/layout
   [:p "List of articles" [:br] [:br]
    (for [item (article-model/fetch-list)]
      [:div
       (:title item)
       [:br]
       (:body item)
       [:br][:br]])]))
Вообще такая организация кода всё еще не очень удобная. Что если будет несколько страниц с похожим функционалом? Нам придется выносить hiccup-код в отдельные модули чтобы сделать его reusable. Давайте сделаем это сразу. Создатим файл view/article_templates.clj и вынесем туда генерацию httml.
(ns optimalist.views.article_templates
  (:use [noir.core]
        [hiccup.core]))

(defn show-list [articles-list]
   [:p "List of articles" [:br] [:br]
    (for [item articles-list]
      [:div
       (:title item)
       [:br]
       (:body item)
       [:br][:br]])])
В модуле article_pages.clj необходимо добавить строку [optimalist.views.article_templates :as article-view] в секцию :require. А страница articles тогда будет выглядеть так:
(defpage articles "/articles" []
  (common/layout
   (article-view/show-list (article-model/fetch-list))))
Следующая наша задача – добавить к элементам списка ссылки на действия. Лично я – большой любитель технологии REST, о которой узнал из Ruby on Rails. Наши четыре действия над сущностью create, read, update, delete подразумевают наличие семи маршрутов:

Маршрут Метод Описание
/articles GET Список записей
/article/:id GET Просмотреть одну запись
/article/edit/:id GET Показать форму редактирования записи
/article/update/:id POST Изменить запись в соответствии с указанными данными
/article/delete/:id POST Удалить запись
/article/new GET Показать пустую форму для создания записи
/article/create
POST
Создать запись в соответствии с указанными данными

Вообще в REST должны использоваться методы не только GET и POST, а также PUT (для создания новой записи) и DELETE (для удаления). Но то ли браузеры не понимают этих методов, то ли веб сервера, но факт остается фактом: использовать мы можем только GET и POST. В Ruby on Rails для обхода этого ограничения создается скрытое поле. И если указывается метод PUT или DELETE, то на самом деле используется POST с пометкой в скрытом поле, что это все-таки PUT/DELETE.

Разные методы отправки запроса нужно использовать не только для красоты. По сети ползают роботы-индексаторы, которые пытаются перейти по ссылкам на другие страницы. Представьте, например, что на вашем сайте кнопка DELETE сделана при помощи обыкновенной ссылки (запрос методом GET), и робот решит по ней перейти. Робот ведь не знает, что эта ссылка приводит к какому-то разрушительному действию, правда? Вот для этого-то все запросы, как-нибудь изменяющие сущность, должны быть отправлены методом POST.

Итак, первая ссылка, которую я хотел бы добавить к списку сущностей – маршрут /article/:id для просмотра записи.

Вначале в article_pages добавим страницу:
(defpage view-article "/article/:id" {id :id}
  (common/layout
   "View article"))

Она пока ничего не делает, но зато её сразу же можно протестить в браузере по маршруту /article/1. Заметьте, что в определении страницы задан хеш параметров {id :id}. Это деструктуризаця параметров запроса, и делает она именно то, на что похоже: создает локальную переменную id, значение которой будет получено из части маршрута :id.

Теперь добавим ссылку на эту страницу в функции show-list
(defn show-list [articles-list]
   [:p "List of articles" [:br] [:br]
    (for [item articles-list]
      [:div  (:title item) [:br]
       (:body item) [:br]
       [:a {:href (url-for (str "/article/" (:id item)))} "View"]
       [:br][:br]])])
Функция url-for умеет создавать абсолютный URL по заданным параметрам. Параметром может быть как текстовая строка с маршрутом, так и сам именованный маршрут. И хотя предложенное выше решение – вполне работоспособное, но оно все-таки не оптимальное. Что если вы решите изменить URL в странице view-article? Тогда вам придется искать все места, где вы на него ссылаетесь, и править маршруты вручную. Лучше использовать именованый маршрут следующим образом:

(url-for optimalist.views.article_pages/view-article {:id (:id item)})

Здесь optimalist.views.article_pages/view-article – полное имя именованного маршрута. Необходимо писать его целиком, т. к. в файле article_templates.clj ничего не известно о aricle_pages.clj. И импортировать article_pages.clj мы тоже не можем, т. к. там уже есть импорт article_templates.clj, и мы получим циклическую зависимость. Поэтому, все-таки, придется писать целиком.

Второй параметр – хеш значений, которые будут переданы в генератор маршрута. Поскольку наш маршрут выглядит как «/article/:id», то ему нужно передать хеш {:id <значение>}.

Вообще, конечно же, такая запись жутко громоздкая и неудобная. Поэтому в начало файла article_templates.clj, после объявления неймспейса, добавим алиас:

(alias 'article 'optimalist.views.article_pages)

Теперь генерацию ссылки можно переписать таким образом:

(url-for article/view-article {:id (:id item)})

Это уже значительно лучше, но все еще пока не идеально. Элементов типа «ссылка» у нас будет достаточно много, особенно, если много разных сущностей. Но типов ссылок всего несколько: new, view, edit, delete, back. Мы могли бы сделать более абстрактный механизм генерации ссылок, который инкапсулирует в себе всю внутреннюю структуру вектора [:a …], в том числе и надпись на ссылке. Создадим файл views/helpers/widget.clj: 
(ns optimalist.views.helpers.widget
  (:require [noir.core]))

(defmacro view-link [page id]
  `(vec [:a
     {:href (noir.core/url-for ~page {:id ~id})} "View"]))
Теперь его можно подключить в article_templates.clj, добавив строку [optimalist.views.helpers.widget] в секцию :use. А вместо записи [:a {href … } «View»] вставим вызов макроса: (view-link article/view-article (:id item)).

Макрос здесь нужен из-за параметра page – символа, к которому привязан именованный маршрут. Конечно, его можно было бы передавать закавыченным, но это не очень-то красиво.

Сам макрос пока-что не слишком удачный. Что если нам понадобится добавить еще несколько похожих ссылок? Copy-paste – не наш метод, поэтому немного расширим наш язык для данного конкретного случая.

Это очень интересная технология, описанная Полом Грэмом в книге «On Lisp», и называется она «bottom-up programming». Идея заключается в том, что вы не только пишете свое приложение, используя Lisp, но и изменяете Lisp, чтобы легче было писать ваше предложение. Снизу, в язык вводятся новые конструкции, упрощающие реализацию приложения. Сверху, приложение описывается при помощи ваших новых конструкций, что делает код значительно короче. Отсюда и bottom-up programming.

Новые конструкции, которые мы вводим в язык, называются утилитами. Давайте добавим две таких:
(defmacro common-url [page id]
  `(noir.core/url-for ~page {:id ~id}))

(defmacro common-link [url text & params]
  `(vec [:a (merge {:href ~url} ~@params) ~text]))
Первый макрос просто инкапсулирует знание о том, как создавать URL. Теперь нам не надо заботиться о том, чтобы дописывать noir.core к url-for, а также создавать хеш для единственного занчения id.

Второй макрос создает html-ссылку с заданным текстом на указанный URL. Необязательный параметр params – это возможные параметры самой ссылки. Например, если нам потребуется задать обработчик onclick, то мы сможем сделать это через params.

Перепишем текст генерации ссылки с применением двух новых утилит:
(defmacro view-link [page id]
  `(common-link (common-url ~page ~id) "View"))
  
(defmacro edit-link [page id]
  `(common-link (common-url ~page ~id) "Edit"))
 Итак, на данный момент, у нас есть список записей и ссылка на просмотр каждой записи. Давайте теперь реализуем функцию view-article:
(defpage view-article "/article/:id" {id :id}
  (common/layout
   (article-view/show
    (article-model/fetch id))))
Чтобы она заработала, нужно в article_templates добавить функцию show:
(defn show [article]
  [:div
   [:h1 (:title article)]
   [:div (:body article)]
   [:br]
   [:a {:href (url-for article/articles)} "Back"]]) 
В article_model необходимо добавить функцию fetch:
(defn fetch [id]
  (first
   (filter #(= id (:id %)) @*data*)))
На этом, пожалуй, все сложности и закончились. Дальше, по накатанной. Проект целиком вы можете найти здесь, а вот как выглядят два основных файла проекта.

article_pages.clj

(ns optimalist.views.article_pages
  (:require [optimalist.views.common :as common]
            [noir.response :as resp]
            [optimalist.views.article_templates :as article-view]
            [optimalist.models.article_model :as article-model])
  (:use [noir.core]))

(defpage "/" []
  (resp/redirect (url-for articles)))

(defpage articles "/articles" []
  (common/layout
   (article-view/show-list (article-model/fetch-list))))

(defpage view-article "/article/:id" {id :id}
  (common/layout
   (article-view/show (article-model/fetch id))))

(defpage edit-article "/article/edit/:id" {id :id}
  (let [item (article-model/fetch id)]
    (common/layout
     (article-view/form (url-for update-article {:id id}) "Update" (:title item) (:body item)))))

(defpage update-article [:post "/article/update/:id"] {:keys [id title body]}
  (article-model/update id title body)
  (resp/redirect (url-for view-article {:id id})))

(defpage new-article "/article/new" []
  (common/layout
   (article-view/form (url-for create-article) "Create")))

(defpage create-article [:post "/article/create"] {:keys [title body]}
  (article-model/create title body)
  (resp/redirect (url-for articles)))

(defpage delete-article [:post "/delete/:id"] {id :id}
  (article-model/delete id)
  (resp/redirect (url-for articles)))

article_templates.clj

(ns optimalist.views.article_templates
  (:require [optimalist.views.helpers.widget :as widget])
  (:use [noir.core]
        [hiccup.core]))

(alias 'article 'optimalist.views.article_pages)

(defn show [article]
  [:div
   [:h1 (:title article)]
   [:div (:body article)]
   [:br]
   [:a {:href (url-for article/articles)} "Back"]])

(defn list-item [item]
  (let [id (:id item)]
    [:div [:div (:title item)]
     (:body item)
     [:form {:id (str "form-" id) :method "POST", :action (url-for article/delete-article {:id id})}
      [:div
       (widget/view-link article/view-article id) " | "
       (widget/edit-link article/edit-article id) " | "
       (widget/delete-link id)]]
     [:br][:br]]))

(defn show-list [articles-list]
  [:div
   (for [item articles-list] 
     (list-item item))
   [:a {:href (url-for article/new-article)} "New"]])

(defn form [url button-text & [title body & rest]]
  [:form {:method "POST", :action url}
   [:input {:name "title", :type "text", :value title}]
   [:br]
   [:textarea {:name "body"} body]
   [:br]
   [:input {:type "submit", :value button-text}]
   [:br]
   [:a {:href (url-for article/articles)} "Back"]])

3 комментария:

  1. За время своего опыта общения с вебовыми оперденями пришел к тому, что вынес логику отображения на сторону клиента. Отдаю статиковую HTML страничку, на которую кладу данные средствами JS/Ajax. С одной стороны появилось больше кода на неправославном JS, с другой - полностью отсутствует вермишель во вьюшках.

    ОтветитьУдалить
  2. А сколько занимает один инстанс Noir в памяти?

    ОтветитьУдалить
  3. Это очень интересный вопрос, который заслуживает отдельного поста. To be continued...

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