Help us understand the problem. What is going on with this article?

ClojureのWebFramework Luminusのチュートリアル

More than 3 years have passed since last update.

以下の公式ドキュメントを和訳しました。
Clojure Luminus - Your first application

この和訳は以下のバージョンのテンプレートを前提としています。
https://clojars.org/luminus/lein-template/versions/2.9.8.76

テンプレートのバージョンに関してはayato_pさんの以下の記事が参考になります。
陳腐化しない Clojure の情報を書くために気をつけるべきこと/あるいは情報が陳腐化していることに気付くために知っておきたいこと

LuminusはClojureのさまざまなライブラリの集合体です。
このチュートリアルで個別のライブラリの使い方もある程度理解することができるでしょう。

ゲストブックアプリ

このチュートリアルではLuminusを使ったシンプルなゲストブックアプリを構築する方法を説明する。
ゲストブックアプリはメッセージを投稿することと他のユーザーが投稿したメッセージのリストを表示することができる。
このアプリケーションはHTMLテンプレーティング、データベースアクセス、プロジェクト設計の基本を実演することになる。

もし、あなたがまだ好みのClojure用のエディタを持っていないとしたら、LightTableを使用してこのチュートリアルを行うことをおすすめする。

JDKのインストール

ClojureはJVM上で動作するので、JDKがインストールされている必要がある。
もし、あなたの環境にJDKがまだインストールされていない場合、OpenJDKをおすすめする。
それはここからダウンロードできる。
ちなみにLuminusはデフォルト設定でJDK 8を必要とすることを記しておく。

Leiningenのインストール

Luminusを動かすためにはLeiningenをインストールする必要がある。
Leiningenは以下のような簡単な手順でインストールできる。

  1. スクリプトをダウンロードする
  2. スクリプトに実行権限を付与する(eg: chmod +x lein)
  3. パスの通ったディレクトリにそれを配置する(eg: ~/bin)
  4. 「lein self-install」を実行し、インストールが完了するのを待つ
$ wget https://raw.github.com/technomancy/leiningen/stable/bin/lein
$ chmod +x lein
$ mv lein ~/bin
$ lein

新しいアプリケーションの作成

Leiningenのインストールができたら、アプリケーションの初期化のためにターミナルで以下のコマンドを実行する。

$ lein new luminus guestbook +h2
$ cd guestbook

上記のコマンドはH2 embedded databaseを使用する新しいテンプレートプロジェクトを作成する。

Luminusアプリケーションの詳解

新しく作成したアプリケーションは以下のような構造をしている。

guestbook
|____.gitignore
|____Procfile
|____project.clj
|____profiles.clj
|____README.md
|____src
| |____guestbook
|   |____core.clj
|   |____handler.clj
|   |____layout.clj
|   |____middleware.clj
|   |____routes
|   | |____home.clj
|   |____db
|     |____core.clj
|     |____migrations.clj
|____env
| |____dev
| | |____clj
| |   |____guestbook
| |     |____config.clj
| |     |____dev_middleware.clj.clj
| |____prod
|   |____clj
|     |____guestbook
|       |____config.clj
|____test
| |____guestbook
|   |____test
|     |____handler.clj
|
|____resources
  |____templates
  | |____about.html
  | |____base.html
  | |____home.html
  | |____error.html
  |____public
  | |____css
  | | |____screen.css
  | |____img
  | |____js
  |____docs
  | |____docs.md
  |____migrations
  | |____20150718103127-add-users-table.down.sql
  | |____20150718103127-add-users-table.up.sql
  |____sql
   |____queries.sql

アプリケーションの最上位のフォルダに入っているファイルに関して見ていこう。

  • Procfile - Herokuへのデプロイを支援するために使われる
  • README.md - 伝統的にアプリケーションのドキュメントが書かれるファイル
  • project.clj - Leiningenがプロジェクトの設定とライブラリの依存関係を管理するために使われる
  • profiles.clj - コードのリポジトリに入れたくないローカル設定を行うために使われる
  • .gitignore - ビルドによって生成されたファイル等のGitのバージョン管理から除外するファイルのリスト

