6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ring/compojureでサーバを始めてみる(入門編) part2

Last updated at Posted at 2015-12-25

(日付をまたいでしまって申し訳ない…)
Clojure Advent Calendar 2015 25日目の記事です。

はじめに

前回の記事から早1年。Clojure 本体を含め、複数のライブラリパッケージが新しくなりました。

前回からの変更点

  • ゲストOSを wheezy から jessie にアップグレードしました。
  • 下記の依存パッケージがバージョンアップしました
  • clojure: 1.6 -> 1.7
  • data.json: 0.2.5 -> 0.2.6
  • java.jdbc: 0.3.6 -> 0.4.2
  • compojure: 1.3.1 -> 1.4.0 (注: clojure 1.5.1 に依存している)
  • lib-noir: 0.9.5 -> 0.9.9 (注: clojure 1.6.0 に依存している)
  • clj-http: 1.0.1 -> 2.0.0
  • clj-oauth: 1.5.1 -> 1.5.3 (注: clojure 1.6.0, clj-http 1.0.1, commons-codec 1.8 に依存している)
  • lein-ring: 0.8.13 -> 0.9.7
  • lein-auto: 0.1.1 -> 0.1.2
  • codox: 0.8.10 -> 0.9.0
  • ring-mock: 0.2.5 -> 0.3.0

さてこの内、最新の clojure 本体に対応していないパッケージがいくつかあります。加えて、 clj-oauth は clj-http の古いバージョンにも依存しています。このままでは複数の古いバージョンのパッケージが同時にインストールされるでしょう。このことにより、開発を行う上で問題となることはありません。しかし、私達には不要なパッケージです。バージョン違いで無駄にパッケージが増えたりすると後で管理するための手間が増えます(ディスクも圧迫しますしね)。このようなパッケージは、そもそも初めからインストールしないでおくようにするに限ります。

不要なパッケージの除外

依存パッケージの中から不要なパッケージを切り離すには、 project.clj 内に :exclusions キーを追記し、その値として除外するパッケージを vector に書き込みます。まず、前回の記事で project.clj がどう書かれていたかを思い出しましょう。

(defproject <project-name> "0.1.0-SNAPSHOT"
  :descriptions "プロジェクトの概要"
  :url          "ソースコードを保管しているリポジトリなどのURL"
  :license      {:name "このプロジェクトが従属するライセンスの名前"
                 :url "ライセンスの条文が置かれているサイトのURL"}
  :dependencies [[org.clojure/clojure "1.6.0"]
                 [org.clojure/data.json "0.2.5"]
                 [org.clojure/java.jdbc "0.3.6"]
                 [compojure "1.3.1"]
                 [hiccup "1.0.5"]
                 [lib-noir "0.9.5"]
                 [clj-http "1.0.1"]
                 [clj-oauth "1.5.1"]]
  :plugins       [[lein-ring "0.8.13"]
                  [lein-auto "0.1.1"]
                  [codox "0.8.10"]]
  :ring          {:handler <project-name>.core.handler/app}
  :profiles      {:dev {:dependencies [[javax.servlet/servlet-api "2.5"]
                                       [ring/ring-mock "0.2.5"]]}})

これを

(defproject <project-name> "0.1.0-SNAPSHOT"
  :descriptions "プロジェクトの概要"
  :url          "ソースコードを保管しているリポジトリなどのURL"
  :license      {:name "このプロジェクトが従属するライセンスの名前"
                 :url "ライセンスの条文が置かれているサイトのURL"}
  :exclusions   [org.clojure/clojure]
  :dependencies [[org.clojure/clojure "1.7.0"]
                 [org.clojure/data.json "0.2.6"]
                 [org.clojure/java.jdbc "0.4.2"]
                 [compojure "1.4.0"]
                 [hiccup "1.0.5"]
                 [lib-noir "0.9.9"]
                 [clj-http "2.0.0"]
                 [clj-oauth "1.5.3" :exclusions [clj-http commons-codec]]]
  :plugins       [[lein-ring "0.9.7"]
                  [lein-auto "0.1.2"]
                  [codox "0.9.0"]]
  :ring          {:handler <project-name>.core.handler/app}
  :profiles      {:dev {:dependencies [[javax.servlet/servlet-api "2.5"]
                                       [ring/ring-mock "0.3.0"]]}})

