понедельник, 16 мая 2011 г.

Web, CRUD, REST на Clojure


В этой статье я расскажу об одном из способов написать web-приложение на Clojure. Как человеку, сильно избалованному фреймворком Ruby on Rails, естественно мне хотелось что-то похожее и на Clojure. Рельсы отличают от других фреймворков очень большая пристрастность в выборе технологий. Например, это обязательное использование паттерна MVC, соглашений вместо конфигурации, принципа DRY (Don't Repeat Yourself) и т. д. Именно это мне и хотелось реализовать на Clojure, т. к. считаю этот язык инструментом следующего поколения даже по сравнению с Ruby, не говоря уже о Java или C#.
В Clojure пока нет устоявшегося стека для web-разработки. Каждый волен выбирать сам любые библиотеки и разрабатывать сайт в любой их комбинации. Я использовал следующие технологии.
  1. View: clj-soy 0.1.0 (обертка над google closure templates).
  2. Controller: ring 0.3.8 (обертка над сервером приложений Jetty), compojure 0.6.2 (DSL для описания маршрутов).
  3. Model: mysql-connector-java 5.1.6, clojure.contrib.sql.

Весь код проекта находится здесь: Архив проекта. Запускать его так: 
$ lein deps
$ lein ring server.

Итак, приступим к созданию простого демонстрационного блога.

$ lein new www-test.

Мой project.clj выглядит так:

(defproject www-test "1.0.0-SNAPSHOT"
  :description "Clojure web-site demo"
  :dependencies [[org.clojure/clojure "1.2.1"]
         [compojure "0.6.2"]
         [clj-soy "0.1.0"]
         [mysql/mysql-connector-java "5.1.6"]
         [ring "0.3.8"]]
  :dev-dependencies [[swank-clojure "1.2.1"]
             [lein-ring "0.4.0"]]
  :ring {:handler www-test.system.routes/app}
  :resources-path "resources")

Здесь параметр ring указывает на пользовательскую функцию, устанавливающую маршруты (см. ниже). Параметр :resources-path устанавливает название дирректории, где находится дирректория public, в которой находятся общие ресурсы приложения (CSS, JavaScript, картинки).

Вьюхи я реализовал на google closure templates. Библиотека clj-soy очень хорошо оборачивает эту функциональность. Учитывая, в скольких проектах google используется эта библиотека, а также, сколько по ней документации, работать с google closure templates – одно удовольствие. Пример шаблона для формы редактирования:

{namespace posts}

/**
 * Edit a post.
 * @param post The post.
 */
{template .edit}
<html>
<body>
<h1>Edit a post</h1>
<br/>

<form action="/posts/update" method="post">
<input type="hidden" name="id" id="id" value="{$post.id}"/>
Title: <input type="text" name="title" id="title" value="{$post.title}"/><br/>
Text: <textarea id="body" name="body" rows="10" cols="30">
    {$post.body}
      </textarea> <br/>
<input type="submit" value="Update" />
</form> 

<br/>
<a href="/posts">Back</a>
</html>
{/template}
  
Файл контроллера c_post.clj выглядит так (название с префиксом c_ выбрал только из удобства работы в Emacs; файлы с разными названиями проще находить через IDO):

(ns www-test.controllers.c-post
  (:use    www-test.system.utils)
  (:require [www-test.models.m-post :as post]
        [clj-soy.template :as soy]
        [clojure.contrib.sql :as sql]))

(defn index []
  (render-action posts#index
      {:posts (post/fetch-list)}))

(defn new []
  (render-action posts#new
      {}))

(defn create [post]
  (post/create post)
  (redirect-to "/posts"))

(defn show [id]
  (render-action posts#show
      {:post (post/fetch id)}))

(defn edit [id]
  (render-action posts#edit
      {:post (post/fetch id)}))

(defn update [post]
  (post/update post)
  (redirect-to (str "/posts/show/" (:id post))))

(defn delete [id]
  (post/delete id)
  (redirect-to "/posts"))

Сделаю несколько пояснений по исходнику. Функции index, new, show, edit только отдают html-представление формы и не производят никаких побочных эффектов. Функции create, update и delete выполняют указанные операции над сущностями и редиректят на одну из страниц блога.

Модель m_post.clj я связал с алиасом post. Таким образом, когда нужно обратиться к базе данных, в контроллере вызов выглядит, например, так: (post/fetch-list), или так (post/update post), если нужно передать параметры. Я передаю все параметры в виде map-а, ключи которого – названия колонок в базе данных.

Рендеринг html-формы я сделал в стиле «соглашение вместо конфигурации». Оператор render-action – это макрос, который принимает на вход название экшена в виде entity#action, а также список параметров для clj-soy. Макрос описан в файле utils.clj и выглядит так.

(defmacro render-action [action params]
  `(let [splitted# (.split ~(str action) "#")
     entities# (first splitted#)
     action# (second splitted#)]
     (render-rest-page entities# action# ~params)))

Как видите, он разбирает параметр action и вызывает render-rest-page:

(defn render [template-file template-ns params]
  (let [tpl (soy/build template-file)]
    (soy/render tpl
        template-ns
        params)))

(defn decorate-page [template-file template-ns params
             app-template-file app-template-ns]
  (let [content (render template-file template-ns params)]
    (render app-template-file
        app-template-ns
        {:content content})))

(defn render-rest-page [entities action params]
  (decorate-page (str *templates* "/" entities "/" action ".soy")
         (str entities "." action)
         params
         (str *templates* "/app/" entities ".soy")
         (str "app." entities)))

Функция decorate-page сначала рендерит указанную форму, затем врендеривает ее в общий шаблон приложения, добавляя стили, картинки и т. д. Мой шаблон posts.soy выглядит так:

{namespace app}

/**
 * List of posts.
 * @param content Content
 */
{template .posts autoescape="false"}

<Масса разного укращающего html-кода>

{$content}

<Окончание html>

{/template}

Модель m_post.clj выглядит так:

(ns www-test.models.m-post
  (:use www-test.system.db
    www-test.system.utils)
  (:require [clojure.contrib.sql :as sql]))

(defn fetch-list []
  (doall
   (map struct-map->soy
    (doall
     (sql/with-connection *db*
       (sql/with-query-results posts
         ["SELECT * FROM post order by id desc"]
         (doall posts)))))))

(defn create [post]
  (sql/with-connection *db*
    (sql/insert-values "post" ["title" "body"]
               [(:title post)
            (:text post)])))

(defn fetch [id]
  (struct-map->soy
   (sql/with-connection *db*
     (sql/with-query-results posts
       [(str "SELECT * FROM post where id = " id)]
       (first (doall posts))))))

(defn update [post]
  (sql/with-connection *db*
    (sql/update-values "post" [(str "id=" (:id post))] post)))

(defn delete [id]
  (sql/with-connection *db*
    (sql/delete-rows "post" [(str "id=" id)])))

Здесь все достаточно просто за исключением функции struct-map->soy. Дело в том, что clj-soy не принимает идиоматичные map-ы в виде {:key value}. Ключ у него должен быть не ключевым словом, а строкой. Поэтому функция struct-map->soy просто переделывает ключи в строки.

(defn map->soy [m]
  (let [k (keys m)
    ks (map #(subs (str %) 1) k)
    ms (reduce (fn [m v] (assoc m (first v) (second v))) {} (map (fn [v1 v2] [v1 v2]) k ks))
    rm (clojure.set/rename-keys m ms)]
    rm))

(defn struct-map->soy [sm]
  (let [m (reduce (fn [m k] (assoc m k (k sm))) {} (keys sm))]
    (map->soy m)))

Файл routes.clj выглядит так:

(ns www-test.system.routes
  (:use compojure.core,
    ring.adapter.jetty,
    ring.util.response,
    www-test.system.utils)
  (:require [www-test.controllers.c-post :as post]
        [compojure.route :as route]
        [compojure.handler :as handler]
        [clj-soy.template :as soy]
        [clojure.contrib.sql :as sql]))

(defroutes main-routes
  (GET "/" [] (redirect-to "/posts"))
  (GET "/posts" [] (post/index))
  (GET "/posts/new" [] (post/new))
  (POST "/posts/create" {post :params} (post/create post))
  (GET "/posts/show/:id" [id] (post/show id))
  (GET "/posts/edit/:id" [id] (post/edit id))
  (POST "/posts/update" {post :params} [post] (post/update post))
  (GET "/posts/delete/:id" [id] (post/delete id))
  (route/resources "/")
  (route/not-found "Page not found"))

(def app
  (handler/site main-routes))

Из неописанных функций здесь только redirect-to:

(defn redirect-to [addr]
  (ring.util.response/redirect addr))

И, наконец, сама БД. Она содержит единственную таблицу:

CREATE TABLE post (
  id int(11) NOT NULL AUTO_INCREMENT,
  title varchar(512) DEFAULT NULL,
  body text,
  PRIMARY KEY (id))

Константа *db* описана в файле db.clj:
(ns www-test.system.db)

(def *db* {:classname "com.mysql.jdbc.Driver"
         :subprotocol "mysql"
         :subname "//127.0.0.1:3306/wwwtest1"
         :user "myuser"
         :password "mypass"})
Вот, в общем, и все. Конечно, это просто демонстрационное приложение, и много еще чего предстоит сделать. Но, возможно, оно кому-то покажется интересным и подтолкнет к разработке собственных сайтов на Clojure.

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

  1. Этот комментарий был удален автором.

    ОтветитьУдалить
  2. Кстати, насчет веб-фреймворков: посмотрите на https://github.com/dnaumov/jaco. Правда, он еще в начальной (очень начальной :)) стадии разработки

    ОтветитьУдалить
  3. Посмотрел фреймворк jaco. Мне сложно его оценить, поскольку нету примеров его использования, а по тестам и исходникам вникнуть в него сложновато. То, в чем я разобрался, мне понравилось: архитектура MVC, CRUD, заготовки для основных форм, валидаторы. Есть и отличия в наших подходах.
    1. Я считаю, что html-формы лучше всего писать на html. Просто обычно этим занимается дизайнер, а перегонять его поток сознания в hiccup -- для меня это слишком.
    2. На мой взгляд, лучший способ метапрограммирования под web -- это генерация кода, как это сделано в Rails и Django. Двигаюсь в этом направлении, хотя и медленно.
    Ну и еще. Маршрутизатор у вас -- это что-то! Может я не совсем проникся идеей, но по-моему уж больно сложно))) Как-никак, Compojure как раз и занимается маршрутизацией, так почему бы сопоставление с образцом не оставить именно этой библиотеке?

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