Clojure
AdventCalendar
Compojure
ring
ClojureDay 25

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

More than 1 year has passed since last update.

遅れてしまって申し訳ない…

はじめに

Clojure でウェブアプリまたはウェブフレームワークといえば、まだ Clojure が 1.2 以前だった頃に ring という Servlet API の Wrappers & Protocols と、それを利用した軽量なフレームワークルーティングライブラリ(修正 2015/12/11)である Compojure 、そして、 Compojure を利用して作られた Conjure や Noir といったフルスタックなフレームワークが開発されましたが、Clojure 1.2 時点でのリリースを境に、どれも開発が止まっていました。
それが今年になって、 Compojure の開発がようやく再開されたことで Clojure でウェブ開発する環境が再び整ったため、試しに手元にある環境で Twitter 連携サービスを作ろうと思い投稿を決定しました。

作業を始める前に

まずはハードの準備が必要ですね。環境は以下の通りです。

稼働環境

ホスト

  • Arch Linux 64bit
  • Intel Core i5-3570
  • DDR3 DIMM(32GB)
  • HDD 1TB(not RAID)

ゲスト1(今回記事に上げる鯖)

  • Debian wheezy on VirtualBox
  • メモリ 4GB
  • CPU 4 cores / 最大使用率100%
  • HDD 4GB(MBR(relatime)), 8GB(/var/log(relatime, noexec)), 4GB(/var/www(relatime))
  • Ring & Compojure(Clojureのフレームワーク)

ゲスト2(データベースサーバ)

  • Debian wheezy on VirtualBox
  • メモリ 4GB
  • CPU 4 cores / 最大使用率 100%
  • HDD 4GB(MBR(relatime)), 8GB(/var/log(relatime, noexec)), 32GB(/var/db(relatime, noexec))

サーバ構成

どういうサービスか

Twitterと連携して、取得したツイートを短期的に保存する

Favstarのリプライ版だと思ってください。また、OAuth認証を通す・通さない、サービスへログインする・しないなど、セッションの状態でサービスを振り分ける事も検討しています。

VM上で動かすため、1台の物理マシンと1台の物理ルータですべてやりくりする必要があるのと、上述の通りデータベースサーバのHDDは高々32GBであることを考慮するとあまり大量のデータは保存できません。参考として、以下の情報を基準にサーバの仕様を考えます:

データ量の見積もり

  1. JSONをテキストとして見た場合、ツイート1つで送受信される容量は4キロバイト前後。
  2. ツイートのリミットは1日 2400件。(Twitterヘルプセンターより)
  3. 人は、その日知り得た情報の内74%をその日の内に忘れ、26%を保持する。

1.について: Apigee's API Consoleでstatuses/update.jsonを適当な内容のツイートと共に呼んでみて下さい。UTF-8では1文字のサイズは高々4バイトで、1度のツイートで送れるテキストは140文字。プロフィールに送れるテキストは160文字。この2つを合わせてもその容量は

300文字×4=1.2キロバイト

です。そして、ツイートとプロフィールを差し引きしたJSON(テキスト)の容量は2.6キロバイトです。

2.について: 本当に2400件みっちり送れば1人でも1日で約10Mの容量になってしまいます。
そこで、所謂"めっちょツイートしている(過去にしていたのも含む)"人が、1日にどれだけツイートしているのかを、以下の2つのサイトを参考にしてみてみましょう。

どうやら単純なツイート数では1位の人が断トツ(?)でヴェネティスさんなので、彼を参考にしましょう。(軽く調べるとKeitai webで規制されなかった頃に"遊びまくってた"とか)
妙に飛び抜けちゃってるのを外すと、1日あたり(150+-50)件のようです。

続いて、ツイ廃アラートで日々ツイート数を教えてくれてる人からも一人、参考にしましょう。
Search APIで調べたところ、最も多い1日のツイート数は400〜600件のようなので、そこに出ているユーザの情報を見てみましょう。

