Веб-приложение на Clojure. Часть 2

  • Tutorial
Здравствуйте, уважаемые пользователи и посетители Хабра. В первой статье Веб-приложения на Clojure были рассмотрены базовые инструменты и библиотеки для построения веб-проектов на Clojure. А именно Leiningen, Ring, Compojure, Monger, Buddy и Selmer. Здесь же речь пойдет об их практическом применении.

Тем кто настроен на саморазвитие, через постижение кода в обход чтения статьи — прошу в конец страницы за ссылкой проекта на 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. Необходимо добавить в него зависимости от используемых нами библиотек:

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 и быть подключенными к интернету. Далее перейдем к созданию серверной части нашего приложения. Логичнее разместить функции отображения, работы с БД, обработчиков и маршрутов по отдельным файлам, чтобы не было конфликтов имен и brain-fucking'a неудобств, но это кому как нравится.



Handler (главный обработчик)

Начнем с handler.clj, о его создании за нас уже позаботился Lein и он находится в каталоге /src/notes (в нем так же находится весь наш Clojure код). Это важная часть нашего приложения в ней содержится переменная app, которая включает в себя маршруты нашего приложения и базовый middleware (обертка запросов и ответов) для HTTP. Добавим в пространство имен маршруты нашего приложения, получится следующий код:

handler.clj
(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. Сразу приношу извинения за огромные куски кода тем кого это смущает, но в них я добавил множество комментариев, чтобы вам было легче понять логику их работы:

routes.clj
(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, содержание его следующее:

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:

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 позволяет добавлять фильтры (функции) для обработки данных в самом шаблоне, тэги и предоставляет гибкие настройки самого себя. Займемся написанием функций отображения страниц:

views.clj
(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 в котором разместится разметка остальных разделов. Начнем:

base.html
<!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. Приступим:

create.html
{% 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 %}





У нас уже есть маршрут, представление и обработчик редактирования заметки, давайте создам для всего этого шаблон с формой:

edit.html
{% 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 принято писать: {{переменная|фильтр}}. Теперь сам код:

note.html
{% extends "base.html" %}
{% block content %}

<h1>{{note.title}}</h1>
<small>{{note.created|format-datetime}}</small>

<p>{{note.text}}</p>

{% endblock %}





И наконец наша главная страница, где будет отображаться список заметок:

note.html
{% 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. На этом я с вами прощаюсь и желаю всего лучшего!
  • +14
  • 9.9k
  • 5
Share post

Similar posts

Comments 5

    0
    Спасибо за интересный цикл, надеюсь на продолжение.

    Что вы думаете о Luminus? (http://www.luminusweb.net/docs) По факту это просто рекомендованный набор библиотек и это упрощает поиски для людей, которые плохо знакомы с экосистемой Clojure (как я). С другой стороны, когда делаешь свои первые робкие шаги, на тебя наваливается куча всего нового и в этом случае, может, имеет смысл начать прям с нуля? То есть Ring, Compojure, Buddy по отдельности, Что посоветуете?
      0
      Здравствуйте, когда я начал свое знакомство с Clojure не имея опыта в программировании вообще, Luminus мне очень сильно помог разобраться с построением веб-приложения, ну и само-собой книга SICP. Вы абсолютно правы, что это рекомендованный набор библиотек + небольшой пример их использования. Имеет смысл просмотреть альтернативы этим библиотекам, как минимум чтобы найти приемлемые решения для себя. Конечно альтернативны Ring я не представляю так как на нем строится подавляющее большинство веб-приложений Clojure или библиотек которые используются в этих приложениях, но заместо Buddy можно посмотреть Friend от одного из создателей ClojureScript (поправьте если ошибаюсь), альтернатива Compojure — clout, но для меня она показалось более сложной, хотя везде есть плюсы и минусы. Посмотрите Noir он раньше использовался в Luminus, но потом его признали устаревшим и не рекомендуют использовать в продакшн-проектах. Но рано или поздно для разработки более-менее сложного проекта вам придется совмещать много библиотек, исходя из этого на мой взгляд сборка Luminus подобрана очень удачно.
      0
      Продолжайте пожалуйста. Замечательная статья.
        0
        Гораздо интереснее концепции языка с его 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.
          0
          Здравствуйте, насчет будущего WEB соглашусь с вами. Благодарю за ссылку на «The Web After tomorrow» будем изучать. Насчет core.async и ClojureScript — сам пока-что только знакомлюсь с ними, но уже внимаю их потенциалу, к сожалению у меня мера их понимания еще не высока, чтобы рассказывать о них что-либо.

        Only users with full accounts can post comments. Log in, please.