Fork me on GitHub

(code "liquidz.uo")

この記事はClojrure Advent Calendar 2011の参加記事です。

今回はCompojureでウェブアプリを作る際に使える ある程度実践的(?)なTipsなどを紹介します。

長文なのでご注意ください

Compojureとは

CompojureはClojure向けの軽量ウェブフレームワークです。

Clojure版Sinatraのようなフレームワークでウェブアプリをシンプルに記述できることが特徴で、ringというウェブアプリケーションライブラリがベースになっています。

Hello World

最初にベースとなるHelloWorldを作ります。 HelloWorldの作り方自体はググれば他にたくさん記事が見つかると思うので 詳細な説明は割愛して、コード内のコメントで軽く補足します。

なおこれから先はLeiningenがインストール済みであることを前提にしています。

プロジェクトの作成

$ lein new helloworld
$ cd helloworld

project.clj の編集

(defproject helloworld "1.0.0-SNAPSHOT"
  :description "FIXME: write description"
  :dependencies [[org.clojure/clojure "1.3.0"]
                 [compojure "0.6.5"] ; 12/03時点で最新のタグ
                 [ring/ring-jetty-adapter "0.3.11"]]
  :main helloworld.core)

src/helloworld/core.clj の編集

(ns helloworld.core
  (:use
    [compojure.core :only [defroutes GET]]
    [compojure.route :only [not-found]]
    [ring.adapter.jetty :only [run-jetty]]))

(defroutes app
  (GET "/" req "hello world")
  ; defroutesは定義した順に処理するためnot-foundは最後に書く
  (not-found "NOT FOUND"))

(defn -main []
  ; heroku向けのport取得
  (let [port (Integer/parseInt (get (System/getenv) "PORT" "8080"))]
    (run-jetty app {:port port})))

実行

$ lein deps
$ lein run
$ open http://localhost:8080

Hello World!!

的ファイルを扱う

無事HelloWorldが表示できました。

あとはhiccupやenliveといったテンプレートエンジンを使えば動的な画面は問題ないでしょう。 では静的な画面は?というと以下のようにします。

project.clj に以下を追加

:web-content "public"

src/helloworld/core.clj のルートを編集

(ns helloworld.core
  (:use
    [compojure.core :only [defroutes GET]]
    ; filesを追加
    [compojure.route :only [not-found files]]
    [ring.adapter.jetty :only [run-jetty]]))

(defroutes app
  (GET "/" req "hello world")
  (files "/") ; publicディレクトリを"/"にひもづける
  (not-found "NOT FOUND"))

静的ファイルを用意

$ pwd
プロジェクトディレクトリ
$ mkdir public; cd public
$ echo NEKO > neko.txt

Jettyの再起動を再起動して http://localhost:8080/neko.txt へアクセスすれば 静的ファイルを参照できます。

発を効率化

先ほどの静的ファイルへの対応では修正後にJettyを再起動しました。 でも修正の度に再起動するのは効率的ではありません。

そこでring-develのreloadstacktrace を使いましょう。

project.clj に追加

:dev-dependencies [[ring/ring-devel "0.3.11"]]

src/helloworld/core.clj の修正

(ns helloworld.core
  (:use
    [compojure.core :only [defroutes GET]]
    [compojure.route :only [not-found files]]
    [ring.middleware reload stacktrace]
    [ring.adapter.jetty :only [run-jetty]]))

(defn index
  "/ にアクセスされたときの処理"
  [req]
  "hello world")

(defroutes main-route
  (GET "/" req (index req)) ; 処理を関数に
  (GET "/err" _ (throw (Exception.))) ; stacktraceの確認用
  (files "/")
  (not-found "NOT FOUND"))

