counter_culture README(翻訳)
Railsアプリ向けの、ターボの効いたカウンタキャッシュです。Rails標準のカウンタキャッシュと比べて多くの点が改善されています。
- カウンタキャッシュの更新を、値の作成や破棄のほか、値の変更時にも行える
- 「多階層カウンタキャッシュ」をサポート(訳注: リレーション階層が離れていてもカウンタキャッシュの更新を直接指定できる)
- 動的なカラム名をサポート: オブジェクトの種類ごとにカウンタキャッシュを分離
- カウントの他に合計も出せる
Ruby 2.6、2.7、3.0、3.1、3.2、3.3、およびRails 5.2、6.0、6.1、7.0、7.1の最新パッチリリースでテストされています。
注意: Rails組み込みのカウンタキャッシュと異なり、counter_cultureはActive Record関連付けの.size
の振舞いを変更しません。データベースへのクエリ発生を避けてキャッシュ値を読み込みたい場合は、カウンタキャッシュを含む属性名を直接お使いください。
product.categories.size # => SELECT COUNT(*)クエリが発生
product.categories_count # => クエリを発生せずにカウンタキャッシュを使う
🔗 インストール
Gemfileにcounter_culture
を追加します。
gem 'counter_culture', '~> 3.2'
次にbundle install
を実行します。
🔗 データベーススキーマ
必要なカラムをすべてのカウンタキャッシュについて作成しなければなりません。counter_culture
のジェネレータで、マイグレーション用のスケルトンを作成できます。
rails generate counter_culture Category products_count
上を実行すると、以下のようなコードを含むマイグレーションが生成されます。
add_column :categories, :products_count, :integer, null: false, default: 0
注意: gemが正常に機能するには、カラムは必ずNOT NULL
に設定し、ゼロ値のデフォルトを設定する必要があります。
既存のデータにカウンタキャッシュを追加する場合は、生成されたマイグレーションに手動で値を設定する必要があります。
🔗 利用法
🔗 シンプルなカウンタキャッシュ
🔗 has_many
関連付け
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category
end
class Category < ActiveRecord::Base
has_many :products
end
Product
モデルにcounter_culture :category
と書くことで、Category
モデルのcategories
テーブルのproducts_count
カラムのカウンタキャッシュが最新に保たれます。
🔗 多対多の関連付け
class User < ActiveRecord::Base
has_many :group_memberships
has_many :groups, through: :group_memberships
end
class Group < ActiveRecord::Base
has_many :group_memberships
has_many :members, through: :group_memberships, class: "User"
end
class GroupMembership < ActiveRecord::Base
belongs_to :group
belongs_to :member, class: "User"
counter_culture :group, column_name: "members_count"
# `members_count`の更新時にgroupでtouchも指定したい場合:
# counter_culture :group, column_name: "members_count", touch: true
end
これで、Group
モデルのmembers_count
カラムに最新のメンバー数が表示されます。
🔗 多階層カウンタキャッシュ
class Product < ActiveRecord::Base
belongs_to :sub_category
counter_culture [:sub_category, :category]
end
class SubCategory < ActiveRecord::Base
has_many :products
belongs_to :category
end
class Category < ActiveRecord::Base
has_many :sub_categories
end
Product
モデルにcounter_culture [:sub_category, :category]
と書くことで、リレーション階層が離れたCategory
モデルのcategories
テーブルのproducts_count
のカウンタキャッシュを最新に保ちます。カウントキャッシュを指定できる階層レベル数に制限はありません。
カウンタキャッシュは、リレーションの階層レベルごとに指定する必要があります。上のコード例で、Category
とSubCategory
のそれぞれにproductのカウントが必要な場合は、Product
クラスを次のように変更します。
class Product < ActiveRecord::Base
belongs_to :sub_category
counter_culture [:sub_category, :category]
counter_culture [:sub_category]
end
🔗 カラム名のカスタマイズ
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category, column_name: "products_counter_cache"
end
class Category < ActiveRecord::Base
has_many :products
end
Product
モデルにcounter_culture :category, column_name: "products_counter_cache"
と書くことで、Category
モデルのcategories
テーブルのproducts_counter_cache
カラムのカウンタキャッシュが最新に保たれます。カウントキャッシュを指定できる階層レベル数に制限はありません。
🔗 動的なカラム名
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category, column_name: proc {|model| "#{model.product_type}_count" }
# product_type属性は ['awesome', 'sucky'] のいずれか
end
class Category < ActiveRecord::Base
has_many :products
end
🔗 増分(delta magnitude)の指定
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category, column_name: :weight, delta_magnitude: proc {|model| model.product_type == 'awesome' ? 2 : 1 }
end
class Category < ActiveRecord::Base
has_many :products
end
Product
モデルに上のように書くことで、Category
モデルのweight
カラムのカウンタキャッシュが最新に保たれます。productがawesome
なら増分は2、それ以外なら増分は1になります。
次のように、delta_magnitude
に固定の増分を指定することもできます。
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category, column_name: :weight, delta_magnitude: 3
end
class Category < ActiveRecord::Base
has_many :products
end
Product
に追加が1件あると、Category
のweight
カラムが3増え、Product
で削除が1件あると3減ります。
🔗 条件付きカウンタキャッシュ
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category, column_name: proc {|model| model.special? ? 'special_count' : nil }
end
class Category < ActiveRecord::Base
has_many :products
end
Product
モデルに上のように書くことで、Category
モデルのspecial_count
のカウンタキャッシュが最新に保たれます。productのspecial?
がtrue
の場合にのみspecial_count
を更新します。
これをcounter_culture_fix_counts
と併用したい場合は、column_names
の設定も指定してください。
🔗 カウンタキャッシュの更新を一時停止する
バックフィルスクリプトなどでcounter_cultureを一時停止したい場合は、以下のようにできます。
Review.skip_counter_culture_updates do
user.reviews.create!
end
user.reviews_count # => 変更されない
🔗 カウントの代わりに合計を出す
カウントを実行する代わりに、合計を自動更新することもできます。
この場合、対象のカウンタを1ずつ増やす代わりに、フィールド値の合計で更新します。
カウントを行うオブジェクトの特定のフィールド値をカウンタの増分に使いたい場合は、:delta_column
オプションを使います。
たとえば、Product
モデルのテーブルにweight_ounces
フィールドがあり、Category
モデルのproduct_weight_ounces
にあるすべてのproductについてweightの合計を最新に保つ場合は、次のようにします。
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category, column_name: 'product_weight_ounces', delta_column: 'weight_ounces'
end
class Category < ActiveRecord::Base
has_many :products
end
Product
モデルに上のように書くことで、Category
モデルのproduct_weight_ounces
のカウンタキャッシュが最新に保たれます。
このカウンタキャッシュの値は、Category
に関連付けられているProduct
の各レコードのweight_ounces
を合計した値になります。
delta_column
オプションでは、:integer
を含むすべての数値型カラムをサポートします。特に、:float
もサポート対象かつテスト済みです。
🔗 foreign_key_values
による外部キーの動的上書き
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category, foreign_key_values:
proc {|category_id| [category_id, Category.find_by_id(category_id).try(:parent_category).try(:id)] }
end
class Category < ActiveRecord::Base
belongs_to :parent_category, class_name: 'Category', foreign_key: 'parent_id'
has_many :children, class_name: 'Category', foreign_key: 'parent_id'
has_many :products
end
上のコードによって、Category
モデルのcategories
テーブルのproducts_count
カラムのカウントキャッシュが最新に保たれます。各productは、直接のcategoryのカウンタと、そのcategoryの親のカウンタの両方に影響します。カウントキャッシュを指定できる階層レベル数に制限はありません。
🔗 カウンタ変更時にタイムスタンプを更新する
counter_culture
gemは、カウンタキャッシュ更新時にモデルのタイムスタンプをデフォルトでは更新しません。カウンタキャッシュカラムの更新時にタイムスタンプも更新したい場合は、touch
オプションにtrue
を指定します。
counter_culture :category, touch: true
このオプションは、カウンタキャッシュ変更時にキャッシュを無効にする必要がある場合に便利です。
🔗 カスタムのタイムスタンプカラム
特定のカウンタキャッシュが変更された場合にのみ更新されるタイムスタンプカラムを独自に指定することもできます。
counter_culture :category, touch: 'category_count_changed'
上のようにオプションを指定すると、category_counter_cache
の更新時にcategory_count_changed
カラムとupdated_at
カラムが常に両方とも更新されます。
🔗 デッドロックの回避と、commit後のカウンタキャッシュ更新
アプリケーションによっては、このgemを使うとカウンタキャッシュの更新に伴ってデッドロックの問題が発生することがあります。この問題を回避するための情報や有用なリンクについては#263を参照してください。
もうひとつの方法は、単にカウンタキャッシュの更新をトランザクションの外に延期することです。これにより、カウンタキャッシュ更新のトランザクションが保証されなくなる代わりにデッドロックが解消されるはずです。この振舞いはデフォルトでは無効であり、以下のように影響を受けるカウンタキャッシュごとに有効にしてください。
counter_culture :category, execute_after_commit: true
注: この機能を利用するには、Gemfileで以下のように依存関係としてafter_commit_action
を手動で指定する必要があります。
...
gem "after_commit_action"
...
counter_culture
にProc
を渡して動的に制御することも可能です。これは、トランザクション内でカウンタキャッシュの更新を一時的に移動したい場合に便利です。
counter_culture :category, execute_after_commit: proc { !Thread.current[:update_counter_cache_in_transaction] }
🔗 カウンタキャッシュ値を手動で展開する
主要なデータのカウンタキャッシュ値を他の場所で使いたい場合があります。これは、たとえばカウンタキャッシュを既存のデータに追加する場合に必要になります。カウンタキャッシュに含まれる無効な値を検出するために、カウンタキャッシュは定期的に実行することをおすすめします(BestVendor社の場合、週に1度実行しています)。
Product.counter_culture_fix_counts
# Productで定義済みの全カウントを自動で修正する
Product.counter_culture_fix_counts exclude: :category
# Productで定義済みの全カウントを自動で修正する
# ただし:categoryのリレーションについては除く
Product.counter_culture_fix_counts only: :category
# Productの:categoryリレーションについてのみカウントを自動で修正する
# :excludeと:onlyには、同じ階層レベルにあるリレーションの配列も指定できる
# カウントの自動修正を多階層にわたって行う場合は、これではなく、その次の[[ ]]書式が必要
Product.counter_culture_fix_counts only: [[:subcategory, :category]]
# Productの2つの階層レベルのリレーション([:subcategory, :category])についてのみカウントを自動で修正する
Product.counter_culture_fix_counts column_name: :reviews_count
# Productの:reviews_count columnでのみカウントを自動で修正する
# これにより、処理済みのカラムをスキップできる
# 1個のカウンタキャッシュカラムにのみ影響する大規模なDB変更で有用
# :exceptと:onlyには配列も指定できる
Product.counter_culture_fix_counts verbose: true
# ログをSTDOUTに出力する
Product.counter_culture_fix_counts only: :category, where: { categories: { id: 1 } }
# Productの「id 1リレーション」を持つ:categoryでのみカウントを自動で修正
カウント用のcounter_culture_fix_counts
メソッドでは、レコードをバッチ処理することでメモリ消費を抑えています。デフォルトのバッチサイズは1000ですが、以下の方法で設定することもできます。
# initializerに追加
CounterCulture.config.batch_size = 100
メソッド呼び出しでも:batch_size
オプションでサイズを指定できます。
Product.counter_culture_fix_counts batch_size: 100
counter_culture_fix_counts
はデバッグ用に、すべての無効な値をハッシュの配列として返します。ハッシュの形式は次のとおりです。
{ entity: カウントを修正するモデル,
id: カウントが誤っているモデルのid,
what: 誤ったカウントがあるカラム名,
wrong: 前回保存されている誤ったカウント,
right: 修正された正しいカウント }
counter_culture_fix_counts
の動作は高速で、クエリ数を最小限に抑えるよう最適化されています。
counter_culture
と同様に、カウント修正時にレコードのタイムスタンプを更新できます。デフォルトのタイムスタンプフィールドを更新したい場合は以下のようにtouch: true
オプションを渡します。
Product.counter_culture_fix_counts touch: true
カスタムのタイムスタンプカラムを指定している場合は、その名前を touch
オプションの値として渡します。
Product.counter_culture_fix_counts touch: 'category_count_changed'
🔗 複数ワーカーでカウンタキャッシュ修正をパラレル化する
start:
オプションとfinish:
オプションは、特に複数ワーカーで同じ処理キューを扱いたい場合に有用です。ワーカーごとにstart:
とfinish:
を設定することで、たとえばワーカー1ではid 1〜9999までの全レコードを処理し、ワーカー2ではid 10000以上のレコードを処理できるようになります。
注意! ここでstart
およびfinish
として渡すIDは、実際にはProduct
のIDではなくCategory
のIDです!
Product.counter_culture_fix_counts start: 10_000
# Productで定義されたid 10000以上のレコードで全カウンタキャッシュを修正する
Product.counter_culture_fix_counts finish: 10_000
# レコード数10,000件まで処理する
Product.counter_culture_fix_counts start: 1000, finish: 2000
# ワーカー1では1000〜2000まで処理する
Product.counter_culture_fix_counts start: 2001, finish: 3000
# ワーカー2では2001〜3000まで処理する
🔗 replicaデータベースを用いるカウンタキャッシュを修正する
カウンタキャッシュの修正では、readの回数がwriteの回数を大幅に上回るのが普通です。このような場合は、readの負荷をreplicaデータベースに逃がすのが合理的です。
Rails 6からは、マルチプルデータベースによってreplicaデータベースがネイティブでサポートされました。replicaデータベースを利用することで、以下のようにdb_connection_builder
オプションを指定してreadトラフィックをread-only replicaに送信できるようになります。
Product.counter_culture_fix_counts db_connection_builder: proc{|reading, block|
if reading # count呼び出しでreadingコネクションをリクエストする
Product.connected_to(role: :reading, &block)
else # すべての呼び出しが非readingコネクションをリクエストする
Product.connected_to(role: :writing, &block)
end
}
🔗 動的なカラム名を扱う
動的なカラム名が使われているカウンタキャッシュを手動で流用する場合、以下の追加設定が必要です。
class Product < ActiveRecord::Base
belongs_to :category
counter_culture :category,
column_name: proc {|model| "#{model.product_type}_count" },
column_names: {
["products.product_type = ?", 'awesome'] => 'awesome_count',
["products.product_type = ?", 'sucky'] => 'sucky_count'
}
# product_type属性は ['awesome', 'sucky'] のいずれか
end
column_names
では、条件文字列の代わりにスコープも指定できます。スコープにはハッシュを直接渡すのではなく、ハッシュを返すProc
を渡すことが推奨されます。スコープを直接提供すると、起動時にスキーマキャッシュが読み込まれてrake db:migrate
などの機能が中断します。
class Product < ActiveRecord::Base
belongs_to :category
scope :awesomes, ->{ where "products.product_type = ?", 'awesome' }
scope :suckys, ->{ where "products.product_type = ?", 'sucky' }
counter_culture :category,
column_name: proc {|model| "#{model.product_type}_count" },
column_names: -> { {
Product.awesomes => :awesome_count,
Product.suckys => :sucky_count
} }
end
この設定を避けて、動的なカラム名を持つカウンタキャッシュを単にスキップし、動的でないモデルのカウンタを修正したい場合は、以下のようにskip_unsupported
オプションを渡せます。
Product.counter_culture_fix_counts skip_unsupported: true
column_names
メソッドに渡したブロック内で、以下のようにコンテキストを利用することも可能です。
class Product < ActiveRecord::Base
belongs_to :category
scope :awesomes, -> (ids) { where(ids: ids, product_type: 'awesome') }
counter_culture :category,
column_name: 'awesome_count'
column_names: -> (context) {
{ Product.awesomes(context[:ids]) => :awesome_count }
}
end
Product.counter_culture_fix_counts(context: { ids: [1, 2] })
🔗 外部キー動的上書きの制限事項
:foreign_key_values
オプションを使っている場合、「カウンタキャッシュ値を手動で流用する」に記載されている方法はサポートされません。独自のコードを書く必要があります。
🔗 paranoia
やdiscard
による論理削除
本gemは、論理削除(soft-delete)をサポートするparanoia gemやdiscardgemをRails 4.2以降で使う場合にカウンタを正しく更新します。ただし、リストア後にカウンタが正しく増加するには、モデル内でcounter_culture
を呼び出す前に論理削除を設定(acts_as_paranoid
またはinclude Discard::Model
)する必要があります。
🔗 Paranoia
class SoftDelete < ActiveRecord::Base
acts_as_paranoid
belongs_to :company
counter_culture :company
end
🔗 Discard
class SoftDelete < ActiveRecord::Base
include Discard::Model
belongs_to :company
counter_culture :company
end
🔗 PaperTrailとの統合
paper_trail gemを利用していて、counter_cultureによってカウンタキャッシュカラムが変更されたときに新しいバージョンを作成したい場合は、以下のようにwith_papertrail
オプションを指定できます。
class Review < ActiveRecord::Base
counter_culture :product, with_papertrail: true
end
class Product < ActiveRecord::Base
has_paper_trail
end
🔗 ポリモーフィック関連付け
counter_culture
gemは、1階層レベルのみのポリモーフィック関連付けをサポートするようになりました。
counter_culture_fix_counts
経由でどのモデルを更新する必要があるかを検出するため、counter_cultureはポリモーフィック関連付けに対してDISTINCT
クエリを実行します。
このクエリは負荷が大きくなる可能性があるため、修正すべきモデルの個数を指定するためのpolymorphic_classes
オプションを提供しています。
Image.counter_culture_fix_counts(polymorphic_classes: Product)
# または
Image.counter_culture_fix_counts(polymorphic_classes: [Product, Employee])
🔗 counter_culture
gemに貢献するときの手順
- 常に最新のmasterブランチをチェックアウトし、機能が実装されていないかどうか、バグが修正されていることを確認します。
* GitHubのissue trackerで、同じissueがリクエスト済みかどうか、既に貢献済みかどうかを確認します。
* プロジェクトをforkします。
* feature/bugfix
ブランチを立てます。
* コードに修正や改良を加えたらcommit、pushします。
* 貢献の際は必ずテストコードも追加してください。貢献した機能が将来不意に動かなくならないようにするために重要です。
* Rakefileのバージョンや履歴で問題が発生しないようご注意ください。独自のバージョンを利用したい場合や必要な場合でも貢献は可能ですが、こちらでcherry-pickできるようにコミットを分けておくようお願いします。
🔗 Copyright
Copyright (c) 2012-2021 BestVendor, Magnus von Koeller. See LICENSE.txt for further details.
概要
MITライセンスに基づいて翻訳・公開します。