2012年9月13日木曜日

Datomicの利用(2)

はじめに

おはようございます。当ブログにアクセス頂き、ありがとうございます。
西川きよし師匠の「小さなことからこつこつと」に、とても共感している、たなけんです。
本エントリでは、ClojureアプリケーションでのDatomicの利用について記載します。

仕様

今回のWebアプリケーションでは、利用制限のある外部APIを使用します。そのため、一日の間に、一定回数を超えるアクセスがあった場合、外部APIへの利用を止める処理が必要となります。
そこで、アプリケーションから外部APIへ問い合わせた内容を永続化しておき、次のアクセスの前に、その日のAPIコール回数を(データベース/Datomic)確認し、利用制限を超えている場合は、その旨をユーザに伝えることとします。
APIへの問い合わせ内容として、まずは検索した単語と検索した日時を記録します。
また、調べられている単語や、アクセスされている日時の傾向を把握する為に、入力された全ての履歴を表示する画面も追加します。

実装

実装の大部分は、thearthurさんのgithubのコードを参考にさせて頂きました。

プロジェクトファイル

(defproject my-dictionary "0.1.0-SNAPSHOT"
:description "A tiny web application for requesting Dictionary.com web api"
:url "http://"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [
[org.clojure/clojure "1.4.0"]
[org.clojure/tools.logging "0.2.3"]
[log4j/log4j "1.2.16"]
[log4j/apache-log4j-extras "1.1"]
[clj-http "0.4.3"]
[clj-json "0.5.0"]
[clj-xpath "1.3.0"]
[compojure "1.1.1"]
[ring/ring-jetty-adapter "1.1.1"]
[clj-time "0.4.4"]
[com.datomic/datomic-free "0.8.3488"]]
:ring {:handler my-dictionary.core/interface-for-client}
)
view raw project.clj hosted with ❤ by GitHub

Datomicのクライアント(Datomic用語でpeer)であるcom.datomic/datomic-freeおよび、時間計算に便利な(Joda Time LibraryのClojureラッパ)ライブラリであるclj-timeを追加しました。

ns マクロ

(ns my-dictionary.core
(:use [datomic.api :only [db q] :as d])
(:require
:reload-all
[clojure.tools.logging :as logging]
[clojure.string :as string]
[clj-http.client :as http-client]
[clj-xpath.core :as xpath]
[clj-json.core :as json]
[compojure.core :as compojure-core]
[compojure.route :as compojure-route]
[clj-time.core :as time]
[clj-time.coerce :as time-coerce])
(:import
(java.io.IOException)
(org.xml.sax.SAXException)
(myDictionary.java.AppException)))
view raw core.clj hosted with ❤ by GitHub

プロジェクトファイル同様Datomicおよびclj-timeライブラリの利用を宣言します。

  • use節: datomic.apiのうちdbとqは名前空間を付けずに呼び出せ、他の関数は名前空間のエイリアスにdを仕様
  • require節: clj-time.coreおよびclj.time.coerceの名前空間のエイリアスを設定


データベースおよびスキーマの作成(DDL)

(def uri "datomic:free://localhost:4334//my-dictionary")
(d/create-database uri)
(def conn (d/connect uri))
(defn add-attribute-work-word
""
[]
(d/transact conn [{
:db/id #db/id[:db.part/db]
:db/ident :work/word
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "A word"
:db.install/_attribute :db.part/db}]))
(defn add-attribute-work-time
""
[]
(d/transact conn [{
:db/id #db/id[:db.part/db]
:db/ident :work/time
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one
:db/doc "Time"
:db.install/_attribute :db.part/db}]))
(add-attribute-work-word)
(add-attribute-work-time)
view raw core.clj hosted with ❤ by GitHub

データベース及びスキーマを定義します。無料版のDatomicではメモリにデータを保存するmemプロトコルと、H2 Database Engineを内部で利用し、ファイルにデータを保存するfreeプロトコルを利用することができます。
Datomicのスキーマ定義はRDBMSとはやや異なり、属性毎に個別に定義を宣言することでエンティティ全体を組み立てる形式となります。
具体的には、上記ソースでは、エンティティがworkで、wordとtimeがworkの属性として宣言されています。(それぞれ型はstringとinstant(日時))
RDBMSでの定義を(単純化して)考えると、テーブル名がworkとなり、wordとtimeはテーブルworkの中の列となります。
RDBMSのようにトップダウンでスキーマを定義する訳ではなく、逆にボトムアップでスキーマを定義して行きます。
実際、列の型を変えるなど、プログラミングしながら、属性を追加したり、削除したのですが、スキーマ変更に伴い、わざわざ既存のデータを変更(エクスポート&インポート)しなくても良かった点には驚きました。(RDBMSを利用している場合は、スキーマを変更した場合は、データのインポートが必須となるので)
データモデルを固める前に、いろいろと試すことができる、柔軟であるといった意味では、開発時に余計な作業でプログラマの時間を奪わない工夫がされていると感じました。
その一方、データ型が不揃いであることから、検索時の性能劣化、柔軟すぎて不整合が発生しないだろうかなど、運用時に考慮すべき課題も目につきました。

  • d/create-database関数: 引数のurlをアドレスとするデータベースを作成(データベースを作成していないと、コネクション作成時に例外が発生する点に注意が必要)
  • d/connection関数: 引数のurlで指定されたデータベースへの接続を確立する
  • d/transact関数: 引数の式をトランザクションとして実行する