(defroutes app
  (-> main-route
    (wrap-reload '[helloworld.core])
    wrap-stacktrace))

実行

$ lein deps
$ lein run

Jettyの再起動に関係なく index の戻り値が反映されるのが確認できたでしょうか? また stacktrace を使うと /err にアクセスした際に、画面上に例外の内容を表示させることができます。

なお reload ですが、defroutes 内の変更は反映されないようなので、 routeの変更の際にはJettyの再起動が必要です。(この点、対処方法があれば誰か教えてください。)

Middlewareで拡張

先ほどの reload, stacktrace はringのmiddlewareと言われるもので、 これらを使うとCompojureの挙動を拡張することができます。

主要なmiddlewareは以下の通りです。

ring.middleware.params/wrap-params

QueryString, POSTデータを {:params request} に展開

(defroutes main-routes
  (GET "/" {params :params}
    (get params "get_parameter")))

(defroutes app
  (-> main-routes wrap-params))

ring.middleware.nested-params/wrap-nested-params

添字付きのパラメータをネストしたマップに展開。要 wrap-params

なお展開できる階層は1階層のネストまで

(defroutes main-routes
  (GET "/" {params :params} (str params)))

(defroutes app
  (-> main-routes wrap-nested-params wrap-params))
$ open "http://localhost:8080/?a[b]=c&a[d]=e"
{"a" {"d" "e", "b" "c"}}

ring.middleware.keyword-params/wrap-keyword-params

パラメータ名をStringからKeywordに変換。wrap-params, wrap-nested-paramsと一緒に使う

(defroutes main-routes
  ; 分配束縛が楽
  (GET "/" { {:keys [param1 param2]} :params}
    (str "param1 = " param1 ", param2 = " param2)))

(defroutes app
  (-> main-routes wrap-keyword-params wrap-params))

ring.middleware.session

セッションを扱う。 セッション情報はリクエストの :session キーで渡される

(ns helloworld.core
  (:use
    ..省略..
    [ring.util.response :only [redirect]]))

(defroutes main-routes
  (GET "/set/:vlue" [value]
    ; セッションのセットはレスポンスに :session を指定するだけ
    (assoc (redirect "/") :session {:value value}))

  (GET "/" { {:keys [value], :or {value "no data"}} :session}
    (str "value = " value)))

(defroutes app
  (-> main-routes wrap-session))
$ open "http://localhost:8080/set/helloworld"

ring.middleware.flash

セッションを使って一時的なメッセージを保存。要 wrap-session

リダイレクト先でちょろっとメッセージを表示したいときとかに使う

(ns helloworld.core
  (:use
    ..省略..
    [ring.util.response :only [redirect]]))

(defroutes main-routes
  (GET "/set/:value" [value]
    ; flashのセットはレスポンスに :flash で指定
    (assoc (redirect "/") :flash value))
  (GET "/" {flash :flash}
    (str "flash = " flash)))

(defroutes app
  (-> main-routes wrap-flash wrap-session))

flashをセット

$ open "http://localhost:8080/set/helloworld"

セットされてるのが確認できますが、もう一度アクセスすると一時的な情報なので削除されています。

$ open "http://localhost:8080/"

なお以下のような凡ミスはしないようご注意を

http://twitter.com/#!/uochan/status/141546228574982144

ring.middleware.cookies

クッキーを扱う。クッキー情報はリクエストの :cookies キーで渡される

(ns helloworld.core
  (:use
    ..省略..
    [ring.util.response :only [redirect]]))

(defroutes main-routes
  (GET "/set/:value" [value]
    ; クッキーの設定はレスポンスの :cookies キーで行う
    (assoc (redirect "/") :cookies {:hello {:value value :path "/"}}))
  (GET "/" {cookies :cookies}
    (str cookies)))

(defroutes app
  (-> main-routes wrap-cookies))

クッキー設定時には上記以外に :domain, :port, :max-age, :expires, :secure, :http-only が使えます。

詳細は以下のソース末尾を見ると良いです。

https://github.com/mmcgrana/ring/blob/master/ring-core/src/ring/middleware/cookies.clj

ring.middleware.file/file

静的ファイルを扱います。こちらだと project.clj に :web-content を指定しなくてもディレクトリを割り当てられます。

(defroutes app
  (-> main-routes (wrap-file "public")))

Middlewareのちょっとした注意

wrap系は処理をラップした関数を返すので ->, ->>で適用する場合には逆順に処理されるので注意してください。

(wrap-A (wrap-B (wrap-C app))) ; A->B->Cの順で処理される
(-> app wrap-A wrap-B wrap-C)  ; C->B->Aの順で処理される
(-> app wrap-params wrap-keyword-params) ; NG: paramsの前にkeyword-paramsが処理される
(-> app wrap-keyword-params wrap-params) ; OK: paramsのあとにkeyword-paramsが処理される

倒くさい

route毎にどのmiddlewareをラップすれば良いのかわからない!面倒くさい! という人用に(?)、Compojureではsiteapiを用意しています。

compojure.handler/site

HTMLを出力するroute向け。以下7つをラップ

  • wrap-session
  • wrap-flash
  • wrap-cookies
  • wrap-multipart-params
  • wrap-params
  • wrap-nested-params
  • wrap-keyword-params

compojure.handler/api

ウェブAPI向け。以下3つをラップ

  • wrap-params
  • wrap-nested-params
  • wrap-keyword-params

ストする

ここまでに紹介したmiddlewareを使えば一般的なウェブアプリであれば 問題なく開発できるかと思います。

最後にテストです。例えば以下のようなAPIのテストを書いてみましょう。

project.clj の dependencies に以下を追加

[org.clojure/data.json "0.1.1"]

src/helloworld/core.clj

(ns helloworld.core
  (:use
    ..省略..
    [compojure.handler :only [api]]
    [clojure.data.json :only [json-str]]))

(defn word-count [text]
  (->> text (re-seq #"\w+") (map (fn [x] {x 1})) (apply (partial merge-with +))))

(defroutes api-route
  (GET "/wc" { {:keys [text]} :params}
    (json-str (word-count text))))

(defroutes app
  (api api-route))

HTMLを返すルートのテストは難しいですが、APIでデータを返すルートのテストはring-mock を使うことで簡単に記述することができます。

project.clj の dev-dependencies に以下を追加

[ring-mock "0.1.1"]

test/helloworld/test/core.clj

(ns helloworld.test.core
  (:use
    helloworld.core
    clojure.test
    [clojure.data.json :only [read-json]]
    [ring.mock.request :only [request]]))

(deftest word-count-test
  (let [; ring.mock.request/request でレスポンスを取得
        ; 第3引数のマップはQueryStringに展開される
        res (app (request :get "/wc" {:text "hello world hello"}))
        ; JSON形式からマップに変換
        body (-> res :body read-json)]
    ; are って便利
    (are [x y] (= x y)
      200 (:status res)
      2 (:hello body)
      1 (:world body))))
$ lein deps
$ lein test
Testing helloworld.test.core
Ran 1 tests containing 3 assertions.
0 failures, 0 errors.

このような感じで ring-mock を使うと関数のテストだけではカバーできない 実際にリクエストで得られる結果もテストできます。

後に

長文になってしまいましたがいかがでしたでしょうか? 少しでも Compojure でのウェブアプリ開発に役立てれば幸いです。

具体的なコード例は以下にコミットしてあります。 (middlewareまわりは面倒だったので書いてないです。もし要望があれば書きます)

https://github.com/liquidz/practical-compojure-sample

なお間違いや、より良い方法などあればご指摘ください!

» Go page top

blog comments powered by Disqus