redis-objects を継承関係の親子で使うと保存先の key が変わることがある
こんにちは、simomu です。
今日は redis-objects
gem を継承関係の親子で利用すると、Redis の保存先の key が変わってしまうことがある話をします。
以後断りの無い場合は
- redis-objects 1.7.0
環境下での話とします。
redis-objects
redis-objects
gem は Redis のデータ型を Ruby のオブジェクト ( e.g. String, Integer, Array...)にマップする機能と、Redis へのアクセスをモデルクラスに組み込むことができる機能を備えた gem です。ActiveRecord なモデルに組み込んで、モデルに関連付けたカウンターやリストなどを用意するのがよくある利用場面になると思います。
class User < ApplicationRecord
include Redis::Objects
counter :my_posts
end
User.find(1).my_posts.increment
継承関係の親子で使うと保存先の key が変わることがある
たとえば、以下のように Parent と Child が存在していて、親クラスと子クラスにそれぞれ redis-objects
の counter や value を設定しているとします。
class Parent
include Redis::Objects
counter :counter
def id = 1
end
class Child < Parent
value :value
def id = 1
end
この状況下で、例えば Child#value
が利用する Redis の key を確認してみます。#key
で Redis の保存先の key を取得することができ、"child:1:value"
であることが確認できます。
child = Child.new
puts child.value.key
# => "child:1:value"
redis-objects
gem を利用している時、Redis の key 名はデフォルトでは {クラス名}:{ID}:{redis object 名}
になります。
ここで、親クラスで定義されている counter
の key 名も確認してみると、これは "child:1:counter"
と表示されます。
puts child.counter.key
# => "child:1:counter"
しかしながら、もう一度別の Ruby プロセスで同様に Redis の key を確認します。
今度は親クラスの counter
→ 子クラスの value
の順番で key を確認してみます。
child = Child.new
puts child.counter.key
# => "parent:1:counter"
puts child.value.key
# => "parent:1:value"
すると、親クラスで定義されている counter
も、子クラスで定義されている value
も、両方とも key の prefix が parent
になってしまいました。つまりは、親子で定義された redis object どちらを先に参照するかによって、子クラスで定義された redis object の保存先の key が全て変わってしまう ということが発生します。
これは、Child が別のインスタンスであったとしてもこの影響は残り続けます。
その Ruby プロセスが親と子のどちらの redis object を先に参照したかによって key が変わり、その Ruby プロセス中ではそのまま固定されます。
## 毎回 Child.new して別インスタンスにしてみる
puts Child.new.counter.key
# => "parent:1:counter"
puts Child.new.value.key
# => "parent:1:value"
## --- 別 Ruby プロセス --- ##
puts Child.new.value.key
# => "child:1:value"
puts Child.new.counter.key
# => "child:1:counter"
これは、例えば Ruby on Rails において STI を利用してるモデルの親クラスに共通で使う redis object、子クラスには固有の redis object を定義するようなことをしていた場合に、Ruby on Rails が親子のどちらの redis object を先に参照するかによって Redis の保存先が変わってしまうことになります。Ruby on Rails のプロセスが複数存在していた場合は、プロセスによって Redis 保存した情報が取得出来たり出来なかったりすることが発生します。
原因
この事象の原因を探るために、redis-objects
gem が Redis の key を決定する流れを軽く見てみます。ここでは counter
を例に見てみます。
counter :counter
のようにカウンターの redis object を定義するメソッドは lib/redis/objects/counters.rb
に存在しています。
25 行目で redis_objects[name.to_sym]
に定義した redis object 情報を格納し、29 ~ 37 行目で Redis::Counter
を返すメソッド [1] を定義しています。33 行目の redis_field_key(name)
が、redis object 名に対応する Redis の key を返していそうです。
redis_field_key
は lib/redis/objects.rb
に定義されていて、Redis::Objects
を include した先のクラスの特異メソッドとして定義されます。
130 行目に first_ancestor_with
メソッドを実行してます。このメソッドが Redis の key の prefix で使用するクラスを取得してきてそうです。
first_ancestor_with
はそのすぐ下に定義されています。
ここでやっていることをざっくりまとめると、「今のクラスに指定された名前の redis object が存在していれば今のクラスを返し、なければ親クラスを同様に探索する」ということをやっているようです。
redis_object
は内部ではクラスインスタンス変数として定義されているため、子クラスの redis_object
には子クラスで定義された redis object の情報のみが格納されていて、親クラスの情報は見ることが出来ません。
こうして取得できた、「指定された redis object が定義されているクラス」を元に、redis_field_key
メソッドは redis_prefix
を通して Redis の key の prefix が決定されます。
redis_prefix
は、渡されたクラスを元に決定された prefix をクラスインスタンス変数にメモ化する形で保持されます。
以上から、redis-objects
が保存先の key を決定する流れを軽くまとめると
- redis object が定義さているクラスを探索
- 見つかったクラスを元に prefix を決定し、クラスインスタンス変数
@redix_prefix
に保存 - 2回目以降は
@redix_prefix
をそのまま利用する
になります。
今回の事象は 1 で見つかるクラスが、redis object が定義されている場所によって Child
か Parent
で変わってしまい、更にその結果が @redix_prefix
にメモ化されるため、参照順によって key の prefix が変わった上で固定され続けることが発生します。
redis-objects 2.0.0-beta ではどうなのか
redis-objects
2.0.0-beta では、redis_prefix の作成方法に変更があり、クラス名に相当する箇所がクラス名のフルパスを利用するような変更が入っています。( e.g. Nested::Child
-> nested__child
)
しかしながら、redix_prefix に使用するクラスの探索と、結果をメモ化する箇所は概ね変わっていないため、2025年2月時点のredis-objects
2.0.0-beta でも同様の問題は発生します。
対策
この事象は redis object が定義されたクラス名をもとに redix_prefix を決定することに起因します。そのため、redis object が定義されるクラスを統一するか、redix_prefix
を上書き固定することで解決できそうです。
例えば、Class#inherited
を用いて、Parent
クラスが継承された際に child
クラスに counter
を定義することで解決できます。このようにすれば counter
を定義するという振る舞いは Parent
に記述しつつ、実際に counter
が定義されるのは Parent
のサブクラスの Child
ということになり、参照する redis object によって redix_prefix を決定するクラスが変わることがなくなります。
class Parent
include Redis::Objects
def self.inherited(subclass)
subclass.counter :counter
end
def id = 1
end
class Child < Parent
value :value
def id = 1
end
puts Child.new.counter.key
# => "child:1:counter"
puts Child.new.value.key
# => "child:1:value"
注意点としては、この方式を採用する場合は Parent
クラスそのものには counter
は定義されなくなるので、Parent
単体では利用することはできなくなり、抽象クラスのような扱いになります。
別の方法としては、redis-objects
gem では、redis_prefix を自前で設定することができるため、例えば子クラスの方で self.redix_prefix=
を使い child
に強制させることが出来ます。
class Parent
include Redis::Objects
counter :counter
def id = 1
end
class Child < Parent
include Redis::Objects
self.redis_prefix = 'child'
value :value
def id = 1
end
puts Child.new.counter.key
# => "child:1:counter"
puts Child.new.value.key
# => "child:1:value"
このようにすれば、redis object の参照順にかかわらず key は "child:1:value"
になります。
まとめ
今回は、redis-objects
を継承関係の親子で使うと保存先の key が変わることがあるという事象についての原因と、現時点で取れる対策についての話をしました。
この事象については redis-objects
側の不具合なのかどうかは何とも言えず、もともと redis-objects
継承関係の親子でそれぞれ定義される使い方を想定されていない可能性もあります。このあたりは redis-objects
の過去の issue 等での議論がないかをもう少し調べてみようと思います。
-
正確には、
Redis::Counter
を返すメソッドが定義されたモジュール ↩︎
Discussion