Тем кто настроен на саморазвитие, через постижение кода в обход чтения статьи — прошу в конец страницы за ссылкой проекта на Github.
Введение
И так начнем по порядку. Чтобы вам было интереснее, я решил выбрать более-менее прикладную направленность статьи. Сегодня мы создадим простое веб-приложение на Clojure, сие будет управлять заметками. Предполагаю, что у вас уже установлены Leiningen, Clojure и MongoDB (не забудьте его включить). Львиная доля содержания находится непосредственно в комментариях в коде, который для вашего удобства скрыт в спойлеры.
IDE
Для Clojure есть много разных редакторов и IDE, в этой статье я не стану приводить их плюсы и минусы, пуще вообще некогда не стану. У всех разные предпочтения, что использовать решать только вам. Я использую LightTable который написан на ClojureScript и полностью им доволен, для него имеется большое количество модулей, из коробки он располагает всем необходимым для начала разработки на Clojure и ClojureScript, в нем присутствует модуль ParEdit. Вам ничего не придется настраивать для подключения к проекту удаленно или локально по repl. Взаимодействие с repl в LightTable весьма своеобразно, на мой субъективный взгляд очень удобно — вы можете вызывать функции и просматривать их результаты в отдельном окне в режиме live (как например в Emacs и во всех других IDE) или делать тоже самое непосредственно в коде, достаточно перевести курсор на первую или последнюю скобку выражения и нажать cmd + enter (MacOS), после этого LightTable создаст соединение repl и скомпилирует это выражение, вам остается ввести название скомпилированной функции или переменной строкой ниже и просмотреть его результат прямо в коде.
Back-end
Project
Первым делом создадим наш проект: $ lein new compojure notes
Теперь у нас есть каталог с заготовкой нашего приложения. Давайте перейдем в него и откроем в редакторе файл project.clj. Необходимо добавить в него зависимости от используемых нами библиотек:
(defproject notes "0.1.0-SNAPSHOT"
:description "Менеджер заметок"
:min-lein-version "2.0.0"
:dependencies [; Да-да, сам Clojure тоже подключаем
; как зависимость
[org.clojure/clojure "1.6.0"]
; Маршруты для GET и POST запросов
[compojure "1.3.1"]
; Обертка (middleware) для наших
; маршрутов
[ring/ring-defaults "0.1.5"]
; Шаблонизатор
[selmer "0.8.2"]
; Добавляем Monger
[com.novemberain/monger "2.0.1"]
; Дата и время
[clojure.joda-time "0.6.0"]]
; Поскольку веб-сервер подключать мы будем в
; следующей статье, пока доверим это дело
; Ring'у в который включен свой веб-сервер Jetty
:plugins [[lein-ring "0.8.13"]]
; При запуске приложения Ring будет
; использовать переменную app содержащую
; маршруты и все функции которые они содержат
:ring {:handler notes.handler/app}
:profiles {:dev
{:dependencies
[[javax.servlet/servlet-api "2.5"]
[ring-mock "0.1.5"]]}})
При запуске проекта Leiningen установит все указанные зависимости автоматически, все что вам необходимо так это указать их в project.clj и быть подключенными к интернету. Далее перейдем к созданию серверной части нашего приложения. Логичнее разместить функции отображения, работы с БД, обработчиков и маршрутов по отдельным файлам, чтобы не было конфликтов имен и
Handler (главный обработчик)
Начнем с handler.clj, о его создании за нас уже позаботился Lein и он находится в каталоге /src/notes (в нем так же находится весь наш Clojure код). Это важная часть нашего приложения в ней содержится переменная app, которая включает в себя маршруты нашего приложения и базовый middleware (обертка запросов и ответов) для HTTP. Добавим в пространство имен маршруты нашего приложения, получится следующий код:
(ns notes.handler
(:require
; Маршруты приложения
[notes.routes :refer [notes-routes]]
; Стандартные настройки middleware
[ring.middleware.defaults :refer [wrap-defaults site-defaults]]))
; Обернем маршруты в middleware
(def app
(wrap-defaults notes-routes site-defaults))
Routes (маршруты)
Теперь создадим файл routes.clj, в нем разместим наши маршруты — которые при запросах методами GET и POST по указанным URI будут вызывать функции обработчиков форм, и отображений страниц. В этой части приложения мы используем API Compojure. Сразу приношу извинения за огромные куски кода тем кого это смущает, но в них я добавил множество комментариев, чтобы вам было легче понять логику их работы:
(ns notes.routes
(:require
; Работа с маршрутами
[compojure.core :refer [defroutes GET POST]]
[compojure.route :as route]
; Контролеры запросов
[notes.controllers :as c]
; Отображение страниц
[notes.views :as v]
; Функции для взаимодействия с БД
[notes.db :as db]))
; Объявляем маршруты
(defroutes notes-routes
; Страница просмотра заметки
(GET "/note/:id"
[id]
; Получим нашу заметку по ее ObjectId
; и передадим данные в отображение
(let [note (db/get-note id)]
(v/note note)))
; Контролер удаления заметки по ее ObjectId
(GET "/delete/:id"
[id]
(c/delete id))
; Обработчик редактирования заметки
(POST "/edit/:id"
request
(-> c/edit))
; Страница редактирования заметки
; на деле, полагаю использовать
; ObjectId документа в запросах
; плохая идея, но в качестве
; примера сойдет.
(GET "/edit/:id"
[id]
; Получим нашу заметку по ее ObjectId
; и передадим данные в отображение
(let [note (db/get-note id)]
(v/edit note)))
; Обработчик добавления заметки
(POST "/create"
; Можно получить необходимые нам значения
; в виде [title text], но мы возьмем
; request полностью и положим
; эту работу на наш обработчик
request
; Этот синтаксический сахар аналогичен
; выражению: (create-controller request)
(-> c/create))
; Страница добавления заметки
(GET "/create"
[]
(v/create))
; Главная страница приложения
(GET "/"
[]
; Получим список заметок и
; передадим его в fn отображения
(let [notes (db/get-notes)]
(v/index notes)))
; Ошибка 404
(route/not-found "Ничего не найдено"))
Controllers (обработка форм)
Для обработки POST, иногда GET запросов, в маршрутах выше мы используем так называемые функции «контролеры» (обработчики), вынесем их в отдельный файл. Здесь я намеренно опускаю полноценную проверку валидности входных данных так как это заслуживает отдельной статьи. Имя этому файлу controllers.clj, содержание его следующее:
(ns notes.controllers
(:require
; Функция редиректа
[ring.util.response :refer [redirect]]
; Функции для взаимодействия с БД
[notes.db :as db]))
(defn delete
"Контролер удаления заметки"
[id]
(do
(db/remove-note id)
(redirect "/")))
(defn edit
"Контролер редактирования заметки"
[request]
; Получаем данные из формы
(let [note-id (get-in request [:form-params "id"])
note {:title (get-in request [:form-params "title"])
:text (get-in request [:form-params "text"])}]
; Проверим данные
(if (and (not-empty (:title note))
(not-empty (:text note)))
; Если все ОК
; обновляем документ в БД
; переносим пользователя
; на главную страницу
(do
(db/update-note note-id note)
(redirect "/"))
; Если данные пусты тогда ошибка
"Проверьте правильность введенных данных")))
(defn create
"Контролер создания заметки"
[request]
; Получаем данные из формы
; не будем плодить переменные
; и сразу создадим hash-map
; (ассоциативный массив)
(let [note {:title (get-in request [:form-params "title"])
:text (get-in request [:form-params "text"])}]
; Проверим данные
(if (and (not-empty (:title note))
(not-empty (:text note)))
; Если все ОК
; добавляем их в БД
; перенесем пользователя
; на главную страницу
(do
(db/create-note note)
(redirect "/"))
; Если данные пусты тогда ошибка
"Проверьте правильность введенных данных")))
DB (взаимодействие с MongoDB)
Очень интересная часть приложения, в её построении нам поможет библиотека Monger. Создадим файл db.clj, в нем будут хранится функции для взаимодействия с MongoDB. Конечно мы можем вызывать функции Monger напрямую в маршрутах и контролерах, но за это мы получим возмездие в отладке, расширении вместе с кучей дублирующегося кода, тем самым приумножим конечное кол-во строк кода. Monger так-же позволяет делать запросы к MongoDB посредством DSL запросов (для реляционных СУБД есть отличная библиотека sqlcorma), это очень удобно для сложных запросов, но в этой статье я не буду их описывать. Давайте добавим функции в db.clj:
(ns notes.db
(:require
; Непосредственно Monger
monger.joda-time ; для добавления времени и даты
[monger.core :as mg]
[monger.collection :as m]
[monger.operators :refer :all]
; Время и дата
[joda-time :as t])
; Импортируем методы из Java библиотек
(:import org.bson.types.ObjectId
org.joda.time.DateTimeZone))
; Во избежание ошибок нужно указать часовой пояс
(DateTimeZone/setDefault DateTimeZone/UTC)
; Создадим переменную соединения с БД
(defonce db
(let [uri "mongodb://127.0.0.1/notes_db"
{:keys [db]} (mg/connect-via-uri uri)]
db))
; Приватная функция создания штампа даты и времени
(defn- date-time
"Текущие дата и время"
[]
(t/date-time))
(defn remove-note
"Удалить заметку по ее ObjectId"
[id]
; Переформатируем строку в ObjectId
(let [id (ObjectId. id)]
(m/remove-by-id db "notes" id)))
(defn update-note
"Обновить заметку по ее ObjectId"
[id note]
; Переформатируем строку в ObjectId
(let [id (ObjectId. id)]
; Здесь мы используем оператор $set
; с его помощью если в документе имеются
; другие поля они не будут удалены
; обновятся только те которые есть
; в нашем hash-map + если он включает
; поля которых нет в документе они
; они будут добавлены к нему.
; Так-же обновлять документы можно
; по их ObjectId с помощью
; функции update-by-id,
; для наглядности я оставил обновление
; по любым параметрам
(m/update db "notes" {:id id}
; Обновим помимо документа
; дату его создания
{$set (assoc note
:created (date-time))})))
(defn get-note
"Получить заметку по ее ObjectId"
[id]
; Если искать документ по его :_id
; и в качестве значения передать
; ему строку а не ObjectId
; мы получим ошибку, поэтому
; переформатируем его в тип ObjectId
(let [id (ObjectId. id)]
; Эта функция вернет hash-map найденного документа
(m/find-map-by-id db "notes" id)))
(defn get-notes
"Получить все заметки"
[]
; Find-maps возвращает все документы
; из коллеции в виде hash-map
(m/find-maps db "notes"))
(defn create-note
"Создать заметку в БД"
; Наша заметка принимается от котролера
; и имеет тип hash-map c видом:
; {:title "Заголовок" :text "Содержание"}
[note]
; Monger может сам создать ObjectId
; но разработчиками настоятельно рекомендуется
; добавить это поле самостоятельно
(let [object-id (ObjectId.)]
; Нам остается просто передать hash-map
; функции создания документа, только
; добавим в него сгенерированный ObjectId
; и штамп даты и времени создания
(m/insert db "notes" (assoc note
:_id object-id
:created (date-time)))))
Views (представление HTML шаблонов)
В файле views.clj мы разместим функции отображающие HTML шаблоны и передающие в них данные. В этом деле нам поможет библиотека Selmer, вдохновленная системой представления данных в шаблонах Django. Так же Selmer позволяет добавлять фильтры (функции) для обработки данных в самом шаблоне, тэги и предоставляет гибкие настройки самого себя. Займемся написанием функций отображения страниц:
(ns notes.views
(:require
; "Шаблонизатор"
[selmer.parser :as parser]
[selmer.filters :as filters]
; Время и дата
[joda-time :as t]
; Для HTTP заголовков
[ring.util.response :refer [content-type response]]
; Для CSRF защиты
[ring.util.anti-forgery :refer [anti-forgery-field]]))
; Подскажем Selmer где искать наши шаблоны
(parser/set-resource-path! (clojure.java.io/resource "templates"))
; Чтобы привести дату в человеко-понятный формат
(defn format-date-and-time
"Отформатировать дату и время"
[date]
(let [formatter (t/formatter "yyyy-MM-dd в H:m:s" :date-time)]
(when date
(t/print formatter date))))
; Добавим фильтр для использования в шаблоне
(filters/add-filter! :format-datetime
(fn [content]
[:safe (format-date-and-time content)]))
; Добавим тэг с полем для форм в нем будет находится
; автоматически созданное поле с anti-forgery ключом
(parser/add-tag! :csrf-field (fn [_ _] (anti-forgery-field)))
(defn render [template & [params]]
"Эта функция будет отображать наши html шаблоны
и передавать в них данные"
(-> template
(parser/render-file
; Добавим к получаемым данным постоянные
; значения которые хотели бы получать
; на любой странице
(assoc params
:title "Менеджер заметок"
:page (str template)))
; Из всего этого сделаем HTTP ответ
response
(content-type "text/html; charset=utf-8")))
(defn note
"Страница просмотра заметки"
[note]
(render "note.html"
; Передаем данные в шаблон
{:note note}))
(defn edit
"Страница редактирования заметки"
[note]
(render "edit.html"
; Передаем данные в шаблон
{:note note}))
(defn create
"Страница создания заметки"
[]
(render "create.html"))
(defn index
"Главная страница приложения. Список заметок"
[notes]
(render "index.html"
; Передаем данные в шаблон
; Если notes пуст вернуть false
{:notes (if (not-empty notes)
notes false)}))
Front-end
HTML шаблоны
Наше приложение почти готово, осталось создать HTML файлы в которых будут отображаться данные. Каталог /resources необходим для размещения статических файлов, т.е. даже после компиляции приложения в .jar или .war файл мы сможем заменять в нем файлы. В public в следующей статье мы добавим CSS таблицы. Ну а пока создадим каталог templates где расположим HTML файлы.
Первым делом создадим базовый файл для всех шаблонов, в нем будет содержаться основная разметка для всех страниц и блок content в котором разместится разметка остальных разделов. Начнем:
<!DOCTYPE html>
<html>
<head>
<META http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>{{title}}</title>
</head>
<body>
<ul>
<li>
{% ifequal page "index.html" %}
<strong>Все заметки</strong>
{% else %}
<a href="/">Все заметки</a>
{% endifequal %}
</li>
<li>
{% ifequal page "create.html" %}
<strong>Добавить заметку</strong>
{% else %}
<a href="/create">Добавить заметку</a>
{% endifequal %}
</li>
</ul>
{% block content %}
{% endblock %}
</body>
</html>
Теперь сверстаем шаблон с формой создания заметки. В него, как и во все формы нашего приложения необходимо добавить тэг {% csrf-field %}, который мы создали в view.clj, иначе при отправке формы мы получим ошибку Invalid anti-forgery token. Приступим:
{% extends "base.html" %}
{% block content %}
<h1>Создать заметку</h1>
<form action="POST">
{% csrf-field %}
<p>
<label>Заголовок</label><br>
<input type="text" name="title" placeholder="Заголовок">
</p>
<p>
<label>Заметка</label><br>
<textarea name="text"></textarea>
</p>
<input type="submit" value="Создать">
</form>
{% endblock %}
У нас уже есть маршрут, представление и обработчик редактирования заметки, давайте создам для всего этого шаблон с формой:
{% extends "base.html" %}
{% block content %}
<h1>Редактировать заметку</h1>
<form method="POST">
{% csrf-field %}
<input type="hidden" name="id" value="{{note._id}}">
<p>
<label>Заголовок</label><br>
<input type="text" name="title" value="{{note.title}}">
</p>
<p>
<label>Заметка</label><br>
<textarea name="text">{{note.text}}</textarea>
</p>
<input type="submit" value="Сохранить">
</form>
{% endblock %}
Далее сверстаем шаблон просмотра заметки, в нем внимательный читатель в теге small увидит странное представление данных, это нечто иное как наш фильтр созданный в view.clj. Для вызова фильтров в Selmer принято писать: {{переменная|фильтр}}. Теперь сам код:
{% extends "base.html" %}
{% block content %}
<h1>{{note.title}}</h1>
<small>{{note.created|format-datetime}}</small>
<p>{{note.text}}</p>
{% endblock %}
И наконец наша главная страница, где будет отображаться список заметок:
{% extends "base.html" %}
{% block content %}
<h1>Заметки</h1>
{% if notes %}
<ul>
{% for note in notes %}
<li>
<h4><a href="/note/{{note._id}}">{{note.title}}</a></h4>
<small>{{note.created|format-datetime}}</small>
<hr>
<a href="/edit/{{note._id}}">Редактировать</a> | <a href="/delete/{{note._id}}">Удалить</a>
</li>
{% endfor %}
</ul>
{% else %}
<strong>Заметок еще нет</strong>
{% endif %}
{% endblock %}
Заключение
Теперь наше приложение готово, конечно мы пропустили очень важный шаг — тестирование функций в repl и просмотр их результатов, но в следующей статье мы остановимся на нем подробно.
Давайте запускать: $ lein ring server
Эта команда как и lein run установит все зависимости, запустит наше приложение на базовом веб-сервере Ring'a Jetty по адресу localhost:3000. Замечу, что пока мы не можем cкомпилировать наше приложение в .jar или .war файл или запускать его через lein run.
Дополнительные ссылки
В следующей статье мы добавим к нашему веб-приложению веб-сервер immutant, и реализуем авто-обновление кода «на лету» т.е. по мере сохранения файлов в редакторе наш сервер будет автоматически обновлять их и мы сможем видеть результаты изменения кода после перезагрузки страницы а не сервера как сейчас. В данный момент «на лету» мы можем изменять лишь HTML файлы. А так-же добавим к нашему HTML Bootstrap классы, так как выглядит верстка очень не весело, несмотря на то, что суть статей заключается не в красивом оформлении, полагаю не стоит возвращаться в WEB 1.0.
Несмотря на пристальную проверку перед публикацией статьи, я буду благодарен вам если заметите неточности в описании или ошибки в грамматике и дадите мне знать об этом в сообщениях. Так-же хотелось бы поблагодарить людей проявивших интерес к моей первой статье, с радостью продолжаю рассказывать вам о веб-разработке на Clojure. На этом я с вами прощаюсь и желаю всего лучшего!
Комментарии (5)
mprokopov
22.07.2015 18:01Гораздо интереснее концепции языка с его core.async и реактивное программирование для веба в виде ClojureScript, плюс уникальные Datomic и его инкарнация для ClojureScript –Datascript.
Сильная сторона кложуры в правильном подходе к разработке асинхронного кода с концепциями STM, атомами, акторами. Все то, что вы показали вполне одинаково делается на всех ЯП включая PHP. Для меня было настоящим откровением открытие функционального программирования, которое я сделал для себя с clojure. Настоящая эссенция такого подхода в том, что разработка на кложуре начинается с данных, в отличие от разработки на объектно-ориентированных языках, которая начинается с абстрактных классов (код, который не делает ничего) и суть которого в сокрытии данных от пользователя, эдакий блек-бокс.
Ну а для веба настоящий хардкор начинается с Om/React, Datomic и вебсокетов.
Clojure я изучил для себя просто так, для расширения кругозора, особенно не планируя на нем что-то разрабатывать. Писал себе на Ruby on Rails, «как все». Но именно после изучения/применения ClojureScript вдруг совершенно ясно стали видны ограничения RoR и стали резать глаза такие незаметные до этого всевозможные костыли, которые подставляются справа и слева для придания интерактивности сайту (JQuery + KnockoutJS + JSON backend + ajax timer polling).
Websocket – именно так выглядит будущее веба + реактивное программирование. Браузер уже по сути является исполняемой средой.
Ну и очень рекомендую Никиту Прокопова почитать The Web After tomorrow и попробовать использовать его разработки Datascript и Rum на ClojureScript.Yeshua Автор
22.07.2015 18:35Здравствуйте, насчет будущего WEB соглашусь с вами. Благодарю за ссылку на «The Web After tomorrow» будем изучать. Насчет core.async и ClojureScript — сам пока-что только знакомлюсь с ними, но уже внимаю их потенциалу, к сожалению у меня мера их понимания еще не высока, чтобы рассказывать о них что-либо.
artifex
Спасибо за интересный цикл, надеюсь на продолжение.
Что вы думаете о Luminus? (http://www.luminusweb.net/docs) По факту это просто рекомендованный набор библиотек и это упрощает поиски для людей, которые плохо знакомы с экосистемой Clojure (как я). С другой стороны, когда делаешь свои первые робкие шаги, на тебя наваливается куча всего нового и в этом случае, может, имеет смысл начать прям с нуля? То есть Ring, Compojure, Buddy по отдельности, Что посоветуете?
Yeshua Автор
Здравствуйте, когда я начал свое знакомство с Clojure не имея опыта в программировании вообще, Luminus мне очень сильно помог разобраться с построением веб-приложения, ну и само-собой книга SICP. Вы абсолютно правы, что это рекомендованный набор библиотек + небольшой пример их использования. Имеет смысл просмотреть альтернативы этим библиотекам, как минимум чтобы найти приемлемые решения для себя. Конечно альтернативны Ring я не представляю так как на нем строится подавляющее большинство веб-приложений Clojure или библиотек которые используются в этих приложениях, но заместо Buddy можно посмотреть Friend от одного из создателей ClojureScript (поправьте если ошибаюсь), альтернатива Compojure — clout, но для меня она показалось более сложной, хотя везде есть плюсы и минусы. Посмотрите Noir он раньше использовался в Luminus, но потом его признали устаревшим и не рекомендуют использовать в продакшн-проектах. Но рано или поздно для разработки более-менее сложного проекта вам придется совмещать много библиотек, исходя из этого на мой взгляд сборка Luminus подобрана очень удачно.