はじめに
今回は、前回作ったTwitter風アプリをGoogle App Engineで動くように改造していきます。
Google App Engineは単なるサーバ環境の提供ではなくデータ管理等も含む独自環境です。そのため、Ruby on Railsのアプリケーションを動かす上で大きな問題点になってくるのは、データ管理機構がRDBではないことです。
GAEのデータ管理機構「Datasotre(BigTable)」は基本的には Key-Value ストアです。一応、RDBのようにテーブルや行列という概念を持っており、RDB同様に行単位でデータの読み書きを行え、上位ライブラリではSQL風言語もサポートしています。しかし、基本はKey-Value ストアであり検索で指定できる条件は限られています。リレーション(join)等もありません。
Ruby on Railsの高生産性を支えている大きな要素は、RDBを簡単に扱えるO/RマッパーActiveRecordと、それに連携したテンプレートActionViewやコントローラーActionControllerのコンビネーションです。しかし、ActiveRecordは完全にRDBに依存した設計のため、Datasotreを扱う事はできません。従って現時点では、GAE上で動くRuby on Railsアプリを作るのは簡単ではありません。
そういった点の回避策も含めて、GAE上でRailsアプリを動かし、Curlクライアントと連携していく手順を見ていきましょう。
GAE環境のインストール
今回使うrails_on_gaeですが、筆者のWindows環境では正しく動作しませんでした。今回はMac OS XやLinux等のUNIX系OSを使う前提で、環境構築などを説明していきます。
Google App Engine SDK for Javaのインストール
Downloads - Google App Engine - Google CodeからGoogle App Engine SDK for Java(2009年9月30日時点は appengine-java-sdk-1.2.5.zip )をダウンロードし、適当な場所(例えば /usr/local/appengine-java-sdk-1.2.5 )に展開します。また、展開した中の binディレクトリをPATHに追加します。
WAR作成ツールのインストール
gemでインストールします。
% sudo jruby -S gem install warbler --no-ri --no-rdoc
GAE/J用のRailsアプリを作る
GAE/J上で動かすには、Ruby on Rails自体やGAEのAPIをアクセスするためのライブラリが必要になります。これらの設定を行ってくれるrails_on_gae プラグインが、technohippy さんによりtechnohippy's rails_on_gae at master - GitHubにて公開されてます。これを使うと簡単にGAEで動くJRuby on Rails環境が簡単につくれます。
ここでは jtinytterとういプロジェクトを作成します(この作業は時間がかかる場合があります)。
% jruby -S rails jtinytter % cd jtinytter % jruby -S script/plugin install git://github.com/technohippy/rails_on_gae.git % jruby -S rake gae:init % cp /usr/local/appengine-java-sdk-1.2.5/lib/user/appengine-api-1.0-sdk-1.2.5.jar lib
GAE向けにソースを書き換える
最初に説明したようにGAEではActiveRecordが使えませんので、 GAEのDatastoreを扱えるモジュールを使ってモデルを書き換えます。今回はモデルのみの変更で済むように工夫しています。従って、コントローラー、テンプレート等は前回作ったものからコピーしてください。
Datastoreの操作については、JRubyから直接DatastoreのJava APIを呼び出す事もできますが、今回は上記のインストールで入手できる「Bumble」というモジュールを使います。このモジュールはOla Biniさんの作ったモジュールで、Datastore の機能をRubyから簡単に使えるAPIと、Rails風の関連機能(has_many、belongs_to)を追加したシンプルなものです。ソースコードはここで見れます。
書き換えの内容については次ページ以降で解説していきます。
サンプルソースについて
こちらよりサンプルソースをダウンロードできます。なお、サンプルソース実行にあたってはライセンスファイル「curl-license-5.dat」が必要になります。Curl IDEなどに含まれているので、コピーして使用してください。
前回サンプルソースのお詫びと訂正
前回のサンプルソースですが、データベースの設定ファイルが間違っていました。お詫びして訂正します。
JRubyで動かすにはconfig/database.yml ファイルには「jdbcsqlite3」を指定する必要がありました。config/database.ymlファイルの4行目を以下のように修正してください(※現在公開されているサンプルソースは修正済みです)。
#adapter: sqlite3 adapter: jdbcsqlite3
GAE用の書き換え 1
それでは、前回ローカル環境で構築したアプリをGAEで動作させるために、どのように書き換えたのか見ていくことにしましょう。
Friendshipモデルの書き換え
ActiveRecordを使ったモデルはActiveRecord::Baseを継承してしましたが、BumbleではBumbleをinclude(Mixin)するだけです。ActiveRecordFeaturesForBumbleというActiveRecordと互換を取るために作成したモジュールもinclude(Mixin)しています。詳しくは後述します。
ActiveRecordではカラム情報(属性情報)はRDBから自動的に取得していますが、Bumbleではdsでカラム名(属性名)を定義します。belongs_toの定義は多少違いますが、ActiveRecordと似た形になっています。
条件に合うレコードすべて取得するメソッドはBumbleでも all ですが、一致検索条件を hash で指定します。また Datastoreの制限で <> (一致しない) は指定できないので、self.friendsメソッドでは自分自身のレコードを除くためにRuby配列の reject!メソッドを使っています。
次の self. not_friend_usersメソッドでは、Datastoreには in 演算子の機能がないので、やはりRubyのイテレータを使って全ユーザーから友人のレコードを削除し、友人以外の一覧を作っています。self.create_selfメソッドは同じです。
class Friendship < ActiveRecord::Base belongs_to :user belongs_to :friend, :class_name => "User", :foreign_key => "friend_id" def self.friends(my_id) Friendship.all(:conditions => ["user_id = ? and friend_id <> ?", my_id, my_id]) end def self.not_friend_users(my_id) User.all(:conditions => ["id not in (?)", Friendship.find_all_by_user_id(my_id).map(&:friend_id)]) end def self.create_self(my_id) Friendship.new(:user_id => my_id, :friend_id => my_id).save! end end
class Friendship include Bumble include ActiveRecordFeaturesForBumble ds :user_id, :friend_id belongs_to :user, User belongs_to :friend, User def self.friends(my_id) Friendship.all(:user_id => my_id).reject {|f| f.friend_id == my_id} end def self.not_friend_users(my_id) not_friends = User.all Friendship.all(:user_id => my_id).each do |friend| not_friends.reject! {|user| user.id == friend.friend_id} end not_friends end def self.friend_user_ids(my_id) Friendship.all(:user_id => my_id).map{|f| f.friend_id} end def self.create_self(my_id) Friendship.new(:user_id => my_id, :friend_id => my_id).save! end end
Userモデル
Userモデルでの変更は、Friendshipモデルで説明したのとほぼ同じです。ただし、画像が格納されるディレクトリが変更になっているのはJavaのデプロイ環境に合わせ、app/以下のプログラムが public/WEB-INFディレクトリ下に置かれるからです。
class User < ActiveRecord::Base has_many :friendships has_many :statuses def self.authenticate(screen_name, password) user = self.find_by_screen_name(screen_name) (user && user.password == password) ? user.id : nil end IMAGES_DIR = "./public/images/" PROFILE_IMAGES_DIR = "profile_images" def self.profile_image_selector Dir.glob(IMAGES_DIR + PROFILE_IMAGES_DIR + "/*").map {|path| path.sub(IMAGES_DIR, '')} end after_create {|rec| Friendship.create_self(rec.id)} end
class User include Bumble include ActiveRecordFeaturesForBumble ds :screen_name, :password, :profile_image_path has_many :friendships, Friendship, :user_id has_many :statuses, Status, :user_id def self.authenticate(screen_name, password) user = User.first(:screen_name => screen_name) (user && user.password == password) ? user.id : nil end IMAGES_DIR = "../images/" PROFILE_IMAGES_DIR = "profile_images" def self.profile_image_selector Dir.glob(IMAGES_DIR + PROFILE_IMAGES_DIR + "/*").map {|path| path.sub(IMAGES_DIR, '')} end after_create {|rec| Friendship.create_self(rec.id)} end
Statusモデル
Datastoreにはjoinの機能はありませんので、友人毎の発言を取得しRubyの機能で合成、ソートしています。JSON用のHashデータを作成する部分も、Datastoreから取得したデータをHashに変換する際に便利な attributsメソッドがないため、やや煩雑なコードになっています。
class Status < ActiveRecord::Base belongs_to :user def self.all_frends(my_id) Status.all(:conditions => ["friendships.user_id = ?", my_id], :joins => "JOIN friendships ON statuses.user_id = friendships.friend_id", :order => "created_at DESC") end def api_data(image_url) status_attrs = self.attributes user_attrs = User.find(self.user_id, :select => "id,screen_name").attributes user_attrs["profile_image_url"] = image_url.call(self.user.profile_image_path) status_attrs["user"] = user_attrs status_attrs end end
class Status include Bumble include ActiveRecordFeaturesForBumble ds :text, :user_id, :created_at belongs_to :user, User def self.all_frends(my_id) statuses = [] Friendship.all(:user_id => my_id).each do |friend| statuses += Status.all(:user_id => friend.friend_id) end statuses.sort {|a, b| b.created_at <=> a.created_at} end def api_data(image_url) status_attrs = Hash["text", self.text, "user_id", self.user_id, "created_at", self.created_at] user = User.find(self.user_id) user_attrs = Hash["id", user.id, "screen_name", user.screen_name] user_attrs["profile_image_url"] = image_url.call(self.user.profile_image_path) status_attrs["user"] = user_attrs status_attrs end end
ActiveRecordFeaturesForBumbleモジュール
Bumbleの足りない機能を追加するActiveRecordFeaturesForBumbleですが、かなり難易度の高いRubyモジュールのため、詳細に説明すると本連載の範囲を超えてしまいますので、内容を簡単に説明します。
まずモジュールの構成ですが、クラスに追加する インスタンスメソッドを InstanceMethodsモジュールに定義、クラスメソッドをClassMethods モジュールに定義し、include時に呼び出される self.includedメソッドを使ってincludeしたクラスに各メソッドの追加を行っています。このようなスタイルはRailsの内部やプラグインでよく使われています。
id、update_attributes、save、destroyメソッドはBumbleには無いですが、コントローラー等で使われているメソッドの追加です。また、new_record?、errors、to_paramメソッドは明示的に呼び出されていませんがテンプレート用のメソッド等から呼び出されています。これらのメソッドはActiveRecordの全仕様を実装したものではなく、このアプリで必要な部分のみの実装です。例えば、入力値等の検証(Validation)結果を戻すerrorsはいつでもエラー無しを戻します。
さて、find、allはBumbleで定義されているメソッドの置き換えです。最後の方にあるalias first find; alias all_origin all;でBumbleに定義されたfindメソッドをfirst、allメソッドをall_originという名前を付けて保存し、find、allを再定義しています。Rubyはオープンクラスで既存クラスのメソッドを後から追加・変更できます。find、allのおもな変更点は。新規に作成しまだDBに保存してないインスタンスと、DBから読み出したインスタンスを区別するための目印を付ける事です、この目印をチェックするのがnew_record?メソッドです。
saveメソッドではややトリッキーな事を行っています。まずafter_createで定義されたDatastoreレコードの作成後に実行するコードの実行する部分と、カラム名(=インスタンス変数名)により特殊な処理を行う_instance_conversionです。
関連を示すID番号等はformから文字列でポストされてきますが、Datastore内には数値として格納されています。この変換はActiveRecord内で自動的に行われますがBumbleでは行われません。そこで_instance_conversionは_idで終わるカラムは数値だという前提で、文字列を数値に変換しています。また、created_atカラムには現在の時間を代入しています。
module ActiveRecordFeaturesForBumble module InstanceMethods def id key end def update_attributes(attrs) attrs.each do |k, v| send "#{k}=", v end save end def save _instance_conversion begin new_recod = new_record? save! _after_create.call(self) if respond_to?(:_after_create) && new_recod true rescue RAILS_DEFAULT_LOGGER.debug("+++ save error :#{$!}") false end end def destroy delete! end def new_record? !instance_variable_get(:@_finded_record) end def errors ret = [] def ret.count 0 end ret end def to_param key.to_s end private def _instance_conversion methods = self.class.instance_methods(false) methods.each do |method| if method =~ /_id=$/ send(method, send(method[0..-2]).to_i) elsif method == "created_at=" self.created_at = Time.now end end end end module ClassMethods def find(id) obj = self.get(id) obj.instance_variable_set(:@_finded_record, true) obj end def all(opt = {}) list = all_origin(opt) list.each {|e| e.instance_variable_set(:@_finded_record, true)} list end def after_create(&block) cattr_accessor :_after_create self._after_create = block end end def self.included(base) base.send :include, InstanceMethods base.class_eval("class << self; alias first find; alias all_origin all; end") base.send :extend, ClassMethods end end
GAE用の書き換え 2
その他
(1)環境設定のconfig/enviroment.rbファイルですがrails_on_gaeプラグインが作り出した内容を一部変更しています。
require 'rubygems' require 'lib/require_fix' require 'lib/rake_fix' #require 'lib/actionmailer-2.3.2.jar' require 'lib/actionpack-2.3.2.jar' #require 'lib/activerecord-2.3.2.jar' #require 'lib/activeresource-2.3.2.jar' require 'lib/activesupport-2.3.2.jar' require 'lib/rails-2.3.2.jar' require 'lib/jruby-openssl-0.5.1.jar' require 'lib/bumble' # <--- bumble_appengine_jruby からオリジナルのbumbleに変更 require 'lib/beeu' require 'lib/active_record_features_for_bumble' # <--- 追加
(2)コントローラーの共通部分app/controllers/application_controller.rbですが、CurlからのアクセスにはCSRF対策の値がポストされないので、protect_from_forgeryをコメントアウトします。
(3)デフォルトのindex.htmlファイルがあると入り口ページが表示されませんのでpublic/index.htmlを削除します。
日本語文字化け対処
現在のDatastoreライブラリですが、日本語が文字化けするので以下のパッチをconfig/initializers/datastore_patch.rbに書きます。このパッチはmilk1000ccさんが作られたものです。
module AppEngine module Datastore def Datastore.ruby_to_java(value) # :nodoc: if SPECIAL_RUBY_TYPES.include? value.class value.to_java else case value when Fixnum java.lang.Long.new(value) when Float java.lang.Double.new(value) when String #value.to_java_string java.lang.String.new(value) # Thanks http://d.hatena.ne.jp/milk1000cc/20090802/1249218370 else value end end end end end
OpenSSLの制限
GAEでは一部暗号化の関数が制限されていて、セッションに使う Cookieの改ざん検出の部分でエラーになってしまいます。そこでメッセージダイジェスト作成関数をconfig/initializers /openssl_patch.rb ファイルで変更しています。
# 実アプリには使わないで下さい require 'digest/sha1' module ActiveSupport class MessageVerifier private def generate_digest(data) Digest::SHA1.hexdigest(data) end end end
ローカルでの実行とデバック
GAE用のアプリはPC上で開発用サーバを使って実行できます。ただし、JavaアプリのデプロイのようにWARファイルの作成が必要になります。また実行中にソースコードを変更しても、変更は反映されません。
手順
% jruby -S warble war # WARファイルの作成 % dev_appserver.sh tmp/war # 開発用サーバーの起動
アプリは http://localhost:8080/ で確認できます。開発時にDatastoreの内容を確認した場合は、http://localhost:8080/_ah/admin にアクセスする事で、下の画面のように Datastoreの内容を見たりレコードを削除したりできます。
GAEへのデプロイ
ローカルで動作が確認できたら、実際のGAE環境にデプロイしてみましょう。 GAEを使うには、アプリケーションID(Application Identifier)を取得する必要があります。この手順はCodeZine: Google App Engine for Javaを使ってみよう!(4ページ)に詳しく書かれています。
デプロイする際には appengine-web.xml ファイルの <application> ~ </application> の ~ の部分にアプリケーションIDを書きます。デプロイは次のコマンドで行います。途中でアプリケーションID取得で使った e-mail、パスワードでの認証が行われます。
% appcfg.sh update tmp/war Reading application configuration data... ... ... Email: # <--- e-mailを入力 Password for ~~~: # <---- パスワードを入力 ... ... Update completed successfully. Success. Cleaning up temporary files...
デプロイが完了したら、 http://アプリケーションID.appspot.com/ をアクセスするとアプリが使えます。ただし、最初のアクセス時にはページが表示されるまでにかなり時間がかかりま す。
CurlクライアントからのGAEアクセス
ここで使用しているCurlの開発用ライセンスは、localhostから読み込んだコードしか実行できないためGAEサーバのCurlページは実行できません。しかし開発用ライセンスで動くCurlアプリからインタネット上のサービスにはアクセスできますので、今回のサーバ側ソフトをGAEサーバをアクセスするように変更し、PC(localhost)上で動かせばGAEサーバ上のTwitter風サービスをCurlから使う事が出来ます。
やり方は app/views/statuses/start.html.erb ファイルの6行目を以下のよう変更します。
|| {let server_url = "http://<%= request.host_with_port() %>/"} {let server_url = "http://アプリケーションID.appspot.com/"}
そしてWARファイルを再作成し、開発用サーバを立ち上げ http://localhost:8080/statuses/start をアクセスするとCurlクライアントから GAEサーバにアクセスできます。
Curl製Twitterクライアントからのつぶやきポスト
さて、GAEサーバ上のアプリとCurlクライアントはうまく接続できたでしょうか。GAEというクラウド上にアプリ(サーバ)を立てることで、あとは専用のCurlクライアントさえあれば、誰でも接続できるようになります。しかも自分専用、あるいは少人数で使う分にはデータ量も少ないため、無料で使い続けることができます。
せっかくですのでCurlクライアントを機能拡張してみましょう。前回作った、Curl製Twitterクライアントにはつぶやきをポストする機能がありませんでしたので、追加してみます。
コードは次のようになります。フォームを作って入力された値をサーバにポストするコードは連載第2回 リッチクライアントCRUDアプリを作成するで作ったものを流用しました。HttpFormを使う事で入力したつぶやきを簡単にサーバにポストできます。
{curl 7.0 applet} {curl-file-attributes character-encoding = "utf8"} {import * from CURL.IO.JSON} {let server_url = "http://<%= request.host_with_port() %>/"} {let json_data_path = "statuses/friends_timeline.json"} {let login = "neko"} {let password = "pass"} {set-http-authentication {url server_url}, login, password} {let twitters: RecordSet = {RecordSet {RecordFields {RecordField "screen_name", caption = "", domain = String}, {RecordField "text", caption = "", domain = String}, {RecordField "icon", caption = "", domain = String} } } } {define-proc public {add-rec text: String, screen_name: String, profile_image_url: String }: void let new-rec: Record = {twitters.new-record} {new-rec.set "text", text} {new-rec.set "screen_name", screen_name} {new-rec.set "icon", profile_image_url} {twitters.append new-rec} } {define-proc public {get-time-line}:void {let twittersJsonValue: JsonValue = {JsonValue-parse {url server_url & json_data_path} } } {twitters.delete-all} {for twitter: JsonObject in twittersJsonValue do let found?: bool = false let text: String = {String} let screen_name: String = {String} let profile_image_url: String = {String} let user: JsonObject = {twitter.get "user"} set (text, found?) = {twitter.get-if-exists "text"} set (screen_name, found?) = {user.get-if-exists "screen_name"} set (profile_image_url, found?) = {user.get-if-exists "profile_image_url"} {if found? then {add-rec text, screen_name, profile_image_url} } } } {define-class public open IconCell {inherits StandardRecordGridCell} field private icon:Frame = {Frame width={make-elastic}, height={make-elastic}} {constructor public {default} {construct-super} set self.height = 48px {self.add-internal self.icon} set self.cells-take-focus? = self.can-update? } {method public open {refresh-data}:void let (data:String, valid?:bool) = {self.get-formatted-data} {try set self.icon.background = {url data} catch e:Exception do {unset self.icon.background} {output "icon get error " & e.message & " " & data } } } } {let form_text:TextField = {TextField name = "status[text]", width = 17.5cm, height = 0.8cm}} {let post_form:HttpForm = {HttpForm {url server_url & "statuses.json"}, method = HttpRequestMethod.post, encoding = HttpFormData.urlencoded-mime-type, default-character-encoding = "utf8", {spaced-vbox width = 20cm, margin = 5pt, "いまなにしてる?", {HBox spacing = 0.3cm, form_text, {CommandButton horigin="right", label = "つぶやく", {on Action do {try {with-open-streams response:#TextInputStream = {post_form.submit-open character-encoding = {get-character-encoding-by-name "utf8"}} do {set form_text.value = ""} } catch e:Exception do {popup-message "Error :" & e.message} } {get-time-line} } } } } } } {let time_line: RecordGrid = {RecordGrid width=20cm, height=12.7cm, editable? = false, record-source = twitters, {RecordGridColumn "icon", width = 48px, column-resizable? = false, cell-spec = IconCell}, {RecordGridColumn width = 3cm, "screen_name"}, {RecordGridColumn width = 15cm, "text"} } } {get-time-line} {value {spaced-vbox post_form, time_line } }
まとめ
本連載ではRuby on RailsとCurlをかんたんに接続できることについて、実際のコードを交えながら解説してきました。第3回ではRailsの特徴であるScaffoldを使ってCurlソースを生成する方法も紹介しました。
連載後半では、今話題のクラウド(GAE)上のサーバにRailsを配置し、クライアントCurlと連携する方法について説明しました。開発ライセンスのためやや煩雑な部分もありましたが、正規ライセンスであればこういった問題も解消され、手軽にクラウドを活用できることがご理解いただけたかと思います。将来的には自社サーバではなくクラウドを軸にしたクライアント配布体制の構築も視野に入ってきます。
業務の効率化を高めるためにもユーザーインターフェースの重要性は増すばかりです。クライアントにCurlを使い、環境構築がすぐにできるRailsをサーバーサイドに使うことで、こういった要望にも応えられるシステムを手軽に構築することも可能です。Curl + Ruby on Rails、ぜひ試してみてください。