Ruby-GetText 1.91.0 のキャッシュに関するバグの対処法
Ruby-GetText 1.92.0 ではすでに修正されています
現在
という環境のパッケージアプリケーションを作ってます。
そこで 『適当に操作してるとたまに日本語化されていないページが表示される』 というバグがあがってきたのですが、それを直したときの苦労話をメモとして残しておきます。
GetText まわりで何かおかしいのは予想がつきますが、そもそもそのバグがなかなか再現できず*1、GetText のせいなのか、Windows のせいなのか、リリースまで1週間を切っているのに無理やり Rails 2.1 に上げたせいなのかわからず、最初は同じような報告が無いか(脱線しつつ)検索しただけで半日がつぶれました。
いよいよ本格的に向き合わなきゃダメそうだと思い Ruby-GetText を読み始めたのですが Windows 環境での debugger の動かし方が良くわからず、print debug を仕込んでは、アクセスを繰り返すスクリプトを立ち上げて再現するまでボーっとするを繰り返した結果、gettext.rb の次の部分に狙いを定めました。
module GetText ... def bound_target(klass = self) # :nodoc: id = klass.object_id if cached? tgt = @@__cache_bound_target[id] return tgt if tgt end ret = (klass.kind_of? Module) ? klass : klass.class if ret.name =~ /^\#<|^$/ or ret == GetText #GC for dead object_ids. ret = Object if @@__cache_bound_target.size > CACHE_BOUND_TARGET_MAX_SIZE @@__cache_bound_target.clear end end @@__cache_bound_target[id] = ret ret end
ここでは self.object_id を key に返り値のクラス*2をキャッシュしています。しかし object_id は再利用されるため、同じ object_id が割り当てられた違うクラスのインスタンスで再度呼び出された場合意図しない値が返ります。*3
もしこの現象が起きたとしても、間違って返ってきたクラスの textdomain が同じならば、問題は浮かび上がってこないようです。普通ひとつのアプリケーションで textdomain なんて複数つかわねーよと思うかもしれませんが、Rails で GetText を利用する場合は "rails" という textdomain が GetText 内部で使用されており、キャッシュによって運悪く ActiveRecord::Errors とかが返ってきたときバグが再現するというのが真相だったようです。
また後半部分では、『何らかの条件』を満たした場合に @@__cache_bound_target.size が 50000 ( == CACHE_BOUND_TARGET_MAX_SIZE ) を越えている場合はキャッシュをクリアするという処理なのですが、この『何らかの条件』部分がいつ true になるのかわからず、観測上は一度もキャッシュが削除されることはありませんでした。
この問題は Rails と組み合わせたときだけでなく Ruby-GetText 単体で使っても起こりえます。例えば次のソースを実行してみるとユニークな object_id が 4000 ~ 10000 くらいにまで達したときに停止しました。(foo.po では 'Hello, foo!' => 'Konnichiha, foo!'、bar.po では 'Hello, bar!' => 'Konnichiha, bar!' となっています)
#!/usr/bin/env ruby require 'rubygems' require 'gettext' class Foo include GetText bindtextdomain('foo') def hello; _('Hello, foo!'); end end class Bar include GetText bindtextdomain('bar') def hello; _('Hello, bar!'); end end loop do foo = Foo.new.hello bar = Bar.new.hello unless foo == 'Konnichiha, foo!' && bar == 'Konnichiha, bar!' puts GetText.send(:class_variable_get, :@@__cache_bound_target).size break end end
そしてこれを修正しようと試み以下のコードを書きました。これを require "gettext"
直後に実行されるようにしたところ、とりあえず問題は起こらなくなりました。
後半の特に ret.name =~ /^\#<|^$/
や ret = Object
あたりが何を意味してるのかわからず、『/^#< /
というのはインスタンスの文字列表現かなぁ』とか『hensu = Module.new
で作った Module は name が空文字列らしい』とか思いを巡らせた結果、やっぱりよくわからないので『インスタンスがいっぱい渡ってくるときはキャッシュをクリアしたい!っていうコードを書き損ねたもの』だと自分に都合よく解釈しました。
module GetText silence_warnings { CACHE_BOUND_TARGET_MAX_SIZE = 50 } def bound_target(klass = self) # :nodoc: id = klass.object_id if cached? tgt = @@__cache_bound_target[id] return tgt if tgt end ret = (is_instance = !klass.kind_of?(Module)) ? klass.class : klass if is_instance or ret == GetText #GC for dead object_ids. #ret = Object if @@__cache_bound_target.size > CACHE_BOUND_TARGET_MAX_SIZE @@__cache_bound_target.clear end end @@__cache_bound_target[id] = ret ret end end
Rails 内で使用したときは @@__cache_bound_target.size が 250 くらいですでに問題が発生し始めたため CACHE_BOUND_TARGET_MAX_SIZE は 50 にしています。
元々の意図からはかなり外れてしまっていると思いますが、とりあえず動くのでこれでがまんしています。
ここまで書いたついでに、Ruby-GetText がらみでもうひとつ。
Ruby-GetText 1.91.0 から ActionView::Helpers::FormBuilder#label がローカライズされるようになりましたが、ActionView::Helpers::FormHelper#label の方はローカライズされないため form_for が使えない場面で微妙な感じになります。
そんなときはこんな感じのコードを仕込めば FormBuilder#label と FormHelper#label の両方に対応できます。
module ActionView module Helpers class InstanceTag def to_label_tag_with_gettext(text = nil, options = {}) text ||= object.class.human_attribute_name(method_name) to_label_tag_without_gettext(text, options) end alias_method_chain :to_label_tag, :gettext