প্রতিবার পেজ লোড হলে ডেটাবেসে অ্যাসোসिएটেড রেকর্ড কাউন্ট করার বদলে, ActiveRecord-এর কাউন্টার ক্যাশিং ফিচার কাউন্টার সংরক্ষণ করতে এবং এটি আপডেট করতে দেয় যেকোনো সময় একটি অ্যাসোসिएটেড অবজেক্ট তৈরি বা রিমুভ হয়। AppSignal Academy-এর এই épisod-এ, আমরা ActiveRecord-এ কাউন্টার ক্যাশিং সম্পর্কে সবকিছু শিখব।
চলুন আর্টিকেল এবং রেসপন্সের একটি ক্লাসিক উদাহরণ নিই একটি ব্লগের সাথে। প্রতিটি আর্টিকেলের রেসপন্স থাকতে পারে, এবং আমরা ব্লগের ইনডেক্স পেজে প্রতিটি আর্টিকেলের টাইটেলের পাশে রেসপন্সের সংখ্যা দেখাতে চাই তার জনপ্রিয়তা দেখানোর জন্য।
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@articles = Article.all
end
# ...
end
আমাদের রেসপন্স প্রিলোড করতে হবে না, কারণ আমরা ইনডেক্স পেজে তাদের ডেটা দেখাচ্ছি না। আমরা একটি কাউন্টার দেখাচ্ছি, তাই আমরা শুধু প্রতিটি আর্টিকেলের জন্য রেসপন্সের সংখ্যা জানতে চাই। কন্ট্রোলার সব আর্টিকেল খুঁজে বের করে এবং সেগুলো @articles ভেরিয়েবলে রাখে ভিউ ব্যবহারের জন্য।
Articles
<% @articles.each do |article| %>
<%= article.title %>
<%= article.description %>
<%= article.responses.size %> responses
<% end %>
ভিউ প্রতিটি আর্টিকেলের ওপর লুপ করে এবং এর টাইটল, ডেসক্রিপশন এবং পাওয়া রেসপন্সের সংখ্যা রেন্ডার করে। কারণ আমরা ভিউতে article.responses.size কল করি, ActiveRecord জানে এটি অ্যাসোসিয়েশন কাউন্ট করতে হবে পুরো রেকর্ড প্রতিটি রেসপন্সের জন্য লোড করার বদলে।
টিপ: যদিও #count রেসপন্সের সংখ্যা কাউন্ট করার জন্য আরও ইন্টুইটিভ চয়স মনে হয়, এই উদাহরণটি #size ব্যবহার করে, কারণ #count সবসময় একটি COUNT কোয়ারি করবে, যেখানে #size কোয়ারি স্কিপ করবে যদি রেসপন্সগুলো ইতিমধ্যে লোডেড থাকে।
Started GET "/articles" for 127.0.0.1 at 2018-06-14 16:25:36 +0200
Processing by ArticlesController#index as HTML
Rendering articles/index.html.erb within layouts/application
Article Load (0.2ms) SELECT "articles".* FROM "articles"
↳ app/views/articles/index.html.erb:3
(0.2ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 2]]
↳ app/views/articles/index.html.erb:7
(0.3ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 3]]
↳ app/views/articles/index.html.erb:7
(0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 4]]
↳ app/views/articles/index.html.erb:7
(0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 5]]
↳ app/views/articles/index.html.erb:7
(0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 6]]
↳ app/views/articles/index.html.erb:7
(0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 7]]
↳ app/views/articles/index.html.erb:7
(0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 8]]
↳ app/views/articles/index.html.erb:7
(0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 9]]
↳ app/views/articles/index.html.erb:7
(0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 10]]
↳ app/views/articles/index.html.erb:7
(0.1ms) SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ? [["article_id", 11]]
↳ app/views/articles/index.html.erb:7
Rendered articles/index.html.erb within layouts/application (23.1ms)
Completed 200 OK in 52ms (Views: 45.7ms | ActiveRecord: 1.6ms)
ব্লগের ইনডেক্স রিকোয়েস্ট করলে N+1 কোয়ারি ফলাফল হিসেবে আসে, কারণ ActiveRecord লেজি-লোড করে প্রতিটি আর্টিকেলের রেসপন্স কাউন্ট একটি আলাদা কোয়ারিতে।
কোয়ারি থেকে COUNT() ব্যবহার করা
প্রতি আর্টিকেলের জন্য একটি এক্সট্রা কোয়ারি এড়াতে, আমরা আর্টিকেল এবং রেসপন্স টেবিলগুলো একসাথে জয়ন করতে পারি অ্যাসোসিয়েটেড রেসপন্স একটি একক কোয়ারিতে কাউন্ট করতে।
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@articles = Article.
joins(:responses).
select("articles.*", 'COUNT("responses.id") AS responses_count').
group('articles.id')
end
# ...
end
এই উদাহরণে, আমরা আর্টিকেল কোয়ারিতে রেসপন্স জয়ন করি এবং COUNT("responses.id") সিলেক্ট করি রেসপন্সের সংখ্যা কাউন্ট করতে। আমরা প্রোডাক্ট আইডি দিয়ে গ্রুপ করব রেসপন্স প্রতি আর্টিকেল কাউন্ট করতে। ভিউতে, আমাদের responses_count ব্যবহার করতে হবে size কল করার বদলে রেসপন্স অ্যাসোসিয়েশনে।
এই সমাধান এক্সট্রা কোয়ারি প্রতিবন্ধক করে প্রথম কোয়ারিকে ধীর এবং জটিল করে। যদিও এটি এই পেজের পারফরম্যান্স অপ্টিমাইজ করার একটি ভালো প্রথম ধাপ, আমরা এক ধাপ آگে যেতে পারি এবং কাউন্টার ক্যাশ করে যাতে আমাদের প্রতিটি পেজ ভিউতে প্রতিটি রেসপন্স কাউন্ট করতে না হয়।
কাউন্টার ক্যাশ
যেহেতু ব্লগের আর্টিকেলগুলো (আশা করি) আপডেট হওয়ার চেয়ে বেশি পড়া হয়, একটি কাউন্টার ক্যাশ এই পেজ কোয়ারি করা দ্রুত এবং সহজ করার জন্য একটি ভালো অপ্টিমাইজেশন।
প্রতিবার আর্টিকেলগুলো প্রদর্শন হলে রেসপন্সের সংখ্যা কাউন্ট করার বদলে, একটি কাউন্টার ক্যাশ একটি আলাদা রেসপন্স কাউন্টার রাখে যা প্রতিটি আর্টিকেলের ডেটাবেস রোতে সংরক্ষিত থাকে। কাউন্টারটি আপডেট হয় যেকোনো সময় একটি রেসপন্স যোগ বা রিমুভ হয়।
এটি আর্টিকেল ইনডেক্সকে এক ডেটাবেস কোয়ারিতে রেন্ডার করার অনুমতি দেয়, কোয়ারিতে রেসপন্স জয়ন করার প্রয়োজন ছাড়া। এটি সেট আপ করতে, belongs_to রিলেশনে সুইচ ফ্লিপ করুন counter_cache অপশন সেট করে।
# app/models/response.rb
class Response
belongs_to :article, counter_cache: true
end
এইতে Article মডেলে responses_count নামক একটি ফিল্ড প্রয়োজন। counter_cache অপশন নিশ্চিত করে সেই ফিল্ডের নম্বরটি অটোমেটিক্যালি আপডেট হয় যেকোনো সময় একটি রেসপন্স যোগ বা রিমুভ হয়।
টিপ: ফিল্ডের নাম ওভাররাইড করা যায় true-এর বদলে একটি সিম্বল ব্যবহার করে counter_cache অপশনের ভ্যালু হিসেবে।
আমরা আমাদের ডেটাবেসে একটি নতুন কলাম তৈরি করব কাউন্ট সংরক্ষণ করতে।
$ rails generate migration AddResponsesCountToArticles responses_count:integer
invoke active_record
create db/migrate/20180618093257_add_responses_count_to_articles.rb
$ rake db:migrate
== 20180618093257 AddResponsesCountToArticles: migrating ======================
-- add_column(:articles, :responses_count, :integer) -> 0.0016s
== 20180618093257 AddResponsesCountToArticles: migrated (0.0017s)
=============
যেহেতু রেসপন্সের সংখ্যা এখন আর্টিকেলস টেবিলে ক্যাশ করা হয়েছে, আমাদের আর্টিকেল কোয়ারিতে রেসপন্স জয়ন করতে হবে না। আমরা কন্ট্রোলারে Article.all ব্যবহার করব সব আর্টিকেল ফেচ করতে।
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@articles = Article.all
end
# ...
end
ভিউতে আমাদের কিছু পরিবর্তন করতে হবে না, কারণ রেইলস বুঝতে পারে #size মেথডের জন্য কাউন্টার ক্যাশ ব্যবহার করতে।
Articles
<% @articles.each do |article| %>
<%= article.title %>
<%= article.description %>
<%= article.responses.size %> responses
<% end %>
আমাদের ইনডেক্স আবার রিকোয়েস্ট করলে, আমরা একটি কোয়ারি এক্সিকিউট হওয়া দেখি। কারণ প্রতিটি আর্টিকেল তার রেসপন্সের সংখ্যা জানে, এটিকে রেসপন্স টেবিল কোয়ারি করার প্রয়োজন নেই।
Started GET "/articles" for 127.0.0.1 at 2018-06-14 17:15:23 +0200
Processing by ArticlesController#index as HTML
Rendering articles/index.html.erb within layouts/application
Article Load (0.2ms) SELECT "articles".* FROM "articles"
↳ app/views/articles/index.html.erb:3
Rendered articles/index.html.erb within layouts/application (3.5ms)
Completed 200 OK in 42ms (Views: 36.5ms | ActiveRecord: 0.2ms)
স্কোপড অ্যাসোসিয়েশনের জন্য কাউন্টার ক্যাশ
ActiveRecord-এর কাউন্টার ক্যাশ কলব্যাকগুলো শুধু রেকর্ড তৈরি বা ডিস্ট্রয় করার সময় ফায়ার করে, তাই একটি স্কোপড অ্যাসোসিয়েশনে কাউন্টার ক্যাশ যোগ করলে কাজ করবে না। অ্যাডভান্সড কেসের জন্য, যেন শুধু *পাবলিশড* রেসপন্সের সংখ্যা কাউন্ট করা, চেক আউট করুন counter_culture জেম।
কাউন্টার ক্যাশ পপুলেট করা
কাউন্টার ক্যাশের আগে বিদ্যমান আর্টিকেলগুলো জন্য, কাউন্টার আউট অফ সিঙ্ক থাকবে, কারণ ডিফল্টভাবে এটি ০। আমরা একটি অবজেক্টের জন্য কাউন্টার "রিসেট" করতে পারি .reset_counters মেথড ব্যবহার করে সেটিতে এবং অবজেক্টের আইডি এবং রিলেশন পাস করে যার জন্য কাউন্টার আপডেট করা উচিত।
Article.reset_counters(article.id, :responses)
নিশ্চিত করতে এটি প্রোডাকশনে ডিপ্লয় করার সময় চলে, আমরা এটিকে একটি মাইগ্রেশনে রাখব যা লাস্ট মাইগ্রেশনে কলাম যোগ করার পর সরাসরি চলে।
$ rails generate migration PopulateArticleResponsesCount --force
invoke active_record
create db/migrate/20180618093443_populate_article_responses_count.rb
মাইগ্রেশনে, আমরা Article.reset_counters কল করব প্রতিটি আর্টিকেলের জন্য, আর্টিকেলদের আইডি এবং :responses অ্যাসোসিয়েশনের নাম হিসেবে পাস করে।
# db/migrate/20180618093443_populate_article_responses_count.rb
class PopulateArticleResponsesCount < ActiveRecord::Migration[5.2]
def up
Article.find_each do |article|
Article.reset_counters(article.id, :responses)
end
end
end
এই মাইগ্রেশন ডেটাবেসে সব আর্টিকেলের কাউন্ট আপডেট করে তাদেরসহ যারা কাউন্টার ক্যাশের আগে অস্তিত্বে ছিল।
কলব্যাক
কারণ কাউন্টার ক্যাশ কলব্যাক ব্যবহার করে কাউন্টার আপডেট করতে, সরাসরি SQL কমান্ড এক্সিকিউট করা মেথডগুলো (যেন #destroy-এর বদলে #delete ব্যবহার করা) কাউন্টার আপডেট করবে না।
সিচুয়েশনগুলোতে যেটি যেকোনো কারণে হয়, একটি রেক টাস্ক বা একটি ব্যাকগ্রাউন্ড জব যোগ করা সেন্সिबল হতে পারে যা কাউন্টারগুলো পিরিয়ডিক্যালি সিঙ্কে রাখে।
namespace :counters do
task update: :environment do
Article.find_each do |article|
Article.reset_counters(article.id, :responses)
end
end
end
ক্যাশড কাউন্টার
কোয়ারিতে অ্যাসোসिएটেড অবজেক্ট কাউন্ট করে N+1 কোয়ারি প্রতিবন্ধক করতে সাহায্য করতে পারে, কিন্তু কাউন্টার ক্যাশ করা অধিকাংশ অ্যাপ্লিকেশনের জন্য কাউন্টার দেখানোর আরও দ্রুত উপায়। ActiveRecord-এর বিল্ট-ইন ক্যাশড কাউন্টার অনেক সাহায্য করতে পারে, এবং counter_culture এর মত অপশন আরও জটিল প্রয়োজনীয়তার জন্য ব্যবহার করা যায়।
ActiveRecord-এর কাউন্টার ক্যাশ সম্পর্কে কোনো প্রশ্ন আছে? দয়া করে @AppSignal-এ আমাদের জানাতে সংকোচ করবেন না। অবশ্যই, আমরা জানতে চাই আপনি এই আর্টিকেল কেমন লেগেছে, অথবা আপনার আর কোনো বিষয় আছে যা আপনি আরও জানতে চান।