ソースディレクトリ

すべてのソースコードはsrcフォルダ以下に置かれる。
今回作成するアプリケーションはguestbookという名前なので、プロジェクトの最上位の名前空間はこのguestbookになる。
では、作成されたすべての名前空間に関して見ていこう。

guestbook

  • core.clj - サーバーの起動と終了に関するロジックを含む、このアプリケーションのエントリポイントである。
  • handler.clj - アプリケーションの基本的なルーティングを定義する。これはアプリケーションのエントリポイントである。
  • layout.clj - ページのコンテンツを描画するためのレイアウトヘルパーを記述するための名前空間である。
  • middleware.clj - アプリケーションのカスタムミドルウェアを含む名前空間である。

guestbook.db

db名前空間はアプリケーションのモデルを定義し、永続化層を処理するために使用される。

  • core.clj - データベースとのやり取りのための関数を保持するために使用される
  • migrations.clj - データベースマイグレーションを実行するために使用される

guestbook.routes

routes名前空間はhomeとaboutページのルーティング定義とコントローラが配置される場所である。
あなたが認証のような複数のルーティング定義や特定のワークフローを追加する場合は、ここにそのための名前空間を作成する必要がある。

  • home.clj - アプリケーションのhomeページとaboutページを定義する名前空間

Envディレクトリ

これは環境依存コードのソースを含むフォルダである。dev設定は開発中に使用され、prod設定は本番環境用にコンパイルされるときに使用される。

config.clj - 通常は開発用設定を含む
dev_middleware.clj - 本番ではコンパイルされるべきでない開発時に使用するミドルウェアを含む

テストディレクトリ

ここはアプリケーションのテストを配置する場所であり、いくつかのサンプルテストがすでに定義されている。

リソースディレクトリ

これはアプリケーションのすべての静的なリソースを配置する場所である。
resourcesディレクトリ下のpublicディレクトリに配置したファイルはアプリケーションサーバーによってクライアントにそのまま配信される。
いくつかのCSSリソースがすでに作成されている。

HTMLテンプレート

templatesディレクトリはページ表示のためのSelmerテンプレートのために準備されている。

about.html - aboutページ
base.html - サイトのベースレイアウト
home.html - homeページ

SQLクエリ

SQLクエリはresources/sqlフォルダの中にある。

queries.sql - SQLクエリとそれらに結びつく関数を定義する。

マイグレーションディレクトリ

LuminusはMigratusでマイグレーション管理を行っている。
マイグレーションはupとdownのSQLファイルで管理される。

これらのファイルは慣例的に日付を使ってバージョン管理され、順番に適用される。

20150718103127-add-users-table.up.sql - テーブル作成のためのマイグレーションファイル
20150718103127-add-users-table.down.sql - テーブル削除のためのマイグレーションファイル

プロジェクトファイル

上で記述したように、すべての依存関係はproject.cljファイルを更新することによって管理される。私たちの作成したアプリケーションのプロジェクトファイルはルートフォルダに存在し、以下のような内容となっている。