彼もまた、飛び抜けてる分を除けば1日約200ツイートということになります。

3.について: これはエビングハウスの忘却曲線の受け売りです。人の記憶力がこの曲線に従うと決めておきます。すると、反復しなければ直近1ヶ月以上前の記憶をほとんど忘れていることになるので、これより多くの情報を保持する必要はなさそうです。

以上を踏まえれば、一人あたり1ヶ月分(200件*30日*4キロバイト=24メガバイト)を保存できれば十分ということになります。

プロジェクトと依存ライブラリ

さて、それでは本題に入りましょう。まずは依存ライブラリを紹介します。

Compojure

Clojureでおなじみのフレームワークです。当時これより後に出ていたフルスタックなフレームワークは、ほとんどこの Compojure をベースにして開発されています(今となってはほぼ過去の話ですが)。Compojure で管理できる部分は

  1. URLのルーティング
  2. Response(View)
  3. Handler(関数)

の3つです。ただし、 Response には データ構造や言語の構文を解釈してHTMLに変換する ための機能はありません。あくまでも、文字列、マップ、関数、deref、ファイル、シーケンス、InputStream、URLと言った、Clojureでもよく使う クラス や データ構造 に対して、 render関数の実装を与えている だけです。

Hiccup

そこで必要になるのがHiccupです。
Hiccupは、HTML を Vector として記述し、それをライブラリ側が解釈してHTMLに変換します。例えば

(html [:p])

このコードは

<p></p>

に変換されます。ちなみに、Clojure の Vector は 32分木をビット演算で走査する 形で実装されているため、かなり高速に動作します。これで HTMLを楽に記述する方法 は確保しました。しかし、まだ足りません。

lib-noir

  • リダイレクト
  • セッション / クッキー
  • 入力値の検査(validation)
  • 内容のキャッシュ
  • パスワードのハッシュ化

などなど、compojureだけではどうにもならない機能は少なくないです。これらを解決してくれるのが lib-noir です。詳しい使い方は後述します。
ページ表示周りのライブラリは揃いました。次に必要になるのはデータベースへのアクセスです。

java.jdbc

データベースにアクセスするには java.jdbc ライブラリを使います。 Java用のドライバがあるデータベース としては MySQL が有名ですが、このライブラリは他にも SQLite, HSQLDB, PostgreSQL, Apache Dirby などのデータベース用のドライバもセットでインストールします。

さて、これでMVC周りは揃いました。以下に記載するライブラリは Twitterとの連携に必要 になるものです。

clj-http

HTTPクライアント用のライブラリです。HTTPアクセスする際に必要になるパラメータを、マップ、ベクタ、キーワードなどのClojureの便利なリーダマクロとデータ構造を使って書けるようになっています。

clj-oauth

Twitter といえば避けて通れないのが OAuth ですね。このライブラリは OAuth認証に必要な機能 を一通り揃えてくれています。

なお、Clojure の Twitterクライアントライブラリ といえば twitter-api がありますが、もうちょっと便利なものが欲しいので自作します。

lein-ring

これは ClojureでServlet APIを動かすためのラッパとClojureからServlet APIにアクセスするための規約を定義したClojure用のサーバライブラリであるring を コマンドから操作する ための leiningen プラグイン です。
lein ring server <port> で現在開発中のウェブアプリケーションの実態をブラウザで確認でき(ブラウザが起動する)、 lein ring server-headless <port> でブラウザの起動なしにウェブアプリケーションを動かすことができます。
lein ring warlein ring uberwar で、 Tomcat用のウェブアプリケーションのアーカイブ を作成できます。

lein-auto

ソースコードの変更を即座に反映し、その反映されたコードに対して自動的にテストを実行してくれます。ウェブアプリケーション開発の必須アイテムです。プラグインをインストールして lein auto test と打つだけでOKです。

codox

