26
29

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.

ClojureAdvent Calendar 2014

Day 25

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

Last updated at Posted at 2014-12-31

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

はじめに

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

では、よいお年を!

26
29
0

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
26
29

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?