データの追加および検索(DML)

(defn add-a-work
""
[word date]
(d/transact conn [{:db/id #db/id[:db.part/user] :work/word word :work/time (time-coerce/to-date date)}]))
(defn find-all
""
[]
(q '[:find ?n ?t :where [?e :work/word ?n] [?e :work/time ?t]] (db conn)))
(defn today?
""
[date]
(let [now (time/now) today (time/date-time (time/year now) (time/month now) (time/day now))]
(time/within? (time/interval today (time/plus today (time/days 1))) (time-coerce/from-date date))))
(defn find-today
""
[]
(q '[:find ?n ?t :where [?e :work/word ?n] [?e :work/time ?t] [(my-dictionary.core/today? ?t)]] (db conn)))
view raw core.clj hosted with ❤ by GitHub

スキーマの定義が管理用領域への(メタ)データの書き込みであるのに対し、データの追加はユーザ領域へのデータの書き込みとなります。(どちらも "d/transact conn データ" と書かれているのはその為です。)
add-a-work関数では、引数に単語と日時を取り、workエンティティを追加します。
find-all関数では、登録されている全てのworkエンティティを取得します。q関数の引数となっているクォートされたベクタがSQLで言う所のSELECT文に相当します。
:findに続く?n ?tは、SQLのSELECTに続く列名の様なものです。しかし、SQLとは異なり属性定義(スキーマ)に紐付いている訳ではなく、任意の文字列が利用可能です(?nameなど)。
?nおよび?tが何を表わしているのかは、続くwhere節で定義されます。
”[?e :work/word ?n]”は「任意のエンティティ?eの属性:work/wordの値は?nである」ということを宣言しています。言い換えると、任意のエンティティ?eは、属性に:work/wordを持ってることになります。つまり「?nは属性に:work/wordをもっているとあるエンティティの:work/wordの値である」ということを表わしています。
"[?e :work/time ?t]"も同様に、「?tは属性に:work/timeをもっているとあるエンティティの:work/timeの値である」となります。
では複数ある:work/wordの値と:work/timeの値が、それぞれ同一エンティティに紐付いていることはどのように示されているのでしょうか?(SQLでテーブルを結合する際に、結合する基準となる列を指定するようなイメージ)
実はDatomicでは特にキーを指定する必要がありません。なぜなら前の式”[?e :work/word ?n]”の?eと、後の式"[?e :work/time ?t]"の?eは、任意ではあるが同一のエンティティと見なすからです。Datomicのクエリで使用される名前(?nなど)は、同じ名前であれば同じオブジェクトを指していると見なされます。この結果、find-all関数の結果として、属性に:work/wordと:work/timeを持っているエンティティの、それぞれの値の集合が返されます。この仕組みをDatomicでは論理プログラミング(Logic programming)と呼んでいます。

  • time/now関数: 現在日時を取得
  • time/within?: 引数のintervalの中に引数の日時が含まれて入ればtrueを返す
  • time-coerce/to-date: Jodaのdatetime型からjava.util.Date型へ変換
  • time-coerce/from-date: java.utils.Date型からJodaのdatetime型へ変換


クライアントからのアクセスのコントロール

(defn limit?
""
[n]
(> n (count (find-today))))
(defn call-api
""
[url prms extract-function]
(if (limit? 50)
(do
(add-a-work ((nth prms 1) 1) (time-coerce/to-date (time/now)))
(-> (build-url-with-prms url prms)
(get-body ,)
(extract-function ,)
(json/generate-string ,)))
(json/generate-string (create-data '({:pos "" :text "I am sorry, this system is busy. Please access again tomorrow"})))))
view raw core.clj hosted with ❤ by GitHub

外部APIにアクセスする前に、その日のアクセス総数を取得し、制限を超える場合は、検索をせずにsorryメッセージを返すよう、limit?関数を追加、call-api関数を変更しました。

全履歴を取得する管理画面

(def root-url "http://api-pub.dictionary.com/v001")
(def common-headers {"Access-Control-Allow-Origin" "*" "Content-Type" "application/json"})
(compojure-core/defroutes interface-for-client
;
(compojure-core/GET "/dictionary/:word" [word]
(merge {:headers common-headers}
{:body
(call-api root-url
(list
[:vid (:vid properties)] ["q" word] ["type" "define"] ["site" "dictionary"])
extract-dictionary)}))
;
(compojure-core/GET "/example/:word" [word]
(call-api root-url
(list
[:vid (:vid properties)] ["q" word] ["type" "example"])
extract-example))
;
(compojure-core/context "/random" []
("/dictionary" []
(call-api root-url
(list
[:vid (:vid properties)] ["type" "random"] ["site" "dictionary"])
extract-random))
("/thesaurus" []
(call-api root-url
(list
[:vid (:vid properties)] ["type" "random"] ["site" "thesaurus"])
extract-random)))
;
(compojure-core/GET "/spelling/:word" [word]
(call-api root-url
(list
[:vid (:vid properties)] ["q" word] ["type" "spelling"])
extract-spelling))
;
(compojure-core/GET "/find-all" []
(str (find-all)))
;
(compojure-route/not-found "Page not found"))
view raw core.clj hosted with ❤ by GitHub

httpクライアントからfind-allを呼ぶインターフェースを追加しました。

今回の作業は以上。最後までお読み頂き、ありがとうございました。
たなけん(作業時間2時間)

0 件のコメント:

コメントを投稿