odeの開発メモ日記

プログラマーやってます。今までの道のり ポケコンbasic→C(DirectX5 ネットやろうぜ)→Perl(ちょろっと)→Java→C#→Ruby→Android(Java)

classをカスタマイズするために再定義じゃなくてaliasしないとだめな場合がある話

当たり前といえば当たり前なaliasの挙動の話です。
わかってたはずなのに、とある状況だと気づかなかったのでメモります。


今回はまった部分は以下の部分の修正
railsでdb保存した場合に自動でcreated_at列に現在の時間が入る機能。
これををレガシーDBの列名がdt_creationなのでそれに対応したいと思いました。
(列名に型のprefix名がついちゃってるとか本当かんべんしてー)


オリジナルのコード

module ActiveRecord
  module Timestamp
    def self.included(base) #:nodoc:
      base.alias_method_chain :create, :timestamps
--- 省略 ---
    end
    
--- 省略 ---

    private
      def create_with_timestamps #:nodoc:
        if record_timestamps
          current_time = current_time_from_proper_timezone

          write_attribute('created_at', current_time) if respond_to?(:created_at) && created_at.nil?
          write_attribute('created_on', current_time) if respond_to?(:created_on) && created_on.nil?
--- 省略 ---
        end

        create_without_timestamps
      end
--- 省略 ---
  end
end


これを下記のようにcreate_with_timestampsメソッドを再定義しても動きません。


再定義ver(NG)

module ActiveRecord
  module Timestamp
    def create_with_timestamps #:nodoc:
      if record_timestamps
        current_time = current_time_from_proper_timezone
        write_attribute('dt_creation', current_time) if respond_to?(:dt_creation) && dt_creation.nil?
      end
    end
  end
end


aliasで元のcreateを上書きすればOK


alias ver(OK)

module ActiveRecord
  class Base
    def create_with_timestamps_regacy #:nodoc:
       current_time = current_time_from_proper_timezone
      write_attribute('dt_creation', current_time) if respond_to?(:dt_creation) && dt_creation.nil?
      create_without_timestamps_regacy
    end
    alias_method_chain :create, :timestamps_regacy
  end
end


これはaliasした場合、古いメソッド名のほうを再定義しても新しいメソッド名には反映されないからです。


簡単なtestコード

def test
  "test"
end

def test_new
  "test_new"
end

alias test test_new

def test_new
  "test_new_override"
end

p test # test_newと表示(test_newの再定義は反映されない)


よくよく考えるといつもよくやる下記の動作ができてるってことはそりゃそうだと思った。
http://www.ruby-lang.org/ja/man/html/_A5AFA5E9A5B9A1BFA5E1A5BDA5C3A5C9A4CEC4EAB5C1.html#alias

# メソッド foo を定義
def foo
  "foo"
end

# 別名を設定(メソッド定義の待避)
alias :_orig_foo :foo

# foo を再定義(元の定義を利用)
def foo
  _orig_foo * 2
end

p foo  # => "foofoo"

もし、古いメソッド名のほうを再定義して新しいメソッドにも影響あったら上記のやりたいことできないもんね。


aliasの内部的な仕組みを想像するとメソッド定義のコピーであってメソッドのポインタをコピーしてるわけではなさそう。
もしくは再定義するとオリジナルのメソッドポインタとは違う、別のメソッドポインタで作られているのかもしれない。

まとめ

カスタマイズするには再定義ではだめな場合がある(aliasされて実装されてる機能の場合)
目には目を、aliasにはaliasをw

実行環境

ruby (1.8.6)
rails (2.3.5)

set_primary_keyではまった。というかRailsレシピのあほー。set系メソッドでやっちゃった話

昔のアプリのDBにActiveRecordで繋げる必要がでたので
プライマリキーの名前を変更するset_primary_keyというメソッドを使いました。


そしたら動かない。。idはない列ですとでる。


なんでだろ。。


Railsレシピの通りに書いてたのに。

