是非に及ばず

プログラミングに関する話題などを書いていきます(主にRailsやAndroidアプリ開発について)

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が携帯に対応した!というのはうれしい事なんだけど、
当然PHPPerlのソースはあっても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("&amp;")

    "<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
  • クライアントの別ターミナルでpsqllocalhost:5432へ接続
$ 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サイト安定運用の心構えについて。

    • RailsのサイトのPVなど
      • 8サイト合計で、月間6000万PV
      • データ転送量は月間4.5TB
    • Ruby/Railsのバージョン
      • Rubyは基本的に1.8.6(RubyEnterpriseEdition)。1.8.6でライブラリなどが動かない時は柔軟に対応
      • Railsのバージョンは1.2.xから2.3.xまでバラバラ(古い時期から作ってるから)
      • その時々の安定バージョン、または最新版を使っている
    • 実行環境
      • mongrel, thin, Passengerなど試行錯誤中だが、今後はPassengerに寄せていこうと考えている
      • いくつかのサイトでは既にPassengerで動いている
      • パフォーマンスの面でもmongrelと比較したら2割ほどPassengerのほうがレスポンスが良かった
    • 仮想サーバによる運用
      • Railsの載っているサーバは、全て仮想サーバ(VM)で動いている
      • 1台のホスト上に13台のVMが稼動している
    • mongrelの最適なインスタンス
      • VMの場合、メモリ1GBで10個、メモリ2GBなら24個くらい

楽天

楽天Railsサイト運用と楽天製KVS(KeyValueStore)であるROMAに関して。

    • アクセス数
      • 非常に多い、300万リクエスト/日
      • ピーク時は350リクエスト/秒
    • Railsのパフォーマンス
      • 実践投入前にCakePHPとの性能比較を行った
      • CakePHPと比べてもRailsは悪くない
      • CPUの限界が、Railsの限界になった。サーバ増やせば解決=スケールアウト可能
    • パフォーマンス改善
      • サーバのメモリは、キャッシュ用に500MBくらい残して使い切るくらいがベスト
      • DBは常にボトルネックになる。Railsではモデルに手を入れて、テーブル分割で対応
      • KVSの利用も検討すべき。楽天では当然ROMAを検討中(普通の人はmemcachedTokyoCabinetあたりでしょうね)

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の実行方法のまとめ

前置き

この記事ではrailsを利用する前提なので、全てのコマンドはRAILS_ROOT直下で実行する事を想定しています。

まとめて全部実行

この場合、テスト用データベースの作成などもやってくれる。

$ 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

まとめて実行してHTMLに結果を出力

これは便利かもしれない。"-f h:出力ファイル名"がポイント。h:を忘れるとエラーになるので注意。

$ rake db:test:prepare
$ spec spec -f h:spec_report.html
出力結果