[2009 年 2 月号] |
[技術講座]
今回から Seaside とデータベースとの接続を扱っていきます。データベースとつなぐことで、大規模なデータの取り扱いが可能となり、より本格的な Web アプリが作れるようになります。手始めはオブジェクト指向データベースとの接続です。オブジェクト指向データベースというと難しそうですが、実はテーブルなどへのマッピング作業が不要な分、RDB よりもシンプルなコードとなるのです。Seaside とのシームレスな統合をお楽しみ下さい。
データベースに接続するためには、そのためのライブラリのインストール等が Squeak 側に必要になります。そうした手間を省くため、DB関連のライブラリやパッチをまとめて組み込んだ SeasideJOnePlusDB というバージョンがありますので、そちらを利用することにします。
下記からダウンロードできます。
SeasideJOnePlusDB
例によって Zip ファイルを展開するだけでインストールは終了です。OODBMS (オブジェクト指向データベース管理システム)として SStore と OmniBase 、ORM ( Object - Relational マッピング)として Glorp というものが入っています。Seaside で DB を使いたいというニーズに十分応えられるものと言えるでしょう。
今回は簡単に使え、かつそれなりにスケーラビリティのある OODBMS である、OmniBase を選択することにします。1
*1 RDB との接続には ORM フレームワークの Glorp を利用します。次回で扱います。
OmniBase は、オブジェクト指向データベース管理システムに属するものです。RDB と異なり、オブジェクトをテーブルに変換することなく、そのままの形で格納できるため、スキーマ定義や、オブジェクトとテーブルのマッピングといった準備が不要で、すぐに使うことができます。Smalltalk の OODBMS というと GemStone 2が有名ですが、OmniBase はそこまで本格的なものではなく、小規模から中規模程度のデータを扱うのに適したものになっています3。
OmniBase はそのままでも使うことができますが、今回は SeasideJOnePlusDB に含まれている、DBAccessor というファサードを用いることにします4。これによって、さらに簡単に OmniBase を扱えます。
*2 Seaside と GemStone を組み合わせた GLASS というものもあります。巻末に情報を載せておきました。
*3 OmniBase が扱えるデータ量の上限は 4 GB 程度です。
*4 巻末で紹介した「 OmniBase の使い方」ではファサードを経由しない OmniBase の利用法を解説しています。
では、早速オブジェクトを DB に格納してみることにします。まずは'こんにちは'という文字列を入れてみましょう。以下をワークスペースで実行してみてください。
"DBの取得" dbSession := SBOmniBaseAccessor default. "格納" dbSession commitDo: [:transaction | transaction root at: 'hello' put: 'こんにちは' ].
SBOmniBaseAccessor default で、データベースセッションを取得しています。セッションから commitDo: でコミット用トランザクションを開始することができます。トランザクションからルート辞書を取得し( transaction root )、その辞書に at: put: でキーをつけて、オブジェクトを入れれば完了です。トランザクションはcommitDo:のブロックを抜けた時点で自動的に終了します。
取り出しは、読み込み用トランザクションを readDo: で開始し、ルート辞書に対してキーを at: で指定するだけです。
"取り出し"
dbSession readDo: [:transaction |
(transaction root at: 'hello') inspect
].
データベースとのセッションを終えるには closeDb を送ります。
dbSession closeDb. "DBを閉じる"
ルート辞書に全てを保存していると、項目が増えたときに収拾がつかなくなってしまうので、辞書の中にさらに辞書を登録して使うということがよく行われます。DBAccessor には、dictionaryNamed: というメソッドが用意されており、ルート辞書内に、好きな名前で新たな辞書を登録して使えるようになっています。
dbSession := SBOmniBaseAccessor default. "格納" dbSession commitDo: [:transaction | (dbSession dictionaryNamed: 'myTest') at: 'test' put: {'これはテストです'. Date today. Time now} ]. "取り出し" dbSession readDo: [:transaction | ((dbSession dictionaryNamed: 'myTest') at: 'test') inspect ]. dbSession closeDb.
上記のコードは 'myTest' という名前の辞書を用意し、その中に配列を格納して、すぐに取り出すという例になっています。
OmniBase には、コンテナや BTree の利用など、スケーラビリティを持たせるための仕組みがさらに用意されています。興味のある方は、"OmniBase Tutorial" を読んでみると良いでしょう。
OmniBase のファサードである DBAccessor には、ユーザ ID とパスワードを簡易に登録できる機能が備わっています。今度はこれを使って見ましょう。前回の投票アプリで不十分だった、ユーザ認証の処理( MyLoginComponent>>login )での利用を考えています。
まずはユーザIDとパスワードの登録です。addUserId:password:domain: というメソッドを使います。下記をワークスペースで実行してみましょう。
dbSession := SBOmniBaseAccessor default. dbSession addUserId: 'user1' password: 'pwd1' domain: 'SeasideGO'. dbSession addUserId: 'user2' password: 'pwd2' domain: 'SeasideGO'.
ユーザ ID とパスワードが正しいかどうかを確認するには、verifyPassword:forUser:domain: を使います。下記の結果を「式を表示」で見ると true が返ることを確認できます。
dbSession verifyPassword: 'pwd1' forUser: 'user1' domain: 'SeasideGO'.
ユーザ ID 登録の機能といっても、特別なことをしているわけではなく、単に永続化した辞書にユーザ ID とパスワードのペアを格納しているだけです。内部的には 'userPasswords' という名前の辞書に、さらに 'SeasideGO' という名前の辞書を登録し、そこにユーザ ID とパスワードを入れるということを行っています。以下を実行してみると、末端の辞書の様子を見ることができます5。
dbSession readDo: [:transaction | ((dbSession dictionaryNamed: 'userPasswords') at: 'SeasideGO') inspect ]
*5このようなことができてしまうため、本来はパスワードをエンコードして格納したほうが良いでしょう。
それではいよいよアプリケーションを書いていきましょう。前回作成した投票アプリを拡張して、データベース対応にしていきます。
イメージを SeasideJOnePlusDB に刷新しましたので、前回のソースコードを SeasideJOnePlusDB に取り込む必要があります。.st ファイルを Squeak の画面にドロップし、「全体をファイルイン」を選べば完了です6。
読み込み後にはイメージを保存しておきましょう。Squeak のデスクトップをクリックして「保存」を選びます。
*6より詳しくは前回の「ソースコードの読み込み」を参照してください。
まずは MyLoginComponent を改良して、データベース内の認証情報を使うように変更したいと思います。
MyLoginComponent>>loginは以下のようになっていました。
MyLoginComponent >> login ('private'カテゴリ)
login (self userName notEmpty and: [self userName = self password]) ifTrue: [self answer: true] ifFalse: [self answer: false]
ユーザ ID とパスワードが同じ文字列の時にのみ true を返すという、いかにも情けないものだったわけです。OmniBase を使えば以下のように書けます。
MyLoginComponent >> login ('private'カテゴリ)
login | dbSession | dbSession := SBOmniBaseAccessor default. (dbSession verifyPassword: self password forUser: self userName domain: 'SeasideGO') ifTrue: [self answer: true] ifFalse: [self answer: false]
動作確認をしましょう。前回同様、'voteTask' という名前でアプリケーションを登録しておきます。
MyVoteTask registerAsApplication: 'voteTask'
Web ブラウザからアクセスします。
https://localhost:9090/seaside/voteTask
先ほど登録した'user1' と 'pwd1' もしくは 'user2' と 'pwd2' の組み合わせでのみ、ログインすることができます。これで少しは格好がつくようになりました。
では、今度は投票結果を OmniBase に保存できるようにしてみましょう。MyVoteTask>>vote で投票結果を result という変数に受け取るようになっていましたので、この後に、保存用の処理を書き加えることにします。
MyVoteTask >> vote ('private'カテゴリ)
vote
"投票の開始"
result := self call: MyVoteComponent new.
result には、MyStarRankComponent のインスタンスが複数、配列の形で入っています。コンポーネントのインスタンスをそのままの形で DB に格納するのはさすがに無駄が多いので、辞書の形に変えてから格納することにしましょう。storeResult というメソッドを定義することにします。
MyVoteTask >> storeResult ('private'カテゴリ)
storeResult "格納用の辞書の作成" | resultDict dbSession | resultDict := Dictionary new. result do: [:each | resultDict at: each topic put: each stars]. "DBへ格納" dbSession := SBOmniBaseAccessor default. dbSession commitDo: [:transaction | (dbSession dictionaryNamed: 'voteResults') at: 'user1' put: resultDict ]
ルート辞書下に 'voteResults' という辞書を作り、そこに投票結果( resultDict )を、ユーザ ID をキーにして入れています。キーは 'user1' 固定になってしまっていますが、ひとまずこれで格納されるかどうかだけ確認してみましょう。
storeResult を vote の後で送るように MyVoteTask>>go を書き換えます。
MyVoteTask >> go ('actions'カテゴリ)
go
self isolate: [
self login.
self showWelcome.
self vote.
self storeResult.
].
self isolate: [self showResult]
では、改めて 'user1' でログインし、好きなように投票してみてください。
これで結果が DB に入ったはずです。ワークスペースから確認してみます。
dbSession := SBOmniBaseAccessor default.
dbSession readDo: [:transaction |
((dbSession dictionaryNamed: 'voteResults') at: 'user1') inspect
]
Web ブラウザで投票結果として表示されているものと中身が同じですね。確かに入っています7。
*7うまく行かない場合、キャッシュが残っている可能性があります。その場合は下の "New Session" リンクでアクセスし直してみて下さい。
ひとまず DB に投票結果が保存できるようになりましたが、登録用のキーが 'user1' 固定になってしまっています。本当はユーザごとに結果を保持したいので、現在ログインしているユーザの ID をキーに使わないといけません。
Seaside はユーザごとにセッションを管理しています。ユーザがアプリケーションにアクセスすることで、セッションが始まります。アプリケーションを操作している間は、コンポーネントを call: で遷移したとしても、ずっとそのセッションは有効です。アプリケーションへのリクエストが一定時間行われないと、セッションはタイムアウトし、破棄されます。
このセッションを使えば、現在ログインしているユーザ情報を保持しておくことも簡単にできます。早速やってみましょう。
セッションは、WASession というクラスで実現されています。今回はユーザ ID を保持するようにしたいため、WASession を継承した MyVoteSession というクラスを作り、currentUserId という変数を定義することにします。
WASession subclass: #MyVoteSession instanceVariableNames: 'currentUserId' classVariableNames: '' poolDictionaries: '' category: 'SeasideGo-Lesson'
「アクセッサの作成」でアクセッサも作っておきましょう。MyVoteSession に対する作業はこれだけです。
次に、アプリケーションがこの拡張したセッションを使うように設定する必要があります。ワークスペースで以下を実行します。
app := MyVoteTask registerAsApplication: 'voteTask'. app preferenceAt: #sessionClass put: MyVoteSession.
1 行目はルートコンポーネント MyVoteTask をアプリケーションとして登録するという、いつものコードですね。登録したアプリケーションを app という変数に入れて、後からメッセージを送れるようにしています。ポイントは次の preferenceAt:put: の部分です。ここで拡張したセッションクラス( MyVoteSession )を設定しています。
同じことは Web ブラウザ上からもできます。投票アプリを表示している状態で、下の "Configure" リンクをクリックします。
アプリケーションの設定ツール画面になるので、General の欄までスクロールして、Session Class の "override" リンクをクリックします。
プルダウンメニューが出るようになるので、MyVoteSession を選び、Save します。
このようにしてもセッションクラスを指定できます。普段はコード上で設定してしまうのが楽ですが、ツールからの設定もコードにアクセスできない運用時などに重宝しますので、両方覚えておくと良いでしょう。
なお、いずれの場合も変更を永続的に有効にしたい場合は、Squeak の環境をメニューから「保存」しておく必要があります。これを行わないと次回の立ち上げ時にはデフォルトの設定に戻ってしまいます。
では MyVoteSession を投票アプリから使ってみることにします。コンポーネントからは session というメソッドで、いつでもセッションを取り出すことができます。
まずログイン成功時に現在のユーザ ID をセッションに入れることにします。MyLoginComponent>>login を以下のように書き換えます。
MyLoginComponent >> login ('private'カテゴリ)
login
| dbSession |
dbSession := SBOmniBaseAccessor default.
(dbSession verifyPassword: self password
forUser: self userName
domain: 'SeasideGO')
ifTrue: [self session currentUserId: self userName.
self answer: true]
ifFalse: [self answer: false]
あとは格納時に、セッションが保持するユーザ ID をキーとして使えば良いだけです。
MyVoteTask >> storeResult ('private'カテゴリ)
storeResult
"格納用の辞書の作成"
| resultDict dbSession |
resultDict := Dictionary new.
result do: [:each | resultDict at: each topic put: each stars].
"DBへ格納"
dbSession := SBOmniBaseAccessor default.
dbSession commitDo: [:transaction |
(dbSession dictionaryNamed: 'voteResults')
at: self session currentUserId
put: resultDict
]
ついでに投票中にも、誰がログインしているかをわかりやすくするため、「〜さんの投票」という表示を付け加えることにします。MyVoteComponent>>renderContentOn: を以下のようにすれば良いでしょう。
MyVoteComponent >> renderContentOn: ('rendering'カテゴリ)
renderContentOn: html
html text: self session currentUserId, 'さんの投票'.
html table: [
ranks do:[:each |
html tableRow: [html tableData: [html render: each]]
]
].
html horizontalRule.
html form: [html submitButton callback: [self answer: ranks]; with: '集計']
では再びWebブラウザからアクセスしてみましょう。
https://localhost:9090/seaside/voteTask
今回は 'user2' でログインします。
投票中には、「 user2 さんの投票」とちゃんと出ていますね。
ワークスペースで以下を実行して投票結果を確認できます。
dbSession := SBOmniBaseAccessor default.
dbSession readDo: [:transaction |
((dbSession dictionaryNamed: 'voteResults') at: 'user2') inspect
]
ばっちり入っていますね。投票アプリは MyVoteTask をルートとし、複数のコンポーネント( MyLoginComponent 、MyVoteComponent など)が切り替わって登場しますが、すべてが共通のセッションオブジェクトを共有できていることがわかります。
ユーザごとに投票結果が格納されるようにはなりましたが、実はまだ問題があります。一つはコンポーネントにデータベース固有のコードが入り込んでしまっている点、もう一つはアプリケーション終了時にデータベースを閉じていない点です。これらのことを解決するため、MyVoteSession にもう一工夫加えましょう。
データベース関連のコードがコンポーネント内に書かれていると、将来データベースシステムを変更したくなったときなどに、該当する部分をあちこち書き換えなければならないことになります。Seaside では、こうしたことを防ぐため、セッション側にデータベースアクセスのコードを封じ込めるということが行われます。
これは GoF のデザインパターンでいうところの「アダプタ」パターンに該当します。セッション側で汎用的な永続化用のインターフェースを提供することで、コンポーネントがデータベース固有のインターフェースをなるべく意識しないで済むようにします。特定のデータベースシステムに依存したコードが隠蔽されるため、たとえデータベースシステムの変更の必要が生じても、セッションクラスを取りかえるだけで対処できるようになります。
それではこのセッションアダプタパターンを適用し、MyVoteSession に OmniBase へのアダプタの役割を持たせることにしましょう。
まず、dbSession というインスタンス変数を新たに追加します。クラス定義は以下のようになります。
WASession subclass: #MyVoteSession
instanceVariableNames: 'currentUserId dbSession'
classVariableNames: ''
poolDictionaries: ''
category: 'SeasideGo-Lesson'
「アクセッサの作成」でアクセッサも作っておきます。 次に setUpDatabaseSession というファクトリメソッドを作り、OmniBase 用のデータベースセッションを返すようにしておきます。
MyVoteSession >> setUpDatabaseSession ('private'カテゴリ)
setUpDatabaseSession ^SBOmniBaseAccessor default
dbSession のアクセッサでは、dbSession が nil であったときに、上記の setUpDatabseSession を使って初期化を行うようにします。いわゆる遅延初期化( lazy initialization )というやつですね。
MyVoteSession >> dbSession ('accessing'カテゴリ)
dbSession
dbSession ifNil: [dbSession := self setUpDatabaseSession].
^ dbSession
これで MyVoteSession から OmniBase を操作できる下地が整いました。後はアダプタとなるメソッドを書いていくだけです。
まずユーザ認証用のメソッドを MyVoteSession に追加しましょう。MyLoginComponent>>login で書いたもののアレンジです。
MyVoteSession >> verifyPassword:forUser: ('actions'カテゴリ)
verifyPassword: password forUser: userId
^self dbSession verifyPassword: password
forUser: userId
domain: 'SeasideGO'
新たに定義され たMyVoteSession>>verifyPassword:forUser: を使うように MyLoginComponent>>login を書き換えます。
MyLoginComponent >> login ('private'カテゴリ)
login (self session verifyPassword: self password forUser: self userName) ifTrue: [self session currentUserId: self userName. self answer: true] ifFalse: [self answer: false]
データベース固有のコードが排除され、再びコンポーネントのコードがすっきりしましたね。
続いて投票結果を格納するためのメソッドを MyVoteSession に追加しましょう。
MyVoteSession >> store: ('actions'カテゴリ)
store: voteResultDictionary "DBへ格納" self dbSession commitDo: [:transaction | (self dbSession dictionaryNamed: 'voteResults') at: self currentUserId put: voteResultDictionary ]
これは MyVoteTask>>storeResult の下半分からほとんどそのまま抽出したものです。結果として MyVoteTask>>storeResult は非常にシンプルになります。
MyVoteTask >> storeResult ('private'カテゴリ)
storeResult
"格納用の辞書の作成"
| resultDict |
resultDict := Dictionary new.
result do: [:each | resultDict at: each topic put: each stars].
"DBへ格納"
self session store: resultDict
以上の変更で、投票アプリのコンポーネントは特定のデータベースシステムに依存しないものとなりました。
セッションアダプタパターンを使うと、さらに良いことがあります。一般にデータベースは、データへのアクセスが終わった後、リソースを解放するために、データベースへのセッションを閉じる必要があります。この処理を自動化できるのです。
Seaside のセッションは、ユーザからのアクセスが一定期間行われないと、タイムアウトして、自動的に破棄されるようになっています。この際に WASession>>unregistered が呼ばれるようになっているので、これをオーバーライドすることで、セッションに関わる様々なリソースを解放することが可能です。
では、MyVoteSession に、OmniBase へのセッションを解放する処理を付け加えることにしましょう。まず解放用に tearDownDatabaseSession というメソッドを書くことにします。
MyVoteSession >> tearDownDatabseSession ('private'カテゴリ)
tearDownDatabaseSession self dbSession closeDb
次に WASession>>unregistered をオーバーライドし、この tearDownDatabseSession が呼び出されるようにします。スーパークラスの unregistered で、Seaside のセッション自身を解放する処理が実装してあるので、必ず super unregistered を入れておくことを忘れないようにしましょう。
MyVoteSession >> unregistered ('private'カテゴリ)
unregistered super unregistered. self tearDownDatabaseSession
ちなみにデフォルトでは 600 秒間ユーザからのアクセスがないと、セッションが unregistered されるようになっています。これはデータベースアプリの性質によっては、調整したほうが良い場合があります。
Web ブラウザから" Configure" リンクで設定ツールを開くと、Session Expiry Seconds という欄を確認できます。これを書き換えることで、セッションが破棄されるまでの時間をアプリケーションごとにコントロールできます。
同じことをワークスペース上で行うには以下のようにします。
app := MyVoteTask registerAsApplication: 'voteTask'.
app preferenceAt: #sessionClass put: MyVoteSession.
app preferenceAt: #sessionExpirySeconds put: 900. "900秒に変更"
コードからのアプリケーション設定も、上記のように長くなってくるとメソッドとしてまとめた方が管理しやすくなります。
MyVoteTask に registerAsApplication というクラスメソッドを作りましょう。
MyVoteTask class >> register AsApplication ('registration'カテゴリ)
registerAsApplication | app | app := self registerAsApplication: 'voteTask'. app preferenceAt: #sessionClass put: MyVoteSession. app preferenceAt: #sessionExpirySeconds put: 900
これでワークスペースでは、
MyVoteTask registerAsApplication
と書くだけで済むようになります。
最後に仕上げとして、複数ユーザからの投票を集計して表示するコンポーネントを作ってみたいと思います。MyVoteResultComponent という名前にしましょう。
WAComponent subclass: #MyVoteResultComponent instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'SeasideGo-Lesson'
インスタンス変数は特にありません。セッションから集計結果を取ってくるからです。
MyVoteSession 側では、集計結果を辞書の形で返す computeAll というメソッドを用意することにします。
MyVoteSession >> computeAll ('actions'カテゴリ)
computeAll | voteDict | voteDict := Dictionary new. self dbSession readDo: [:transaction | (self dbSession dictionaryNamed: 'voteResults') valuesDo: [:eachResultDict | eachResultDict keysAndValuesDo: [:topic :stars | voteDict at: topic put: (voteDict at: topic ifAbsentPut: [0]) + stars ] ] ]. ^ voteDict
'voteResults' の辞書を valuesDo: でイテレートし、各ユーザの投票結果の辞書( eachResultDic )を取り出しています。フルーツの名前( topic )と星の数( stars )を keysAndValuesDo: で取り出し、集計用の辞書( voteDict )に追加していくわけですね。
セッション側は少しややこしいですが、コンポーネント側のレンダリングは簡単です。単に上記の MyVoteSession>>computeAll を使ってテーブルを書いていくだけです。
MyVoteResultComponent >> renderContentOn: ('rendering'カテゴリ)
renderContentOn: html
| voteDict assocs |
"セッション経由で集計結果を取り出す"
voteDict := self session computeAll.
"星の数でソート"
assocs := voteDict associations
asSortedCollection: [:a :b | a value > b value].
"テーブルとして表示"
html table: [
assocs do: [:each |
html tableRow: [
html tableData: [html text: each key].
html tableData: [html text: each value]
].
]
]
カスタマイズしたセッションクラスを使うので、セッションクラスの設定をしておくのを忘れないようにしましょう8。MyVoteTask の時と同様に、クラスメソッド registerAsApplication を用意します。
MyVoteResultComponent class >> registerAsAccplication ('registration'カテゴリ)
registerAsApplication | app | app := self registerAsApplication: 'voteResult'. app preferenceAt: #sessionClass put: MyVoteSession
ワークスペースで以下を実行して準備完了です。
MyVoteResultComponent registerAsApplication
それでは Web ブラウザから確認してみましょう。
https://localhost:9090/seaside/voteResult
無味乾燥な画面ではありますが、結果がちゃんと表示されましたね。
一度投票したユーザは投票できないようにする、集計結果で、投票したユーザ数が表示されるようにするなどの拡張を加えてみるのも面白いでしょう。
*8MyVoteTask から call: されない独立したアプリケーションなので、別途この設定が必要です。
今回書いたソースコードの最終版をまとめて取り込めるように、.st ファイルとして置いておきます。
下記をダウンロードしてお使い下さい。
SeasideGo-Lesson-Chap4.st
上記ファイルのソースでは、MyVoteTask と MyVoteResultComponent にクラスメソッド initialize を追加しています。
MyVoteTask class >> initialize ('class initialization'カテゴリ)
initialize self registerAsApplication
MyVoteResultComponent class >> initialize ('class initialization'カテゴリ)
initialize self registerAsApplication
ソースコードを Squeak の中に読み込む(正式にはファイルインといいます)と、クラスメソッド initialize が、読み込みが終わった時点で呼ばれます。
上記ソースでは、これを利用して、ファイルインしただけで 2 つのアプリケーションが自動登録されるようになっています。registerAsApplication をワークスペースからいちいち送らなくともよくなるので、このやり方も覚えておくと便利でしょう。
今回はデータベース対応の第一弾として、OmniBase への接続を取り上げました。仰々しい部分がなく、シンプルに書けるという印象を持ったのではないかと思います。小中規模のアプリケーションであれば、OmniBase でも十分に対処していくことが可能です。
次回は Glorp を使った RDB との接続に入っていきます。RDB は OODB と異なり、スキーマ定義などの作業が必要になりますが、検索機能が強力など、OODB とは異なる良さがあります。Seaside とデータベースとの統合では避けて通れない話題でしょう。今回使ったセッションアダプタパターンを使い、複数の DB を切り替えられるようなアプリを作っていきます。お楽しみに。
© 2008 - 2009 Masashi Umezawa |
|