こうします。
トップレベルにある :exclusions は、自分自身を含めて、そのプロジェクトが依存しているすべてのパッケージから指定した依存を取り除きます。今回はトップレベルで一度 clojure のパッケージを除くように指定しつつ clojure 本体への依存を記述しているので、依存しているすべてのパッケージから clojure のパッケージを取り除き、その後このプロジェクト自身だけが clojure 1.7 に依存しているという状態を作り出すことになります。依存パッケージ単位(今回は clj-oauth )で記述される :exclusions はそのパッケージの中からだけ指定した依存を取り除きます。

ツイートのデザイン

さて、それではウェブコンテンツをデザインしていきましょう。
tweet.png
今回はDisplay Requirementに可能な限り準拠するために、ウェブでのツイート表示を模倣することにします。

hiccup と vector

レイアウトを整理しましょう。まず、以下のような4行構造になっているというのはすぐに想像できるはずです。

  • 1行目: ユーザ情報
  • 2行目: 本文
  • 3行目: 投稿日
  • 4行目: 各種コマンドアイコン

これらの構造を hiccup が解釈できる vector の形で表現してみましょう。

[:div.tweet [:div.user]
            [:div.text]
            [:div.created_at]
            [:div.actions]]

この4行の vector それぞれの内側に、実際に表示するコンテンツを挿入していきます。キーワードの名前の内、#. の前にある部分がタグの要素名です。要素名に # が続く場合は、その要素に id 属性が割り振られ、 . が続く場合は class 属性が割り振られます。それ以外の属性を付与する場合は、その要素と同じ vector の中に Map の形で記述します。例えば、

[:div.tweet {:data-role "list"} some-content]

こんな感じに。今回は、前回の記事と併せてテストの書き方と Clojure 自体でのウェブコンテンツの生成の仕方に着目するため、 CSS の内容は一旦後回しにしましょう(また後の記事で詳しく追っていきます)。この構造に実際のツイートのデータを挿入していきます。Twitter から JSON を貰ってくる部分は前回の記事で書いたので、あれを掘り起こしましょう。ええと、確か…

(defn- status-show
  [^long id]
  (let [url    "http://api.twitter.com/1.1/statuses/show/"
        ; OAuthライブラリの側にクエリ文字列のためのWrapperはない。
        params {:headers      {"authorization" (authorization-header (credentials url (str id ".json")))}
                :query-params {:id id}}]
       (http/get url params)))

そうそう、これですね。今回はこれを

  1. URLパラメータで指定された id を元に Twitter から JSON データを貰ってくる
  2. JSON を Clojure の Map に変換する
  3. Map の中のデータをもとにコンテンツを生成する

上記3つの流れに変えます。これに伴い、テストコードの見直しも行います。今度はJSONが正しく返ってきたかだけではなく、指定した id のツイートと同じ内容で vector が作成されているか、そしてその vector から HTML が期待通りに生成されているかを確認する必要があります。

テストコードの修正

完成形のイメージ

vector の形をイメージしましょう。実装コードに間違いがなければ、

