Railsのnamed_scopeをまとめて実行するサンプル
概要
例えば、こんなモデルがあったとして、
class Member < ActiveRecord::Base belongs_to :customer named_scope by_email, lambda{|email| {:conditions => ['email = ?', email]} } named_scope by_deleted, lambda{|flag| {:conditions => ['deleted = ?', flag]} } end
定義したnamed_scopeをまとめて実行するメソッド(ここではsearch)を作成する事で
以下のように複雑な検索条件に対応しやすくなってコードもスッキリするというお話。
Member.search.find(:all) Member.search(:deleted => false).find(:all) Member.search(:deleted => true, :email => "sample@example.com").paginte(:all, :per_page => 10, :page => params[:page]) customer = Customer.first customer.members.search(:deleted => false).find(:all)
named_scopeをまとめて実行するメソッド
では、本題の検索処理のソースを掲載。
class Member < ActiveRecord::Base def self.search(params = {}) exec_scopes = [] # [実行するスコープ, 引数] を格納する配列 if params[:deleted] == true exec_scopes << [Member.scopes[:by_deleted], true] elsif params[:deleted] == false exec_scopes << [Member.scopes[:by_deleted], false] end if params[:email] exec_scopes << [Member.scopes[:by_email], params[:email]] end # 実行するスコープがない場合 if exec_scopes.size == 0 return Member.scoped end # named_scopeをまとめて実行 exec_scopes.reverse.inject(Member) {|p, s| scope = s.shift args = s if args.size > 0 # 引数がある場合 scope.call p, *args else # 引数がない場合 scope.call p end } end end
重要なポイント
重要なのは、実行するスコープがない場合。つまり、Member.search.find(:all)みたいな呼び出しをされた時にMember.scopedを返す事。これをうっかり忘れると、下記で説明する場合に意図した動きにならないので注意が必要である。
Member.scopedを忘れた場合
class Member < ActiveRecrod::Base def self.search(params = {}) exec_scopes = [] # [実行するスコープ, 引数] を格納する配列 if params[:deleted] == true exec_scopes << [Member.scopes[:by_deleted], true] elsif params[:deleted] == false exec_scopes << [Member.scopes[:by_deleted], false] end if params[:email] exec_scopes << [Member.scopes[:by_email], params[:email]] end # named_scopeをまとめて実行 exec_scopes.reverse.inject(Member) {|p, s| scope = s.shift args = s if args.size > 0 # 引数がある場合 scope.call p, *args else # 引数がない場合 scope.call p end } end end
このような場合、以下のようなコードを書くと
customer = Customer.find(1) customer.members.search.find(:all)
select * from members where customer_id = 1
という感じのSQLが実行されるはずであるが、実際には以下のSQLが実行されてしまう
select * from members
これに対応するために、実行するスコープがない場合(exec_scopes.size == 0)にMember.scopedをreturnすれば良い。
Railsで携帯版のGoogleAnalyticsを使う方法
ついにGoogle Analyticsが携帯に対応した!というのはうれしい事なんだけど、
当然PHPやPerlのソースはあってもRailsはない・・・
仕方ないので、PHP版ソースをRailsに翻訳して使う事にする。
ちょっと長いけど、コピペで動くはず。
ga.phpに相当する処理をクラスとして用意する
コントローラのアクションに全て書こうとすると、非常に長くなってしまうのでメインの処理はライブラリに分けておく。1x1の透明GIFの出力に関しては、コントローラ側でやる事にする。
2009/11/25追記
元のPHP版のソースはよく見ると、ドコモIDしか取得していないので他キャリアはクッキーで管理している。しかし、実際の携帯はクッキー対応端末が少ないので、他キャリアも端末IDを取得する必要がある。
そこで、jpmobileを使用して端末IDを取得する処理を入れている。なお、request.mobile.identで端末IDを取得していないのは、使っているjpmobileのバージョンが古いため。jpmobileが古いとiモードIDに対応していないので、自前で取得している。たぶん、jpmobile0.0.6ならrequest.mobile.identだけで良いはず。
-- lib/mobile_google_analytics.rb -- require 'digest/md5' require 'net/http' require 'timeout' require 'resolv-replace' require 'jpmobile' Net::HTTP.version_1_2 #== Example # # require 'jpmobile' # class SampleController < ApplicationController # def ga # ga = MobileGoogleAnalytics.new(:request => request, :params => params, :cookies => cookies) # headers["Cache-Control"] = "private, no-cache, no-cache=Set-Cookie, proxy-revalidate" # headers["Pragma"] = "no-cache" # headers["Expires"] = "Wed, 17 Sep 1975 21:32:10 GMT" # send_data( # ga.gif_data, # :disposition => "inline", # :type => "image/gif" # ) # ga.track_page_view # end class MobileGoogleAnalytics VERSION = "4.4sh" COOKIE_NAME = "__utmmobile" COOKIE_PATH = "/" COOKIE_USER_PERSISTENCE = 63072000 GIF_DATA = [ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b ] attr_reader :request, :params, :cookies, :logger def initialize(attrs) @request = attrs[:request] @params = attrs[:params] @cookies = attrs[:cookies] @logger = Logger.new("#{RAILS_ROOT}/log/mobile_google_analytics.log") end def get_ip return "" unless request.remote_ip # Capture the first three octects of the IP address and replace the forth # with 0, e.g. 124.455.3.123 becomes 124.455.3.0 ip = "" if request.remote_ip =~ /^([^.]+\.[^.]+\.[^.]+\.).*/ ip = "#{$1}0" end return ip end # Get a random number string. def get_random_number rand(0x7fffffff) end # Writes the bytes of a 1x1 transparent gif into the response. def gif_data data = GIF_DATA.map{|m| [m].pack('C')} data.join("") end # Generate a visitor id for this hit. # If there is a visitor id in the cookie, use that, otherwise # use the guid if we have one, otherwise use a random number. def get_visitor_id(guid, account, user_agent, cookie) logger.info "guid=#{guid}, account=#{account}, user_agent=#{user_agent}, cookie=#{cookie}" # If there is a value in the cookie, don't change it. if cookie return cookie; end message = ""; if guid && guid.length > 0 # Create the visitor id using the guid. message = guid + account else message = user_agent + get_random_number.to_s + get_random_string(30) end md5_str = Digest::MD5.hexdigest(message.to_s); return "0x" + md5_str[0, 16] end # Track a page view, updates all the cookies and campaign tracker, # makes a server side request to Google Analytics and writes the transparent # gif byte data to the response. def track_page_view time_stamp = Time.now domain_name = request.host # Get the referrer from the utmr parameter, this is the referrer to the # page that contains the tracking pixel, not the referrer for tracking # pixel. document_referer = params[:utmr] || "-" document_path = params[:utmp] || "" account = params[:utmac] || "" user_agent = request.user_agent || "" # Try and get visitor cookie from the request. cookie = cookies[COOKIE_NAME] guid = get_serial_number visitor_id = get_visitor_id(guid, account, user_agent, cookie); # Always try and add the cookie to the response. cookies[COOKIE_NAME] = { :value => visitor_id, :expiers => Time.now + COOKIE_USER_PERSISTENCE, :path => COOKIE_PATH, :domain => domain_name } utm_gif_location= "http://www.google-analytics.com/__utm.gif"; # Construct the gif hit url. url_params = { "utmwv" => VERSION, "utmn" => get_random_number.to_s, "utmhn" => domain_name, "utmr" => document_referer.to_s, "utmp" => document_path.to_s, "utmac" => account.to_s, "utmcc" => "__utma=999.999.999.999.999.1;", "utmvid" => visitor_id, "utmip" => get_ip } utm_url = utm_gif_location + "?" + url_params.to_query response= send_request_to_google_analytics(utm_url); result = {:response => response, :request_url => utm_url, :body => nil} if response.is_a?(Net::HTTPOK) result[:body] = response.body else logger.error "send request failed!, response=#{response.inspect}, request_url=#{utm_url}" end return result end # Make a tracking request to Google Analytics from this server. # Copies the headers from the original request to the new one. # If request containg utmdebug parameter, exceptions encountered # communicating with Google Analytics are thown. def send_request_to_google_analytics(utm_url) headers = { "User-Agent" => request.user_agent.to_s, "Accept-Language" => request.accept_language.to_s } get_contents(utm_url, :headers => headers) end # ランダムな文字列を返す def get_random_string(length = 8) t = Time.now srand(t.to_i ^ t.usec ^ Process.pid) source = ("a".."z").to_a + (0..9).to_a + ("A".."Z").to_a str = "" length.times{ str += source[rand(source.size)].to_s } return str end # URLの内容を取得し、レスポンスを返す # #+url+:: リクエストURL #戻り値:: Net::HTTPResponse。エラー時はnil def get_contents(url, opts = {}) opts.reverse_merge! :open_timeout => 1, :read_timeout => 3, :headers => {} begin uri = URI.parse(url) Net::HTTP.start(uri.host, uri.port){|http| http.open_timeout = opts[:open_timeout].to_i http.read_timeout = opts[:read_timeout].to_i return http.get(uri.request_uri, opts[:headers]) } rescue TimeoutError => e logger.error "TimeoutError, request_url=#{url}, message=#{e.message}" rescue Exception => e logger.error "Error, request_url=#{url}, message=#{e.message}" end nil end # 携帯の端末番号を返す def get_serial_number sn = nil case request.mobile when Jpmobile::Mobile::Docomo # iモードID if request.env["HTTP_X_DCMGUID"] sn = request.env["HTTP_X_DCMGUID"] end # DoCoMoのエミュレータ case request.user_agent when 'DoCoMo/2.0 ISIM0505(c100;TB;W24H16)' sn = 'ISIM0505' when 'DoCoMo/2.0 ISIM0606(c100;TB;W24H16)' sn = 'ISIM0606' end when Jpmobile::Mobile::Au # EZ番号 sn = request.mobile.subno() when Jpmobile::Mobile::Softbank sn = request.mobile.serial_number() if sn == nil sn = request.mobile.x_jphone_uid() end when Jpmobile::Mobile::Vodafone sn = request.mobile.serial_number() if sn == nil sn = request.mobile.x_jphone_uid() end when Jpmobile::Mobile::Jphone sn = request.mobile.serial_number() if sn == nil sn = request.mobile.x_jphone_uid() end when Jpmobile::Mobile::Emobile # EMnet対応端末ユニークID sn = request.mobile.em_uid() end return sn end end
ga.phpに相当するアクションを作る
とりあえず、適当なコントローラにgaアクションをつくり/gaでアクセスできるようにしておく。
routes.rbの設定は面倒なので省略。
require 'jpmobie' class MobileController < ApplicationController def ga ga = MobileGoogleAnalytics.new(:request => request, :params => params, :cookies => cookies) headers["Cache-Control"] = "private, no-cache, no-cache=Set-Cookie, proxy-revalidate" headers["Pragma"] = "no-cache" headers["Expires"] = "Wed, 17 Sep 1975 21:32:10 GMT" send_data( ga.gif_data, :disposition => "inline", :type => "image/gif" ) ga.track_page_view end end
ビューで使うためのヘルパーを作る
以下のメソッドをApplicationHelperに追加する。
これが完了したら、モバイルサイトのビューで<%= google_analytics_tag %>とすれば作業完了。
-- app/helpers/application_helper.rb -- module ApplicationHelper # Copyright 2009 Google Inc. All Rights Reserved. def google_analytics_tag tracking_code = "MO-xxxxxx-xx" # トラッキングコード ga_account = tracking_code url_params = { "utmac" => ga_account, "utmn" => rand(0x7fffffff).to_s, "utmr" => request.referer || "-", "utmp" => request.request_uri } tmp = [] url_params.keys.sort.each{|key| tmp << "#{key}=" + CGI.escape(url_params[key]) } tmp << "guid=ON" image_url = url_for(:action => :ga) + "?" + tmp.join("&") "<img src=\"#{image_url}\" />" end end
お名前.comのVPSの設定メモ
スペックと月額費用のバランスが一番良いと思うVPSなので思い切って借りてみた。
初期設定(yumの入れ方とか)は、id:wadap:20091014を参考に。
スペック
CPU | CPUx8。Quad-Core AMD Opteron(tm) Processor 2384、2700.111MHz | ||
メモリ | 1GB | ||
HDD | 50GB | ||
スワップ | なし |
※/proc/cpuinfoの情報
sudoの警告を消す
visuoで設定を行えば、sudoできるようになるのだが、毎回"audit_log_user_command(): Connection refused"というメッセージが表示されてしまう。ググって調べてみたが、結論はrpmのsudoを消して、ソースをコンパイルして入れるという事になった。
# cp -p /etc/sudoers /etc/sudoers.org # wget http://www.sudo.ws/sudo/dist/sudo-1.6.9p20.tar.gz # tar xvzf sudo-1.6.9p20.tar.gz # cd sudo-1.6.9p20 # ./configure # make # make install # yum remove sudo # cp -p /etc/sudoers.org /etc/sudoers
sysstatを入れる
sarコマンドが使いたいので、入れておく。
# yum install sysstat -y
使用可能なポートについて
今回のサーバはDBサーバ(PostgreSQL)として使うつもりで、外部のサーバから5432番で接続したかったのだが、お名前.comでは使用可能なポートが制限されているっぽい?
iptablesをオフにしても接続できないので、FAQを検索すると該当するものが見つかった。
http://www.onamae-server.com/support/faq/vps/common/common_28.php
使用可能なポートは以下の通り。DB以外にもsshのポートを22番以外に変更してお手軽にsshアタックを防ごうと思ったんだけど、これはあきらめるしかない。
HTTP | 80 | |
HTTPS | 443 | |
SMTP | 25 | |
POP3 | 110 | |
IMAP | 143 | |
Submission | 587 | |
FTP(FTPS) | 20/21 | |
SSH | 22 | |
DNS | 53 | |
SMTPS | 465 | |
IMAPS | 993 | |
POP3S | 995 |
外部サーバからのDB接続については、sshトンネルを使って接続する事ができたので、解決という事に。
- クライアント側でトンネル接続。
$ ssh -L 5432:localhost:5432 foo@example.com
$ psql -U ユーザ -h localhost -p 5432 -d データベース名
このままでは、ログインしているターミナルを閉じると接続が切れてしまうので実用的ではない。
そこで、以下のようにする。
- クライアントからお名前.comのVPSへ鍵認証でパスワードなしでログインできるようにする
- クライアントからはバックグラウンドオプションをつけてトンネルする
$ ssh -f -N -L 5432:localhost:5432 foo@example.com または $ ssh -f -N -L 5432:examle.com:5432 foo@example.com
この方法で運用するなら、sshトンネル接続を常に維持する必要があるので、
トンネルの接続状態を監視ツールなどでモニタして、切断されたら自動で再接続する必要がある。
named_scopeでオブジェクトを基点として検索をする
「オブジェクトを基点に検索するといい事があるよ」という事を聞いたので、メモしておく。
前提
- ログイン中のユーザが投稿した記事一覧を表示したいとする。
- URLは/articlesで、ArticlesControllerのindexアクションが呼ばれるものとする
- login_userでログイン中のUserオブジェクトが取得できるものとする
- 認証処理は既にされているものとする
- モデルの定義は以下
-- app/models/user.rb -- class User < ActiveRecord::Base has_many :articles end -- app/models/article.rb -- class User < ActiveRecord::Base belongs_to :user end
ダメな例
class ArticlesController < ApplicationController def index @articles = Article.scoped_by_user_id(login_user.id).paginate(:all, :per_page => 10, :page => params[:page]) end end
または
class ArticlesController < ApplicationController def index @articles = Article.paginate(:all, :conditions => ['user_id = ?', login_user.id], :per_page => 10, :page => params[:page]) end end
この方法では、ログイン中のユーザの持っている情報を表示するコントローラが
増えていくと、login_user.idを条件につけ忘れる可能性が高くなる。
良い例
class ArticlesController < ApplicationController def index @articles = login_user.articles.paginate(:all, :per_page => 10, :page => params[:page]) end end
この方法では、ログイン中のユーザを基点にして検索を行うのでlogin_user.idを付け忘れる事はなくなる。
なお、1対1の時(has_one)のときはlogin_user.article.find()とは出来ないので注意。
その場合は、こうして取得してその値がnilかどうかで処理を分ければいい。
@article = login_user.article
RubyKaigi2009 2日目 レポート(感想)
とりあえず、会社の人にも見せようと思ってPowerPointにまとめたんだけど、ブログにもちょっとだけ書いておこう。
使用している技術要素に関しては、自社とほとんど変わらないんだけど、圧倒的にアクセス数が違うなぁという感じがした。
ニフティ
@ニフティ流のケータイポータルサイトの作り方について。
サービスごとに機能を作っていく開発スタイルを垂直統合型、共通機能をAPIという形でサービスとは分離する開発スタイルを水平分業型とニフティでは呼んでいる。
後者は、APIの仕様や性能の制約を受けるものの、開発コストを下げる事が出来たり、メンバーの入れ替わりが激しい場合でも、対応しやすいなどのメリットがあるとの事。
自社サイトでも、画像変換/メール送信/認証などAPIとして切り出せそうだなぁと思った。
ライブラリとして、Ruby/Rails/jpmobileが紹介されていた。やっぱりこの組み合わせは鉄板だよね(自分も使ってます)。
リクルート
Railsサイト安定運用の心構えについて。
PassengerMaxRequestsとPassengerPoolIdleTimeの関係について
PassengerMaxRequestsとPassengerPoolIdleTimeに関する個人的なメモ。
とりあえず、今自分が使用している設定はこちら。お決まりの3行はここでは省略する。
PassengerUseGlobalQueue on PassengerMaxPoolSize 30 PassengerMaxInstancesPerApp 0 PassengerMaxRequests 0 PassengerPoolIdleTime 120 RailsAutoDetect off RailsFrameworkSpawnerIdleTime 86400 RailsAppSpawnerIdleTime 86400
ここから本題。
PassengerMaxRequestsとは、リクエストをいくつ処理したらプロセスを終了させるかを決めるものだ。
ちなみに初期値は0=無制限となっている。
- 例1(ちょっと極端な例)
PassengerMaxRequests 5 PassengerPoolIdleTime 120
この場合、Passengerの各インスタンスはリクエストを5個処理したらプロセスを終了する。
つまり、PassengerPoolIdleTimeは事実上、無視される。
(120秒間に5回のアクセスもないというなら別だが)
- 例2(PassengerMaxRequestsは無制限)
PassengerMaxRequests 0 PassengerPoolIdleTime 120
この場合は、PassengerPoolIdleTimeのみが適用される。
PassengerMaxRequestsを設定しない場合は、0を指定した場合と同様に扱われるので、上記の例は以下と同じだ。
PassengerPoolIdleTime 120
通常は、例2を使うほうが良いと思う。
でも、サーバのスペックが低いとか、メモリが少ないから普段はプロセスを節約したいなどの状況次第では、後者の設定もありだと思う。
Passengerの各インスタンスの終了するタイミングが分かりやすいという事もあるし。
RSpec on Railsの実行方法のまとめ
まとめて全部実行
この場合、テスト用データベースの作成などもやってくれる。
$ rake spec .............. Finished in 0.169177 seconds 14 examples, 0 failures
個別に実行
この場合は、テスト用データベースは自分でrake db:test:prepareして用意する必要がある。
簡易表示
$ rake db:test:prepare $ spec -c spec/models/category_spec.rb .... Finished in 0.071218 seconds 4 examples, 0 failures
詳細表示
$ rake db:test:prepare $ spec -fs -c spec/models/category_spec.rb Category#name が設定されていない場合: - バリデーションに失敗すること - :nameにエラーが設定されている事 Category#name が既に登録されている場合: - バリデーションに失敗すること - :nameにエラーが設定されている事 Finished in 0.071985 seconds 4 examples, 0 failures