(間違い版)
set_primary_key = :hoge_id


通りすがりの同僚がチェック。
あれ、これsetなんだから=いらなくない?


あー、そういえば。

(正しい版)
set_primary_key :hoge_id

と書いてみると。。


動いたーーーー!!!


railsレシピのあほーー。


しかしなんでエラーでないんだ!
と、ソースを見たらこういうことだった。

set_primary_keyの定義
def set_primary_key(value = nil, &block)


引数なしでも動くじゃん。


ってことは
set_primary_keyが引数なしで実行され、その戻り値に=で代入するという
意味のないことをしていたのね。。


これは今回以外の場合でも気づかずにやっちゃいそうだ。。
おいら以外に犠牲者がでないことを祈ります。

複数プロセスからのファイル書き込み(追記の場合)における注意点

実行条件

100プロセス同時に5000行の書き込みを行う。

テストコード

100.times do
  pid = fork do
    open(File.join(File.dirname(File.expand_path(__FILE__)),"aaaaaaaaa.log"), 'a') do |f|
      f.sync = true # syncしないと文字列が途中でおかしくなる場合がある。
      5000.times do
        time = Time.now
        f.write "[#{time.strftime("%Y/%m/%d %H:%M:%S")}][#{$$}]test\n" #OK
        #f.puts "[#{time.strftime("%Y/%m/%d %H:%M:%S")}][#{$$}]test"   #NG
        #f.puts "[#{time.strftime("%Y/%m/%d %H:%M:%S")}][#{$$}]test\n" #これはOK
      end
    end
  end
  Process.detach(pid)
end

気をつけること

  • syncする
  • putsは使わないでwriteを使う
  • flockはしなくても問題なさそうでした(rubyの標準Logger,railsのBufferedLoggerもしてません)
    • 但しローリングをする場合にはflock使うように改良しないとだめでしょうけど。
    • OS依存しそうな気もするので念のため自分の環境でも確認したほうがよいと思います。私はCentOS 5.2で確認しました。

railsのLogは?

