Давно собирался рассказать немного о Noir – классном веб-фреймворке для Clojure. Наконец, у меня появилась возможность написать большой туториал по применению Noir, что я с удовольствием и сделаю :-)
Есть ли вообще необходимость в самоучителе по Noir? Не очень существенная, если честно. На сайте фреймворка (webnoir.org) более чем приличная документация: раздел для быстрого старта, несколько обучалок по использованию Noir и даже индекс API. Мой туториал, возможно, будет вам полезен, если вы, зная Clojure и разобравшись более-менее с Noir, все еще не очень представляете себе архитектуру своего будущего приложения.
Давайте посмотрим, на что похоже приложение 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
First body
Second title
Second body
Second body
Third title
Third body
Third body
(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]])])
(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 для просмотра записи.
(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)})
(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"]])
За время своего опыта общения с вебовыми оперденями пришел к тому, что вынес логику отображения на сторону клиента. Отдаю статиковую HTML страничку, на которую кладу данные средствами JS/Ajax. С одной стороны появилось больше кода на неправославном JS, с другой - полностью отсутствует вермишель во вьюшках.
ОтветитьУдалитьА сколько занимает один инстанс Noir в памяти?
ОтветитьУдалитьЭто очень интересный вопрос, который заслуживает отдельного поста. To be continued...
ОтветитьУдалить