(defproject guestbook "0.1.0-SNAPSHOT"

  :description "FIXME: write description"
  :url "http://example.com/FIXME"

:dependencies [[org.clojure/clojure "1.7.0"]
               [selmer "0.8.7"]
               [com.taoensso/timbre "4.0.2"]
               [com.taoensso/tower "3.0.2"]
               [markdown-clj "0.9.67"]
               [environ "1.0.0"]
               [compojure "1.4.0"]
               [ring-webjars "0.1.1"]
               [ring/ring-defaults "0.1.5"]
               [ring-ttl-session "0.1.1"]
               [ring "1.4.0"
                :exclusions [ring/ring-jetty-adapter]]
               [metosin/ring-middleware-format "0.6.0"]
               [metosin/ring-http-response "0.6.3"]
               [bouncer "0.3.3"]
               [prone "0.8.2"]
               [org.clojure/tools.nrepl "0.2.10"]
               [org.webjars/bootstrap "3.3.5"]
               [org.webjars/jquery "2.1.4"]
               [migratus "0.8.2"]
               [conman "0.1.1"]
               [to-jdbc-uri "0.2.0"]
               [com.h2database/h2 "1.4.187"]
               [org.immutant/web "2.0.2"]]

  :min-lein-version "2.0.0"
  :uberjar-name "guestbook.jar"
  :jvm-opts ["-server"]

  :main guestbook.core
  :migratus {:store :database}

  :plugins [[lein-environ "1.0.0"]
            [lein-ancient "0.6.5"]
            [migratus-lein "0.1.5"]]
  :profiles
  {:uberjar {:omit-source true
             :env {:production true}
             :aot :all}
   :dev           [:project/dev :profiles/dev]
   :test          [:project/test :profiles/test]
   :project/dev  {:dependencies [[ring/ring-mock "0.2.0"]
                                 [ring/ring-devel "1.4.0"]
                                 [pjstadig/humane-test-output "0.7.0"]
                                 [mvxcvi/puget "0.8.1"]]


                  :repl-options {:init-ns guestbook.core}
                  :injections [(require 'pjstadig.humane-test-output)
                               (pjstadig.humane-test-output/activate!)]
                  ;;when :nrepl-port is set the application starts the nREPL server on load
                  :env {:dev        true
                        :port       3000
                        :nrepl-port 7000}}
   :project/test {:env {:test       true
                        :port       3001
                        :nrepl-port 7001}}
   :profiles/dev {}
   :profiles/test {}})

project.cljはアプリケーションについてそれぞれ異なった観点から記述した、キーと値のペアを含んだ単なるClojureのリストであることが分かるだろう。

たいていの場合、新しいライブラリをプロジェクトに追加することが多い。これらのライブラリは特に:dependenciesのベクトルを使う。新しいライブラリをプロジェクト内で使うには、私たちはただ依存関係をここに追加するだけでいい。

:pluginsベクトル内の要素は環境変数の読み込み(via. Environ plugin)などの機能性の追加を提供するために使われる。

:profilesは、開発あるいは本番環境のビルドに向けてプロジェクトを初期化するために使用する。
これは環境ごとに異なるmap形式の設定となっている。

このプロジェクトは:dev:testの複合プロファイルとして設定していることを記しておく。
これらのプロファイルは:project/dev:project/testからの変数だけでなく、profiles.clj中の:project/dev:project/testからの変数も含んでいる。

後者には共有のコードリポジトリにチェックインしないローカル環境変数を含むべきである。

project.cljビルドファイルの構築に関するより詳細な事項に関しては公式のLeiningenのドキュメントを参照してください。

データベースの作成

まず、アプリケーションのモデルを作るためにmigrationsフォルダ中に配置されている<date>-add-users-table.up.sqlを開こう。
ファイルは以下のような記述になっている。

CREATE TABLE users
(id VARCHAR(20) PRIMARY KEY,
 first_name VARCHAR(30),
 last_name VARCHAR(30),
 email VARCHAR(30),
 admin BOOLEAN,
 last_login TIME,
 is_active BOOLEAN,
 pass VARCHAR(100));

以下のようにusersテーブルを私たちのアプリケーションにより適切な形に書き換えよう。

CREATE TABLE guestbook
(id INTEGER PRIMARY KEY AUTO_INCREMENT,
 name VARCHAR(30),
 message VARCHAR(200),
 timestamp TIMESTAMP);

guestbookテーブルはコメントした人の名前、メッセージの内容、タイムスタンプなどのメッセージを記述するすべてのフィールドを保存する。
ファイルを保存してプロジェクトのルートディレクトリで以下のコマンドを実行しよう。

$ lein run migrate

もし、すべてがうまく行ったなら、これでデータベースが初期化されるはずだ。

データベースへのアクセス

次にsrc/guestbook/db/core.cljファイルを見てみよう。
ここにはデータベースへの接続設定がすでにあることが分かる。

(ns guestbook.db.core
  (:require
    [clojure.java.jdbc :as jdbc]
    [yesql.core :refer [defqueries]]
    [taoensso.timbre :as timbre]
    [environ.core :refer [env]])
  (:import java.sql.BatchUpdateException))

(def conn
  {:classname   "org.h2.Driver"
   :connection-uri (:database-url env)
   :make-pool?     true
   :naming         {:keys   clojure.string/lower-case
                    :fields clojure.string/upper-case}})

(defqueries "sql/queries.sql" {:connection conn})

データベースの接続設定は実行時の:database-url環境変数から読み込まれる。
この変数は開発環境の場合はprofiles.cljから取り込まれ、本番環境の場合は以下のように環境変数に設定されている必要がある。

$ export DATABASE_URL="jdbc:h2:./guestbook.db"

H2 embedded databaseを使用するため、URLの中でプロジェクトを起動したディレクトリからの相対パスで指定したファイルにデータは保存される。

データベースのクエリにマッピングされる関数はdefqueriesが呼び出された時に生成される。
そのdefqueriessql/queries.sqlファイルを参照していることが分かるだろう。
このファイルはresourcesの中にあるので、ファイルを開いて中身を見てみよう。

-- name: create-user!
-- creates a new user record
INSERT INTO users
(id, first_name, last_name, email, pass)
VALUES (:id, :first_name, :last_name, :email, :pass)

-- name: update-user!
-- update an existing user record
UPDATE users
SET first_name = :first_name, last_name = :last_name, email = :email
WHERE id = :id

-- name: get-user
-- retrieve a user given the id.
SELECT * FROM users
WHERE id = :id

各関数は-- name:[関数名]という形式のコメントを使用することで定義されていることが分かるだろう。
関数名を定義した次の行のコメントは関数のドキュメントになっており、さらに次の行からSQLを記述するような形式になっている。
SQLで使用するパラメータは:[パラメータ名]という表記になっている。
実際にクエリの内容を以下のように書き換えてみよう。

-- name:save-message!
-- creates a new message
INSERT INTO guestbook
(name, message, timestamp)
VALUES (:name, :message, :timestamp)

-- name:get-messages
-- selects all available messages
SELECT * from guestbook

最後にこの名前空間はrunというヘルパー関数を提供してくれる。この関数はconnというアトムからデータベースへの接続を探してクエリ関数を実行する。

アプリケーションを起動する

以下のようにして開発モードでアプリケーションを起動することができる。

>lein run
00:49:54.865 [main] DEBUG org.jboss.logging - Logging Provider: org.jboss.logging.Slf4jLoggerProvider
15-Jul-19 00:49:55 Nyx INFO [guestbook.handler] - nREPL server started on port 7000
15-Jul-19 00:49:55 Nyx INFO [guestbook.handler] -
-=[guestbook started successfullyusing the development profile]=-
00:49:55.772 INFO  [org.projectodd.wunderboss.web.Web] (main) Registered web context /
15-Jul-19 00:49:55 Nyx INFO [guestbook.core] - server started on port: 3000

一度サーバーを起動したら、ブラウザでhttp://localhost:3000を開くことができ、アプリケーションが動作していることが分かるはずだ。起動時に以下のようなパラメータを指定するか、PORTという環境変数を設定することでサーバーを別のポートで起動させることができる。

lein run 8000

表示されたページが私たちにデータベースを初期化するためのマイグレーションを実行することを即してることに注意してほしい。しかし、私たちはすでにマイグレーションを以前行っているため、もう一度行う必要はない。

ページの作成と入力フォームの取り扱い

ルーティング定義はguestbook.routes.home名前空間に定義されている。そのファイルを開き、データベースから取り出したメッセージを表示するためのロジックを追加しよう。
まずはじめにdbBouncerring.util.responseの名前空間への参照を一緒に追加する必要がある。

(ns guestbook.routes.home
  (:require
    ...
    [guestbook.db.core :as db]
    [bouncer.core :as b]
    [bouncer.validators :as v]
    [ring.util.response :refer [redirect]]))

次にフォームのパラメータをバリデートするための関数を作る。

(defn validate-message [params]
  (first
    (b/validate
      params
      :name v/required
      :message [v/required [v/min-count 10]])))

この関数は:name:messageのキーに対応する値が私たちが定めたルール従っているかチェックするためにBouncerのvalidate関数を使用している。
具体的には、nameは必須で、messageは少なくとも10文字である必要があるようにしている。
Bouncerはmessageの場合のようにバリデータに複数のルールを渡すためにベクタ構文を使用している。

min-countを使用した場合のようにバリデータが追加のパラメータをとる場合、ベクタ構文が同様に使用される。その値は、暗黙的に第一引数としてバリデータに渡されます。

こうして、以下のようにメッセージをバリデートし、保存するための関数を追加した。

(defn save-message! [{:keys [params]}]
  (if-let [errors (validate-message params)]
    (-> (redirect "/")
        (assoc :flash (assoc params :errors errors)))
    (do
      (db/save-message!
       (assoc params :timestamp (java.util.Date.)))
      (redirect "/"))))

この関数はフォームのパラメータを含むリクエストからの:paramsキーを取得する。
validate-message関数がエラーを返した場合、/にリダイレクトし、エラーと一緒に提供されたパラメータを入れたレスポンスを:flashキーに関連付ける。
エラーを返さない場合、データベースにメッセージを保存し、リダイレクトする。

さらにhome-pageコントローラを以下のように変更する。

(defn home-page [{:keys [flash]}]
  (layout/render
   "home.html"
   (merge {:messages (db/get-messages)}
          (select-keys flash [:name :message :errors]))))

この関数はバリデートエラーなどの:flashセッションからのすべてのパラメータと一緒に現在保存されているメッセージをテンプレートに渡してhomeページの描画する。

ルーティングの設定としてはhome-pagesave-message!の両方にリクエストを渡す必要がある。

(defroutes home-routes
  (GET "/" request (home-page request))
  (POST "/" request (save-message! request))
  (GET "/about" [] (about-page)))

compojure.coreからPOSTを参照できるようにすることを忘れないようにしよう。

(ns guestbook.routes.home
  (:require ...
            [compojure.core :refer [defroutes GET POST]]
            ...))

コントローラが出来上がったので、resources/templatesディレクトリ内にあるhome.htmlテンプレートのファイルを開こう。
そのままの状態だと、contentブロック中のcontent変数の内容をただ表示するだけになっている。

{% extends "base.html" %}
{% block content %}
  <div class="jumbotron">
    <h1>Welcome to guestbook</h1>
    <p>Time to start building your site!</p>
    <p><a class="btn btn-primary btn-lg" href="http://luminusweb.net">Learn more &raquo;</a></p>
  </div>

  <div class="row">
    <div class="span12">
    {{docs|markdown}}
    </div>
  </div>
{% endblock %}

メッセージを繰り返し処理するようにし、リスト中のそれぞれの値を表示するようにcontentブロックを変更しよう。

{% extends "base.html" %}
{% block content %}
  <div class="jumbotron">
    <h1>Welcome to guestbook</h1>
    <p>Time to start building your site!</p>
    <p><a class="btn btn-primary btn-lg" href="http://luminusweb.net">Learn more &raquo;</a></p>
  </div>

  <div class="row">
    <div class="span12">
        <ul class="messages">
            {% for item in messages %}
            <li>
                <time>{{item.timestamp|date:"yyyy-MM-dd HH:mm"}}</time>
                <p>{{item.message}}</p>
                <p> - {{item.name}}</p>
            </li>
            {% endfor %}
        </ul>
    </div>
</div>
{% endblock %}

上記のようにメッセージを繰り返し処理するためにforイテレータを使用した。それぞれのメッセージはmessagename、 timestampのキーを使用したマップになっているため、キーの名前でアクセスすることができる。タイムスタンプを人間が読むことのできる形式に変換する日付フィルターが使用できることも記しておこう。

最後にユーザーがメッセージの送信するためのフォームを作成する。ユーザーが送信したnameとmessageの値をテンプレートにはめ込み、それらに関連するすべてのエラーを描画する。
また、CSRF対策のためにフォーム内でcsrf-fieldタグを使用している。

<div class="row">
    <div class="span12">
        <form method="POST" action="/">
                {% csrf-field %}
                <p>
                    Name:
                    <input class="form-control"
                           type="text"
                           name="name"
                           value="{{name}}" />
                </p>
                {% if errors.name %}
                <div class="alert alert-danger">{{errors.name|join}}</div>
                {% endif %}
                <p>
                    Message:
                <textarea class="form-control"
                          rows="4"
                          cols="50"
                          name="message">{{message}}</textarea>
                </p>
                {% if errors.message %}
                <div class="alert alert-danger">{{errors.message|join}}</div>
                {% endif %}
                <input type="submit" class="btn btn-primary" value="comment" />
        </form>
    </div>
</div>

最終的なhome.htmlテンプレートは以下のようになるだろう。

{% extends "base.html" %}
{% block content %}
<div class="row">
    <div class="span12">
        <ul class="messages">
            {% for item in messages %}
            <li>
                <time>{{item.timestamp|date:"yyyy-MM-dd HH:mm"}}</time>
                <p>{{item.message}}</p>
                <p> - {{item.name}}</p>
            </li>
            {% endfor %}
        </ul>
    </div>
</div>
<div class="row">
    <div class="span12">
        <form method="POST" action="/">
                {% csrf-field %}
                <p>
                    Name:
                    <input class="form-control"
                           type="text"
                           name="name"
                           value="{{name}}" />
                </p>
                {% if errors.name %}
                <div class="alert alert-danger">{{errors.name|join}}</div>
                {% endif %}
                <p>
                    Message:
                <textarea class="form-control"
                          rows="4"
                          cols="50"
                          name="message">{{message}}</textarea>
                </p>
                {% if errors.message %}
                <div class="alert alert-danger">{{errors.message|join}}</div>
                {% endif %}
                <input type="submit" class="btn btn-primary" value="comment" />
        </form>
    </div>
</div>
{% endblock %}

入力フォームをよりかっこよく整形するためにresources/public/cssフォルダに配置されたscreen.cssファイルを更新することができるようになった。

body {
    height: 100%;
    padding-top: 70px;
    font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
    line-height: 1.4em;
    background: #eaeaea;
    width: 550px;
    margin: 0 auto;
}

.messages {
  background: white;
  width: 520px;
}
ul {
    list-style: none;
}

ul.messages li {
    position: relative;
    font-size: 16px;
    padding: 5px;
    border-bottom: 1px dotted #ccc;
}

li:last-child {
    border-bottom: none;
}

li time {
    font-size: 12px;
    padding-bottom: 20px;
}

form, .error {
    width: 520px;
    padding: 30px;
    margin-bottom: 50px;
    position: relative;
  background: white;
}

これでブラウザのページをリロードすればゲストブックのページに対面することになるだろう。ゲストブックにコメントを追加し、それが正しく動作することを確認してみよう。

アプリケーションのパッケージング

アプリケーションは以下のコマンドを実行することによってスタンドアローンでのデプロイ形式にすることができる。

$ lein uberjar

これは以下のようにして実行可能なjarを作成する。

$ java -jar target/guestbook.jar

このチュートリアルの完全なソースファイルはここで取得できる。より完全なサンプルを求める場合はGithub上にあるドキュメントページのソースファイルを読むといいだろう。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away