クラシル開発ブログ

クラシル開発ブログ

【Rails】 ActiveHash gemのクラシルでの事例とハマりポイント

こんにちは、開発部の高橋です。f:id:jity:20200225114534p:plain

最近弊社のRailsプロジェクトでactive_hashというgemが使われ始めました。

個人的にも結構重宝しているgemでとても便利なのですが、一方で特性を理解せずに使うとハマりやすいgemでもあると思っています。

今回は、ActiveHashのクラシルでの事例と自分の過去の知見に基づくハマりポイントなどを書いていきます。

目次

ActiveHashとは

データをRDBではなくHashやYAMLで定義し、それをActiveRecordライクに利用できるGem。

コード上にデータを持つためmigrationやseedなどの考慮が不要で、テーブルを持つほどでもない・ほとんど変更のないようなちょっとした静的データを保持する際にとても便利です。

詳しい使い方はREADMEを見てほしいですが、基本的には以下のようにActiveHashに用意されているクラスを継承し、データを設定して使います。

class Foo < ActiveHash::Base
  self.data = [
    { id: 1, name: 'a' },
    { id: 2, name: 'b' },
    { id: 3, name: 'c' },
  ]
end

上記のようにdata属性にデータを追加していく方法以外にも、json,yamlなどのファイルにデータを設定してを読み込ませることもできます。

クラシルでの事例

導入経緯

以前もブログにて紹介された「サーバーサイド&SRE改善MTG」での議題として自分が発案し、導入に至りました。

ちなみに、この改善MTGは1月で1周年を迎えたようです 🎉

tech.dely.jp

もともとクラシルのプロジェクトにはactive_hashが使われるようなユースケースが存在しており、すでにレポジトリ内にYAMLなどで定義された静的データが散在してました。

それらがバラバラに管理され、使われ方も統一されていなかったところを一元化したいというところが主な導入意図です。

また、今後何かしらのマスターデータを管理していく際にも便利なので、今のうちに入れておきたいということで入れました。

使われ方

データはyamlで管理し、app/models配下にActiveRecord継承なモデルと一緒に置くというおそらくはスタンダードな使い方で利用してます。

ActiveYamlRecordという親クラスをつくり、各クラスで継承していきます。

# app/models/active_yaml_record.rb
class ActiveYamlRecord < ActiveYaml::Base
  set_root_path Rails.root.join('config', 'masters')
end

# app/models/foo.rb
class Foo < ActiveYamlRecord
end
- id: 1
  name: 'aaa'
- id: 2
  name: 'bbb'
# ...

2020年2月時点ではまだ3モデル程ですが、これから要所要所でガンガン使っていきたいと思っています。

ハマりポイント

ActiveHash導入に関して、自分の過去の経験からハマりやすい(ハマった)ポイントをいくつかご紹介しようと思います。

インスタンス変数がクラスインスタンス変数相当

ActiveRecordでは取得されるごとに生成されるオブジェクトは異なりますが、ActiveHashでは常に同じオブジェクトが返ります。

これはデータが読み込まれる際に各レコードのインスタンスが作成され、クラスインスタンス変数に保持され使い回されるからです。

以下はActiveRecordとの挙動の差です。

class Foo < ActiveHash::Base
  self.data = [
    { id: 1, name: 'a' },
    { id: 2, name: 'b' }
  ]
end

require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.define do
  create_table :bars, force: true do |t|
  end
end

class Bar < ActiveRecord::Base
end
Bar.insert_all([{id: 1}, {id: 2}])

b1 = Bar.first
b2 = Bar.first
p b1
#=> #<Bar id: 1>
p b2
#=> #<Bar id: 1>
p b1.equal? b2
#=> false

f1 = Foo.first
f2 = Foo.first
p f1
#=> #<Foo:0x00007ff31791e738 @attributes={:id=>1, :name=>"a"}>
p f2
#=> #<Foo:0x00007ff31791e738 @attributes={:id=>1, :name=>"a"}>
p f1.equal? f2
#=> true

