W3C File APIを使ってJavaScriptでファイル加工
現在W3Cで仕様策定が進められているFile APIを使うと、JavaScriptからローカルファイルの情報や内容にアクセス出来るようになります。
Firefoxでは3.0時代から似たような機能が実装されていたようですが*1、今回やや仕様を変更した上で標準化されます。
まだワーキングドラフトの段階ですが、Firefox 3.6 RC1*2に既にほとんどのAPIが実装されていますので、今すぐに使ってみることができます。
そこで、試しにこんなサンプルを作ってみました。Firefox 3.6で以下のページにアクセスしてみてください。
ファイル選択欄でビットマップファイル(.bmp)を選択、あるいはブラウザにドラッグアンドドロップすると、その画像をネガポジ反転(階調の反転)して表示します。複数選択も可です。*3 *4
ファイル加工の際にサーバと一切通信せず、JavaScriptだけで処理を行っていることがポイントです。JavaScript/CSSを全てHTMLインラインで記述していますので、上のページをローカルに保存しておけば、LANケーブルを引っこ抜いても動作させられます。
今回はごく単純なサンプルですが、オープンソースの画像処理ライブラリ等をJavaScriptに移植すれば、もっと本格的な画像編集アプリケーションも作れそうです。
…もっとも、正直なところ、この類の処理をJavaScriptで実装するメリットはあまり無いのも事実なのですがw
強いて言えば、
- プラグイン無しのブラウザだけで動作可能なアプリケーションで、
- かつWeb上にデータを送信する必要が無い
あたりがメリットに成り得るでしょうか。
実際はCSVやXMLなどのテキストデータを読み込ませてちょっとした処理を行うとか、ファイルをアップロードする前になんらかのバリデーションやフィルタをかけるとか、そういう使い方がメインになる…のかな?
サンプル解説
先程のサンプルのコード説明を少しだけ。抜粋になりますので、全コードは上のページでソース表示して見てください。File APIだけでなく、HTML5の各種機能も色々ごっちゃ混ぜになった説明になっていますが、あしからず。また、各仕様のアップデートにより、将来的にこのページの説明が間違ったものとなる可能性もありますので、ご注意ください。
最初に、ヘッダやフッタを除いたHTML本体部分*5のコードを載せておきます。
fileタイプのinput要素にmultiple属性をつけてやると、ファイルが複数選択できるようになります。
// 203〜208行目 <p> ビットマップファイルを選択、またはここへドロップしてください。 (複数選択可) </p> <input id="file_select" type="file" multiple="multiple" /> <div id="result_container"></div>
選択されたファイルを取得する方法
fileタイプのinput要素で選択されたファイルはfiles属性から取得することができます。
// 30〜33行目 var file_select = document.getElementById('file_select'); file_select.addEventListener('change', function() { update(file_select.files); }, false);
ドロップされたファイルを取得する方法
本題ではありませんが、ドラッグアンドドロップされたファイルの情報の取り方も。
ドラッグアンドドロップAPIの詳細は省きますが、DataTransferオブジェクトのfiles属性から取得することができます。
// 36〜42行目 document.documentElement.addEventListener('dragover', function(event) { event.preventDefault(); }, false); document.documentElement.addEventListener('drop', function(event) { event.preventDefault(); update(event.dataTransfer.files); }, false);
取得したファイルを処理する
いずれの方法で取得したファイルもFileList型になっていますので、添字を使って各ファイルにアクセスします。ファイルを一つしか選択しなかった場合でも必ずFileList型になります。
// 53〜55行目 for (var i = 0; i < files.length; i++) { result_container.appendChild(convert(files[i])); }
ファイルの情報を参照する
各ファイルの情報はFile型として格納されています。
name属性でパスを含まないファイル名、type属性でMIMEタイプ、size属性でバイト単位のサイズを取得できます。
// 63〜69行目 var info = document.createElement('div'); var name = document.createElement('a'); name.appendChild(document.createTextNode(file.name)); info.appendChild(name); var text = ' (' + file.size + 'バイト) [' + file.type + ']'; info.appendChild(document.createTextNode(text)); result.appendChild(info);
ファイルの内容をバイナリで取得する
ファイルの内容を読み込むにはFileReader型を使用します。
readAsBinaryStringメソッドで、ファイルの内容をそのままバイナリで取得できます。
非同期で読み込まれますので、onloadイベントハンドラをセットしておき、その中でresult属性からデータを参照する形になります。*6
// 80〜108行目 var byte_reader = new FileReader(); byte_reader.onload = function() { try { var bitmap = new BitMapImage(byte_reader.result); /* 中略 */ } }; byte_reader.readAsBinaryString(file);
ファイルの内容をDataスキームURLで取得する
サンプルでは処理が重くなるのでコメントアウトしてありますが、readAsDataURLメソッドを使うと、ファイルの内容をDataスキームURLで取得できます。
これをそのままimgタグのsrc属性に設定すれば画像を表示できますし、aタグのhref属性に設定すればクリックでダウンロードさせることもできます。*7
// 73〜77行目 var url_reader = new FileReader(); url_reader.onload = function() { name.setAttribute('href', url_reader.result); }; url_reader.readAsDataURL(file);
ファイルの内容をテキストで取得する
サンプルでは使っていませんが、readAsTextメソッドを使うと、エンコードの変換を行った上でテキストを読み込むことができます。テキストファイル類はこちらを使用するのが良いでしょう。
バイナリデータを加工する
File APIには直接関係ありませんが、JavaScriptでバイナリデータを扱うには少し工夫が要ります。
中身はバイナリですが、あくまでもString型のデータとして返ってきますので、バイナリコードを取り出すには都度String#charCodeAtをかける必要があります。
サンプルでは、加工したデータを一旦配列にためています。
// 177行目 reversed_data[reversed_data.length] = 255 - this.data.charCodeAt(i);
この段階ではただの整数の配列ですので、再度これをバイナリデータに変換してやる必要があります。これはString.fromCharCodeで行えます。
// 187〜188行目 this.data = this.data.substr(0, this.off_bits) + String.fromCharCode.apply(null, reversed_data);
これでデータの加工は完了です。
その他
今回は加工したデータをimgタグで表示しただけですが、例えば取得・加工したデータをXMLHttpRequestで送信したり、aタグのhref属性にくっつけてダウンロード出来るようにしたり*8 *9、あるいはWeb Storageに保存してみたり、色々使い方はあるかと思います。
加工処理に時間がかかる場合はWeb Workersを使い、別スレッドで処理するようにしても良いかもしれません。
今までも非標準のブラウザ独自実装を使えば色々凝ったこともできましたが、これからは標準でこういう機能が使えるようになるということで、ちょっとワクワクしますね。
現在のところFile APIが使えるのはFirefox 3.6だけのようですが、他のHTML5関連の仕様も含め、早く多くのブラウザで使えるようになって欲しいなぁ、と思います。…そう遠いことではなさそうな気もしますけど。
参考サイト
*1:参考:Taken SPC : Firefox 3 における <input type="file"> で指定されたファイルへのアクセス
*2:2010年1月11日現在
*3:Windows形式、24bitカラー、圧縮なしのビットマップファイル以外には対応していません。
*4:あまり大きな、あるいは多数のファイルを選択すると、処理が非常に重くなります。
*5:articleタグ内
*6:同期読み込み用のインターフェースもあります。
*7:MIMEタイプによってはそのままブラウザのウィンドウに表示されます。
*8:MIMEタイプをapplication/octet-streamにすれば、常にダウンロードダイアログを出させることができます。
*9:ただし常にファイル名の入力が必要になります。本当はもっとベターなダウンロードの方法があれば良いのですが…。
Google App Engine/JRubyでTwitterの自動フォロー返し
先日Google App EngineのSDKが1.2.6にバージョンアップし、アプリケーションがメールを受信できるようになりました。
Twitterの自動フォロー返しを実現するには、おおまかに分けて
- フォロー通知のメールを受信したタイミングでプログラムを呼び出す
- 定期的にメールサーバにフォロー通知メールを確認しに行く
- 定期的にfollowingとfollowersの差分をチェックする
という3パターンがあると思います。
GAEのメール受信機能を使えば、この中の1番が手軽に実現できそうです。
ということで、ごく簡単な自動フォロー返しの仕組みをGAE/JRubyで作ってみました。
下準備
GAE/JRubyの環境を整えるところまでは以下の記事を参照して下さい。
gemでインストールできるApp Engine SDKは2009/10/18現在まだ1.2.5ですが、特に問題なく動くようです。開発サーバでのテストメール送信機能が使えないのは痛いですが…。
環境が整ったら、ディレクトリを作って必要なgemのインストールをしておきます。
$ mkdir sample-twitter-follow-back $ cd sample-twitter-follow-back $ appcfg.rb gem install sinatra appengine-apis
設定ファイルの作成
rackupの設定ファイルを書きます。
$ vim config.ru
require 'appengine-rack' AppEngine::Rack.configure_app( :application => 'your-application-id', :version => 1, # 1. メール受信サービスを有効化する :inbound_services => [ :mail ] ) # 2. 管理者アカウント以外からのアクセスを禁止する use AppEngine::Rack::AdminRequired require 'main' run Sinatra::Application
ソース中のコメント部分を少し補足しておきます。
1. メール受信サービスを有効化する
AppEngine::Rack.configure_appメソッドは:applicationと:version以外にもいくつかオプション引数を取ります。*1
各種サービスに関しては、上のように:inbound_servicesオプションに使いたいサービス名を指定することでWEB-INF/appengine-web.xmlにその内容が反映され、サービスが使えるようになります。
2. 管理者アカウント以外からのアクセスを禁止する
今回はメール受信サービスから呼び出される以外の使い方を想定していませんので、全体に“管理者のみ許可”のアクセス制限をかけてしまいます。
他にもAppEngine::Rack::LoginRequiredやAppEngine::Rack::SSLRequiredなどのセキュリティオプションがあり、Rack::Builder#mapメソッドと組み合わせることで適用範囲を指定することもできます。
アプリケーションの作成
アプリケーション本体のソースを書きます。
$ vim main.rb
require 'sinatra' require 'yaml' require 'appengine-apis/logger' require 'appengine-apis/urlfetch' # 1. メールが届くと、メールアドレスを含んだURLが呼び出される post '/_ah/mail/:account@:domain' do |account, domain| logger = AppEngine::Logger.new # 2. Twitterアカウント情報を読み込む accounts = YAML.load_file('accounts.yaml') unless accounts[account] # 3. 不明なアカウントの場合はログに記録しておく logger.warn("Received a mail for an unkonwn account.") logger.warn("Account Name: #{account}") logger.warn("Domain: #{domain}") halt 'unkonwn account' end username = accounts[account]['username'] password = accounts[account]['password'] # 4. POSTデータとして送られくるメールを読み込む mail_data = env['rack.input'].read # 5. メールを解析する props = java.util.Properties.new session = javax.mail.Session.get_default_instance(props) stream = java.io.ByteArrayInputStream.new(mail_data.to_java_bytes) message = javax.mail.internet.MimeMessage.new(session, stream) # 6. Twitter通知メールの固有ヘッダから必要な情報を得る mail_type = message.get_header('X-TwitterEmailType').to_a.first sender_id = message.get_header('X-TwitterSenderID').to_a.first if mail_type == 'is_following' && sender_id # 7. フォロー通知メールなのでフォロー返しをする url = "https://twitter.com/friendships/create/#{sender_id}.json" request = Net::HTTP::Post.new('/') request.basic_auth(username, password) options = { :method => request.method, :payload => request.body, :headers => { 'Authorization' => request['Authorization'] } } response = AppEngine::URLFetch.fetch(url, options) unless response.code.to_i == 200 # 8. リクエスト失敗時はログに記録して500エラーを返す logger.error("Reguest failed of following back.") logger.error("Status: #{response.code} #{response.message}") logger.error("User ID: #{sender_id}") halt 500, 'request failed' end else # 9. それ以外のメールはログに記録しておく subject = message.subject from = message.from.to_a.join(', ') logger.warn("Received a mail which is not 'is_following'.") logger.warn("Subject: #{subject}") logger.warn("From: #{from}") end 'ok' end
先ほどと同じように、コメント部分について少し補足しておきます。
1. メールが届くと、メールアドレスを含んだURLが呼び出される
メール受信サービスのドキュメントを読むと、メールを受信する毎に"/_ah/mail/<address>"というURLに対してPOSTリクエストが発行されるようです。
URLに含まれるメールアドレスからアカウント名を取得しておきます。
2. Twitterアカウント情報を読み込む
Twitterのアカウント情報は別ファイルにまとめておくことにしました。こうすれば、複数のアカウントの自動フォローを1つのアプリケーションで担当することができます。
アカウント情報ファイルの詳細は後述します。
3. 不明なアカウントの場合はログに記録しておく
メールは誰でも送信できますので、想定していないアカウントに対してメールが送られてくることもあるかもしれません。どうもGAEのメール受信サービスはリクエストが失敗するとメールの再送をかけるようになっているようなので、ここはログに書き残しておくだけで正常なレスポンスを返しておきます。そうしないと延々と再送をかけられてしまいます。
4. POSTデータとして送られくるメールを読み込む
先ほどのドキュメントにもありましたが、メールソースはPOSTデータとして送られてきますので、一旦全てメモリ上に読み込んでしまいます。
5. メールを解析する
読み込んだメールソースをJavaのMimeMessageクラスを使って解析します。この辺は詳しい説明は省きます。こういう流れなのだと思って下さい。あるいはJavaのリファレンスを参照して下さい。
6. Twitter通知メールの固有ヘッダから必要な情報を得る
Twitterからのフォロー通知メールには、プログラムがメールを処理しやすいように、いくつか固有のヘッダが付加されています。
ここから情報を取得すれば、わざわざ面倒なメール本文の解析を行う必要はありません。
7. フォロー通知メールなのでフォロー返しをする
フォロー返しはAppEngine::URLFetch.fetchメソッドを使って行っています。Basic認証なのでHTTPSでリクエストします。
Net::HTTP::PostクラスはBasic認証に使うAuthorizationヘッダのエンコードと、POSTパラメータ*2のエンコードのために使っているだけで、直接リクエストには使用しません。自力でエンコードするならNet::HTTP::Postクラスを使う必要はありません。
8. リクエスト失敗時はログに記録して500エラーを返す
前述の通り、リクエスト失敗時はメールの再送をかけてくれるようなので、フォローに失敗した場合はわざと500エラーを返しておきます。
ただ、例えば「パスワードが間違っている」などの「再送しても成功しないケース」だった場合、再送のループに陥ってしまいます。きちんと作るならその辺りの配慮も必要になります。
9. それ以外のメールはログに記録しておく
これも今まで出てきたパターンと同じように、ログに記録した上で再送を防ぐために正常終了しています。
アカウント情報ファイルの作成
これでアプリケーションは完成ですので、最後にアカウント情報ファイルを同じディレクトリに保存しておきます。
$ vim accounts.yaml
user1: username: Sample_User1 password: 1234ABCD user2: username: testuser2 password: foobarbaz
上の例で言うと"user1", "user2"の部分が「アカウント名」になります。「アカウント名」とTwitterへのログインに使う「ユーザ名」は同じでも異なっていても構いません。
言うまでもありませんが、これらのアカウント情報をクラウド上にアップロードすることはそれなりのリスクを伴います。その辺りは自己責任で行って下さい。
メールアドレスの登録
自動フォローしたいTwitterアカウントの「設定」ページでメールアドレスを登録します。メールアドレスは
<アカウント名>@<アプリケーションID>.appspotmail.com
になります。ドメインが"appspotmail.com"なのに注意して下さい。
アプリケーションのアップロード
本来ならここで開発サーバでのテストを行いたいところですが、前述の通り、2009/10/18現在はまだgoogle-appengine gemの各モジュールがSDK 1.2.6の機能に対応していないため、さっさと実環境にアップロードして、そちらで動作確認してしまいます。*3
config.ruの:applicationと:versionが正しいことを確認しておいて下さい。
$ appcfg.rb update .
アップロードが完了したら、試しに先ほど登録したメールアドレスに対して適当なメールを送信してみたり、実際に他のアカウントからフォローしてみたりして動作を確認しておくと良いと思います。
問題点
これでとりあえず自動フォローの仕組みが完成しました。しかし、このアプリケーションにはいくつかの重大な欠陥が存在します。
届いたメールがそのまま失われてしまうのが最たる問題でしょうか。フォロー通知メールなら別に消えたって構わないかもしれませんが、ダイレクトメッセージのメールやその他のメールまで消えてしまうのは問題です。転送するなり、データストアやログに記録しておくなりしたほうが良いでしょう。
パスワードを平文でアップロードしなければいけないのも気になる点です。この点はOAuthの仕組みを実装することで解決できそうです。
最もどうにもならない問題は、「GAEの1リクエスト30秒制限」と「JRubyインスタンスのロードの遅さ」の組み合わせによる「タイムアウトの頻発」でしょうか。
ログを監視しているとわかりますが、ぶっちゃけた話、JRuby環境をロードするだけで普通に30秒制限を突破してDeadlineExceededException例外が発生していたりします。幸い何度も再送をかけてくれるおかげで、そのうち成功してくれますが…。
今後GAE/JRubyの最適化が進めば解決するかもしれませんが、やはりGAEのRubyネイティブ対応が欲しいところです。
更なるフォロワー管理
さて、これで「フォロー通知メール反応型の自動フォロー」は実現できましたが、フォロワーの完全自動管理を行うためには、これだけでは全然足りません。
皆さんもご経験があるかもしれませんが、フォロー通知メールは100%信頼のおけるものではなく、実際には「フォローされたのにメールが送られてこない」というパターンもよくあります。
それに、bot等のプログラムではフォローを解除された場合の「自動フォロー外し」機能も必要なことがあるかもしれません。
これらはGAEのCronサービスと、Twitterの"friends/ids", "followers/ids" APIを組み合わせることで解決可能ですが、それはまた別の機会に。
- Scheduled Tasks With Cron for Java - Google App Engine - Google Code
- Twitter API Wiki / Twitter REST API Method: friends ids
- Twitter API Wiki / Twitter REST API Method: followers ids
簡単にやり方だけ書いておきますと、Cronで一日一回タスクを起動し、friends/idsとfollowers/idsをチェックして、差分IDに対してフォロー/アンフォローを実行していくだけです。
実際には前述の30秒制限もあり、確実に動作させるにはTask Queueサービスを利用するなど、色々工夫が必要になりそうな気がします。
…まぁ、レンタルでも自前でも、自由に使えるサーバ環境を用意できるならば、GAEではなくそちらを使ったほうが簡単だったりもしますが…。
HTTP通信を伴うライブラリのRSpecを書く
伴う、というかそれがメインの機能だったりもするわけですが。
少し前、以下の記事でごく単純なOAuthコンシューマの実装を行いました。
この小さなライブラリの使い方は以下の通りです。
require 'simple-oauth' simple_oauth = SimpleOAuth.new('COMSUMER-KEY', 'COMSUMER-SECRET', 'ACCESS-TOKEN', 'TOKEN-SECRET' ) response = simple_oauth.get('http://example.com/') response = simple_oauth.post('http://example.com/', :foo => 'bar')
これで全機能です。*1
getやpostメソッドでは、内部でNet::HTTPを利用してHTTP通信を行っています。
さて、このライブラリのRSpecを書こうとしたとき、どのように書けばよいのでしょうか?*2
テストを実行する度に、実際にどこかのWebサーバにアクセスするというのもなんだか気持ち悪いです。
SimpleOAuthに限って言えば、キモはAuthorizationヘッダの生成部分なので、最悪そのメソッドだけをテストするようなコードにして済ませてしまう、という選択肢もあるかもしれません。
しかしそれではRSpecの「振舞を記述する」という目的に沿いませんし、やはりちゃんとHTTP通信が行えるかどうか、せめてNet::HTTPに処理が渡るところまでは正しく動くことを確認できるようにしたいですよね。
このようなケースでは、次の2パターンの解決策があると思います。
- Net::HTTPをモックオブジェクト化する
- WEBrickを使ってダミーサーバを立てる
以下、それぞれの手法で実際にスペックを書いていきます。
Net::HTTPをモックオブジェクト化する
ライブラリの中でNet::HTTP及びその関連クラスのメソッドをどう呼び出しているかにも関わってくるのですが、幸いSimpleOAuthではリクエストの送出はNet::HTTP#requestメソッドに集約されています。
つまり、このメソッドをフックしてそこにスタブを仕込むことで、実際のHTTP通信を伴うことなく、かつライブラリの全機能を漏れなくテストすることができます。
この手法を用いて、SipmleOAuth#getメソッドに対する1つのexampleが定義されただけの最小のスペックファイルを書いてみます。
require 'rubygems' require 'json' require 'simple-oauth' describe SimpleOAuth do before do @simple_oauth = SimpleOAuth.new('CONSUMER-KEY', 'CONSUMER-SECRET', 'ACCESS-TOKEN', 'TOKEN-SECRET') end def create_http_mock(request_class) http = Net::HTTP.new('localhsot') http.should_receive(:request) .with(kind_of(request_class)) .and_return do |request| response = Net::HTTPOK.new(nil, 200, nil) response.stub!(:body).and_return({ :method => request.method, :path => request.path, :body => request.body, :authorization => request['Authorization'], }.to_json) response end Net::HTTP.should_receive(:new).at_least(1).and_return(http) # Point end it 'should access a protected resource by using #get method' do create_http_mock(Net::HTTP::Get) response = @simple_oauth.get('http://localhost/') response.should be_an_instance_of(Net::HTTPOK) body = JSON.parse(response.body) body['authorization'].should match(/^OAuth/) # other expectations... end # other examples... end
create_http_mockメソッドでNet::HTTPをモックオブジェクト化し、その後にSimpleOAuth#getメソッドを実行することで、HTTP通信を伴うことなくテストを実行します。また、モックオブジェクト化したNet::HTTPが、その呼び出しを検証可能なパラメータを含んだレスポンスを返すようにすることで、きちんと結果に対するexpectationを書くことができます。
この手法のポイントとなるのは、「Net::HTTP.newをフックする」というところです。実際にフックしたいのはNet::HTTP#requestなのですが、Net::HTTPのインスタンスはライブラリの内部で生成されているため、直接は手が出せません。そこでNet::HTTP.newをフックし、予めNet::HTTP#requestをスタブ化したモックオブジェクトを返すようにすることで、ライブラリ内部で呼び出されるNet::HTTP#requestもフックすることができます。
WEBrickを使ってダミーサーバを立てる
テストの実行中にのみローカルにダミーのWebサーバを立て、そこに向けてリクエストを送るようにすることで、実際のHTTP通信まで含めた挙動をチェックすることもできます。ダミーサーバにはWEBrickを利用するのが簡単でしょう。
この手法を用いて、先程と同じSimpleOAuth#getメソッドのexampleのみのスペックファイルを書いてみます。
require 'webrick' require 'rubygems' require 'json' require 'simple-oauth' describe SimpleOAuth do before :all do @server_thread = Thread.new do # (1) server = WEBrick::HTTPServer.new( :Port => 10080, :Logger => WEBrick::Log.new('/dev/null'), # (7) :AccessLog => [], # (7) :StartCallback => Proc.new { Thread.main.wakeup } # (3) ) server.mount_proc('/test') do |request, response| response.content_type = 'application/json' response.body = { :method => request.request_method, :query => request.query, :authorization => request['Authorization'], }.to_json end Signal.trap(:INT) { server.shutdown } # (4) server.start end Thread.stop # (2) end after :all do Process.kill(:INT, Process.pid) # (5) @server_thread.join # (6) end before :each do @simple_oauth = SimpleOAuth.new('CONSUMER-KEY', 'CONSUMER-SECRET', 'ACCESS-TOKEN', 'TOKEN-SECRET') end it 'should access a protected resource by using #get method' do response = @simple_oauth.get('http://localhost:10080/test') response.should be_an_instance_of(Net::HTTPOK) body = JSON.parse(response.body) body['authorization'].should match(/^OAuth/) # other expectations... end # other examples... end
先程のモックオブジェクト化したNet::HTTPと同様、レスポンスとして検証可能なパラメータを含んだJSONオブジェクトを返すことで、呼び出し側のexampleの中できちんとexpectationを書くことができます。
先程と比較して注意すべきポイントが多くなりますので、以下に箇条書きします。サンプルコード中のコメントの番号と対応しています。
- ダミーサーバをメインスレッドで動かすとそこで処理が止まってしまいますので、別スレッドで動かします。
- スレッドを立ち上げた後、WEBrickがstartするまでメインスレッドを停止しておきます。こうしないと、別スレッドでWEBrickがstartする前にメインスレッドでテストが始まってしまいます。
- StartCallbackに渡したProcオブジェクトは、WEBrickがstartしたタイミングで呼び出されます。ここで停止しておいたメインスレッドを再開します。
- WEBrickはSIGINTをキャッチしたら終了するようにしておきます。
- 全てのテストが完了したら、自プロセスに対してSIGINTを投げます。これを上で設定したtrapに捕捉させ、WEBrickを終了します。
- スレッドをjoinして後始末完了です。
- ログは邪魔なので全部捨てるようにしておきます。
この手法の利点は、ダミーサーバ側にいくつか定義の追加が必要ですが、401や404など、リクエストが失敗した場合の振舞も手軽にチェックできることでしょうか。
HTTP通信以外の場合
HTTP以外のソケット通信を伴うライブラリの場合は、Net::HTTPの代わりにTCPSocketをモックオブジェクト化したり、WEBrickの代わりにGServerを使ったりする必要があると思います。実際に試してはいませんが、今回書いたサンプルよりは複雑になってしまうでしょう。
多分、探せばその辺の面倒な部分をサポートしてくれるgemはどこかにありそうな気はしますが…。
HTTP通信に関しても、もっとエレガントな手法があるかもしれません。その辺りをご存知の方がいらっしゃいましたら、是非ご教授お願い致します。
MySQL/Rubyにおける正しいエンコーディング変更方法
MySQLの文字化けを直したい!
発端はSequelを使ってMySQLのデータを操作するRubyスクリプトを書いていたときでした。
UTF-8で保存したはずの文字列が、妙に文字化けしています。
$ mysql -h host -u user -p database ...(略)... Type 'help;' or '\h' for help. Type '\c' to clear the buffer. mysql> \s -------------- mysql Ver 14.14 Distrib 5.1.30, for portbld-freebsd7.1 (i386) using 5.2 ...(略)... Server characterset: ujis Db characterset: utf8 Client characterset: ujis Conn. characterset: ujis ...(略)... mysql>
データベースの文字コードがutf8で、それ以外が全てujisになっています。
自分で管理しているサーバであれば、ここでmy.cnfを編集し、[mysqld]セクションや[client]セクションにdefault-character-setを指定してやれば良いのですが、あいにく今回はさくらのレンタルサーバを使用しているため、編集する権限がありません。
SET NAMESを使ってみたものの
まず最初に試したのは"SET NAMES"ステートメントでした。
- MySQL :: MySQL 5.1 リファレンスマニュアル :: 9.4 接続のキャラクタセットおよび照合順序
- MySQL :: MySQL 5.1 リファレンスマニュアル :: 12.5.3 SET 構文
$ irb -r sequel irb(main):001:0> mysql = Sequel.connect('mysql://user:password@host/database') => #<Sequel::MySQL::Database: "mysql://user:password@host/database"> irb(main):002:0> mysql << 'SET NAMES utf8' => nil irb(main):003:0> mysql.fetch("SHOW VARIABLES LIKE 'character_set_%'"){ |x| p x } {:Variable_name=>"character_set_client", :Value=>"utf8"} {:Variable_name=>"character_set_connection", :Value=>"utf8"} {:Variable_name=>"character_set_database", :Value=>"utf8"} {:Variable_name=>"character_set_filesystem", :Value=>"binary"} {:Variable_name=>"character_set_results", :Value=>"utf8"} {:Variable_name=>"character_set_server", :Value=>"ujis"} {:Variable_name=>"character_set_system", :Value=>"utf8"} {:Variable_name=>"character_sets_dir", :Value=>"/usr/local/share/mysql/charsets/"} => #<Sequel::MySQL::Dataset: "SHOW VARIABLES LIKE 'character_set_%'"> irb(main):004:0>
実際、この状態でSELECTやINSERTを行うと、きちんとUTF-8のデータが扱えます。
SET NAMESは危ない?
めでたしめでたし…といきたいところなのですが、どうも色々とググってみると、この方法はあまりよろしくないようです。
- SET NAMESは禁止
- へぼへぼCTO日記 - libmysqlclientを使うプログラムはset namesをutf8であっても使ってはいけない
- MySQL5開拓団 - 日本語処理の鉄則 / KLab株式会社
- tmtm日記(2006-06-09) [MySQL][Ruby] セキュアなプログラミング
詳細は上記のサイトなどを参照してもらうとして、簡単に説明すると、「SET NAMESによるエンコーディングの変更はクライアント側の状態に影響を及ぼさないため、mysql_real_escape_string()*1などによるエスケープ処理が正しく行われなくなる」ということのようです。
つまり、前述の例だと、サーバもユーザプログラムもデータをutf8としてやり取りしているのに、クライアントライブラリ内で文字列をエスケープする際はujisとしてエスケープ処理が行われてしまうわけです。
utf8とujisならまぁ特に問題も無さそうですが、上記参考サイトの説明にもある通り、ここにShift-JISが絡んでくると色々まずいですね。\x5cとか。
それに上記サイトには「UTF-8であっても油断してはいけない」とありますので、より「正しい方法」を探してみることにしました。
SET NAMESの代替手段
先程のサイトによると、SET NAMESの代替手段としては、設定ファイルの変更などを除くと、MySQL C APIで言うところの
という2つの方法があるようです。
実際にMySQL C APIのリファレンスマニュアルを見てみても、それ以外に方法はなさそうでした。
Sequelが内部で利用しているMySQL/Rubyは、ほぼそのままMySQL C APIのラッパーとなっていますが、若干古いためか、mysql_set_character_set()に相当する機能が存在しません。
ということは、残る手段はmysql_options()に限られるわけです。
mysql_options()を使ってみる
Sequel及びMySQL/Rubyのソースを軽く読んでみたところ、Sequel.connectのオプション引数として:encodingあるいは:charsetパラメータを渡せば、それがそのままMySQL C APIのmysql_options(MYSQL_SET_CHARSET_NAME)として実行されるようです。
ということで試してみました。
$ irb -r sequel irb(main):001:0> mysql = Sequel.connect('mysql://user:password@host/database', :encoding => 'utf8') => #<Sequel::MySQL::Database: "mysql://user:password@host/database"> irb(main):002:0> mysql.fetch("SHOW VARIABLES LIKE 'character_set_%'"){ |x| p x } {:Variable_name=>"character_set_client", :Value=>"ujis"} {:Variable_name=>"character_set_connection", :Value=>"ujis"} ...(略)... => #<Sequel::MySQL::Dataset: "SHOW VARIABLES LIKE 'character_set_%'"> irb(main):003:0>
…あれ?変わっていないですね。実際にSELECTやINSERTをしてみても、やっぱり文字化けします。
Sequelが悪いのかと思い、直接MySQL/Rubyを呼び出して同じ操作をしてみました。
$ irb -r mysql irb(main):001:0> mysql = Mysql.init => #<Mysql:0x2876711c> irb(main):002:0> mysql.options(Mysql::SET_CHARSET_NAME, 'utf8') => #<Mysql:0x2876711c> irb(main):003:0> mysql.real_connect('host', 'user', 'password', 'database') => #<Mysql:0x2876711c> irb(main):004:0> mysql.query("SHOW VARIABLES LIKE 'character_set_%'").each_hash { |x| p x } {"Variable_name"=>"character_set_client", "Value"=>"ujis"} {"Variable_name"=>"character_set_connection", "Value"=>"ujis"} ...(略)... => #<Mysql::Result:0x2869d424> irb(main):005:0>
やっぱり変わりません。
Cで直接mysql_options()を使ってみる
もうこうなったら、Cで書いて直接MySQL C APIを叩くしかありません。
mysql_encoding.c
#include <stdio.h> #include <mysql.h> int main(int argc, char **argv) { MYSQL mysql; MYSQL_RES *result; MYSQL_ROW row; unsigned int num_fields; unsigned int i; unsigned long *lengths; mysql_init(&mysql); mysql_options(&mysql, MYSQL_SET_CHARSET_NAME, "utf8"); mysql_real_connect(&mysql, "host", "user", "password", "database", 0, NULL, 0); //mysql_query(&mysql, "SET NAMES utf8"); // … (A) //mysql_set_character_set(&mysql, "utf8"); // … (B) mysql_query(&mysql, "SHOW VARIABLES LIKE 'character_set%'"); result = mysql_use_result(&mysql); num_fields = mysql_num_fields(result); while ((row = mysql_fetch_row(result))) { lengths = mysql_fetch_lengths(result); for (i = 0; i < num_fields; i++) { printf("[%.*s] ", (int)lengths[i], row[i] ? row[i] : "NULL"); } putchar('\n'); } mysql_free_result(result); mysql_close(&mysql); return 0; }
実際にはエラー処理も入れていますが、長くなるので省いています。
$ gcc -Wall -O2 -lmysqlclient -I/usr/local/mysql/5.1/include/mysql -L/usr/local/mysql/5.1/lib/mysql mysql_encoding.c -o mysql_encoding $ ./mysql_encoding [character_set_client] [ujis] [character_set_connection] [ujis] ...(略)...
やっぱりダメです。
しかし、mysql_encoding.cのソース中にある(A)や(B)のコメントアウトを外すとちゃんとエンコーディングがutf8にセットされることから、サーバもクライアントも正しく動いていないわけではなさそうです。
mysql_options()のソースを読んでみる
どうもmysql_options(MYSQL_SET_CHARSET_NAME)だけではエンコーディングは切り替わらないような気がしてきました。
若干面倒ですが、仕方ないのでMySQLのクライアントライブラリのソースを読んで調べてみます。
下のページからMySQL 5.1.38のソースをダウンロードしてきて、適当な場所に展開します。
問題のコードはlibmysql/client.c内にあるようです。
まずmysql_options()が実際に何をしているのか見てみます。3074行目から。
int STDCALL mysql_options(MYSQL *mysql,enum mysql_option option, const void *arg) { DBUG_ENTER("mysql_option"); DBUG_PRINT("enter",("option: %d",(int) option)); switch (option) { ...(略)... case MYSQL_SET_CHARSET_NAME: my_free(mysql->options.charset_name,MYF(MY_ALLOW_ZERO_PTR)); mysql->options.charset_name=my_strdup(arg,MYF(MY_WME)); break; ...(略)... default: DBUG_RETURN(1); } DBUG_RETURN(0); }
mysql->options.charset_nameに渡された値を格納しているだけのようです。
では、次にこの値が参照されるのはどこでしょうか?軽く検索をかけてみると、1774行目から次のような関数が定義されていました。
C_MODE_START int mysql_init_character_set(MYSQL *mysql) { ...(略)... mysql->charset=get_charset_by_csname(mysql->options.charset_name, MY_CS_PRIMARY, MYF(MY_WME)); ...(略)... return 0; }
get_charset_by_csname()の宣言は以下の通り。
CHARSET_INFO *get_charset_by_csname(const char *cs_name, uint cs_flags, myf my_flags);
ものすごくばっさりと省略しましたが、要は先程のmysql->options.charset_nameを基にCHARSET_INFO構造体を得、それをmysql->charsetに格納しているようです。
このmysql_init_character_set()が呼ばれているのはどこかというと、1845行目から始まるmysql_real_connect()定義の中。あまりに長いので引用しませんが、2213行目です。
つまり、mysql_init() -> mysql_options() -> mysql_real_connect() の順番自体は間違っていないわけです。
その後、このmysql->charsetがどう扱われているかというと…。軽く確認してみた限りでは、特にサーバとの通信の中でこの値が使われている様子はありませんでした。一応2273行目で
buff[8]= (char) mysql->charset->number;
みたいなことをしていますが、前後を見る限りあまり関係なさそうです。
うーん、ここまで読んだ感じ、mysql_options(MYSQL_SET_CHARSET_NAME)はmysql->charsetを書き換えているだけで、サーバに対して何か働きかけをしているわけではないような…。
mysql_set_character_set()も読んでみる
比較のためにmysql_set_character_set()も読んでみます。この関数は、この記事の最初で参照したサイトの中でもエンコーディング切り替えの手段として示されていましたし、また先程のサンプルプログラムでも実際に切り替えに成功していました。
同じファイルの3225行目から始まっています。
int STDCALL mysql_set_character_set(MYSQL *mysql, const char *cs_name) { struct charset_info_st *cs; ...(略)... (cs= get_charset_by_csname(cs_name, MY_CS_PRIMARY, MYF(0)))) ...(略)... sprintf(buff, "SET NAMES %s", cs_name); if (!mysql_real_query(mysql, buff, (uint) strlen(buff))) { mysql->charset= cs; } ...(略)... return mysql->net.last_errno; }
どうやらSET NAMESした後にmysql->charsetにCHARSET_INFO構造体をセットしているだけのようです。
…ん?結局SET NAMESするんですか?
mysql_real_escape_string()も読んでみる
最初にSET NAMESは危ないとした根拠として、mysql_real_escape_string()によるエスケーピングを挙げました。この関数も読んでみます。
こちらはlibmysql/libmysql.cの1623行目から始まっていました。
ulong STDCALL mysql_real_escape_string(MYSQL *mysql, char *to,const char *from, ulong length) { if (mysql->server_status & SERVER_STATUS_NO_BACKSLASH_ESCAPES) return (uint) escape_quotes_for_mysql(mysql->charset, to, 0, from, length); return (uint) escape_string_for_mysql(mysql->charset, to, 0, from, length); }
escape_quotes_for_mysql()やescape_string_for_mysql()の中までは追いませんが、パッと見てmysql->charsetを参照していることがわかります。
なるほど、要はこのmysql->charsetが「サーバと実際にやりとりしている文字コード」と合致していれば良いわけですね。
整理してみる
なんとなく理解できそうになってきたので、一旦整理してみます。
SET NAMESが危ない理由は?
mysql_real_escape_string()などの内部で参照されているmysql->charsetの情報が実態と異なってしまう場合があるためです。引用はしませんが、mysql_query()でSET NAMESを実行しても、当然ながらmysql->charsetを一緒に書き換えてくれたりはしません。
mysql_set_character_set()が安全な理由は?
SET NAMESした後にmysql->charsetも一緒にセットしてくれるからです。これならサーバとやりとりしている文字コードと、mysql_real_escape_string()などが参照する文字コードが一致するため、間違った文字列操作は発生しません。
mysql_options()って結局何?
MYSQL_SET_CHARSET_NAMEに対する操作に限って言えば、mysql->charsetを指定した文字コードにセットしてくれるだけです。それを特にサーバに知らせたりはしてくれません。この場合、やはりmysql->charsetが示す文字コードが実態と異なってしまいます。SET NAMESだけした場合の丁度逆のパターンですね。
mysql_options()とmysql_set_character_set()の関係って?
つまり、細かいことを無視すれば「mysql_options(MYSQL_SET_CHARSET_NAME) + "SET NAMES" = mysql_set_character_set()」の式が成り立ちます。mysql_set_character_set()は単体で十分な働きをしますが、mysql_options(MYSQL_SET_CHARSET_NAME)とSET NAMESはそれぞれ単体では不足なのです。
じゃあmysql_options()とSET NAMESの関係は?
mysql_options(MYSQL_SET_CHARSET_NAME)はSET NAMESを代替する機能ではなく、お互いを補完しあうものであり、必ずペアで使用しなければなりません。
結局、MySQL/Rubyでの正しいエンコーディング変更方法は?
前述の通り、MySQL/Rubyではmysql_set_character_set()が使えませんので、代わりに同等の働きをするmysql_options(MYSQL_SET_CHARSET_NAME) + "SET NAMES"を実行すればOKです。
mysql = Mysql.init mysql.options(Mysql::SET_CHARSET_NAME, 'utf8') mysql.real_connect('host', 'user', 'password', 'database') mysql.query('SET NAMES utf8')
Sequelなら次のようになります。
mysql = Sequel.connect('mysql://user:password@host/database', :encoding => 'utf8') mysql << 'SET NAMES utf8'
まとめ
ここまで辿り着くのにかなりかかりました…。
手間取った原因は、色々な参考サイトを読んでいく中で、てっきり
- mysql_options(MYSQL_SET_CHARSET_NAME)とmysql_set_character_set()は同等の機能を持ち
- いずれもSET NAMESを代替するものであり
- SET NAMESは絶対に使用してはいけない
のだと勘違いしてしまった点にあります。
実際は
- SET NAMESは単体で使ってはいけないだけ
- mysql_options(MYSQL_SET_CHARSET_NAME)はSET NAMESと組み合わせて使うもの
- あるいはmysql_set_character_set()を用いれば上記2つを代替できる
ということだったようです。
調べ物をするときは思い込みをせず、きちんと正しい意味で捉えましょう、という教訓なのでした…!
あ、でも、ここに書いてあることはあくまでも私の理解レベルに基く記述ですので、間違っている危険性はかなりあると思います。「それは違う」というツッコミがありましたら是非お願い致します!
appengine-jrubyで簡単GAE/JRuby開発
少し前のエントリで、GAE/JRuby上でRackアプリを動かす手順を書きました。
このときの手順は、Java SDKをインストールして、GAE SDKをダウンロードして、jruby-complete.jarをコンパイルして…と、やや煩雑なものでした。この当時はおそらくこれが一般的な手法だったのではないかと思います。
しかし、今や時代は変わりました。今GAE/JRubyでRackアプリを作るのに必要な作業は、たったひとつのgemのインストールだけです。
有志によってGAE/JRuby上の開発を支援する"appengine-jruby"というプロジェクトが進められており、その成果として"google-appengine"というgemが開発されています。これを利用すれば、GAE SDKも、jruby-complete.jarも、JRuby-Rackも、何一つ自分で用意する必要はありません。*1更に、設定用のXMLファイルすら書く必要がありません。驚くほど簡単にGAE/JRuby上にRackアプリを構築できてしまいます。*2
実際にごくシンプルなRackアプリとSinatraアプリを作りつつ、どれほどお手軽にGAE/JRubyによる開発ができてしまうのかを実感してみたいと思います。
なお、以下の作業は全てCentOS 5.3上でRuby 1.9.1を使って行っています。またJDKはyum経由でjava-1.6.0-openjdkパッケージとjava-1.6.0-openjdk-develパッケージをインストール済みです。
まず作ってみる
最初はお決まりの"Hello world"でいってみます。素のRackアプリです。以前のエントリでもまずこれを作成しましたので、その時の作業量と比較すると"google-appengine gem"の威力がよりわかりやすくなると思います。
gemのインストール
以下のコマンドを実行します。ここで実行するgemはCRubyのgemですので、気を付けてください。
$ gem install google-appengine Successfully installed rack-1.0.0 Successfully installed appengine-rack-0.0.2 Successfully installed appengine-sdk-1.2.2 Successfully installed appengine-jruby-jars-0.0.2 Successfully installed appengine-tools-0.0.2 Successfully installed google-appengine-0.0.2 6 gems installed
いくつか依存関係にあるgemが一緒にインストールされます。GAE SDKなど必要なものは全てこれらの中に含まれています。
アプリケーションの作成
ではアプリケーションの作成に取り掛かります。まず適当にディレクトリを掘ります。
$ mkdir hello $ cd hello
次に、"config.ru"という名前*3でアプリケーションの設定ファイル兼ソースファイルを作成します。
- config.ru
require 'appengine-rack' AppEngine::Rack.configure_app( :application => 'application-id', # Replace your application id. :version => 1 # Replace your application version. ) run lambda { Rack::Response.new('Hello world!') }
"*.ru"というファイルはrackup用の設定ファイルです。rackupというのはrackのミドルウェアを簡単に扱うためのユーティリティですが、その辺りの詳しいことは以下のページ等を参照してください。
- ウノウラボ Unoh Labs: RackでWebアプリのWebサーバー依存を無くす
- KoshigoeBLOG: Rack解説を試みて失敗しつつも晒す、の巻
- あーありがち - CGI を rackup してみた
コードの中身は、だいたいパッと見て意味はわかるかと思います。設定として最低限必要な「アプリケーションID」と「バージョン番号」をセットし、runメソッドに渡すブロック*4内でアプリケーションの処理本体を記述しています。ここでは単に"Hello world!"を返しているだけです。
開発サーバの起動
では開発サーバを起動して、アプリケーションの動作を確認してみます。
まだ他の設定ファイルもライブラリも何も用意していない?大丈夫です、その辺は全て"google-appengine gem"のユーティリティ群がなんとかしてくれます。
$ dev_appserver.rb .
外からアクセスする場合は、次のようにアクセスを受け付けるアドレスの範囲を指定します。この辺りはGAE SDKに含まれるdev_appserver.shと同じです。
$ dev_appserver.rb --address=0.0.0.0 .
コマンド実行後、つらつらとログが表示されていきますが、このタイミングでプロジェクトに必要なライブラリをコピーし、設定ファイルを自動生成しています。この辺りの初期化処理は、関係する部分に変更が無ければ次回以降はスキップされます。
しばらく待つと以下のメッセージが表示され、アクセス可能な状態になります。実際にブラウザでアクセスして動作を確認してみてください。
The server is running at http://localhost:8080/
GAEへのアップロード
最後に、本番環境で動かしてみます。
アップロード前に、先程の"config.ru"内のアプリケーションIDとバージョン番号が正しいことを確認してください。
$ appcfg.rb update .
途中でメールアドレスとパスワードを聞かれますので入力します。
Update completed successfully. Success. Cleaning up temporary files...
上記のメッセージが出力されたら無事成功です。GAE上のアプリケーションにアクセスして動作を確認してみてください。
ちなみに、開発サーバを一度も立ち上げずにGAEへアップロードした場合でも、アップロード前にちゃんと必要なライブラリや設定ファイルが生成されるので問題ありません。あまり無いケースだとは思いますが。
手順のまとめ
以上で"Hello world"アプリは完成です。
手順をおさらいすると、
- gemのインストール(2回目以降は不要)
- "config.ru"の作成
- 開発サーバの起動(スキップ可)
- アップロード
だけです。以前の、自力でGAE/JRuby環境を用意していたころと比べると、驚くほど簡単になりました。
Sinatraアプリを作ってみる
とは言え、素のRackアプリで色々やるのは何かとキツイので、Sinatraを導入するところくらいまではやっておこうと思います。
Sinatraはとてもシンプルで扱いやすいWebアプリケーションフレームワークです。公式には“最小労力で手早くウェブアプリケーションを作成するためのDSL”だと説明されています。
シンプルですが、Webアプリケーションを書くのに必要十分な機能は揃っています。GAE/JRubyで軽めのWebアプリをサクッと作りたい場合はRailsより適しているかもしれません。
Sinatraについては以下のページ等を参考にしてください。
- Sinatra: README (Japanese)
- Rubyの軽量Webフレームワーク「Sinatra」がステキ - 医者を志す妻を応援する夫の日記
- ウノウラボ Unoh Labs: Sinatra気に入った
Sinatraのインストール
Sinatraは一緒にGAEにアップロードすることになりますので、プロジェクトのディレクトリ内にインストールします。
これも以前はgemコマンドの-iオプション等を使っていましたが、"google-appengine gem"に付いてくるappcfg.rbコマンド経由でgemの管理ができるようになりました。
以下のように適当にディレクトリを作成後、その中でインストールを行います。
$ mkdir greeting $ cd greeting $ appcfg.rb gem install sinatra Successfully installed rack-1.0.0 Successfully installed sinatra-0.9.4 2 gems installed
プロジェクトディレクトリ内にインストールしていますので、別のプロジェクトを作成した際はその都度インストールする必要があります。
設定ファイルの作成
"config.ru"を作成します。今回はアプリケーション本体は別ファイルにしますので、"config.ru"は純粋に設定ファイルの役割を果たします。
- config.ru
require 'appengine-rack' require 'application' AppEngine::Rack.configure_app( :application => 'application-id', # Replace your application id. :version => 1 # Replace your application version. ) run Sinatra::Application
2行目でrequireしているのがアプリケーション本体のファイルです。他は特に説明は要らないかと思います。
アプリケーションファイルの作成
Sinatraが動くことが確認できればいいので、パスに指定した文字列に合わせてあいさつを変えるだけの簡単なアプリケーションにします。“Hello world レベル2”といった感じです。
- application.rb
require 'rubygems' require 'sinatra' get '/' do 'Hello world!' end get '/morning' do 'Good morning.' end get '/evening' do 'Good evening.' end get '/night' do 'Good night.' end get '/bye' do 'Good bye.' end
これも特に説明は要らないかと思います。
開発サーバの起動&アップロード
これ以降は基本的に先程と同じ作業です。
$ dev_appserver.rb --address=0.0.0.0 .
で動作を確認し、
$ appcfg.rb update .
でGAEへアップロードします。
次のステップへ
appengine-jrubyに含まれるのはGAE/JRubyアプリの作成支援機能だけではありません。
以下のドキュメントを見てもわかる通り、DatastoreにURLFetchなど、GAEに用意されている一通りのサービスを利用するためのAPIが揃っています。
まだAPIの詳細は確認していませんが、おそらくこれ一つでそれなりにきちんとしたWebアプリケーションは作れてしまうのではないでしょうか。もうJavaのAPIを意識する必要も無さそうです。
これまでJavaの低レベルAPIを直接触って色々やってきましたが、せっかくなので方針を転換し、appengine-jrubyを使い倒す方向で色々弄ってみようと思います。
Twitter Streaming APIをRubyで試してみる
Streaming APIとは
TwitterのStreaming APIをご存知でしょうか。
2009年4月頃から試験的に公開されているAPIなので、ご存知な方も多いと思います。2009年8月現在でまだαテスト中ですが、これを利用すると、push型でリアルタイムに情報を受け取ることができます。
TwitterのAPIは基本的にpull型なので、クライアントが能動的にリクエストを発行しなければ情報を受け取ることができません。しかし、1時間あたりのリクエスト回数、1リクエストあたりの最大データ数など、APIには様々な制限がかけられていますので、例えばリアルタイム性の必要なプログラムや、大量のtweetsが必要な統計プログラムなどの用途で通常のAPIを利用するのは厳しいものがあります。
そのような時はStreaming APIを使うと問題が解決するかもしれません。
過半数のAPIは利用に許可が必要ですし、また通常のAPIとは機能のベクトルが異なるため、「自分のタイムラインを取得して表示する」というような普通のTwitterクライアントライクな用途には使えませんが、アイデアさえあれば色々と面白いことができるのではないかと思います。
Streaming APIの概要
通常のAPIは、
という流れになります。
Streaming APIの場合、クライアントがTwitterサーバへ接続を開き、リクエストを送るところまでは同じなのですが、その後はエラー等で切断されるまでずっと接続を維持し続けます。その間、サーバからは次々にレスポンスが送られてきますので、クライアントは順次それを受け取って処理していくことになります。
Streaming APIを使う上での注意点
詳しくはAPIのドキュメントに書いてありますが、重要なもののみまとめると、
- 同一アカウントからの同時接続数は1本のみ。同一アカウントから2本以上の接続があった場合、古い接続は強制的に切断される。*1
- 使用するHTTPライブラリ*2は、レスポンスボディを全て読み込んでから返すタイプではなく、順次読み込んでいけるタイプのものでなくてはならない。
- サーバは接続の維持のために空行を送り返すことがあるので、クライアントはそれを許容できる*3作りでなくてはならない。
- 通常のstatusの他に、「ユーザがstatusを削除した」という通知など、他の情報も混ざって送られてくるので、適切に処理すること。
- エラーや通信の遅延など、様々な要因によって接続が切断されることもあるので、必要ならば自動で再接続する仕組みを組み込むこと。
- 一部のAPIは膨大な量のデータを連続して返すので、それを受けるプログラムはパフォーマンスに関して相当な努力が必要かもしれない。
- サービスの品質はベストエフォートである。
といったところだと思います。
使ってみる
百聞は一見にしかず…ということで、試しに一回Streaming APIを使ってみることにします。
Streaming APIの種類は後述しますが、ここではとりあえず"spritzer" APIをサンプルとします。これはPublic Timelineからランダムに抽出された発言が延々と流れてくるAPIです。
#!/usr/bin/env ruby # coding: utf-8 require 'net/http' require 'uri' require 'rubygems' require 'json' USERNAME = '_USERNAME_' # ここを書き換える PASSWORD = '_PASSWORD_' # ここを書き換える uri = URI.parse('http://stream.twitter.com/spritzer.json') Net::HTTP.start(uri.host, uri.port) do |http| request = Net::HTTP::Get.new(uri.request_uri) # Streaming APIはBasic認証のみ request.basic_auth(USERNAME, PASSWORD) http.request(request) do |response| raise 'Response is not chuncked' unless response.chunked? response.read_body do |chunk| # 空行は無視する = JSON形式でのパースに失敗したら次へ status = JSON.parse(chunk) rescue next # 削除通知など、'text'パラメータを含まないものは無視して次へ next unless status['text'] user = status['user'] puts "#{user['screen_name']}: #{status['text']}" end end end
実行すると、Ctrl+Cなどでプロセスを終了するまで、標準出力にずらずらと発言が出てくると思います。私が試したときはだいたい秒間5〜10発言程度といった速度でした。Public Timelineはグローバルなので、大半は英語で埋まることになります。
実用的に使うのであれば、取得したデータを標準出力に送るのではなく、例えばファイルやデータベース経由、あるいはプロセス間通信などで別プロセスに引き渡し、別プロセスでリアルタイムにそれらを解析してなんらかの統計情報を得る…とか、そういう使い方になるのではないかと思います。*4
ちなみに、上のコードはレスポンスに"Transfer-Encoding: chunked"がセットされていることを期待した作りになっています。Streaming APIでは基本的にそうなっているようなので問題ないとは思いますが、仮に"Response is not chuncked"のエラーが出るようであれば*5、次のように明示的に区切り文字を指定してデータを取り出すような処理にすれば大丈夫だと思います。
#!/usr/bin/env ruby # coding: utf-8 require 'net/http' require 'uri' require 'rubygems' require 'json' USERNAME = '_USERNAME_' # ここを書き換える PASSWORD = '_PASSWORD_' # ここを書き換える # Net::HTTPResponseクラスにeach_lineメソッドを追加 module Net class HTTPResponse def each_line(rs = "\n") stream_check while line = @socket.readuntil(rs) yield line end self end end end uri = URI.parse('http://stream.twitter.com/spritzer.json') Net::HTTP.start(uri.host, uri.port) do |http| request = Net::HTTP::Get.new(uri.request_uri) request.basic_auth(USERNAME, PASSWORD) http.request(request) do |response| response.each_line("\r\n") do |line| status = JSON.parse(line) rescue next next unless status['text'] user = status['user'] puts "#{user['screen_name']}: #{status['text']}" end end end
この際、データの区切りは"\n"ではなく"\r"であることに注意してください。*6"\n"は本文内にも出現し得るためです。細かいことはAPIのドキュメントに書いてあります。
Streaming APIの種類
Streaming APIには2009/08/16現在7つのAPIが用意されていますが、使い方は基本的に全て同じです。
タイプ別に大きく3つに分けることができます。
Public Timeline垂れ流し系
Public Timelineの内容、つまり鍵のかかっていない全ての発言を取得します。
このタイプのAPIには"firehose", "gardenhose", "spritzer"の3つが用意されています。
firehoseはPublic Timelineの全ての発言を含みます。利用には許可が必要です。
gardenhoseはfirehoseからランダムにサンプリングした発言を含みます。利用には許可が必要です。
spritzerはgardenhoseよりも更に少なくサンプリングした発言を含みます。誰でも利用できます。
GETメソッドでリクエストを送ります。"count", "delimited"パラメータを取ることができますが、説明は省略します。
鍵をかけていない全てのユーザの全ての発言が流れてきますので、当然firehoseは恐ろしい量のデータが送られてくることになります。一番量の少ないspritzerでもかなりの勢いですので、この辺を扱う際は十分な注意が必要でしょう。
特定ユーザの発言追跡系
"follow"パラメータで指定したユーザの発言、及びそのユーザに向けられた発言だけを取得できます。*7鍵付きのユーザの発言は含まれません。
このタイプのAPIには"birddog", "shadow", "follow"の3つが用意されています。
birddogはfollowするユーザを20万まで指定できます。利用には許可が必要です。
shadowはfollowするユーザを5万まで指定できます。利用には許可が必要です。
followはfollowするユーザを200まで指定できます。誰でも利用できます。
POSTメソッドでリクエストを送ります。"follow"パラメータには、追跡したいユーザのID*8をカンマ区切りで並べて指定します。"delimited"パラメータについては省略します。
特定キーワードの抽出系
"track"パラメータで指定したキーワードを含む発言だけを取得できます。鍵付きのユーザの発言は含まれません。
このタイプのAPIには"track"のみが用意されています。誰でも利用できます。
POSTメソッドでリクエストを送ります。"track"パラメータには、キーワードをカンマ区切りで並べて指定します。キーワードは50個まで、1つのキーワードの長さは1〜30バイトです。
キーワードは大文字・小文字を区別せず、単語単位で検索されます。複数指定した場合はORになります。
この“単語単位”が曲者で、現状英語での単語単位…つまりスペースやその他記号で区切られた場合しかヒットしないようで、日本語キーワードを指定した場合はほとんど機能しません。
ただ、ハッシュタグなんかに対する抽出には使えますし*9、また例えば"nicovideo"を指定するとニコニコ動画のURLを含む発言を抽出できますので、使いようによってはなんとかなることもあります。
もう一回使ってみる
最後にもう一つサンプルを。今度は"track" APIを使ってみます。
#!/usr/bin/env ruby # coding: utf-8 require 'net/http' require 'uri' require 'rubygems' require 'json' USERNAME = '_USERNAME_' # ここを書き換える PASSWORD = '_PASSWORD_' # ここを書き換える uri = URI.parse('http://stream.twitter.com/track.json') Net::HTTP.start(uri.host, uri.port) do |http| request = Net::HTTP::Post.new(uri.request_uri) request.set_form_data('track' => 'bit') request.basic_auth(USERNAME, PASSWORD) http.request(request) do |response| raise 'Response is not chuncked' unless response.chunked? response.read_body do |chunk| status = JSON.parse(chunk) rescue next next unless status['text'] && status['text'].include?('http://bit.ly') user = status['user'] puts "#{user['screen_name']}: #{status['text']}" end end end
実行すると、bit.lyによる短縮URLを含んだ発言がずらーっと出てきます。さすがにかなりの勢いになりますので注意してください。
キーワードにドットを含めると上手くいかないようなので、"bit"で抽出して、その後スクリプト内で更にふるいにかけています。
このデータを加工すれば、bit.lyでどんなURLが注目を集めているか、といった統計を得ることができます。確か既にそんなサービスがあったように記憶していますが、おそらくこんな感じでStreaming APIを利用しているのではないでしょうか。
まとめ
まだαテスト中なため、品質面では過度な期待はできませんし、このAPI自体今後どう変わっていくかわかりません。*10しかし、それでもアイデア次第で様々な可能性が秘められている機能だと思います。一度Streaming APIで遊んでみるのはいかがでしょう。
*1:通常のAPIとStreaming APIの併用は可能。
*2:もしくはそれに類するもの
*3:単に無視すれば良いです。
*4:RubyならマルチスレッドでQueueを使えば簡単にその類の連携ができます。
*5:上のコードの場合、認証失敗などでエラーが発生した際もこれが出ますので、そこは区別するよう注意してください。
*6:"\r"のあとには常に"\n"が続くようなので、ここではまとめて区切り文字としています。
*7:より厳密に言うと、"in_reply_to_*"パラメータでそのユーザが指定されている発言です。@付きで言及されていても、"in_reply_to_*"パラメータで指定されていない場合は含まれません。
*8:必ず数値で指定
GAE/JRubyで開発サーバを使わずに単体テストする方法
前置き
Google App Engine for JRuby(以下GAE/JRuby)*1で開発をするにあたって不便に感じるものの一つに、単体テストのし難さがありました。
開発サーバ(dev_appserver)って起動するまでに結構時間がかかるので*2、少し修正を加えるたびに開発サーバを立ち上げ直すのも地味にストレスが溜まるんですよね…。そもそもサーブレットの中で単体テストを書くという行為自体が非効率的だと言わざるを得ません。
そんなわけで、なんとか開発サーバに頼らずに単体テストを行う方法がないものかと思っていたら…。
普通にGAEの公式ドキュメント中に解説がありました。初めからちゃんと読んでおけって話ですね…。
どうやらGAE/Jの提供する各サービスの肩代わりをしてくれるAPI群(ローカルサービス)がSDKに用意されており、それを利用することで、素のJava実行環境上でもGAE/Jのサービスを呼び出すプログラムを実行することができるようです。
Javaでの使い方は上のページと、それに加えて以下のページが参考になります。
- 404 shin1のつぶやき ないわー Not Found: GAE/Jの単体テスト
- 404 shin1のつぶやき ないわー Not Found: GAE/Jでローカル実行時にApiProxyLocalから取得できるサービス一覧メモ
更にググっていたら、GAE/JRubyで同じことをするための、まさにビンゴな解説がありました。ありがたやありがたや。
というわけで、今回はこれらの参考資料から得られた情報を私なりにまとめなおして、GAE/JRubyでローカルサービスを用いて単体テストを行う手順を書いてみようと思います。
SDKのバージョンの違いについて
本題に入る前に、一つ注意しておきたい点があります。
それは、ローカルサービスの使い方がSDKのバージョンによって微妙に異なる、ということです。
2009/08/12現在のGAE SDK for Javaの最新バージョンは1.2.2ですが、どうやら1.2.1から1.2.2に上がった際に後述するApiProxy.Environmentインターフェースの一部が変更されたらしく、1.2.1以前のバージョンを対象に書かれたコードがそのままでは動作しなくなっています。
上に挙げた参考資料も全て1.2.1以前のバージョンを対象としています。更に困ったことに、一番最初に挙げたGAE公式日本語ドキュメントですら1.2.1以前のバージョンを基にした解説となっています。
というか、そもそもGAE公式サイト日本語版のダウンロードページに掲載されているSDKが、未だにバージョン1.2.0どまりなんですよね…。
これに限らず、GAE公式サイトに関しては日本語版の情報はちょっと遅れていますので、何かおかしな点があったらまず英語版のドキュメントを参照する癖を付けておくことをお勧めします。英語に強い方は最初から英語版だけ読んでいたほうがいいかもしれません。
ちなみに、先程の単体テスト解説ページの英語版はこちらです。
この記事でもSDKのバージョンは1.2.2を前提として話を進めていきます。
ローカルサービスの使い方
それではRubyからGAE/Jのローカルサービスを使う手順を追っていってみます。
実行環境の構築
まず最初に行うことは、ローカルサービスを実行するための環境を構築することです。
…と言ってもよくわかりませんが、要点をまとめると
- GAE/Jのサービスを立ち上げるためには、appengine-web.xmlなどで指定されたパラメータが必要である
- しかし、ローカルサービスを利用するプログラムはGAE外で動作するため、直接それらのパラメータを知ることができない
- そのため、かわりにローカルサービスが利用する各種パラメータを設定したクラスを作成してやる必要がある
ということのようです。
実行環境の構築と言っても、やること自体はそんなに難しいことではなく、ApiProxy.Environmentインターフェースを実装するだけです。
これをRubyで書くと次のようになります。
import com.google.apphosting.api.ApiProxy class BaseEnvironment include ApiProxy::Environment def getAppId; 'Unit Tests' end def getVersionId; '1.0' end def getRequestNamespace; '' end def getAuthDomain; '' end def isLoggedIn; false end def getEmail; '' end def isAdmin; false end def getAttributes; {} end end
Javaインターフェースの実装は、JRubyではincludeで実現できます。
ローカルサービスは実際にGoogleアカウントでの認証を行ったりはしませんので、かわりにgetAuthDomain, isLoggedIn, getEmail, isAdminあたりのメソッドの戻り値を変更することで認証状態を制御したりします。
これらのメソッドの細かい意味はまだ全て把握できていません。良い解説ページなどをご存知の方は是非ご教示ください。
ローカルサービスの立ち上げ
「立ち上げ」という言い回しは正確ではないのですが…まぁ、わかりやすいので。正しい解説は公式のドキュメントなどを参照してください。
先程作ったApiProxy.Environmentの実装クラスと、ApiProxyの実装であるApiProxyLocalImplクラス、2つのインスタンスを作成し、それぞれをApiProxyにセットします。
Rubyで書くと次のようになります。先程のコードからそのまま続いていると考えてください。
import com.google.appengine.tools.development.ApiProxyLocalImpl class BaseApiProxyLocalImpl < ApiProxyLocalImpl; end ApiProxy.environment_for_current_thread = BaseEnvironment.new ApiProxy.delegate = BaseApiProxyLocalImpl.new(java.io.File.new('.'))
BaseApiProxyLocalImpl.newの引数に*3カレントディレクトリを渡していますが、ここで渡したディレクトリを基準としてDatastoreなどのローカルサービスが使用するファイルが保存されます。
より正確に言うと、"(渡したディレクトリ)/WEB-INF/appengine-generated/"というディレクトリが作成され、その中に"local_db.bin"などのファイルが配置されます。カレントディレクトリ以下に作られたくない場合は他の適当なディレクトリを指定してください。
ちなみに、ApiProxyLocalImplから一旦継承したクラスをインスタンス化している理由は、直接ApiProxyLocalImplクラスをインスタンス化しようとすると次のようなエラーが出てしまうためです。
`new_proxy': no public constructors for Java::ComGoogleAppengineToolsDevelopment::ApiProxyLocalImpl (TypeError)
これの解決方法がわかればBaseApiProxyLocalImplクラスは不要になるのですが。
ストレージを利用しないデータストア
ここまででローカルサービスを利用する準備は整いましたが、Datastoreサービスについてはもう一つオプションのステップがあります。
先程説明したように、ローカルサービスのDatastoreも実際にファイルにデータを保存するため、プロセスをまたいでデータを持ち越すことができます。
しかし、テストによっては毎回空のデータストアが立ち上がってくれた方が嬉しい場合もありますよね。
そのようなニーズのために、Datastoreサービスが「ストレージから読み込まず、書き込みもしない」ようになる…つまりメモリ内だけで完結するようになるオプションが用意されています。
これも先程のコードからそのまま続いているものと考えてください。
import com.google.appengine.api.datastore.dev.LocalDatastoreService ApiProxy.delegate.set_property(LocalDatastoreService::NO_STORAGE_PROPERTY, 'true')
これを追加することで、Datastoreサービスがメモリ内で完結するようになります。ただし、WEB-INF以下のディレクトリだけは作成されますので注意してください。
ローカルサービスの終了
使い終わったら後片付けをしましょう。
まず、Datastoreサービスがストレージを利用しないように設定していた場合(NO_STORAGE_PROPERTY)は、次の1ステップが必要になります。NO_STORAGE_PROPERTYを設定していないのであれば必要ありません。
ApiProxy.delegate.getService('datastore_v3').clear_profiles
何をしているのか正確には把握していませんが、おそらく保存しない不要なデータをクリアしているのでしょう。
次に、Datastore APIを一度でも呼び出しているのであれば、Datastoreサービスを停止するために次のコードを実行する必要があります。
ApiProxy.delegate.getService('datastore_v3').stop
これがないとDatastoreサービスが生き続けるためか、プログラム終了後もプロセスが終了してくれません。Datastore APIを一度も利用していないのであれば不要です。*4
最後に、ApiProxyにセットしていた二つのインスタンスを空にリセットします。
ApiProxy.delegate = nil ApiProxy.environment_for_current_thread = nil
このステップは無くてもいいらしいですが、せっかくなのでキレイキレイしておきましょう。
ローカルサービスを利用するスクリプトの実行方法
これでローカルサービスを使えるようになる…わけですが、ローカルサービスを実現するAPI群はGAE SDKの中に含まれていますので、適切にCLASSPATHを通しておく必要があります。これを忘れると、当然ですが、クラスが未定義だと言われて実行できません。
CLASSPATHを通す必要のあるファイルは以下の3つです。
- ${GAE_HOME}/lib/impl/appengine-api-stubs.jar
- ${GAE_HOME}/lib/impl/appengine-api.jar
- ${GAE_HOME}/lib/impl/appengine-local-runtime.jar
環境変数GAE_HOMEにはGAE SDKルートディレクトリのパスが設定されていると考えてください。
これらを環境変数CLASSPATHに設定しておくか、あるいはjrubyコマンドの引数に次のような形で渡してやります。
$ jruby -J-classpath ${GAE_HOME}/lib/impl/appengine-api-stubs.jar:${GAE_HOME}/lib/impl/appengine-api.jar:${GAE_HOME}/lib/impl/appengine-local-runtime.jar unit_tests.rb
ローカルサービスを使いやすくするLocalGAEライブラリ
以上のような手続きを毎回行うのもそれなりに面倒ですので、その辺を扱いやすいようにまとめた簡単なライブラリを作成しました。
ライブラリの使い方
ライブラリ本体は"local_gae.rb"のみです。使い方はシンプルで、
require 'local_gae' LocalGAE::Service.start # ここにローカルサービスを使う処理 LocalGAE::Service.stop
とするだけです。上で述べた環境構築やサービスの立ち上げ、終了処理などは全てLocalGAE::Serviceクラスが請け負います。
LocalGAE::Service.startメソッドはオプションを取ることができ、
require 'local_gae' # :data_dir = データを保存する基準ディレクトリを変更する LocalGAE::Service.start(:data_dir => '/tmp') # ここにローカルサービスを使う処理 LocalGAE::Service.stop # :no_storage = Datastoreがストレージを利用しないようにする LocalGAE::Service.start(:no_storage => true) # ここにローカルサービスを使う処理 LocalGAE::Service.stop
のように指定します。"data_dir"のデフォルトはカレントディレクトリになっています。
また、startメソッドにブロックを渡すこともできます。
require 'local_gae' LocalGAE::Service.start(:no_storage => true) do # ここにローカルサービスを使う処理 end
ブロックを抜けるときに自動でstopしますので、stopのし忘れを防げます。
実行スクリプトの使い方
スクリプトを実行する際に、先程実行したような長いコマンドを毎回入力しなくて済むように、簡単なシェルスクリプト"jruby_local_gae.sh"を同梱しました。
中身はこんな感じです。
#!/bin/sh JRUBY=${JRUBY_HOME:-/usr/local/jruby}/bin/jruby LIB_IMPL=${GAE_HOME:-/usr/local/appengine-java-sdk}/lib/impl ${JRUBY} -J-classpath ${LIB_IMPL}/appengine-api.jar:${LIB_IMPL}/appengine-local-runtime.jar:${LIB_IMPL}/appengine-api-stubs.jar $@
あらかじめ環境変数にJRUBY_HOMEとGAE_HOMEを適切に設定しておくか、あるいはスクリプト内のデフォルト値を書き換えて使用してください。
使い方は単にjrubyコマンドの代わりにjruby_local_gae.shを呼び出すだけです。
$ ./jruby_local_gae.sh unit_tests.rb
サンプルの単体テストスクリプトについて
LocalGAEを使った単体テストの書き方のサンプルとして、Datastoreサービスのシンプルなラッパーライブラリ"simple_datastore.rb"と、その単体テスト"test_simple_datastore.rb"を同梱しました。
低レベルなDatastore APIを使うこのライブラリは、ベタベタにGAE/J依存な癖に開発サーバ上でのテストが面倒なスクリプトのサンプルとしては割とうってつけなのではないかと思います。
あくまでサンプルにするつもりだったのですが、書いてみたらそれなりの長さになってしまったため、ここではコードは載せません。
$ ./jruby_local_gae.sh test_simple_datastore.rb
とするとテストが通るのが確認できると思いますので、興味のある方は色々弄ってみてください。
ちなみにこの単体テスト、本当はRSpecで書いてみたかったのですが、個人的にまだ勉強中で理解に怪しい部分があるため、今回は妥協してTest::Unitで書いています。
実行環境の違いについて
以上で説明は全て終了ですが、ローカル環境で単体テストを行うにあたり、注意しておくべき点があります。
それは、ローカルサービスは特定のAPIを肩代わりしてくれるだけであり、実行環境はあくまでも素のJavaである、ということです。
例えばGAE/Jのサンドボックス内ではファイルの書き込みが禁止されていますが、ローカルで動かすプログラムではいくらでもファイルの書き込みを行うことができます。
このあたりを把握していないと、「ローカルでは動くのにGAE/J上で動かない」という状況になってしまいかねません。*6
GAE/Jのサンドボックスで出来ることと出来ないことをきちんと意識してテストを書くことをお勧めします。
まとめ
ローカルGAE/J環境は完全なGAE/J環境の再現ではありませんが、それでも開発サーバを介さずに単体テストを行えるのは非常に便利です。是非活用していきましょう。