遅れてしまって申し訳ない…
はじめに
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であることを考慮するとあまり大量のデータは保存できません。参考として、以下の情報を基準にサーバの仕様を考えます:
データ量の見積もり
- JSONをテキストとして見た場合、ツイート1つで送受信される容量は4キロバイト前後。
- ツイートのリミットは1日 2400件。(Twitterヘルプセンターより)
- 人は、その日知り得た情報の内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 で管理できる部分は
- URLのルーティング
- Response(View)
- 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 war
や lein 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関数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))
では、よいお年を!