このような挙動になるため、ActiveRecordと同じ気分でインスタンスの状態を変更するとハマるので注意が必要です。

class Foo < ActiveHash::Base
  self.data = [
    {id: 1, name: 'a'},
    {id: 2, name: 'b'},
  ]

  attr_accessor :foo
end

f1 = Foo.first
f1.foo = 'bar'
p f1.foo
#=> "bar"

f2 = Foo.first
p f2.foo
#=> "bar"
# nilではなくf1で代入した値が返る

データがロードされるタイミング(特にActiveYaml, ActiveJsonの場合)

これはActiveYamlやActiveJsonなど、データをファイルで持ってる場合に特に気をつけたいことです。

これらのクラスのデータロードのデフォルトの挙動は、取得メソッドが実行されたタイミングになります。

具体的には以下のメソッドが実行された際にファイルのパースが実行され各クラスにデータが格納されます。

  • find
  • find_by_id
  • all
  • where
  • method_missing

github.com

class Foo < ActiveYaml::Base
  set_root_path File.expand_path(__dir__, './')
end
# この時点ではまだYAMLは未ロード

Foo.find(params[:id]) # ここで初めてYAMLが読み込まれる

小さいデータであれば問題はないようにも思えますが、大きなデータの場合は初回リクエストだけロードに時間がかかるようになってしまいます。

また、スレッドセーフな作りになってるわけではなさそうなので、複数スレッドで実行される際にも注意が必要です。

以下のように、意図しない挙動になってしまう可能性があります。

class Foo < ActiveYaml::Base
  set_root_path File.expand_path(__dir__, './')
end

t1 = Thread.new do
  p Foo.first
  p 't1 done'
end

t2 = Thread.new do
  p Foo.first
  p 't2 done'
end

t1.join
t2.join
$ ruby sample.rb
nil
"t2 done"
#<Foo:0x00007fe224c622e0 @attributes={:id=>1, :name=>"name1"}>
"t1 done"

これはおそらく以下のような状況になってるものと思われます。

  • t1
    • ActiveFile::Base.firstが呼び出される
    • データ読み込みフラグ(data_loaded)のチェック
    • .reloadが呼び出される
    • data_loadedフラグがtrueになる
    • YAMLのロード処理が走る
  • t2
    • ActiveFile::Base.firstが呼び出される
    • データ読み込みフラグ(data_loaded)のチェック
    • データ読み込み済み(data_loaded=true)と判定される
    • ActiveHash::Base.firstが呼び出される
    • t1のロードが終わっておらずデータが空なので[].firstが処理として実行される
    • nilが返る
  • t1
    • YAMLのロード完了
    • ActiveHash::Base.firstが呼び出される
    • レコードが返る

アプリケーションサーバーなどでマルチスレッドな環境を利用している場合は、意図しない挙動が引き起こされやすい状況と言えそうです。

対応策

この対応策として思いつくものとしては、クラスロード時にデータを読み込んでしまうことです。

class Foo < ActiveYaml::Base
  self.reload
end

上記のようにクラス定義で.reloadを呼び出せばクラスが読み込まれる起動時などにデータを読み込ませることができるため、メソッドの初回呼び出し時でもロードが行われません。

あるいは、config/initializers配下で呼び出す方法もあるかと思います。

#  https://github.com/zilkey/active_hash#defining-data の応用
# config/initializers/data.rb
Rails.application.config.to_prepare do
  Country.reload
end

ただしどちらも起動が遅くなるというデメリットがあるため、各々のアプリケーションに適用できるかどうかは要確認です。

いずれにせよ、利用前にある程度意識しておく必要はあるかと思います。

最後に

delyではRailsエンジニアを絶賛大募集中です。

  • 大量のトラフィックをさばきたい
  • 食の課題を解決したい
  • ユーザーファーストな開発がしたい

といったことに少しでも興味があるかたは、是非お声がけください!

www.wantedly.com