ちゃんと上記対策やってましたので問題なく複数プロセスで使っていいと思います。
これ調べるまで少し不安な部分がありましたけど安心しました(^^;

putsは使わないでwriteを使う理由

どうやら本文と改行を別々に書きこんでるみたいです。
そのため下記のような症状が出ます。


2行のはずが1行にまとまってしまったパターン

[2009/11/17 16:45:48][32628]test[2009/11/17 16:45:48][32626]test


上記で消えた改行がよそにいって1行空いてしまうパターン

[2009/11/17 16:45:46][32614]test
[2009/11/17 16:45:46][32614]test

[2009/11/17 16:45:46][32612]test
[2009/11/17 16:45:46][32612]test

(たまにあるぐらいです、よく中身を見ましょう。大体10行ぐらいでしょうか。)


File#putsのソースを見て確認しました。(これは1.8.6のですが現時点の最新版のruby1.9.1-p243でも同様でした)
io.c

rb_io_puts(argc, argv, out)
    int argc;
    VALUE *argv;
    VALUE out;
{
    int i;
    VALUE line;

    /* if no argument given, print newline. */
    if (argc == 0) {
	rb_io_write(out, rb_default_rs);
	return Qnil;
    }
    for (i=0; i<argc; i++) {
	if (NIL_P(argv[i])) {
	    line = rb_str_new2("nil");
	}
	else {
	    line = rb_check_array_type(argv[i]);
	    if (!NIL_P(line)) {
		rb_protect_inspect(io_puts_ary, line, out);
		continue;
	    }
	    line = rb_obj_as_string(argv[i]);
	}
	rb_io_write(out, line); // 本文を書き込んでる
	if (RSTRING(line)->len == 0 ||
            RSTRING(line)->ptr[RSTRING(line)->len-1] != '\n') {
	    rb_io_write(out, rb_default_rs); // 改行がない場合に改行を書き込んでる
	}
    }

    return Qnil;
}

syncをする理由

下記の症状になります。

文字の途中に混ざっちゃいます。
[2009/11/17 16[2009/11/17 16:57:22][318]test

(たまにあるぐらいです、よく中身を見ましょう。大体5行ぐらいでしょうか。)

実行環境

CentOS 5.2
ruby (1.8.6 p287)

rubyでデコメのパース

前書き

受信したデコメをパースしたいと思います。

  • パーサーにはTMailを使用します。
  • Railsは使用しません。
  • 空メの受け取り部分については書いてません。空メの内容を受け取った後の処理になります。
  • 別にデコメに限らずただのテキストメールのパース、携帯でないHTMLメールのパースにも使えると思います。


取得する内容は下記のとおり

  • from,toアドレス
  • 件名
  • テキスト本文
  • HTML本文
  • 添付画像(ファイル名、CID、バイナリ)

つまづいた点(解決済み)

  • 添付ファイルのCID(content-id)の取得
    • ドコモだけcidがとれなかった。
  • subjectの文字コード問題
    • デコードされた結果がエンコード前の文字コードになる。(プログラムではUTF8で取得したいのにJISでとれてしまう。。UTF8の件名はUTF8でとれる。)

そのほかの問題への対策としてMbMailを使わせて頂きました。
下記の対策がなされています。

  • 携帯等のRFC違反のドットが連続するアドレスが扱えるよう、TMail::Parser を置き換え。
  • 機種依存文字の対応(絵文字等)、文字コード変換器に NKF を使用するように変更。デフォルトで使われるiconvだと対応していない。

multipartのパースのためのutilとしても使いました。(privateメソッドだったので若干ソース修正してます)
http://tmty.jp/tag/mbmail/


これらの問題を解決するためにTMailにパッチをあてます。
私の場合はMbMailに含まれるTMailのソースに上書きしました。
また、MbMailがRailsに依存しないで動くように修正してあります。

ソース

メイン文

$KCODE = 'u'
require 'rubygems'
require 'tmail'
require 'tmtysk-mbmail/lib/mb_mail.rb'

def parse_mail(file_data)
  puts "---------------------"
  mail = MbMail::DMail.parse(file_data)
  mail.organize_mail_parts

  p "to: "+mail.to.to_s # toは配列
  p "from: "+mail.from.to_s # fromは配列
  p "subject: #{mail.subject}"
  p "text: "+mail.text_part.body

  html = ""
  unless mail.html_part.nil?
    html = mail.html_part.body
  end
  p "html: "+html

  mail.in_lined_image_parts.each_with_index do |ip, index|
    # なぜかドコモだけcidがとれなかった。内部の@bodyには入ってたので無理やり取得する。
    #p "cid: "+ip["content-id"].id.to_s
    content_id_ip = ip["content-id"]
    def content_id_ip.inner_body
      @body
    end
    content_id = ""
    unless content_id_ip.inner_body.nil?
      content_id = content_id_ip.inner_body.strip
      # cidは<>で囲まれてるので除去する。
      if content_id[0, 1] == "<"
        content_id = content_id[1, content_id.length-1]
      end
      if content_id[content_id.length-1, 1] == ">"
        content_id = content_id[0, content_id.length-1]
      end
    end

    file_name = (ip['content-location'] && ip['content-location'].body) ||
      ip.sub_header("content-type", "name") ||
      ip.sub_header("content-disposition", "filename")
    file_name = TMail::Unquoter.unquote_and_convert_to(file_name, 'utf-8')

    p "file #{index+1}"
    p "file_name: #{file_name}"
    p "cid: #{content_id}"
    #p ip.body #ファイルのバイナリ
  end

  puts ""
  puts ""
end


file_names = ["/tmp/test1.eml", "/tmp/test2.eml"]
file_names.each do |file_name|
  file = File.open(file_name) do |f|
    parse_mail f.read
  end
end

tmtysk-mbmail/lib/mb_mail/dmail.rb

# ode改良 start railsでなくなった対応
require 'base64'
# ode改良 end

# 携帯メール対応モジュール
module MbMail
  # HeaderField 操作用に TMail から移管
  class HeaderField < TMail::HeaderField; end

  # デコメールクラス
  class DMail < TMail::Mail

    def []=( key, val )
      dkey = key.downcase

      if val.nil?
        @header.delete dkey
        return nil
      end

      case val
        when String
        header = new_hf(key, val)
        when HeaderField
        # HeaderField が与えられた場合、そのままヘッダに代入する
        header = val
        when Array
        ALLOW_MULTIPLE.include? dkey or
        raise ArgumentError, "#{key}: Header must not be multiple"
        @header[dkey] = val
        return val
      else
        header = new_hf(key, val.to_s)
      end
      if ALLOW_MULTIPLE.include? dkey
       (@header[dkey] ||= []).push header
      else
        @header[dkey] = header
      end

      val
    end

    # docomo のデコメールフォーマットに変換する
    def to_docomo_format
      converted_for_carrier(:docomo)
    end

    # au のデコレーションメールフォーマットに変換する
    def to_au_format
      converted_for_carrier(:au)
    end

    # softbank のデコレメールフォーマットに変換する
    def to_softbank_format
      converted_for_carrier(:softbank)
    end

    protected

    # 指定された content-type のパーツを取得する
    def get_specified_type_parts(mimetype)
      specified_type_parts = []
      specified_type_parts << self if Regexp.new("^#{mimetype}$", Regexp::IGNORECASE) =~ self.content_type
      if /^multipart\/(.+)$/ =~ self.content_type then
        self.parts.each do |p|
          specified_type_parts += p.get_specified_type_parts(mimetype)
        end
      end
      specified_type_parts
    end

    private

    # 指定のキャリアのデコメールフォーマットに変換する
    # 現時点では、:docomo, :au, :softbank のみ対応
    def converted_for_carrier(carrier = :docomo)
      organize_mail_parts

      dm = MbMail::DMail.new
      self.header.each do |key,value|
        next if key == 'content-type' # content-type は引き継いだらダメ
        dm[key] = value.to_s
      end
      dm.body = ""
      dm.content_type = 'multipart/mixed'

      # text/plain パートの作成
      tp = MbMail::DMail.new
      case carrier
        when :docomo
        tp.content_type = 'text/plain; charset="Shift_JIS"'
        tp.transfer_encoding = 'Base64'
        tp.body = Base64.encode64(Jpmobile::Emoticon::unicodecr_to_external(NKF.nkf('-m0 -x -Ws', @text_part.body), Jpmobile::Emoticon::CONVERSION_TABLE_TO_DOCOMO))
        when :au
        tp.content_type = 'text/plain; charset="Shift_JIS"'
        tp.transfer_encoding = 'Base64'
        tp.body = Base64.encode64(Jpmobile::Emoticon::unicodecr_to_external(NKF.nkf('-m0 -x -Ws', @text_part.body), Jpmobile::Emoticon::CONVERSION_TABLE_TO_AU))
        when :softbank
        tp.content_type = 'text/plain; charset="UTF-8"'
        tp.transfer_encoding = 'Base64'
        table = Jpmobile::Emoticon::CONVERSION_TABLE_TO_SOFTBANK
        emoticon_converted = @text_part.body.gsub(/&#x([0-9a-f]{4});/i) do |match|
          unicode = $1.scanf("%x").first
          case table[unicode]
            when Integer
            [(table[unicode].to_i-0x1000)].pack('U')
            when String
            table[unicode]
          else
            match
          end
        end
        tp.body = Base64.encode64(emoticon_converted)
      else
        tp.content_type = 'text/plain; charset="UTF-8"'
        tp.transfer_encoding = 'Base64'
        tp.body = Base64.encode64(@text_part.body)
      end

      # text/html パートの作成
      # ode改良 start テキストメールのみの場合対応
      if @html_part.nil?
        hp = nil
      else
        hp = MbMail::DMail.new
        case carrier
          when :docomo
          hp.content_type = 'text/html; charset="Shift_JIS"'
          hp.transfer_encoding = 'Base64'
          hp.body = Base64.encode64(Jpmobile::Emoticon::unicodecr_to_external(NKF.nkf('-m0 -x -Ws', @html_part.body), Jpmobile::Emoticon::CONVERSION_TABLE_TO_DOCOMO))
          when :au
          hp.content_type = 'text/html; charset="Shift_JIS"'
          hp.transfer_encoding = 'Base64'
          hp.body = Base64.encode64(Jpmobile::Emoticon::unicodecr_to_external(NKF.nkf('-m0 -x -Ws', @html_part.body), Jpmobile::Emoticon::CONVERSION_TABLE_TO_AU))
          when :softbank
          hp.content_type = 'text/html; charset="UTF-8"'
          hp.transfer_encoding = 'Base64'
          table = Jpmobile::Emoticon::CONVERSION_TABLE_TO_SOFTBANK

          emoticon_converted = @html_part.body.gsub(/&#x([0-9a-f]{4});/i) do |match|
            unicode = $1.scanf("%x").first
            case table[unicode]
              when Integer
              [(table[unicode].to_i-0x1000)].pack('U')
              when String
              table[unicode]
            else
              match
            end
          end
          hp.body = Base64.encode64(emoticon_converted)
        else
          hp.content_type = 'text/plain; charset="UTF-8"'
          hp.transfer_encoding = 'Base64'
          hp.body = Base64.encode64(@html_part.body)
        end
      end
      # ode改良 end

      # キャリアによって multipart 構成を分岐
      alt_p = MbMail::DMail.new
      alt_p.body = ""
      alt_p.content_type = 'multipart/alternative'
      alt_p.parts << tp
      # ode改良 start テキストメールのみの場合対応
      if hp
        alt_p.parts << hp
      end
      # ode改良 end
      case carrier
        when :au
        dm.parts << alt_p
        @in_lined_image_parts.each do |ip| dm.parts << ip end
        @attached_image_parts.each do |ap| dm.parts << ap end
      else
        rel_p = MbMail::DMail.new
        rel_p.body = ""
        rel_p.content_type = 'multipart/related'
        rel_p.parts << alt_p
        @in_lined_image_parts.each do |ip| rel_p.parts << ip end
        dm.parts << rel_p
        @attached_image_parts.each do |ap| dm.parts << ap end
      end

      dm
    end

    # オリジナルのメール構成を解析し、各パーツに分離する
    def organize_mail_parts
      @text_part = get_specified_type_parts('text/plain').first
      @html_part = get_specified_type_parts('text/html').first
      # いったん全ての画像をインライン扱いとし、
      # その後本文から参照されていない画像を添付扱いとする
      # au でいずれの添付タイプについても同等に扱われているため
      @in_lined_image_parts = get_specified_type_parts('image/(gif|jpeg|jpg)')
      @in_lined_image_parts.map do |ip| ip.content_disposition = 'inline' end
      @attached_image_parts = []
      @in_lined_image_parts.delete_if do |ip|
        if /TMail\:\:MessageIdHeader\s\"<([\.@_0-9a-zA-Z]+)>/ =~ ip["content-id"].inspect then
          unless Regexp.new("src=['\"]cid:#{$1}", Regexp::IGNORECASE) =~ @html_part.body then
            ip.content_disposition = 'attachment'
            @attached_image_parts << ip
            true
          end
        else
          false
        end
      end
    end

    # ode改良 start 外部から使えるようにする。
    public :organize_mail_parts
    public
    attr_accessor :text_part, :html_part, :in_lined_image_parts
    # ode改良 end

  end
end

tmtysk-mbmail/lib/mb_mail/mb_mailer.rb

# ode改良 start rails依存のを除去
module MbMail
  # http://www.kbmj.com/~shinya/rails_seminar/slides/#(33)
  # JapaneseMailer 実装より
  class MbMailer
    Dir[File.join(File.dirname(__FILE__), '../tmail/**/*.rb')].sort.each { |f| require f }

    public
    # ヘッダに日本語を含める場合に用いる base64 カプセリング
    # http://wiki.fdiary.net/rails/?ActionMailer
    def base64(text, charset="iso-2022-jp", convert=true)
      if convert
        if charset == "iso-2022-jp"
          text = NKF.nkf('-j -m0', text)
        end
      end
      text = [text].pack('m').delete("\r\n")
      "=?#{charset}?B?#{text}?="
    end
  end
end
# ode改良 end

tmtysk-mbmail/lib/tmail/unquoter.rb

# ode改良 start alias_method_chainを使えるようにした。
module ActiveSupport
  module CoreExtensions
    module Module
      # Encapsulates the common pattern of:
      #
      #   alias_method :foo_without_feature, :foo
      #   alias_method :foo, :foo_with_feature
      #
      # With this, you simply do:
      #
      #   alias_method_chain :foo, :feature
      #
      # And both aliases are set up for you.
      #
      # Query and bang methods (foo?, foo!) keep the same punctuation:
      #
      #   alias_method_chain :foo?, :feature
      #
      # is equivalent to
      #
      #   alias_method :foo_without_feature?, :foo?
      #   alias_method :foo?, :foo_with_feature?
      #
      # so you can safely chain foo, foo?, and foo! with the same feature.
      def alias_method_chain(target, feature)
        # Strip out punctuation on predicates or bang methods since
        # e.g. target?_without_feature is not a valid method name.
        aliased_target, punctuation = target.to_s.sub(/([?!=])$/, ''), $1
        yield(aliased_target, punctuation) if block_given?

        with_method, without_method = "#{aliased_target}_with_#{feature}#{punctuation}", "#{aliased_target}_without_#{feature}#{punctuation}"

        alias_method without_method, target
        alias_method target, with_method

        case
          when public_method_defined?(without_method)
          public target
          when protected_method_defined?(without_method)
          protected target
          when private_method_defined?(without_method)
          private target
        end
      end

      # Allows you to make aliases for attributes, which includes
      # getter, setter, and query methods.
      #
      # Example:
      #
      #   class Content < ActiveRecord::Base
      #     # has a title attribute
      #   end
      #
      #   class Email < Content
      #     alias_attribute :subject, :title
      #   end
      #
      #   e = Email.find(1)
      #   e.title    # => "Superstars"
      #   e.subject  # => "Superstars"
      #   e.subject? # => true
      #   e.subject = "Megastars"
      #   e.title    # => "Megastars"
      def alias_attribute(new_name, old_name)
        module_eval <<-STR, __FILE__, __LINE__+1
          def #{new_name}; self.#{old_name}; end          # def subject; self.title; end
          def #{new_name}?; self.#{old_name}?; end        # def subject?; self.title?; end
          def #{new_name}=(v); self.#{old_name} = v; end  # def subject=(v); self.title = v; end
        STR
      end
    end
  end
end

class Module
  include ActiveSupport::CoreExtensions::Module
end
# ode改良 end


require 'nkf'
# convert_to で機種依存文字や絵文字に対応するために
# Unquoter 内で NKF を使用するようにしたもの
module TMail
  class Unquoter
    class << self

      def unquote_and_convert_to(text, to_charset, from_charset = "iso-8859-1", preserve_underscores=false)
        return "" if text.nil?
        text.gsub(/(.*?)(?:(?:=\?(.*?)\?(.)\?(.*?)\?=)|$)/) do
          before = $1
          from_charset = $2
          quoting_method = $3
          text = $4

#          p "#{before} #{from_charset} #{quoting_method} #{text}"

          before = convert_to(before, to_charset, from_charset) if before.length > 0
          before + case quoting_method
            when "q", "Q" then
            unquote_quoted_printable_and_convert_to(text, to_charset, from_charset, preserve_underscores)
            when "b", "B" then
            unquote_base64_and_convert_to(text, to_charset, from_charset)
            when nil then
            # will be nil at the end of the string, due to the nature of
            # the regex used.
                ""
          else
            raise "unknown quoting method #{quoting_method.inspect}"
          end
        end
      end

      # http://www.kbmj.com/~shinya/rails_seminar/slides/#(30)
      def convert_to_with_nkf(text, to, from)
#        p text, to, from
        if text && to =~ /^utf-8$/i && from =~ /^iso-2022-jp$/i
          NKF.nkf("-Jw", text)
        elsif text && from =~ /^utf-8$/i && to =~ /^iso-2022-jp$/i
          NKF.nkf("-Wj", text)
        else
          convert_to_without_nkf(text, to, from)
        end
      end

      alias_method_chain :convert_to, :nkf
    end
  end
end


# ode改良 start subjectがJISでデコードされる不具合のため対応。
module TMail
  class Decoder
    def self.decode( str, encoding = nil )
      #何もしない。実際のデコードはUnquoter#convert_toで行われるため。
      return str
    end
  end
end
#ode改良 end


# 下記のバージョンでutf8としてデコードするでもOK。(TMail.KCODEをUTF8にしておく必要あり)
# だけど後で実際のデコードをUnquoter#convert_toでもっと汎用的に行うのでここでデコードに必要な情報をかけさせるのも微妙かと思う。
# あとここのデコードはJIS、EUC、SJISだけという日本専用になっている。多分ここはTMailの管理が日本のときのなごりなのかも!?
#module TMail
#  class Decoder
#    OUTPUT_ENCODING = {
#      'EUC'  => 'e',
#      'SJIS' => 's',
#      'UTF8' => 'w', #utf8を追加
#    }
#    def self.decode( str, encoding = nil )
#      encoding ||= (OUTPUT_ENCODING[TMail.KCODE] || 'j')
#      opt = '-mS' + encoding
#      str.gsub(ENCODED_WORDS) {|s| NKF.nkf(opt, s) }
#      str
#    end
#
#  end
#end

実行環境

ruby (1.8.6)
tmail (1.2.3.1)
MbMail (verはないっぽい。2008-12-24のもの)

rubyのバージョンアップでcapistranoの設置がうまくいかなくなった

最近rubyを1.8.6p369にアップしたんですが
capistrano2.5.5+ruby1.8.6p369
だと複数の設置先がある場合に固まりました。
(パスワード入力までいかなかったり、パスワード入れれてもその後で固まったり。設置先が1箇所の場合は問題なくできました)
rubyをruby1.8.6p287に戻したら直りました。
うむむ。
最近でたcapistrano2.5.7にしても解決はしませんでした。


しかもcapistrano2.5.7にしたら今まで動いてたものが動かなくなる始末。。
設置後に再起動してくれない状況。
恐らく

namespace :deploy do
  task :restart, :roles=>:app do

などで上書きしてたのが動いてない気がします。
(上記の書き方あんま良くないやり方で動かなくなった説もありますが。。)


というわけでしばらくはcapistrano2.5.5+ruby1.8.6p287でいこうと思います。。


ちなみにruby1.8.7p174では問題でませんでした。
けどrails1.2.6のアプリもまだあるし今はまだやめておこう。

railsバッチの作り方

作り方

例えば検索インデックス登録用バッチとしてcreate_search_indexというバッチを作る場合


ファイル構成

app/
batch/ ← このフォルダを追加
      create_search_index/
                          create_search_index_batch.rb - railsバッチを定義する
                          create_search_index.sh.sample - 実行用シェルスクリプトのサンプル
                          create_search_index.sh - 実行用シェルスクリプト。sampleをコピーして使う。svn除外にする。
・・・


create_search_index.sh.sample

#!/bin/sh

#staging
ruby `dirname $0`/../../script/runner `dirname $0`/create_search_index_batch.rb  --fetch_size 50
#--fetch_size 50はバッチへの引数です。

#production
#ruby `dirname $0`/../../script/runner `dirname $0`/create_search_index_batch.rb -e production --fetch_size 50

create_search_index_batch.rbに自由にバッチのスクリプトを書いていきます。
もちろんrailsが適用されているのでWebアプリ側で作ったmodelを扱えます。


起動にはbatch/create_serach_index/create_serach_index.shを使用して下さい。これをcronに登録する感じですね。

設置の際

1バッチごとに1つのrailsアプリを設置します。

例えばbatch1とbatch2がある場合

batch1/app
      /batch
・・・

batch2/app
      /batch
・・・

のように2つのrailsアプリを設置するイメージにします。
capistranoで設置する際はbatch1/current/batchですね)


メリット

  • 更新の際に他のバッチに影響を与えない。

注意点

webアプリでenviroments/xx.rbの設定値を追加した場合等は
次回バッチを更新する際に設定値を追加する必要があります。

他の方法と比べて

通常のrubyスクリプトActiveRecordを単体で使う方法等があると思います。
但しWebアプリのModelを共有で使ったりする場合は
Modelの中でenviroments/xx.rbの中で定数を使った設定を使った場合や
plugin、libを使うとなると面倒なことになると思います。

実行環境

rails(2.1.2)

capistranoで複数のデプロイをしたい場合

設置の単位を分けたい場合があります。
例えばステージングと本番環境(プロダクション)や、ウェブアプリとバッチ等です。

やり方

deploy.rbファイルを用途別に作成する。

  • config/deploys/staging_web.rb(ステージングweb)
  • config/deploys/production_web.rb(本番web)
  • config/deploys/production_batch_create_search_index.rb(各種バッチごと)

上記の各ファイルは通常のdeploy.rbと同じ記述をしてもらうとして、追加で先頭に下記の2行を追加する

load 'deploy' if respond_to?(:namespace) # cap2 differentiator
Dir["#{File.dirname(__FILE__)}/../../vendor/plugins/*/recipes/*.rb"].each { |plugin| load(plugin) }


RAILS_ROOT/Capfileは使用しないので削除しても構わない。


デプロイ時のコマンド
cap -f config/deploys/production_batch_create_search_index
.rb deploy

副次的なメリット

railsアプリのパスでなくても実行可能になります。
今までは
cd /usr/local/rails/hogeapp/
cap deployとしていたのが

cap -f /usr/local/rails/hogeapp/config/deploys/production_batch_create_search_index
.rb deploy
と1行で実行可能になります。

解説

-fオプションによりdeploy.rbだけでない任意のファイルを指定可能になります。
但しこの場合RAILS_ROOT/Capfileが読まれなくなるためにデフォルトのタスクのdeploy等が使用できなくなります。
-Fというオプションを渡すことでCapfileを読むように指定できるのですが、またこれが曲者で-fで指定したタスクの後にCapfileをロードするために
-fで指定したdeploy:start(任意のサーバー thinやpassngerを起動するために上書きする)等をデフォルトのタスク(script/reaper等)に戻してしまいます。
そのためCapfileの中身を少し修正したものをdeploy.rbの先頭に追加することで回避しました。

調査メモ

-fでのファイルの後にCapfileが呼ばれるのをソースを見て確認しました。


option_parserで-fで指定したrecipeを追加し、その後にlook_for_default_recipe_fileで
デフォルトのrecipe(RAILS_ROOTのCapfile)をロードしている。


capistrano/cli/options.rb

        option_parser.parse!(args)

        coerce_variable_types!

        # if no verbosity has been specified, be verbose
        options[:verbose] = 3 if !options.has_key?(:verbose)

        look_for_default_recipe_file! if options[:default_config] || options[:recipes].empty?
その他の方法

capistrano-extというgemを使う方法があるらしいです。

実行環境

capistrano (2.5.5)