ソースコード中の :doc メタを参照して、ドキュメントを生成してくれます。プラグインをインストールした後 lein doc と打てば、その時点で記述されている :doc メタからドキュメントが生成されます。詳しい使い方はweavejester/codoxを参照してください。

プロジェクトの作成

必要なライブラリは一通り確保したので、次はいよいよプロジェクトディレクトリを作成し、プログラミングを始めましょう。プロジェクトのスケルトンを作るのは簡単です。シェル上で lein new compojure <project-name> と打てば compojure用のプロジェクトスケルトン が作成されます。

project.clj の記述

プロジェクトディレクトリの中にある project.clj に 依存ライブラリ や 必要なleiningenプラグイン の情報を記述していきます。

(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"]
        ]
      }
    })

project.clj を記述したら、同じディレクトリ上で lein deps を実行し、依存しているライブラリを一通りインストールしましょう。おや、まだ見慣れない記述がありますね。
:profiles は、 そのパッケージをインストールして利用する際には必要としない ものの、そのプロジェクトの 特定の場面 、 ユーザレベルで常に依存するライブラリやプラグイン の情報を記述する箇所です。 :dev プロファイルは開発時のみ必要となる(そのプロジェクトの中で lein deps を実行した時にだけインストールされる)ライブラリやプラグインの情報を記述します。
この場合は、開発している間のみ Servlet API と ring-mock が必要だということになります。

ring-mock は compojureやringで作成したウェブアプリケーションのコード のための テストライブラリ です。clojure.jarにあるAPIだけでは、ウェブアプリケーションのAPIやURLルートに対してテストをするのは困難なので、その部分を埋めてくれます(clj-http でも同じような事は可能ですが、 テスト目的であればlocalhost以外へのアクセスは不要 なので、今回は ring-mock を使います)。

URLのルーティング

まずは、どのURLが呼ばれた時に、どの関数を返すかを決めましょう。ここで覚えておいて欲しいことがあります。ルートと割り当てる関数を決めたら…

  1. まずは、「そのルートを通して取得・保存するデータを格納しておくための場所」と「そこで保存しているデータベースのレコードなどの構造」を明確かつ可能な限り正確に決める。
  2. 次に、「そのルートへのアクセスを試みて、正しいパラメータを渡したら正しいデータが返ってくるか、間違ったデータを渡したらちゃんとエラーを返すようになっているか」を確かめるための テストコード を書く。
  3. そして、肝心の関数を書き、それが終わったら、テストを走らせる。

これを1関数1ルート単位でちゃんとやること。ページプラグインのこととか、具体的なHTML出力について気にするのは、実装予定の関数が正確なデータを返すことを一通り確かめた後、例えば結合テストの手前あたりで十分です。ページのレイアウトが崩れるとかいう現象は単にタグやスタイルシートの使い方が間違っているか、スタイルシートを読み込めていないだけなので、実際のセキュアやデータの正確性にはほとんど影響しません。では、作業を始めましょう。
プロジェクトディレクトリの src/<project-name>/core/handler.clj を見てみましょう。

(ns <project-name>.core.handler
  (:require
    [compojure.core :refer :all]
    [compojure.route :as route]
    [ring.middleware.defaults :refer [wrap-defaults site-defaults]]))

(defroutes app-routes
  (GET "/" [] "Hello world")
  (route/not-found "Not Found"))

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

初めはこんな風になってます。
トップレベルで束縛されているシンボル app が :ring {:handler ...} と対応しています。 wrap-defaults は、プロキシ、セッションやクッキーなどといった、ウェブサービスに必要な諸々の設定をライブラリ側でしてくれます。 site-defaults は、その設定情報を束縛したシンボル(マップ)です。 project.clj の :ring {:handler ...} には、root / をコントロールしているシンボル(ハンドラ)を渡します。
ring-defaultsパッケージのAPI は、元々は compojure.handler にありました。現在では ring側のミドルウェア としてリファクタリングされています。

