Webサービスというものが新しい技術として流行ったのは2001年頃だった。そこで語られていたビジョンというのは、WSDLで定義したWebサービスAPIをUDDIに登録し、それらがSOAPで通信しながらWeb上に広がるアプリケーションを構成するというようなものである。MS Windows的に言うと、レジストリに登録されたCOMコンポーネントのインターフェイス発見/呼び出しメカニズムのインターネット版ということにな る。ただし、Windowsの場合は、DCOMやCOM+といった、Windowsシステム同士のネットワークやWindowsシステム内部を一貫した分散オブジェクトRPCの文脈でとらえる仕組みを経て、一度レガシーを整理し、.NETに至る。このシステムをビルディングブロックとして利用することが宣伝されたHailstormというマイクロソフト提供のプラットフォームは、個人向けWebサービスをUDDI経由で提供するという触れ込みだった。その後Hailstormは、シングルサインオン認証をめぐる覇権争いに巻き込まれた挙げ句、開放された世界での商業的キーワードとしては消滅してしまった。他方、同時期にローンチして以来、コントロールされた閉鎖環境で運営されてきているシングルサインオンの理想型が、有料サービスXbox Liveとして存続している。
その時点まで一旦遡ってから改めて俯瞰すると、最近のWebサービスを巡る再評価の流れもそれがどうしたと斜に構えやすい。XMLも5年くらい前に流行り、当時はXMLスキーマが今後重要になると言われていたが、そのあたりの知識が役に立つ局面が以後拡大したとは思えない。そういうわけで、この辺りの物事は個人的な関心からは長いこと外れていた。2007年の現在それらはつまらない玩具のようなものに留まるのか、それとも個人ユーザにとって真に使える道具なのか。近頃の各種Webサービスは、APIを開放し、プレスリリースを出して客寄せを行い、利用者が増えたら閉じるという、提供企業による手軽な宣伝活動の産物であり、個人ユーザ囲い込みの道具である。それを利用する個人の動機はWeb広告業界に投じられている金で、その量が5年前とは相当異なる以上、以前とは位相が異なってはいるわけだ。Webサービス同士の競争が起こり良質なサービスが数多く提供されるまでになれば面白くなるかもしれない。しかし、Webサービスを介して提供される元ネタが数少ない企業に独占されている現状では、そんなシナリオが実現する蓋然性は低く、バリエーションの少ない似通ったWebサービスを「マッシュアップ」と称してデプロイする個人は、IT企業の走狗として活動しながら細々と広告収入を稼ぐことになる。
今回の試みでは、「はてな」 の認証WebサービスAPIと、「はてな」のキーワードWebサービスAPIを利用している。1つ目のWebアプリケーションは、「はてな」認証APIを 利用した、「はてな」ユーザが特定の他ユーザに外部サーバを介して任意のデータファイルを渡すためのファイルアップローダである。言語はRubyで、RubyのWebサーバMongrelのモジュールとして書かれている。もう1つのWebアプリケーションは、はてなキーワードAPIを利用した穴埋めクイズ作成と、はてなキーワード連想グラフの視覚化を行う。言語はPerlで、PerlのWebサーバPOE::Component::Server::HTTPのモジュールとして書かれている。
これらは、双方ともスクリプト内でWebサーバモジュールをインクルードするので、コンソールからrubyなりperlなりを通してスクリプトを実行すればそのまま動作し、ホスト用のWebサーバを別途必要としない。起動時に設定ファイルの内容を読んで、以降はWebサーバの一部としてリクエストに応じるWebアプリケーションである。前回のPerlとRubyの比較ではマルチスレッドと組み込みがポイントだったのに対し、今回は、Webアプリケーションの作成と簡易Webサーバという、Webアプリケーションをめぐる 環境の比較を行うという趣向だ。マルチスレッドに関しては、Mongrelが当然のようにマルチスレッドで、モジュールも対応が必要なのに対し、POEはマルチスレッドに対応していないようである。
双方のWebサーバともプロダクション環境に適した性能を有するサーバではなく、またここで解説するWebアプリケーションはXSS対策やSQLインジェクション対策などのセキュリティ上のケアを欠いているので、あくまで各WebサービスAPIの動作サンプルとしてのみ御覧いただきたい。ソースコードの文字コードは双方ともUTF-8で、Windows XP SP2上にて作成し、Microsoft Internet Explorer 7ならびにFirefox 3.0a1 trunk build 20061218で動作確認している。「はてな」のWeb APIの仕様は本記事を書いた2006年末の時点のものに依っているので、仕様変更によって任意の時点でこれらのアプリケーションが動かなくなっている可能性もある。
では一つ目の、Rubyアプリケーションの方から見ていこう。
以下はスクリプト本体
と、rubygemsツールで取得可能な依存ライブラリである。尚、sqlite3のライブラリのバイナリが別途必要で、Windowsの場合はスクリプトのディレクトリにsqlite3.dllを、Unixの場合もSQLite公式サイトで入手できるライブラリのバイナリをパスの通った場所へ置く必要がある。また、Mongrelは0.3.13.3を使用しているが、アップデートが頻繁なので異なるバージョンは不具合が出る可能性もある。
YAMLの設定ファイルをロードし、SQLiteデータベースのテーブルを無ければ新規作成する。
Webサーバのモジュールなので、データベースアクセスのためのグローバルオブジェクト$dbはSync_mを使用してマルチスレッド対応にしておく。アップロードされてくるファイルのファイルサイズと、受け取り済みのデータのサイズとを保存するために、
「はてな」の認証APIの初期化関数を呼び出し、次いで表示するページ内のヘッダをそのままヒアドキュメントを使ってスクリプト内に書いている。
以下に、Mongrelのハンドラ関数群が続く。各種デフォルトハンドラをオーバーライドすることにより、WebアプリケーションはMongrelの動作をカスタマイズするというのがMongrelモジュールの基本コンセプトである。まずは、ユーザが最初にサーバのルートパス('/')にアクセスしたときに、「はてな」のIDとパスワードを認証APIに対して差し出すように促す。
このスクリプトの一番最後にMongrel起動時の初期設定を行う部分があるので、そこを見てもらうとして、この
このページのヘッダはJavaScriptを含んでいて、AJAXの簡単なフレームワークと、「はてな」ユーザ名の実在性を確かめるメソッド、ファイルのアップロード状態をポーリングしながら進捗バーを動的に表示するメソッドなどを含む。AJAXによる画面遷移無しのファイルアップロードを実現するために、ファイルのアップロード先を隠しiframeにするというテクニックが使用されている。
"/check_username"というパスに、あるユーザ名が実在の「はてな」ユーザかどうか確かめるサービス(後述の
"/downloader"でアクセスできる、他ユーザが自分宛にアップロードしたファイルを受け取りダウンロードするためのURLのハンドラを定義する。
"/receiver"のパスが、ユーザがファイルをアップロードする対象である。
あるユーザ名が「はてな」の実在のユーザかどうか確かめるためのwebサービスAPIを「はてな」では提供していないので、「はてな」上に該当ユーザのメンバーページが存在するかどうか、
自分がアップロード中のファイルの進捗状況を示すXMLを返すハンドラを定義する。
自分宛に他ユーザがアップロードしたファイルの一覧を表示するハンドラを定義する。
Mongrelの起動設定と起動、終了処理。どのパス(URI)がどのハンドラクラスによって定義されているか、ここで指定する。
つぎに、Perl webアプリの方を見ていくことにする。まずは、設定ファイルの
スクリプト本体は
必要ライブラリは、Perl 5.8の他に、
のそれぞれCPANシェルを使って入手できる最新バージョンと、各々が依存するライブラリである。オープンソース形態素解析エンジンMeCabは、ナマズのブログで入手可能な0.92のWindows用バイナリと辞書を使用させていただいた。尚、Windows下ではMeCabがShiftJISでビルドされている ため辞書もShiftJIS版を使用し、スクリプト内で必要な変換を行ったが、他プラットフォームでテストする場合はMeCab、辞書ともUTF-8版が必要である。また、JavaScriptのグラフ視覚化ライブラリであるJSVizと、ツールチップライブラリboxoverを利用しており、これらはスクリプト下に
ここでは、日本語UTF-8文字列を使うときにUTF-8に対応していない
Webサーバの設定を行うとともに、Webサーバ上の各パス毎にハンドラ関数を登録している。
これは穴埋めクイズの問題を作る関数で、要は、「はてなキーワード」内の日本語文に対しMeCabで形態素解析を行って、見つかった名詞の部分を隠すことによって穴埋め問題にするという至極単純な仕組みである。
webサーバのルートURLのハンドラ。ユーザが任意の単語を入力すると「はてなキーワード」を検索し、キーワード間の連想グラフをロードする。
GETリクエストで呼び出されると必要なJavaScriptを表示し、POSTリクエストの場合は入力された単語を「はてなキーワード」で検索した後、キーワードのRSSデータから説明文を抜き出してクイズを作成表示する。
JSVizによって「はてなキーワード」の連想グラフを視覚化した物を表示する画面のハンドラ。
「はてなキーワード」で検索を行うためのAJAXコールバックを定義する。JSVizはこれを呼び出して見つかった関連単語を次々と検索し、単語のグラフに新しいノードを付け加えていく。
関連単語を「はてな」の関連キーワードAPIを用いて探すためのハンドラ。「はてな」のコードサンプルで推奨されているようにXMLRPC::Liteモ ジュールを使って検索するが、私が試した限りでは「ディスク」など一部単語で問題が起こるようで、いささか実用性に欠ける。
クイズの答えが正しいかどうか判定し、正解/不正解を返すAJAXコールバックURLのハンドラ。
最後に
2つを書いてみての感想は、モジュール周りの扱いがRubyの方が簡潔で、完成された印象を持った。Perlの方はかなり入り組んでいてモジュールのインストールだけで小一時間かかってしまう(ほとんどがPOEに起因しているが、XML関連モジュールの層も相当ファットである印象を受ける)。Perlの方だけUTF-8を扱う必要がある点も、各モジュールの問題が噴出し、Perlにとって不利な結果となった。もちろんRubyも Mongrelの完成度が低いといった問題はあるものの、Webアプリケーションそのものの問題ではないし、それを言えばPOEは完全にMongrelに劣るので、Webアプリケーションテスト環境を含めた評価としては、やはりRubyの方が洗練されている。今回は順当にRubyに軍配が上がる結果となった。これでRubyそのもののパフォーマンスが向上すれば鬼に金棒といえるだろう。
その時点まで一旦遡ってから改めて俯瞰すると、最近のWebサービスを巡る再評価の流れもそれがどうしたと斜に構えやすい。XMLも5年くらい前に流行り、当時はXMLスキーマが今後重要になると言われていたが、そのあたりの知識が役に立つ局面が以後拡大したとは思えない。そういうわけで、この辺りの物事は個人的な関心からは長いこと外れていた。2007年の現在それらはつまらない玩具のようなものに留まるのか、それとも個人ユーザにとって真に使える道具なのか。近頃の各種Webサービスは、APIを開放し、プレスリリースを出して客寄せを行い、利用者が増えたら閉じるという、提供企業による手軽な宣伝活動の産物であり、個人ユーザ囲い込みの道具である。それを利用する個人の動機はWeb広告業界に投じられている金で、その量が5年前とは相当異なる以上、以前とは位相が異なってはいるわけだ。Webサービス同士の競争が起こり良質なサービスが数多く提供されるまでになれば面白くなるかもしれない。しかし、Webサービスを介して提供される元ネタが数少ない企業に独占されている現状では、そんなシナリオが実現する蓋然性は低く、バリエーションの少ない似通ったWebサービスを「マッシュアップ」と称してデプロイする個人は、IT企業の走狗として活動しながら細々と広告収入を稼ぐことになる。
今回の試みでは、「はてな」 の認証WebサービスAPIと、「はてな」のキーワードWebサービスAPIを利用している。1つ目のWebアプリケーションは、「はてな」認証APIを 利用した、「はてな」ユーザが特定の他ユーザに外部サーバを介して任意のデータファイルを渡すためのファイルアップローダである。言語はRubyで、RubyのWebサーバMongrelのモジュールとして書かれている。もう1つのWebアプリケーションは、はてなキーワードAPIを利用した穴埋めクイズ作成と、はてなキーワード連想グラフの視覚化を行う。言語はPerlで、PerlのWebサーバPOE::Component::Server::HTTPのモジュールとして書かれている。
これらは、双方ともスクリプト内でWebサーバモジュールをインクルードするので、コンソールからrubyなりperlなりを通してスクリプトを実行すればそのまま動作し、ホスト用のWebサーバを別途必要としない。起動時に設定ファイルの内容を読んで、以降はWebサーバの一部としてリクエストに応じるWebアプリケーションである。前回のPerlとRubyの比較ではマルチスレッドと組み込みがポイントだったのに対し、今回は、Webアプリケーションの作成と簡易Webサーバという、Webアプリケーションをめぐる 環境の比較を行うという趣向だ。マルチスレッドに関しては、Mongrelが当然のようにマルチスレッドで、モジュールも対応が必要なのに対し、POEはマルチスレッドに対応していないようである。
双方のWebサーバともプロダクション環境に適した性能を有するサーバではなく、またここで解説するWebアプリケーションはXSS対策やSQLインジェクション対策などのセキュリティ上のケアを欠いているので、あくまで各WebサービスAPIの動作サンプルとしてのみ御覧いただきたい。ソースコードの文字コードは双方ともUTF-8で、Windows XP SP2上にて作成し、Microsoft Internet Explorer 7ならびにFirefox 3.0a1 trunk build 20061218で動作確認している。「はてな」のWeb APIの仕様は本記事を書いた2006年末の時点のものに依っているので、仕様変更によって任意の時点でこれらのアプリケーションが動かなくなっている可能性もある。
では一つ目の、Rubyアプリケーションの方から見ていこう。
hatenawebapp1.conf
は設定ファイルで、YAMLフォーマットである。Webサーバのポートや、認証APIに与えるキー、データベースファイル名などを設定する。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | # hatenawebapp1.conf # # configuration file for hatenawebapp1.rb # Bound address bound_address: "127.0.0.1" # Bound port bound_port: 80 # API key for Hatena Auth api_key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # Private key for Hatena Auth private_key: "xxxxxxxxxxxxxxxx" # File name for the SQLite database db_filename: "hatenawebapp1.db" # Path for uploaded files file_store_path: "./store" |
以下はスクリプト本体
hatenawebapp1.rb
で、ruby 1.8.5 (2006-12-04 patchlevel 2) [i386-mswin32]
で動作確認した。必要ライブラリは、RubyGems-0.9.0
mongrel-0.3.13.3-mswin32
sqlite3-ruby-1.1.0-mswin32
hatenaapiauth-0.1.0
uuidtools-1.0.0
scrapi-1.2.0
と、rubygemsツールで取得可能な依存ライブラリである。尚、sqlite3のライブラリのバイナリが別途必要で、Windowsの場合はスクリプトのディレクトリにsqlite3.dllを、Unixの場合もSQLite公式サイトで入手できるライブラリのバイナリをパスの通った場所へ置く必要がある。また、Mongrelは0.3.13.3を使用しているが、アップデートが頻繁なので異なるバージョンは不具合が出る可能性もある。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | = begin hatenawebapp1.rb Sample Web Application 1 with Hatena Web Service API : File Transfer between Hatena Users by RyuK (klassphere[at.mark]gmail.com) [Requirements (tested on Microsoft Windows XP )] ruby 1 . 8 . 5 ( 2006 - 12 - 04 patchlevel 2 ) [i386-mswin32] mongrel- 0 . 3 . 13 . 3 -mswin32 sqlite3-ruby- 1 . 1 . 0 -mswin32 hatenaapiauth- 0 . 1 . 0 uuidtools- 1 . 0 . 0 scrapi- 1 . 2 . 0 ... and other dependent Ruby gems = end require 'rubygems' require 'mongrel' require 'yaml' require 'sync' require 'fileutils' require 'pathname' require 'sqlite3' require 'hatena/api/auth' require 'uuidtools' require 'scrapi' |
YAMLの設定ファイルをロードし、SQLiteデータベースのテーブルを無ければ新規作成する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | conf_filename = $PROGRAM_NAME .clone begin $conf = YAML .load_file conf_filename.sub!(/\.rb/, '.conf' ) pn = Pathname. new ( $conf [ "file_store_path" ]) begin $conf [ "file_store_path" ] = pn.realpath rescue SystemCallError Dir .mkdir(pn.to_s, 0701 ) $conf [ "file_store_path" ] = pn.realpath end $db = SQLite3::Database. new ( $conf [ "db_filename" ]) rescue Exception => e STDERR .puts e.to_s exit( 1 ) end # ofn = original file name, rfn = real file name begin $db .execute(<< SQL create table files( sender TEXT , receiver TEXT , ofn TEXT , rfn TEXT , date INTEGER , size INTEGER ); SQL ) rescue SQLite3::SQLException => e if e.to_s != "table files already exists" puts e.to_s exit( 1 ) end end $db .extend(Sync_m) |
Webサーバのモジュールなので、データベースアクセスのためのグローバルオブジェクト$dbはSync_mを使用してマルチスレッド対応にしておく。アップロードされてくるファイルのファイルサイズと、受け取り済みのデータのサイズとを保存するために、
Struct
クラスを使ってDownloadProgress
という構造体を作っておく。さらに、$download_progress
というHash
のオブジェクトを生成し、このオブジェクトがQUERY_STRING
リクエストパラメータとDownloadProgress
オブジェクトとの対応表を保存する。$download_progress
は、デザインパターンで言うところのObserverパターンで、ダウンロード状況のview(MVCの'V')としてWebからの複数リクエストによって同時に参照される可能性があるため、同じようにマルチスレッド対応にしなければならない。1 2 3 4 5 6 7 8 9 10 11 12 13 14 | $verified_users_ipaddress = Hash . new # ip - user $verified_users_ipaddress .extend(Sync_m) $existent_users = Hash . new # user - dummy $existent_users .extend(Sync_m) DownloadProgress = Struct. new ( "DownloadProgress" , :total_size , :current_size ) $download_progress = Hash . new $download_progress .extend(Sync_m) # user - DP |
「はてな」の認証APIの初期化関数を呼び出し、次いで表示するページ内のヘッダをそのままヒアドキュメントを使ってスクリプト内に書いている。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | $hatena_auth = Hatena:: API ::Auth. new ( :api_key => $conf [ "api_key" ], :secret => $conf [ "private_key" ]) $page_head =<< EOS <! DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" > <html> <head> <meta http-equiv= "Content-Type" content= "text/html; charset=utf-8" /> <title> #{$PROGRAM_NAME}</title> </head> <body> <p>Proof of Concept: File Transfer between Hatena Users</p> EOS $page_end =<< EOS </body> </html> EOS def page_head_with_onload(onload) << EOS <! DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" > <html> <head> <meta http-equiv= "Content-Type" content= "text/html; charset=utf-8" /> <title> #{$PROGRAM_NAME}</title> </head> <body onload= "#{onload}" > <p>Proof of Concept: File Transfer between Hatena Users</p> EOS end |
以下に、Mongrelのハンドラ関数群が続く。各種デフォルトハンドラをオーバーライドすることにより、WebアプリケーションはMongrelの動作をカスタマイズするというのがMongrelモジュールの基本コンセプトである。まずは、ユーザが最初にサーバのルートパス('/')にアクセスしたときに、「はてな」のIDとパスワードを認証APIに対して差し出すように促す。
process
というメソッドが ユーザ定義フィルタの役割を果たし、request
を受けてresponse
を返す。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | # Mongrel handlers ######################################### class RootHandler < Mongrel::HttpHandler def process(request, response) response.start do |head, out| head[ "Content-Type" ] = "text/html" out << << EOS #{$page_head} <p>If you are a Hatena user and want to transfer a file to another user, <a href=\" #{$hatena_auth.uri_to_login}\">please follow this link</a> to the uploader.</p> #{$page_end} EOS end end end |
このスクリプトの一番最後にMongrel起動時の初期設定を行う部分があるので、そこを見てもらうとして、この
UploaderHandler
は、"/uploader" というパスにアクセスした場合のハンドラである。リクエスト中のクエリ文字列(request.params["QUERY_STRING"]
)を解析し、認証情報 を取り出して「はてな」認証APIに与える。「はてな」ユーザとして認証されると、$verified_users_ipaddress
ハッシュ表にリモートIPアドレスとユーザ名の組が保存される。ちなみに、このアプリケーションはIPアドレス1つあたり1ユーザとして認識しており、まともなセッション管理を行っていないので、実際に使用するには厳密なセッション管理が必要である。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | class UploaderHandler < Mongrel::HttpHandler def show_error(response, message) response.start do |head, out| head[ "Content-Type" ] = "text/html" out << message end end def process(request, response) unknown_error_page =<< EOS #{$page_head} <p>Error: Unknown Error</p> <script language= "JavaScript" > <!-- history.go(- 1 ) //--> </script> #{$page_end} EOS auth_error_page =<< EOS #{$page_head} <p>Error: Authorization Failed</p> #{$page_end} EOS cert_key = "" if request.params[ "QUERY_STRING" ] =~ /cert=([^&]+)/ cert_key = $1 else show_error(response, unknown_error_page) return end user = nil begin user = $hatena_auth .login(cert_key) rescue Hatena:: API ::AuthError show_error(response, auth_error_page) return end $verified_users_ipaddress .synchronize() do if $verified_users_ipaddress .size > 10000 $verified_users_ipaddress .clear end $verified_users_ipaddress [request.params[Mongrel::Const:: REMOTE_ADDR ]] = user[ 'name' ] end |
このページのヘッダはJavaScriptを含んでいて、AJAXの簡単なフレームワークと、「はてな」ユーザ名の実在性を確かめるメソッド、ファイルのアップロード状態をポーリングしながら進捗バーを動的に表示するメソッドなどを含む。AJAXによる画面遷移無しのファイルアップロードを実現するために、ファイルのアップロード先を隠しiframeにするというテクニックが使用されている。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | response.start do |head, out| head[ "Content-Type" ] = "text/html" results =<< EOS #{page_head_with_onload("setup('#{user['name']}')")} <script language= "JavaScript" > <!-- var isMozilla = navigator.userAgent.indexOf( 'Gecko' ) != - 1 ; var isIE = window.ActiveXObject; function createHttpRequest() { if (isIE) { try { // CLSID_XMLHTTP // v 3 . 0 return new ActiveXObject( "Msxml2.XMLHTTP" ); } catch (e) { try {// v 2 .x return new ActiveXObject( "Microsoft.XMLHTTP" ); } catch (e2) { return null; } } } else if (window.XMLHttpRequest) // non- IE { var hr = new XMLHttpRequest(); if (isMozilla) hr.overrideMimeType( 'text/xml' ); return hr; } else { return null; } } function sendHTTP(data, method, uri, callback, async, caller) { var hr = createHttpRequest(); var args = new Array (); args.push(hr); for (var i = 6 ; i < arguments.length; ++i) { args.push(arguments[i]); } try { hr.open(method, uri, async); hr.setRequestHeader( "If-Modified-Since" , "Thu, 01 Jun 1970 00:00:00 GMT" ); hr.onreadystatechange = function() { if (hr.readyState == 4 ) { callback.apply(caller, args); } } hr.send(data); delete hr; } catch(e) { alert( "sendHTTP: " + e); } } function setup(owner_name) { loadFileList(owner_name); checkUsername(); } var filename = "" ; var query_progress = "" ; var time_start = 0 ; function startPolling(form) { if (form.filename.value == "" ) { alert( "Invalid file name" ); return ; } if (form.receiver.value == "" ) { alert( "Invalid user name" ); return ; } var x = document.getElementById( "hidden_div" ); if (x) document.body.removeChild(x); var d = document.createElement( 'div' ); d.setAttribute( "id" , "hidden_div" ); d.innerHTML= '<iframe id="hidden_iframe" name="hidden_iframe" style="display: none; width: 0px; height: 0px; border: 0px"></iframe>' ; document.body.appendChild(d); form.button_upload.disabled = true ; filename = form.filename.value; document.getElementById( "form_area" ).innerHTML = ( "<p>Uploading <b>" + filename + "</b></p>" ); query_progress = (form.sender.value + '&' + form.receiver.value + '&' + filename); form.action = ( "/receiver?" + query_progress); var d = new Date(); time_start = d.getTime(); form.submit(); pollDownloadProgress(); } |
pollDownloadProgress
関数を1秒毎にタイマー呼び出しして/query_progressへAJAX問い合わせを行い、 ダウンロード済みのファイルサイズを更新しつつ進捗バーを伸ばす。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | function pollDownloadProgress() { sendHTTP( '' , 'POST' , './query_progress' + '?' + query_progress, pollDownloadProgressCallback, true ); if (filename != "" ) setTimeout( "pollDownloadProgress();" , 1000); } function pollDownloadProgressCallback(hr) { var nodelist = hr.responseXML.getElementsByTagName( "f" ); if (!nodelist || nodelist.length == 0) return ; var current_size = 0; var total_size = 0; for ( var i = 0; i < nodelist.length; ++i) { var n = nodelist.item(i); if (n == null ) return ; var nn = n.firstChild; if (nn == null ) return ; while (nn != null ) { // nn.textContent == Mozilla only // NODE_TEXT == 3 || NODE_CDATA_SECTION == 4 if (nn != null && (nn.nodeType == 3 || nn.nodeType == 4)) { if (nn.nodeValue != "" ) { var matched = nn.nodeValue.match(/(\\d+)\\/(\\d+)/); if (matched) { current_size = parseInt(matched[1], 10); total_size = parseInt(matched[2], 10); setProgressBar(current_size, total_size); } } } nn = nn.nextSibling; } } } function setProgressBar(received_size, total_size) { document.getElementById( "d2" ).style.width = 400 * (received_size / total_size) + "px" ; var d = new Date(); var elapsed_time = (d.getTime() - time_start) / 1000; if (elapsed_time <= 0) elapsed_time = 1; var speed = parseInt(received_size / elapsed_time / 1024, 10); if (400 * (received_size / total_size) >= 75) document.getElementById( "d1" ).innerHTML = "<font size=-1>" + parseInt(received_size / total_size * 100) + "% (" + speed.toString() + "KB/s)</font>" ; } |
"/check_username"というパスに、あるユーザ名が実在の「はてな」ユーザかどうか確かめるサービス(後述の
CheckUsernameHandler
)が動いているので、そこに対してAJAXで問い合わせを行う。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | var checked_user = "" ; var check_username_requesting = false ; // onchange doesn't work until the focus is out function checkUsername() { if (document.getElementById( "receiver" ).value != "" && document.getElementById( "receiver" ).value != checked_user && !check_username_requesting) { check_username_requesting = true ; checked_user = document.getElementById( "receiver" ).value; document.getElementById( "form_area" ).innerHTML = ( "<p><blink>Checking if <b" + checked_user + "</b> is an existing Hatena user...</blink></p>" ); sendHTTP(checked_user, 'POST ', ' ./check_username ', checkUsernameCallback, true); } setTimeout(checkUsername, 3000); } function isNotUser() { document.getElementById("form_area").innerHTML = ("<p><b>" + checked_user + "</b> is not a Hatena user.</p>"); document.getElementById("button_upload").disabled = true; } function checkUsernameCallback(hr) { // Can' t use responseXML.getElementById() - // For responseXML.getElementById() to return an element with the matching id value, XMLHttpRequest // implementations must be aware of the underlying schema/DTD that defines an id attribute of type ID. // Currently browsers are not schema/DTD aware for XMLHttpRequests, although they support well-known DTDs // like HTML and XHTML for documents. var nodelist = hr.responseXML.getElementsByTagName( "r" ); if (!nodelist || nodelist.length == 0) isNotUser(); check_username_requesting = false ; for ( var i = 0; i < nodelist.length; ++i) { var n = nodelist.item(i); if (n == null ) { isNotUser(); continue ; } var nn = n.firstChild; if (nn == null ) { isNotUser(); continue ; } while (nn != null ) { // nn.textContent == Mozilla only // NODE_TEXT == 3 || NODE_CDATA_SECTION == 4 if (nn != null && (nn.nodeType == 3 || nn.nodeType == 4)) { if (nn.nodeValue != "" ) { document.getElementById( "button_upload" ).disabled = false ; checked_user = nn.nodeValue; document.getElementById( "receiver" ).value = nn.nodeValue; document.getElementById( "form_area" ).innerHTML = ( "<p><b>" + checked_user + "</b> is an existing Hatena user.</p>" ); } } nn = nn.nextSibling; } } check_username_requesting = false ; } function loadFileList(owner_name) { sendHTTP( '' , 'POST' , './list_files' + '?' + owner_name, loadFileListCallback, true ); } function unixtime2localdate(t) { var d = new Date; d.setTime(t * 1000); return d.toLocaleString(); } function loadFileListCallback(hr) { var out = "" ; var nodelist = hr.responseXML.getElementsByTagName( "myfile" ); if (nodelist) { out += "<p>Your files sent to other users:</p>" ; for ( var i = 0; i < nodelist.length; ++i) { var e = nodelist.item(i); out += "<p><a href='./downloader/" ; out += e.getAttribute( "rfn" ); out += "'><b>" ; out += e.getAttribute( "ofn" ); out += "</b></a> (Size: " ; out += Math.floor(parseInt(e.getAttribute( "size" ), 10) / 1024).toString(); out += e.getAttribute( "r" ); out += "'>" ; out += e.getAttribute( "r" ); out += "</a> - Date: " ; out += unixtime2localdate(parseInt(e.getAttribute( "d" ), 10)); out += ")</p>" ; } if (nodelist.length == 0) out += "<p>(no files)</p>" ; } nodelist = hr.responseXML.getElementsByTagName( "sentfile" ); if (nodelist) { out += "<p>Files sent to you from other users:</p>" ; for ( var i = 0; i < nodelist.length; ++i) { var e = nodelist.item(i); out += "<p><a href='./downloader/" ; out += e.getAttribute( "rfn" ); out += "'><b>" ; out += e.getAttribute( "ofn" ); out += "</b></a> (Size: " ; out += Math.floor(parseInt(e.getAttribute( "size" ), 10) / 1024).toString(); out += e.getAttribute( "s" ); out += "'>" ; out += e.getAttribute( "s" ); out += "</a> - Date: " ; out += unixtime2localdate(parseInt(e.getAttribute( "d" ), 10)); out += ")</p>" ; } if (nodelist.length == 0) out += "<p>(no files)</p>" ; } document.getElementById( "files_list" ).innerHTML = out; } //--> </script> <p>Welcome <b> #{user['name']}</b> @ Hatena.</p> <p>You are now authorized to upload a file to transfer to another Hatena user.</p> <span id= "form_area" ><p>Fill in the name of the receiving user and upload a file.</p></span> <p> <!-- the colon at the end of startPolling() is required. --> <form method= "POST" id= "file_form" action= "" enctype= "multipart/form-data" target= "hidden_iframe" onsubmit= "startPolling(this); return false;" > <input type= "hidden" name= "sender" value= "#{user['name']}" > Receiving Hatena User: <input type= "text" id= "receiver" name= "receiver" size= "16" > File to upload: <input type= "file" name= "filename" size= "80" > <input type= "submit" id= "button_upload" value= "Upload this file" disabled> </form> </p> <p> <div id= "empty" style= "background-color: #cccccc; border: 1px solid black; height: 30px; width: 400px; padding: 0px;" align= "left" /> <div id= "d2" style= "position: relative; top: 0px; left: 0px; background-color: #333333; height: 30px; width: 0px; padding-top: 5px; padding: 0px;" /> <div id= "d1" style= "position: relative; top: 0px; left: 0px; color: #f0ffff; height: 30px; text-align: center; font: bold; padding: 0px; padding-top: 5px;" /></div></div></div> </p> <span id= "files_list" ></span> #{$page_end} EOS out << results end end end |
"/downloader"でアクセスできる、他ユーザが自分宛にアップロードしたファイルを受け取りダウンロードするためのURLのハンドラを定義する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | class DownloaderHandler < Mongrel::DirHandler def initialize(path, listing_allowed= true , index_html= "index.html" ) super (path, listing_allowed, index_html) #Mongrel::DirHandler::add_mime_type(".zip", "application/zip") #Mongrel::DirHandler::add_mime_type(".rar", "application/x-rar-compressed") #Mongrel::DirHandler::add_mime_type(".lzh", "application/x-lzh"); #Mongrel::DirHandler::add_mime_type(".xml", "text/xml"); end def process(request, response) user = nil $verified_users_ipaddress .synchronize(Sync_m:: SH ) do if $verified_users_ipaddress .include?(request.params[Mongrel::Const:: REMOTE_ADDR ]) user = $verified_users_ipaddress [request.params[Mongrel::Const:: REMOTE_ADDR ]] end end unless user response.reset response.start do |head, out| head[ "Content-Type" ] = "text/html" out << "Authorization Failed - <a href=\"#{$hatena_auth.uri_to_login}\">Verify again</a>" end return end req_method = request.params[Mongrel::Const:: REQUEST_METHOD ] || Mongrel::Const:: GET req_path = can_serve request.params[Mongrel::Const:: PATH_INFO ] if not req_path response.reset response.start( 404 ) do |head, out| out << "File not found" end else original_filename = "" real_filename = request.params[Mongrel::Const:: PATH_INFO ].clone real_filename.gsub!( "/" , "" ); if real_filename =~ /[^a-zA- Z0 - 9_ -\.]/ || user =~ /[^a-zA- Z0 - 9_ -]/ response.reset response.start( 403 ) do |head, out| out << "Invalid Request" end return end $db .synchronize(Sync_m:: SH ) do $db .execute( "select * from files where rfn = '#{real_filename}' AND receiver = '#{user}'" ) do |row| original_filename = row[ 2 ] end end if original_filename == "" response.reset response.start( 403 ) do |head, out| out << "Not Authorized" end return end response.header[ "Content-Disposition" ] = "filename=\"#{original_filename}\"" ; begin if req_method == Mongrel::Const:: HEAD send_file(req_path, request, response, true ) elsif req_method == Mongrel::Const:: GET send_file(req_path, request, response, false ) else response.start( 403 ) {|head, out| out.write(Mongrel:: ONLY_HEAD_GET ) } end rescue => details STDERR .puts "Error sending file #{req_path}: #{details}" end end end end |
"/receiver"のパスが、ユーザがファイルをアップロードする対象である。
request_progress
メソッドはリクエストのデータを一定量受け取る度にMongrelが呼び出すイベントコールバックで、これをオーバーライドすることによって、アップロードされてくるファイルのアップロード済みデータ量の数値を逐次更新する。Mongrel::CGIWrapper
を 使用してHTMLフォームから送信されてくるデータを解析しているが、使用したMongrelのバージョンではファイルをアップロードしている場合に正常 にそれぞれのクエリ要素を受け取れないというバグがあるので、迂回策として生のデータを正規表現で検索している。ファイルのダウンロードが済むと、Mongrel::CGIWrapper
の仕様に従って、アップロードされてきたファイルのサイズに応じ、一時バッファもしくは一時ファイルから、実際のファイル保存先へとデータを移す。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | class ReceiverHandler < Mongrel::HttpHandler def initialize @request_notify = true end def request_progress(params, clen, total) $download_progress .synchronize() do if $download_progress .size > 1000 $download_progress .clear end $download_progress [params[ "QUERY_STRING" ]] = Struct::DownloadProgress. new (total, total - clen) end end def gen_stored_filename() return UUID .timestamp_create.to_s end def process(request, response) cgi = Mongrel::CGIWrapper. new (request, response) # can't use cgi["sender"] and cgi["receiver"] for multipart/form-data due to a possible bug of Mongrel 0.3.13.3 sender = nil receiver = nil original_filename = nil if request.params[ "QUERY_STRING" ] =~ /^([^&]+)&([^&]+)&(.+)/ sender = $1 receiver = $2 original_filename = $3 original_filename.gsub!( "'" , "" ) original_filename.gsub!(/.*\\/, "" ) original_filename.gsub!( ".*/" , "" ) end if sender =~ /[^a-zA- Z0 - 9_ -]/ || receiver =~ /[^a-zA- Z0 - 9_ -]/ || original_filename =~ /[^a-zA- Z0 - 9_ -\.\\]/ response.reset response.start( 403 ) do |head, out| out << "Invalid Request" end return end $verified_users_ipaddress .synchronize(Sync_m:: SH ) do if $verified_users_ipaddress .include?(request.params[Mongrel::Const:: REMOTE_ADDR ]) if sender != $verified_users_ipaddress [request.params[Mongrel::Const:: REMOTE_ADDR ]] sender = nil end else sender = nil end end $existent_users .synchronize(Sync_m:: SH ) do unless $existent_users .include?(receiver) receiver = nil end end unless sender && receiver && original_filename response.start do |head, out| head[ "Content-Type" ] = "text/html" out << << EOS #{$page_head} <script language= "JavaScript" > <!-- parent.document.getElementById( "form_area" ).innerHTML = ( "<p>An authorization error happened in uploading <b>" + parent.filename + "</b>. <a href=\\" #{$hatena_auth.uri_to_login}\\">Please retry</a></p>"); parent.filename = "" ; parent.setProgressBar( 0 , 0 ); //--> </script> #{$page_end} EOS end return end $download_progress .synchronize() do $download_progress .delete(request.params[ "QUERY_STRING" ]) end file = cgi[ 'filename' ] real_filename = gen_stored_filename() begin if file.size >= 10240 then FileUtils.cp(file.path, $conf [ "file_store_path" ] + real_filename) else open( $conf [ "file_store_path" ] + real_filename, "wb" ) do |fh| fh.write(file.read) end end $db .synchronize() do $db .transaction do |d| d.execute("insert into files values( '#{sender}' , '#{receiver}' , '#{original_filename}' , '#{real_filename}' , #{ Time .now.tv_sec}, #{file.size})") end end rescue Exception => e p e puts e.backtrace end response.start do |head, out| head[ "Content-Type" ] = "text/html" out << << EOS #{$page_head} <script language= "JavaScript" > <!-- parent.document.getElementById( "form_area" ).innerHTML = ( "<p><b>" + parent.filename + "</b> has been successfully uploaded.</p>" ); parent.filename = "" ; parent.setProgressBar( #{file.size}, #{file.size}); parent.loadFileList( '#{sender}' ); parent.document.getElementById( "button_upload" ).disabled = false ; //--> </script> #{$page_end} EOS end end end |
scrapi
のWindows版で1モジュールのdllの読み込みに不具合があるので、問題になるメソッドfind_tidy
をここで上書き修正している。この辺は動的言語の面目躍如と言うべきだが、濫用すると収拾が付かなくなるので個人的にはやむをえない場合以外やるべきではないと思っている。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | # Redefine the find_tidy method in scrapi for Windows to load the correct dll first module Scraper module Reader module_function def find_tidy() return if Tidy.path begin $LOAD_PATH . each do |path| if path =~ /scrapi/ && path =~ /lib$/ if Config:: CONFIG [ 'arch' ] =~ /mswin/ Tidy.path = File .join(path, "/tidy" , "libtidy.dll" ) else Tidy.path = File .join(path, "/tidy" , "libtidy.so" ) end break end end rescue LoadError => e puts e.to_s end end end end |
あるユーザ名が「はてな」の実在のユーザかどうか確かめるためのwebサービスAPIを「はてな」では提供していないので、「はてな」上に該当ユーザのメンバーページが存在するかどうか、
scrapi
によるスクレイピングを行って強引に確かめることで代用する。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | class CheckUsernameHandler < Mongrel::HttpHandler @@scraper = Scraper.define do process "td[align='center'] a[href='/q']" , :ret => :text result :ret end def process(request, response) verified = false $verified_users_ipaddress .synchronize(Sync_m:: SH ) do if $verified_users_ipaddress .include?(request.params[Mongrel::Const:: REMOTE_ADDR ]) verified = true end end unless verified response.start( 403 ) do |head, out| out.write( "Not Authorized" ) end return end found = false $existent_users .synchronize(Sync_m:: SH ) do found = $existent_users .include?(request.body.string) end unless found uri += request.body.string # StringIO found = ( @@scraper .scrape( URI .parse(uri)) == nil ) $existent_users .synchronize() do if $existent_users .size > 1000 $existent_users .clear() end $existent_users [request.body.string] = 1 end end response.start do |head, out| head[ "Content-Type" ] = "text/xml" out.write( "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><r>#{found ? request.body.string : " "}</r>" ) end end end |
自分がアップロード中のファイルの進捗状況を示すXMLを返すハンドラを定義する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | class QueryProgressHandler < Mongrel::HttpHandler def process(request, response) $verified_users_ipaddress .synchronize(Sync_m:: SH ) do unless $verified_users_ipaddress .include?(request.params[Mongrel::Const:: REMOTE_ADDR ]) response.start( 403 ) do |head, out| out.write( "Not Authorized" ) end return end end found = false $download_progress .synchronize(Sync_m:: SH ) do if $download_progress .include?(request.params[ "QUERY_STRING" ]) found = $download_progress [request.params[ "QUERY_STRING" ]] end end response.start do |head, out| head[ "Content-Type" ] = "text/xml" if found out.write( "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><f>#{found.current_size}/#{found.total_size}</f>" ) else out.write( "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><f></f>" ) end end end end |
自分宛に他ユーザがアップロードしたファイルの一覧を表示するハンドラを定義する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | class ListFilesHandler < Mongrel::HttpHandler def process(request, response) $verified_users_ipaddress .synchronize(Sync_m:: SH ) do if $verified_users_ipaddress .include?(request.params[Mongrel::Const:: REMOTE_ADDR ]) if $verified_users_ipaddress [request.params[Mongrel::Const:: REMOTE_ADDR ]] != request.params[ "QUERY_STRING" ] response.start( 403 ) do |head, out| out.write( "Not Authorized" ) end return end else response.start( 403 ) do |head, out| out.write( "Not Authorized" ) end return end end if request.params[ "QUERY_STRING" ] =~ /[^a-zA- Z0 - 9_ -\.\\]/ response.reset response.start( 403 ) do |head, out| out << "Invalid Request" end return end xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><files>' $db .synchronize(Sync_m:: SH ) do $db .execute( "select * from files where sender = '#{request.params[" QUERY_STRING "]}'" ) do |row| xml += "<myfile r=\"#{row[1]}\" ofn=\"#{row[2]}\" rfn=\"#{row[3]}\" d=\"#{row[4]}\" size=\"#{row[5]}\"/>" end $db .execute( "select * from files where receiver = '#{request.params[" QUERY_STRING "]}'" ) do |row| xml += "<sentfile s=\"#{row[0]}\" ofn=\"#{row[2]}\" rfn=\"#{row[3]}\" d=\"#{row[4]}\" size=\"#{row[5]}\"/>" end end xml += "</files>" response.start do |head, out| head[ "Content-Type" ] = "text/xml" out.write(xml) end end end |
Mongrelの起動設定と起動、終了処理。どのパス(URI)がどのハンドラクラスによって定義されているか、ここで指定する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | #stats = Mongrel::StatisticsFilter.new(:sample_rate => 1) # new(defaults={}, &blk) # You pass in initial defaults and then a block to continue configuring. config = Mongrel::Configurator. new :host => $conf [ "bound_address" ], :port => $conf [ "bound_port" ] do listener do uri "/" , :handler => RootHandler. new #uri "/", :handler => Mongrel::DeflateFilter.new # This messes up IE #uri "/", :handler => stats uri "/uploader" , :handler => UploaderHandler. new uri "/downloader" , :handler => DownloaderHandler. new ( $conf [ "file_store_path" ], false ) uri "/receiver" , :handler => ReceiverHandler. new uri "/check_username" , :handler => CheckUsernameHandler. new uri "/query_progress" , :handler => QueryProgressHandler. new uri "/list_files" , :handler => ListFilesHandler. new #uri "/status", :handler => Mongrel::StatusHandler.new(:stats_filter => stats) end trap( "INT" ) { stop } run end puts "Mongrel running on #{$conf[" bound_address "]}:#{$conf[" bound_port "]}" config.join |
hatenawebapp1.rb
は以上である。 スクリプトを動作させると、Mongrelが設定ファイル内のポートで起動するので、Webブラウザでアクセスすると、「はてな」認証を促すリンクが表示される。それをクリックし、認証を通過すると、コールバックURLのhttp://127.0.0.1/uploaderに転送され、そこでファイルのアップロードが可能となる。自分の送信済みファイルと、自分宛に他ユーザが送信したファイルのリストもそこに表示されている。ファイルをアップロードする と、AJAXを利用して画面遷移無しで進捗表示とアップロード完了後のリスト更新が行われる。尚、ごく稀に特定のファイルでアップロードが失敗することがあるようだが、MongrelのCGIWrapper
のバグに起因する問題でありMongrel側の修正を待つしかない。つぎに、Perl webアプリの方を見ていくことにする。まずは、設定ファイルの
hatenawebapp2.conf
である。Rubyアプリの方と同様に、YAML形式を用いてサーバのポートなどを設定している。1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # hatenawebapp2.conf # # configuration file for hatenawebapp2.pl # Bound port bound_port: 80 # JavaScript directory name (not path) javascript_directory: jsdir # Mask for a hidden text in a quiz question quiz_mask: "<font color=red>******</font>" |
スクリプト本体は
hatenawebapp2.pl
である。perl, v5.8.8 built for MSWin32-x86-multi-thread
で動作を確認している。必要ライブラリは、Perl 5.8の他に、
YAML::Syck
POE::Component::Server::HTTP
HTTP::Status
XML::RSS
XMLRPC::Lite
LWP::Simple
MeCab
URI::Escape
threads::shared
Thread::Semaphore
のそれぞれCPANシェルを使って入手できる最新バージョンと、各々が依存するライブラリである。オープンソース形態素解析エンジンMeCabは、ナマズのブログで入手可能な0.92のWindows用バイナリと辞書を使用させていただいた。尚、Windows下ではMeCabがShiftJISでビルドされている ため辞書もShiftJIS版を使用し、スクリプト内で必要な変換を行ったが、他プラットフォームでテストする場合はMeCab、辞書ともUTF-8版が必要である。また、JavaScriptのグラフ視覚化ライブラリであるJSVizと、ツールチップライブラリboxoverを利用しており、これらはスクリプト下に
jsdir
という名称のディレクトリを作ってその中へ全て展開する必要がある。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | =pod hatenawebapp2.pl Sample Web Application 2 with Hatena Web Service API : Hatena Keyword Quiz & Visualization by RyuK (klassphere[at.mark]gmail.com) [Requirements (tested on Microsoft Windows XP)] Mecab is compiled with ShiftJIS with the ShiftJIS dictionary. For platforms other than Windows, use UTF-8 for Mecab and its dic. =cut use 5.8.0; use strict; use warnings; use utf8; use Encode; use YAML::Syck; use POE::Component::Server::HTTP; use HTTP::Status; use XML::RSS; use XMLRPC::Lite; use LWP::Simple; use MeCab; use URI::Escape; $YAML ::Syck::ImplicitTyping = 1; my %quiz_answer = (); # IP address - answer my $conf = YAML::Syck::LoadFile( "hatenawebapp2.conf" ); |
ここでは、日本語UTF-8文字列を使うときにUTF-8に対応していない
XMLRPC::Lite
とSOAP::Lite
内で問題がある箇所の関数を動的に上書き修正している。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | # Patches some functions in XMLRPC::Lite and SOAP::Lite to pass a UTF-8 Japanese string # in its HTTP transport that is a subclass of LWP::UserAgent $SOAP ::Constants::DO_NOT_USE_LWP_LENGTH_HACK = 1; { my $s =<< 'SUBDOC' ; package XMLRPC::Serializer; sub new { my $self = shift ; unless ( ref $self ) { my $class = ref ( $self ) || $self ; $self = $class ->SUPER::new( typelookup => { base64 => [10, sub {1}, 'as_string' ], int => [20, sub { $_ [0] =~ /^[+-]?\d+$/}, 'as_int' ], double => [30, sub { $_ [0] =~ /^(-?(?:\d+(?:\.\d*)?|\.\d+)|([+-]?)(?=\d|\.\d)\d*(\.\d*)?([Ee]([+-]?\d+))?)$/}, 'as_double' ], dateTime => [35, sub { $_ [0] =~ /^\d{8}T\d\d:\d\d:\d\d$/}, 'as_dateTime' ], string => [40, sub {1}, 'as_string' ], }, attr => {}, namespaces => {}, @_ , ); } return $self ; } 1; package SOAP::Utils; sub bytelength { return length ( $_ [0]); } 1; SUBDOC no warnings; local $^W = 0; eval "$s" ; use warnings; } |
Webサーバの設定を行うとともに、Webサーバ上の各パス毎にハンドラ関数を登録している。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | my $aliases = POE::Component::Server::HTTP->new( Port => $conf ->{bound_port}, ContentHandler => { '/' => \&handlerRoot, '/viz' => \&handlerViz, '/quiz' => \&handlerQuiz, '/answer' => \&handlerAnswer, '/assoc' => \&handlerAssoc, '/search' => \&handlerSearch }, Headers => { Server => 'My Server' }, ); |
これは穴埋めクイズの問題を作る関数で、要は、「はてなキーワード」内の日本語文に対しMeCabで形態素解析を行って、見つかった名詞の部分を隠すことによって穴埋め問題にするという至極単純な仕組みである。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | sub makeQuiz { my $s = shift ; my $ip = shift ; my $sjis = ($^O =~ /mswin/i); if ( $sjis ) { utf8::encode( $s ); Encode::from_to( $s , 'utf8' , 'shiftjis' ); } my $m = new MeCab::Tagger( "" ); my $n = $m ->parseToNode( $s ); my @fragments = (); my $count = 0; while ( $n = $n ->{ next }) { if ( defined ( $n ->{surface})) { my $w = $n ->{surface}; my $f = $n ->{feature}; if ( $sjis ) { Encode::from_to( $f , 'shiftjis' , 'utf8' ); utf8::decode( $f ); Encode::from_to( $w , 'shiftjis' , 'utf8' ); utf8::decode( $w ); } if ( $f =~ /^名詞/ && length ( $w ) >= 2) { push @fragments , [ $w , 1]; ++ $count ; } else { push @fragments , [ $w , 0]; } } } if ( $count == 0) { return "" ; } my $masked_index = int ( rand $count ); my $final = "" ; my $current_noun_index = 0; foreach my $f ( @fragments ) { if ( $f ->[1] == 0) { $final .= $f ->[0]; } elsif ( $current_noun_index ++ == $masked_index ) { $final .= $conf ->{quiz_mask}; if ( keys ( %quiz_answer ) > 1000) { %quiz_answer = (); } $quiz_answer { $ip } = $f ->[0]; } else { $final .= $f ->[0]; } } return $final ; } |
webサーバのルートURLのハンドラ。ユーザが任意の単語を入力すると「はてなキーワード」を検索し、キーワード間の連想グラフをロードする。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | sub handlerRoot { my ( $request , $response ) = @_ ; $response ->code(RC_OK); my $out = "" ; my $jsdir = $conf ->{javascript_directory}; if ( $request ->uri =~ / $jsdir \/([^\/]+)$/) { my $f = $1 ; if (! open (FILE, "./$jsdir/$f" )) { return RC_DENY; } while (<FILE>) { $out .= $_ ; } $response ->content( $out ); $response ->header( 'Content-type' => "application/x-javascript" ); close (FILE); return RC_OK; } $out =<< 'HEREDOC' ; <html> <head> <title>Hatena web app 2</title> <meta http-equiv= "Content-Type" content= "text/html; charset=utf-8" /> <script language= "JavaScript" > <!-- function setup() { } function resizeIframe(f) { if (document.body.clientWidth) { f.style.width = document.body.clientWidth + "px" ; f.style.height = (document.body.clientHeight - 100) + "px" ; } } function resizeIframe2(f) { if (document.body.clientWidth) { f.style.width = document.body.clientWidth + "px" ; } } //--> </script> <style type= "text/css" > body { margin: 0; padding: 0; overflow: hidden; } p { text-decoration: none; font-size: 13px; font-weight: normal; font-family: Verdana, Geneva, san-serif; line-height: 150%; margin-top: 0px; margin-bottom: 1em; padding-left: 8px; padding-right: 8px; } form { margin: 0; padding: 0; } </style> </head> <body onload= "setup();" onresize= "resizeIframe(document.getElementById('ifr'));resizeIframe2(document.getElementById('quiz'));" > <p>Proof of Concept: Hatena Keyword Quiz & Visualization</p> <form onsubmit= "frames.ifr.hatenaKeywords.getKeywords(this.word.value); return false;" > <p>はてなキーワード内から検索したい単語を <input type= "text" id= "word" size= "40" > に入力し <input type= "submit" value= "検索" > して、見つかった単語をクリックして下さい。 </p> </form> <p>(キーワードのノードはマウスでドラッグ可能です)</p> <iframe src= "/quiz" id= "quiz" name= "quiz" width= "1000" height= "160" scrolling= "yes" valign= "top" onload= "resizeIframe2(this);" ></iframe> <iframe src= "/viz" id= "ifr" name= "ifr" width= "1000" height= "600" scrolling= "yes" valign= "top" onload= "resizeIframe(this);" ></iframe> </body> </html> HEREDOC $response ->content( $out ); return RC_OK; } |
GETリクエストで呼び出されると必要なJavaScriptを表示し、POSTリクエストの場合は入力された単語を「はてなキーワード」で検索した後、キーワードのRSSデータから説明文を抜き出してクイズを作成表示する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 | sub handlerQuiz { my ( $request , $response ) = @_ ; $response ->code(RC_OK); my $out = "" ; if ( $request ->method =~ /get/i) { $out =<< 'HEREDOC' ; <html> <head> <meta http-equiv= "Content-Type" content= "text/html; charset=utf-8" /> <script language= "JavaScript" > <!-- var isIE = window.ActiveXObject; var isMozilla = navigator.userAgent.indexOf( 'Gecko' ) != -1; function createHttpRequest() { if (isIE) { try { // CLSID_XMLHTTP // v 3.0 return new ActiveXObject( "Msxml2.XMLHTTP" ); } catch (e) { try {// v 2.x return new ActiveXObject( "Microsoft.XMLHTTP" ); } catch (e2) { return null; } } } else if (window.XMLHttpRequest) // non-IE { var hr = new XMLHttpRequest(); if (isMozilla) hr.overrideMimeType( 'text/xml' ); return hr; } else { return null; } } function sendHTTP(data, method, uri, callback, async, caller ) { var hr = createHttpRequest(); var args = new Array(); args. push (hr); for (var i = 6; i < arguments. length ; ++i) { args. push (arguments[i]); } try { hr. open (method, uri, async); hr.setRequestHeader( "If-Modified-Since" , "Thu, 01 Jun 1970 00:00:00 GMT" ); hr.onreadystatechange = function() { if (hr.readyState == 4) { callback.apply( caller , args); } } hr. send (data); delete hr; } catch(e) { alert( "sendHTTP: " + e); } } function getTextContent(xml) { if (xml) { if (xml.textContent) return xml.textContent; // Mozilla // IE if (xml.innerText) return xml.innerText; if (xml.text) return xml.text; } } function setup() { } function checkAnswer(answer) { sendHTTP(answer, "POST" , "/answer" , checkAnswerCallback, true, this, answer); } function checkAnswerCallback(request) { var nodelist = request.responseXML.getElementsByTagName( "a" ); if (nodelist && nodelist. length != 0) { for (var i = 0; i < nodelist. length && i < 10; ++i) { var e = nodelist.item(i); if (e.getAttribute( "r" ) == "true" ) { alert( "正解" ); } else { var answer = "" ; var n = e.firstChild; while (n != null) { // n.textContent == Mozilla only // NODE_TEXT == 3 || NODE_CDATA_SECTION == 4 if (n != null && (n.nodeType == 3 || n.nodeType == 4)) { answer = n.nodeValue; } n = n.nextSibling; } alert( "不正解 - 解答: " + answer); } } } } //--> </script> <style type= "text/css" > body { margin: 0; padding: 0; overflow: hidden; } p { text-decoration: none; font-size: 13px; font-weight: normal; font-family: Verdana, Geneva, san-serif; line-height: 150%; margin-top: 0px; margin-bottom: 1em; padding-left: 8px; padding-right: 8px; } form { margin: 0; padding: 0; } </style> </head> <body onload= "setup();" > <span id= "quiz" ></span> </body> </html> HEREDOC } else { $response ->header( 'Content-Type' => 'text/xml' ); $out = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<r>\n" ; my $w = $request ->content; utf8::decode( $w ); $w =~ tr /A-Za-z0-9/A-Za-z0-9/; $w = URI::Escape::uri_escape_utf8( $w ); if ( $content ) { my $rss = new XML::RSS; utf8::decode( $content ); $rss ->parse( $content ); my $item = ${ $rss ->{items}}[0]; for my $item (@{ $rss ->{items}}) { $out .= "<i a='" ; $out .= $item ->{ link }; $out .= "'>" ; if ( defined ( $item ->{title})) { $out .= "<t><![CDATA[" ; my $t = $item ->{title}; utf8::decode( $t ); $t =~ s/]]>/]]>/g; $out .= $t ; $out .= "]]></t>\n" ; } if ( defined ( $item ->{description})) { $out .= "<d><![CDATA[" ; my $d = $item ->{description}; utf8::decode( $d ); $d =~ s/]]>/]]>/g; $d =~ s|<a[^>]+>||g; $out .= makeQuiz( $d , $request ->{connection}->{remote_ip}); $out .= "]]></d>" ; } $out .= "</i>\n" ; } } $out .= "</r>" ; } $response ->content( $out ); return RC_OK; } |
JSVizによって「はてなキーワード」の連想グラフを視覚化した物を表示する画面のハンドラ。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 | sub handlerViz { my ( $request , $response ) = @_ ; $response ->code(RC_OK); my $out = "" ; my $jsdir = $conf ->{javascript_directory}; if ( $request ->uri =~ / $jsdir \/([^\/]+)$/) { my $f = $1 ; if (! open (FILE, "./$jsdir/$f" )) { return RC_DENY; } while (<FILE>) { $out .= $_ ; } $response ->content( $out ); $response ->header( 'Content-type' => "application/x-javascript" ); close (FILE); return RC_OK; } $out =<< 'HEREDOC' ; <html> <head> <meta http-equiv= "Content-Type" content= "text/html; charset=utf-8" /> <script language= "JavaScript" src= "/jsdir/DataGraph.js" ></script> <script language= "JavaScript" src= "/jsdir/Magnet.js" ></script> <script language= "JavaScript" src= "/jsdir/Spring.js" ></script> <script language= "JavaScript" src= "/jsdir/Particle.js" ></script> <script language= "JavaScript" src= "/jsdir/ParticleModel.js" ></script> <script language= "JavaScript" src= "/jsdir/Timer.js" ></script> <script language= "JavaScript" src= "/jsdir/EventHandler.js" ></script> <script language= "JavaScript" src= "/jsdir/HTMLGraphView.js" ></script> <script language= "JavaScript" src= "/jsdir/SVGGraphView.js" ></script> <script language= "JavaScript" src= "/jsdir/RungeKuttaIntegrator.js" ></script> <script language= "JavaScript" src= "/jsdir/Control.js" ></script> <script language= "JavaScript" src= "/jsdir/boxover.js" ></script> <script language= "JavaScript" > <!-- var isIE = window.ActiveXObject; var isMozilla = navigator.userAgent.indexOf( 'Gecko' ) != -1; // Suppress IE6 SP1 flicker if (isIE) { try { document.execCommand( "BackgroundImageCache" , false, true); } catch (e) { } } function createHttpRequest() { if (isIE) { try { // CLSID_XMLHTTP // v 3.0 return new ActiveXObject( "Msxml2.XMLHTTP" ); } catch (e) { try {// v 2.x return new ActiveXObject( "Microsoft.XMLHTTP" ); } catch (e2) { return null; } } } else if (window.XMLHttpRequest) // non-IE { var hr = new XMLHttpRequest(); if (isMozilla) hr.overrideMimeType( 'text/xml' ); return hr; } else { return null; } } function sendHTTP(data, method, uri, callback, async, caller ) { var hr = createHttpRequest(); var args = new Array(); args. push (hr); for (var i = 6; i < arguments. length ; ++i) { args. push (arguments[i]); } try { hr. open (method, uri, async); hr.setRequestHeader( "If-Modified-Since" , "Thu, 01 Jun 1970 00:00:00 GMT" ); hr.onreadystatechange = function() { if (hr.readyState == 4) { callback.apply( caller , args); } } hr. send (data); delete hr; } catch(e) { alert( "sendHTTP: " + e); } } function getTextContent(xml) { if (xml) { if (xml.textContent) return xml.textContent; // Mozilla // IE if (xml.innerText) return xml.innerText; if (xml.text) return xml.text; } } var HatenaKeywords = function(dataGraph, particleModel) { this.init(dataGraph, particleModel); } HatenaKeywords. prototype = { init: function(dataGraph, particleModel) { this.dataGraph = dataGraph; this.particleModel = particleModel; this.TRAVERSE_DEPTH = 1; this.MAX_PRODUCTS_ORIGIN = 8; this.MAX_PRODUCTS_PER_SIMILARITY = 8; this.MAX_NODES = 20; this.nodesByName = {}; this.nodesCount = 0; }, search: function(keyword) { document.getElementById( 'searchResults' ).innerHTML = "" ; document.getElementById( 'keywordResults' ).style.display = "none" ; parent.document.getElementById( 'word' ).value = keyword; this.particleModel.clear(); this.nodesByName = {}; this.nodesCount = 0; if (this.particleModel.timer.interupt) this.particleModel.timer.start(); var node = new DataGraphNode(true, 2); node.keyword = keyword; this.dataGraph.addNode(node); this.nodesByName[keyword] = node; this.getSimilarKeywords(keyword, 0); sendHTTP(keyword, "POST" , "/quiz" , this.getQuizCallback, true, this, keyword); }, getQuizCallback : function(request) { var nodelist = request.responseXML.getElementsByTagName( "i" ); if (nodelist && nodelist. length != 0) { for (var i = 0; i < nodelist. length && i < 10; ++i) { var e = nodelist.item(i); var keyword = getTextContent(e.getElementsByTagName( "t" )[0]); if (!keyword) keyword = "" ; var desc = getTextContent(e.getElementsByTagName( "d" )[0]); if (!desc) desc = "" ; parent.frames.quiz.document.getElementById( 'quiz' ).innerHTML = "<p>クイズ: 隠された単語は何でしょう?</p><p>" + desc + "</p><form onsubmit=\"checkAnswer(this.answer.value);return false;\"><p>" + "あなたの答え: <input id=\"answer\" type=\"text\" size=\"20\"><input type=\"submit\"" + " value=\"解答をチェック\"></p></form>" ; } } }, getKeywords : function(keyword) { document.getElementById( 'searchResults' ).innerHTML = "<p><blink>Searching...</blink></p>" ; this.particleModel.clear(); this.nodesByName = {}; this.nodesCount = 0; if (this.particleModel.timer.interupt) this.particleModel.timer.start(); sendHTTP(keyword, "POST" , "/search" , this.getKeywordsCallback, true, this, keyword); }, getKeywordsCallback : function(request) { document.getElementById( 'searchResults' ).innerHTML = "" ; var nodelist = request.responseXML.getElementsByTagName( "i" ); if (nodelist && nodelist. length != 0) { var h = document.createElement( 'p' ); h.innerHTML = (nodelist. length .toString() + "個のキーワードが見つかりました。クリックすると関係する単語群を探せます。" ); document.getElementById( 'searchResults' ).appendChild(h); for (var i = 0; i < nodelist. length && i < 10; ++i) { var e = nodelist.item(i); var keyword = getTextContent(e.getElementsByTagName( "t" )[0]); if (!keyword) keyword = "" ; var desc = getTextContent(e.getElementsByTagName( "d" )[0]); if (!desc) desc = "" ; var r = document.createElement( 'div' ); r.className = "keyword" ; var title = ( "fade=[on] header=[" + keyword.replace(/[/g, "" ).replace(/]/g, "" ) + "] body=[" + desc.replace(/[/g, "" ).replace(/]/g, "" ) + "]" ); r.setAttribute( "title" , title); r.setAttribute( "style" , "padding-left: 50px;" ); r.innerHTML = '<p onclick="' + "hatenaKeywords.search('" + keyword.replace(/ "/g, '" ') + "' )" + '">' + "<b><a onmouseover=\"this.style.textDecoration = 'underline'\" onmouseout=\"this.style.textDecoration = 'none'\">" + keyword + '</b></a></p>' ; document.getElementById( 'searchResults' ).appendChild(r); } } }, getSimilarKeywords: function(word, ordinal) { sendHTTP(word, "POST" , "/assoc" , this.getSimilarKeywordsCallback, true, this, word, ordinal); }, getSimilarKeywordsCallback: function(request, parentWord, ordinal) { var max = this.MAX_PRODUCTS_PER_SIMILARITY; if (ordinal == 0) max = this.MAX_PRODUCTS_ORIGIN; var nodelist = request.responseXML.getElementsByTagName( "related" ); for (var i = 0; i < nodelist. length && i < max && this.nodesCount < this.MAX_NODES; i++) { var word = nodelist[i].getAttribute( "w" ); if (this.nodesByName[word]) { var node = this.nodesByName[word]; this.dataGraph.addEdge(node, this.nodesByName[parentWord]); } else { var node = new DataGraphNode(false, 1); node.keyword = word; node.addEdge(this.nodesByName[parentWord], 1); this.dataGraph.addNode(node); this.nodesCount++; this.nodesByName[word] = node; if (ordinal < this.TRAVERSE_DEPTH) this.getSimilarKeywords(word, ordinal + 1); } } } } var hatenaKeywords; function setup() { var FRAME_WIDTH; var FRAME_HEIGHT; if (document.all) { FRAME_WIDTH = document.body.offsetWidth - 10; FRAME_HEIGHT = document.documentElement.offsetHeight - 10 - 28; } else { FRAME_WIDTH = window.innerWidth - 10; FRAME_HEIGHT = window.innerHeight - 10 - 28; } var view = document.implementation.hasFeature( "org.w3c.dom.svg" , '1.1' ) ? new SVGGraphView(0, 26, FRAME_WIDTH, FRAME_HEIGHT, true) : new HTMLGraphView(0, 26, FRAME_WIDTH, FRAME_HEIGHT, true); var particleModel = new ParticleModel(view); particleModel.start(); var control = new Control(particleModel, view); var dataGraph = new DataGraph(); hatenaKeywords = new HatenaKeywords(dataGraph, particleModel); var nodeHandler = new NodeHandler(dataGraph, particleModel, view, control); dataGraph.subscribe(nodeHandler); var buildTimer = new Timer(150); buildTimer.subscribe(nodeHandler); buildTimer.start(); } var NodeHandler = function( dataGraph, particleModel, view, control ) { this.dataGraph = dataGraph; this.particleModel = particleModel; this.view = view; this.nodeQueue = new Array(); this.relationshipQueue = new Array(); this[ 'newDataGraphNode' ] = function(dataGraphNode) { this.enqueueNode(dataGraphNode); } this[ 'newDataGraphEdge' ] = function(nodeA, nodeB) { this.enqueueRelationship(nodeA, nodeB); } this[ 'enqueueNode' ] = function(dataGraphNode) { this.nodeQueue. push (dataGraphNode); } this[ 'enqueueRelationship' ] = function(nodeA, nodeB) { this.relationshipQueue. push ({ 'nodeA' : nodeA, 'nodeB' : nodeB}); } this[ 'dequeueNode' ] = function() { var node = this.nodeQueue. shift (); if (node) { this.addParticle(node); return true; } return false; } this[ 'dequeueRelationship' ] = function() { var edge = this.relationshipQueue. shift (); if (edge) this.addSimilarity(.05, edge.nodeA, edge.nodeB); } this.update = function() { var nodes = this.dequeueNode(); if (!nodes) this.dequeueRelationship(); } this[ 'addParticle' ] = function(dataGraphNode) { particle = this.particleModel.makeParticle(dataGraphNode.mass, 0, 0); dataGraphNode.particle = particle; if (dataGraphNode.fixed) particle.fixed = true; var rx = Math.random() * 2 - 1; var ry = Math.random() * 2 - 1; particle.positionX = rx - 50 / this.view.skew; particle.positionY = ry; for (var j = 0, l = this.particleModel.particles. length ; j < l; j++) { if (this.particleModel.particles[j] != particle) this.particleModel.makeMagnet(particle, this.particleModel.particles[j], -30000, 64); } var particleParent = false; for (var c in dataGraphNode.edges) { if (!particleParent) { particleParent = true; particle.positionX = dataGraphNode.edges[c].particle.positionX + rx; particle.positionY = dataGraphNode.edges[c].particle.positionY + ry; } this.addSimilarity(.2, dataGraphNode, dataGraphNode.edges[c]); } var keyword = dataGraphNode.keyword; if (!keyword) {keyword = "" ;} var n = document.createElement( 'div' ); n.style.position = "absolute" ; n.className = "keyword" ; n.innerHTML = '<div onclick="' + "hatenaKeywords.search('" + keyword.replace(/ "/g,'" ') + "' )" + "\"><a onmouseover=\"this.style.textDecoration = 'underline'\" onmouseout=\"this.style.textDecoration = 'none'\">" + keyword + '</a></div>' ; n.onmousedown = new EventHandler(control, control.handleMouseDownEvent, particle.id) dataGraphNode.viewNode = this.view.addNode(particle, n, 25, isIE ? 10 : 30); //particle.width = dataGraphNode.viewNode.offsetWidth; //particle.height = dataGraphNode.viewNode.offsetHeight; return dataGraphNode; }, this[ 'addSimilarity' ] = function(springConstant, nodeA, nodeB) { particleModel.makeSpring(nodeA.particle, nodeB.particle, springConstant, .2, 80); var props = document.implementation.hasFeature( "org.w3c.dom.svg" , '1.1' ) ? { 'stroke' : "#bbbbbb" , 'stroke-width' : '2px' , 'stroke-dasharray' : '2, 8' } : { 'pixelColor' : "#aaaaaa" , 'pixelWidth' : '2px' , 'pixelHeight' : '2px' , 'pixels' : 15 }; this.view.addEdge(nodeA.particle, nodeB.particle, props); } } //--> </script> <style type= "text/css" > body { margin: 0; padding: 0; overflow: hidden; } p { text-decoration: none; font-size: 13px; font-weight: normal; font-family: Verdana, Geneva, san-serif; line-height: 150%; margin-top: 0px; margin-bottom: 1em; padding-left: 8px; padding-right: 8px; } form { margin: 0; padding: 0; } div.keyword { font-family: Verdana, Geneva, san-serif; font-weight: bold; font-size: 14px; text-align: left; background-repeat: no -repeat; cursor: hand; } #keywordResults { display: none; color: #000000; border-top: 0px; } </style> </head> <body onload= "setup();" > <div id= "searchResults" ></div> <div id= "keywordResults" ></div> </body> </html> HEREDOC $response ->content( $out ); return RC_OK; } |
「はてなキーワード」で検索を行うためのAJAXコールバックを定義する。JSVizはこれを呼び出して見つかった関連単語を次々と検索し、単語のグラフに新しいノードを付け加えていく。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | sub handlerSearch { my ( $request , $response ) = @_ ; $response ->code(RC_OK); if ( $request ->method =~ /get/i) { return RC_DENY; } $response ->header( 'Content-Type' => 'text/xml' ); my $out = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<r>\n" ; my $w = $request ->content; utf8::decode( $w ); $w =~ tr /A-Za-z0-9/A-Za-z0-9/; $w = URI::Escape::uri_escape_utf8( $w ); my $content = get( "http://search.hatena.ne.jp/keyword?word=$w&mode=rss&ie=utf8&page=1" ); if ( $content ) { my $rss = new XML::RSS; utf8::decode( $content ); $rss ->parse( $content ); for my $item (@{ $rss ->{items}}) { $out .= "<i a='" ; $out .= $item ->{ link }; $out .= "'>" ; if ( defined ( $item ->{title})) { $out .= "<t><![CDATA[" ; my $t = $item ->{title}; utf8::decode( $t ); $t =~ s/]]>/]]>/g; $out .= $t ; $out .= "]]></t>\n" ; } if ( defined ( $item ->{description})) { $out .= "<d><![CDATA[" ; my $d = $item ->{description}; utf8::decode( $d ); $d =~ s/]]>/]]>/g; $out .= $d ; $out .= "]]></d>" ; } $out .= "</i>\n" ; } } $out .= "</r>" ; $response ->content( $out ); return RC_OK; } |
関連単語を「はてな」の関連キーワードAPIを用いて探すためのハンドラ。「はてな」のコードサンプルで推奨されているようにXMLRPC::Liteモ ジュールを使って検索するが、私が試した限りでは「ディスク」など一部単語で問題が起こるようで、いささか実用性に欠ける。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | sub handlerAssoc { my ( $request , $response ) = @_ ; $response ->code(RC_OK); if ( $request ->method =~ /get/i) { return RC_DENY; } $response ->header( 'Content-Type' => 'text/xml' ); my $out = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<r>\n" ; my $w = $request ->content; utf8::decode( $w ); $w =~ tr /A-Za-z0-9/A-Za-z0-9/; utf8::encode( $w ); # XML::Parser employed by XMLRPC::Lite has troubles for some words such as "ディスク". # Probably should avoid XMLRPC::Lite and use another module instead in future. eval { 'hatena.getSimilarWord' , {wordlist => [ $w ]} ); unless ( $res ->fault) { foreach (@{ $res ->result->{wordlist}}) { if ( defined ( $_ ->{word})) { $out .= "<related w='" ; $out .= $_ ->{word}; $out .= "'/>\n" ; } } } }; warn $@ if $@; $out .= "</r>" ; $response ->content( $out ); return RC_OK; } |
クイズの答えが正しいかどうか判定し、正解/不正解を返すAJAXコールバックURLのハンドラ。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | sub handlerAnswer { my ( $request , $response ) = @_ ; $response ->code(RC_OK); if ( $request ->method =~ /get/i) { return RC_DENY; } $response ->header( 'Content-Type' => 'text/xml' ); my $out = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<r>\n" ; if ( exists ( $quiz_answer { $request ->{connection}->{remote_ip}})) { my $user_input = $request ->content; utf8::decode( $user_input ); if ( $quiz_answer { $request ->{connection}->{remote_ip}} eq $user_input ) { $out .= "<a r=\"true\"/>" ; } else { my $answer = $quiz_answer { $request ->{connection}->{remote_ip}}; $answer =~ s/]]//g; $out .= "<a r=\"false\"><![CDATA[" . $answer . "]]></a>" ; } } $out .= "</r>" ; $response ->content( $out ); return RC_OK; } POE::Kernel->run; exit ; |
最後に
POE::Kernel
を呼び出し、サーバを起動する。2つを書いてみての感想は、モジュール周りの扱いがRubyの方が簡潔で、完成された印象を持った。Perlの方はかなり入り組んでいてモジュールのインストールだけで小一時間かかってしまう(ほとんどがPOEに起因しているが、XML関連モジュールの層も相当ファットである印象を受ける)。Perlの方だけUTF-8を扱う必要がある点も、各モジュールの問題が噴出し、Perlにとって不利な結果となった。もちろんRubyも Mongrelの完成度が低いといった問題はあるものの、Webアプリケーションそのものの問題ではないし、それを言えばPOEは完全にMongrelに劣るので、Webアプリケーションテスト環境を含めた評価としては、やはりRubyの方が洗練されている。今回は順当にRubyに軍配が上がる結果となった。これでRubyそのもののパフォーマンスが向上すれば鬼に金棒といえるだろう。
コメント
コメントを投稿