; tweet という名の JSON を受け取ったと仮定して…
[:div.tweet [:div.user [:a {:href (str "https://twitter.com/" (get-in tweet [:user :screen_name]))}
            ; profile_image_url のフィールドの扱いは、設定を参照してHTTPSにするかどうかを選択するようにはしたいと思っている。
                           [:img.avatar {:src (get-in tweet [:user :profile_image_url])}]
                           [:strong.name (get-in tweet [:user :name])]
                           [:span.screen_name (str "@" (get-in tweet [:user :screen_name]))]]]
            [:div.text (:text tweet)]
            [:div.created_at (:created_at tweet)]
            [:div.actions #_"ここは保留"]]

まずこんな vector が生成され、それを hiccup の html マクロに解釈させることで最終的な HTML 文字列が生成されます(アクションボタンのところは CSS 依存なので後回しにします)。この vector は html マクロに解釈させると、

<div class="tweet">
  <div class="user">
    <a href="https://twitter.com/<screen_name フィールドに書かれているスクリーン名>">
      <img src="<profile_image_url_https フィールドに書かれている URL>" class="avatar" />
      <strong class="name">&lt;name フィールドに書かれているユーザ名&gt;</strong>
      <span class="screen_name">&lt;screen_name フィールドに書かれている screen名&gt;</span>
    </a>
  </div>
  <div class="text">&lt;text フィールドに書かれているツイート本文&gt;</div>
  <div class="created_at">&lt;created_at フィールドに書かれている投稿日(要フォーマット変換)&gt;</div>
  <div class="actions">&lt;返信等の各種アクションボタン(今回は後回し)&gt;</div></div>

このようになります(実際にはインデントされないことに注意)。

テストコードの拡張

これらの vector と HTML 文字列のテストを加えると、新しいテストコードは以下のようになります。

(ns <project-name>.core.handler-test
  (:require [clojure.test :refer :all]
            [ring.mock.request :as mock]
            [<project-name>.core.handler :refer :all]
            [data.json :as json]
            [oauth.client :as oauth]
            [clj-http.client :as http]
            [hiccup.core :as hiccup]))

(deftest test-status
         ; /<screen_name>/status/xx が Twitter への接続に成功するかどうか。
         ; 失敗した場合は、何らかの接続例外か -1 が返るだろう。
         ; ここで、 status フィールド(ツイートのではなく、HTTP ステータスコードの方)を見つけられなかった場合は
         ; 明示的に -1 を返すようにしている。
         (testing "Tests for connection success:"
                  (is (not= -1
                            (-> (get (app (mock/request :get "/<screen_name>/status/xx")))
                                (get :status -1)))
                      "The connection failed."))
         ; /status/:id が正常に機能するか。かなり強引なやり方でテストしている(後述)。
         (testing "Tests for /status/:id:"
                  ; 接続自体が成功していれば、いずれの場合も Twitter は JSON を返す。
                  (testing "Correctness of returned JSON:"
                           (is (instance? clojure.lang.IPersistentMap
                                          (-> (@#'<project-name>.core.handler/get-json xx)
                                              (get :body)
                                              (json/read-str :keyword-fn keyword)))
                               "Invalid JSON form."))
                  (with-test
                    (defn- valid-vector
                      [tweet]
                      [:div.tweet [:div.user [:a {:href (str "https://twitter.com/" (get-in tweet [:user :screen_name]))}
                                                 [:img.avatar {:src (get-in tweet [:user :profile_image_url])}]
                                                 [:strong.name (get-in tweet [:user :name])]
                                                 [:strong.screen_name (str "@" (get-in tweet [:user :screen_name]))]]]
                                  [:div.text (:text tweet)]
                                  [:div.created_at (:created_at tweet)]
                                  [:div.actions #_"ここは保留"]])
                    ; JSON の内容が正しく vector に展開されているか(hiccup に解釈させる一歩前)
                    (testing "Correctness of expanded vector:"
                             (is (= (-> (@#'<project-name>.core.handler/get-json xx)
                                        (json/read-str :keyword-fn keyword)
                                        valid-vector)
                                    (-> (@#'<project-name>.core.handler/get-json xx)
                                        @#'<project-name>.core.handler/to-vector))
                                 "Incorrect expanded vector."))
                    ; 最終的に出力される HTML は正しいか
                    (testing "Correctness of converted HTML:"
                             (is (= (-> (@#'<project-name>.core.handler/get-json xx)
                                        (json/read-str :keyword-fn keyword)
                                        valid-vector
                                        hiccup/html)
                                    (-> (app (mock/request :get "/<screen_name>/status/xx"))
                                        (get :body)))
                                 "Incorrect output HTML.")))))

(deftest test-authorize
         (are [valid-param requested-param] (= valid-param requested-param)
              ; Twitter側の認証画面にリダイレクトできているか
              (str "https://api.twitter.com/oauth/authorize?oauth_token=" <token>) (get-in (app (mock/request :get "/auth/twitter"))
                                                                                           [:headers "location"]
                                                                                           "")

              ; Twitterからリダイレクトされた時、 oauth_token と oauth_verifier は正しく保存されているか。
              ; Twitter の /oauth/authorize での認証が終わってこちら側にリダイレクトしてきた時に、
              ; パラメータに渡されてきた oauth_token と oauth_verifier が クッキーに残っているか を確認する。
              true (let [request (mock/request :get "/auth/twitter" {:oauth_token <token> :oauth_verifier <verifier>})
                         request (mock/header request "referer" (str "https://api.twitter.com/oauth/authorize?=" <token>))
                         requested (app request)]
                        (and from-twitter?
                             (not-any? false?
                                       [(get-in requested [:headers "Set-Cookie" "oauth_token"] false)
                                        (get-in requested [:headers "Set-Cookie" "oauth_verifier"] false)])))))

前回に比べて大幅な変更が入りました。この中でも

  • testing マクロ
  • is と are の違い
  • with-test マクロ
  • var への deref (@#'<project-name>.core.handler/* のこと)

上記4つのことは覚えておく必要があります。順に説明します。

testing マクロ

testing マクロは、テストの実行の前に指定したメッセージ(第一引数の文字列)を出力してくれるマクロです。つまり、一定の範囲でテストにコンテキストを持たせます。上記コードのように、ネストした使い方も可能です。

is と are の違い

is は単行(もちろん、複数の is マクロを書き連ねることもできます)でテストを走らせるものであるのに対し、 are はそのスコープの中で、同じ比較の仕方ができるものについて、複数の値を同時にテストするためのものです。実際には、 are は clojure.template/do-template マクロを使って、複数の is マクロを template に沿って同時に展開してくれます。一見 are の方がとにかく便利ですが、 are のコード展開は事前に引数として渡された template の内容に固定されます。
例えば、is と同じように各テストのエラーパターンに対してメッセージを出力させていく場合について考えてみましょう。素直に考えれば、

(are [x y msg] (if (= x y) true (throw (Exception. msg)))
     2 (+ 1 1) "hoge"
     4 (+ 2 2) "fuga")

このように書くだろうと思います。

is の実装

ここで、 is のコードを見てみましょう。

(defmacro is
  "Generic assertion macro.  'form' is any predicate test.
  'msg' is an optional message to attach to the assertion.
  
  Example: (is (= 4 (+ 2 2)) \"Two plus two should be 4\")
  Special forms:
  (is (thrown? c body)) checks that an instance of c is thrown from
  body, fails if not; then returns the thing thrown.
  (is (thrown-with-msg? c re body)) checks that an instance of c is
  thrown AND that the message on the exception matches (with
  re-find) the regular expression re."
  {:added "1.1"} 
  ([form] `(is ~form nil))
  ([form msg] `(try-expr ~msg ~form)))

is のエラーメッセージ出力は、専用の機能が使われていることがわかります。 try-expr マクロの実装を追っていくと、 is のメッセージ出力は例外処理をラップしていることがわかります。このような処理を are のコンテキストで再実装、というのはさすがに面倒ですね。では are の expr 中で is マクロを使えばいいのかというと、それは構文エラーになります。 are はコード展開時に is に必要な値を埋め込んで、そうして出来たコードを生成していくように書かれているからです。では、 try-expr マクロを再利用すればいいのかというところですが、それはやめておくべきでしょう。なぜなら、 try-expr マクロを作った本人が doc コメント中にはっきりとこう書いてあるからです。

"Used by the 'is' macro to catch unexpected exceptions. You don't call this."

is と are の使い分け方

この事から、 is と are の使い分け方はおおむね

  • are は、テストの意味が同じであるか、同様の比較ができるものを1箇所に集約させる時に使う
  • is は単行で済むテストや、個別にエラーメッセージを出力させる必要がある時に使う

上記2つに落ち着くかと思います。

with-test マクロ

with-test マクロは、そのスコープの中で共通に利用する関数を新たに定義しつつテストコードを書けるマクロです。イメージは丁度上記コードのような感じです。

var への deref

今回のコードの中で最も注意が必要なのが、 var への deref です。通常、 Clojure で deref を使う機会があるとすれば、それはほとんどの場合において ref, atom, agent といった並列処理や並行処理のために実装されたオブジェクトの状態を取得するときでしょう。それらと同様に deref が機能するという点で、 var にも IDeref の実装が与えられていることは想像できるはずです。しかし、

外の名前空間の var をクォートして更にそれを deref で呼び出している?しかも private な関数から?

おそらく、どういうことになっているのかイメージがつきにくいと思います。私もそうでした。このコードの動きを理解するには、まず Clojure の var がどういう実装なのかについて知る必要があります。端的に言うと、 Clojure の var は、シンボルのインスタンスへのポインタなのです。更には、動的変数のためのスレッドローカルな値を確保するための機能も用意されています(今回はここには触れません)。簡単に図にすると、以下のような感じです。

       参照
[Var] ------> [何らかのインスタンスのシンボル] ※このシンボルの中に実際の値がある。

これに対して外部から deref で呼び出しているということは、こういうことになります。

(var クォートの deref) ---------
                               |
       参照                   \|/
[Var] ------> [何らかのインスタンスのシンボル] ※このシンボルの中に実際の値がある

つまり、 var そのものではなく、 var の参照先のシンボルの情報を直接取得しているのです。
では、 def で定義した var のメタデータとして付けることができる private キーワードにはどういう意味があるのでしょうか。上図を見ていると、大体想像がつくかと思います。

(var クォートの deref) --------
                              |
       参照                  \|/
[Var] ------> [何らかのインスタンスのシンボル]
 ↑
private メタがつくのはここ

private メタで保護されるのは、実際の値ではなくポインタである var の方なのです。何故そうなっているのかというと、 var はあくまでポインタなので、参照する値を後からいくらでも変更できるからです。よって、 def による var の定義に関しては redefined でエラーが飛んでくることはまずありません。しかし、これはシャドウイングが発生する要因でもあります(このことについてまで触れると話が長くなりすぎるので、はしょります。詳しくはググッてね)。
というわけで、今回のテストコードでは、新たに書き直されたハンドラ関数の個々の処理についてテストするために、外部にある関数の参照先を意図的に取得しに行くという(かなりむちゃくちゃな)行動に出ていたのでした。
今回のテストコードの説明については以上になります。では、最後にハンドラ関数の書き直しに移ります。

実装コードの拡張

(ns <project-name>.core.handler
  (:require [data.json :as json]
            [oauth.client :as oauth]
            [clj-http.client :as http]
            [hiccup.core :as hiccup])))

(def ^:private consumer-key #_hoge)
(def ^:private consumer-secret #_fuga)

(def ^:private consumer (oauth/make-consumer <consumer-key>
                                             <consumer-secret>
                                             "https://api.twitter.com/oauth/request_token"
                                             "https://api.twitter.com/oauth/access_token"
                                             "https://api.twitter.com/oauth/authorize"
                                             :hmac-sha1))

(def ^:private request-token (oauth/request-token consumer <callback-uri>))

; アプリケーション認証画面(/oauth/authorize?oauth_token=xxxx...) のURLを生成する。
; 「Twitterアカウントでログイン」ボタンなどのリダイレクト先に必要
(def ^:private user-approval-uri (oauth/user-approval-uri consumer
                                                          (:oauth_token request-token)))

(def ^:private access-token-response (oauth/access-token consumer request-token <verifier>))

(defn- credentials
  [url params]
  (oauth/credentials consumer
                     (:oauth_token access-token-response)
                     (:oauth_token_secret access-token-response)
                     :GET
                     (str url params)))

(defn- get-json
  [^long id]
  (let [url    "http://api.twitter.com/1.1/statuses/show/"
        ; OAuthライブラリの側にクエリ文字列のためのWrapperはない。
        params {:headers      {"authorization" (authorization-header (credentials url (str id ".json")))}
                :query-params {:id id}}]
       (-> (http/get url params)
           (json/read-str :keyword-fn keyword))))

(defn- to-vector
  [tweet]
  [:div.tweet [:div.user [:a {:href (str "https://twitter.com/" (get-in tweet [:user :screen_name]))}
                             ; profile_image_url のフィールドの扱いは、設定を参照して HTTPS にするかどうかを選択するようにはしたいと思っている。
                             [:img.avatar {:src (get-in tweet [:user :profile_image_url])}]
                             [:strong.name (get-in tweet [:user :name])]
                             [:span.screen_name (str "@" (get-in tweet [:user :screen_name]))]]]
              [:div.text (:text tweet)]
              [:div.created_at (:created_at tweet)]
              [:div.actions #_"ここは保留"]])

(defn- status-show
  [^long id]
  (-> (get-json id)
      to-vector
      hiccup/html))

(defroutes app-routes
           ; Twitter認証(今回の記事では省略)
           (GET "/auth/twitter" [] get-auth)
           (context "/:screen_name" [^String screen_name]
                    (GET "/" [] #_"こっち側のサーバ から 最新のデータを取得 する処理")
                    (GET "/status/:id" [^long id] (status-show id))))

(def app (wrap-defaults app-routes site-defaults))

まとめとか雑感とか

  • clojure.test のマクロは地味に便利。

でもウェブだとやっぱり MVC がちゃんと揃ってないフレームワークのテストはまだ辛い…

  • Clojure の Var は deref できる。例えそれが private な Var であっても。

でもできるなら使わずに居たいよね…。名前空間完全修飾とかめんどい…[1]

  • ぬわああああんウェブ開発疲れたもおおおん

この辺(?)にぃ、銭湯の店、あるんですけど、行かないっすか?
???「おっそうだな」
じゃけん夜行きましょうね。
………ヌッ(大雪)
もうやめたくなりますよ〜外出ゥ〜

(話を)もどして、compojure を使うメリットは?

  • ring と作者が同じなので、基本的には一人の作者だけを追いかけていればよい。(多分、依存もそこまでこんがらがらない)
  • 作り自体は割とシンプルなので、ウェブ開発の練習用としてもそれほど悪くはない。
  • 基本的には、これに加えてその目的にあったライブラリをいくつか追加でインストールするだけでよい。

………デメリットは?

  • ないです(大嘘)
  • ハンドラとビューが直結しているので、今回の記事の内容からも想像がつくように、HTML 出力が絡み出すとその分だけテストの難易度が上がってくる(今回はツイートのデータ単体を HTML でデコレートするだけだったけれども、これが一度にリスト表示するとかなると…(´・_・`))。
  • ルートの定義の仕方は、マクロの実装により決められている。つまり、ハードコーディングするしかない。API の中に現在のルートを取得したり、ルートに何らかの値を追加して新しいルートを作るためのものがない(HTTP ヘッダ中の uri フィールドを上手く使えばなんとかなりそうな気がしている。今のところ試す気はないけれど)。もし今すぐ必要だという方は bidi を探して、どうぞ。
  • よって、大量のデータをページ化して小分けに表示するとか、そういう機能は現在の実装だけでは利用できない。そもそもページ化が不要で、今流行りのリッチな遅延表示でいいというのであれば、割となんとかなりそうな気がする。
  • ミニマル構成である(== オールインワンではない)が故のあれこれ。今のところ、まだその事態には遭遇したという話は聞き及んでいないが、依存地獄に陥らないとは言い切れない。(yesod とか使ったことある人ならわかると思う。まあ、あれは cabal が上手に依存解決しきれてないのが原因だったわけだけども。 stack はそのへんどうだっけな…)[2]

では、compojure とその周辺ライブラリでも比較的開発が用意なパターンはなんだろうか。

  • おそらく、 REST API
  • つまり、リクエストに対して返すものが HTML 文字列ではなく、JSON などの形式の決まったデータ構造のみである、というもの。
  • 返り値となる JSON は、 parse すれば Clojure でも容易に扱える(== Clojureのデータ構造に置き換えてテストできる)。ただ、 Twitter の JSON はめんどい…
  • そもそもウェブブラウザ上で表示することの方が少ない API なので、ウェブというよりは、バックエンドのライブラリ開発に近い

ではでは、おやすみなさい〜。

あ、皆さん、よいお年を!

追記

[1] var クォートであっても、名前空間の別名修飾は可能。(@ayato_p さんのご指摘の3.)
[2] 依存関係は、 bidi のそれに比べれば大きい方(@ayato_p さんご指摘の5.)。おそらく、複数のユーティリティライブラリを(本人が自作したのも含めて)インストールして利用しているからだと思われる

6
4
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?