テストコードで入出力されるデータを整理する。

手始めに、 URLから渡されてきたid と Twitterのstatus_id とを対応させてみましょう。まずはテストコードを作成します。ここでチェックすべきポイントは以下の2つです:

  • 正しいデータを渡した時に HTTP Status 200 が返ってくるか。
  • 200で返ってきた時に受け取ったJSONフォーマット が tweets.jsonの形 に従っているか

ここでいう「正しいデータを渡した」とは以下の4つの条件に合致したものを指します:

  • Twitter側が指示した通りにOAuthパラメータを渡している。
  • リクエストメソッドが合っている。
  • 必須パラメータを渡している。
  • 渡したIDと同じステータスIDのツイートが返される。
; <project-name>/test/<projet-name>/core/handler_test.clj

(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]))

; /statuses/xx にアクセスしたら、ちゃんとそのIDのツイートが返ってくるか。
; ステータスコードが200 で ちゃんと:bodyが持ってるツイートのIDが渡したIDと一致しているか を見る。
; ステータスコードが返らないなんてことはないだろうけど、型を合わせるために、ステータスコードが見つからない時は -1 を返すようにさせる。show.json からの id 取得も同様。
(deftest test-status
  (are [valid-status requested-status] (= valid-status requested-status)
    200 (get
          (app (mock/request :get "/<screen_name>/status/xx"))
          :status
          -1)
    ; JSON を マップに変換する のを忘れずに。
    xx  (->
          (app (mock/request :get "/<screen_name>/status/xx"))
          (get :body "")
          (json/read-str :key-fn keyword)
          (get :id -1))))
; <project-name>/src/<project-name>/core/handler.clj

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

(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- 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)))

(defroutes app-routes
  (context "/:screen_name" [^String screen_name]
    (GET "/" [] #_"こっち側のサーバ から 最新のデータを取得 する処理")
    (GET "/status/:id" [^long id] (status-show id))))

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

これで終わり?いやいや、もうちょっとだけ続くんじゃ。このサービスの目的はあくまでもTwitter連携(+ 少々の補完)。ということは、まず Twitterで認証を済ませ 、 Twitter側の/oauth/authorize APIからoauth_tokenを受け取る 必要があります。

となると、テストコードにも追記が必要になりますね。以下の3点を確かめる必要があります:

  • https://api.twitter.com/oauth/authorize から リダイレクト されてくるか
  • URLクエリ に oauth_token はあるか
  • oauth_token がリダイレクトの前後で変化していないか(滅多に起こらないと思うけどね)
; <project-name>/test/<projet-name>/core/handler_test.clj

(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]))

; /statuses/xx にアクセスしたら、ちゃんとそのIDのツイートが返ってくるか。
; ステータスコードが200 で ちゃんと:bodyが持ってるツイートのIDが渡したIDと一致しているか を見る。
; ステータスコードが返らないなんてことはないだろうけど、型を合わせるために、ステータスコードが見つからない時は -1 を返すようにさせる。show.json からの id 取得も同様。
(deftest test-status
  (are [valid-status requested-status] (= valid-status requested-status)
    200 (get
          (app (mock/request :get "/<screen_name>/status/xx"))
          :status
          -1)
    xx  (->
          (app (mock/request :get "/<screen_name>/status/xx"))
          (get :body "")
          (json/read-str :key-fn keyword)
          (get :id -1))))

; コードがネストする場合、
;
; (are [valid-param requested-param] (= valid-param requested-param)
;   valid-param
;     requested-param
;   valid-param
;     requested-param
;   ...)
;
; という風に書くことにする。
(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)
            ])))))

で、後は routes に /auth/twitter を書き加えれば、最初の1歩はOKです。続きはまた次の機会に。

; <project-name>/src/<project-name>/core/handler.clj

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

(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- 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)))

(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))

では、よいお年を!