কম্পিউটার টিউটোরিয়াল

রুবির লুকানো রত্ন: Bullet — N+1 এবং আনইউজড ইগার লোডিং ইস্যু ধরুন

ডাটাবেস অনেক অ্যাপ্লিকেশনের হৃদয়, এবং এতে সমস্যা থাকলে সিরিয়াস পারফরম্যান্স ইস্যু হতে পারে।

ActiveRecord এবং Mongoid-এর মতো ORM আমাদের ইমপ্লিমেন্টেশন অ্যাবস্ট্রাক্ট করতে এবং দ্রুত কোড ডেলিভার করতে সাহায্য করে, কিন্তু কখনও কখনও আমরা ভুলে যাই হুডের নিচে কী কোয়েরি চলছে সেটি চেক করতে।

Bullet gem আমাদের কিছু周知 ডাটাবেস-রিলেটেড সমস্যা চিহ্নিত করতে সাহায্য করে:

  1. "N+1 Queries": যখন অ্যাপ্লিকেশন একটি লিস্টের প্রতিটি আইটেম লোড করার জন্য একটি কোয়েরি রান করে
  2. "Unused Eager Loading": যখন অ্যাপ্লিকেশন ডেটা লোড করে, সাধারণত N+1 কোয়েরি এড়াতে, কিন্তু ব্যবহার করে না
  3. "Missing Counter Cache": যখন অ্যাপ্লিকেশন অ্যাসোসিয়েটেড আইটেমের সংখ্যা পেতে কাউন্ট কোয়েরি এক্সিকিউট করে

এই পোস্টে, আমি দেখাব:

  • কীভাবে একটি রুবি প্রজেক্টে bullet জেম কনফিগার করবেন,
  • উপরোক্ত প্রতিটি সমস্যার উদাহরণ,
  • bullet কীভাবে প্রতিটি ডিটেক্ট করে,
  • প্রতিটি সমস্যা কীভাবে ফিক্স করবেন, এবং
  • কীভাবে bullet-কে AppSignal-এ ইন্টিগ্রেট করবেন।

এই পোস্টের জন্য আমি একটি প্রজেক্ট তৈরি করেছিলাম, তার উদাহরণ ব্যবহার করব।

রুবি প্রজেক্টে Bullet কনফিগার করা

প্রথমে, Gemfile-এ জেমটি যোগ করুন।

আমরা সব এনভায়রনমেন্টে এড করতে পারি, কারণ আমরা এনাবল/ডিজএবল করতে পারি এবং প্রতিটিতে ভিন্ন পদ্ধতি ব্যবহার করতে পারি:

gem 'bullet'

পরবর্তী, এটি কনফিগার করতে হবে।

আপনি যদি রেইলস প্রজেক্টে থাকেন, নিচের কমান্ড রান করুন কনফিগারেশন কোড অটোমেটিক্যালি জেনারেট করতে:

bundle exec rails g bullet:install

যদি নন-রেইলস প্রজেক্টে থাকেন, ম্যানুয়ালি যোগ করতে পারেন, উদাহরণস্বরূপ, spec_helper.rb-এ অ্যাপ্লিকেশনের কোড লোড হওয়ার পর:

Bullet.enable = true
Bullet.bullet_logger = true
Bullet.raise = true

এবং মেইন ফাইলে অ্যাপ্লিকেশনের কোড লোড হওয়ার পর:

Bullet.enable = true

এই পোস্টে কনফিগারেশন সম্পর্কে আরও ডিটেইলস শেয়ার করব। সকল কনফিগারেশন দেখতে চাইলে bullet-এর README পেজে যান।

টেস্টে Bullet ব্যবহার

আগে প্রস্তাবিত কনফিগারেশনের সাথে, Bullet টেস্টে এক্সিকিউট হওয়া খারাপ কোয়েরি ডিটেক্ট করবে এবং এক্সেপশন রেইজ করবে।

চলুন কিছু উদাহরণ দেখি।

N+1 কোয়েরি ডিটেক্ট করা

একটি index অ্যাকশন দেওয়া আছে:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

একটি ভিউ এমন:

# app/views/posts/index.html.erb
<h1>Posts</h1>
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Comments</th>
    </tr>
  </thead>
  <tbody>
    <% @posts.each do |post| %>
    <tr>
      <td><%= post.name %></td>
      <td><%= post.comments.map(&:name) %></td>
    </tr>
    <% end %>
  </tbody>
</table>

bullet একটি এরর রেইজ করবে "N+1" ডিটেক্ট করলে যখন একটি ইন্টিগ্রেটেড টেস্ট রান করবে যা ভিউ এবং কন্ট্রোলার থেকে কোড এক্সিকিউট করে, উদাহরণস্বরূপ, একটি রিকোয়েস্ট স্পেক ব্যবহার করে:

# spec/requests/posts_request_spec.rb
require 'rails_helper'
RSpec.describe "Posts", type: :request do
  describe "GET /index" do
    it 'lists all posts' do
      post1 = Post.create!
      post2 = Post.create!
      get '/posts'
      expect(response.status).to eq(200)
    end
  end
end

এই কেসে, এটি নিচের এক্সেপশন রেইজ করবে:

Failures:
  1) Posts GET /index lists all posts
  Failure/Error: get '/posts'
  Bullet::Notification::UnoptimizedQueryError: user: fabioperrella
  GET /posts
  USE eager loading detected
  Post => [:comments]
  Add to your query: .includes([:comments])
  Call stack
  /Users/fabioperrella/projects/bullet-test/app/views/posts/index.html.erb:17:in `map'
  ...
  # ./spec/requests/posts_controller_spec.rb:9:in `block (3 levels) in '

এটি ঘটে কারণ ভিউটি post.comments.map(&:name)-এ প্রতিটি কমেন্টের নাম লোড করার জন্য একটি কোয়েরি এক্সিকিউট করছে:

Processing by PostsController#index as HTML
Post Load (0.4ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:14
Comment Load (0.0ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index.html.erb:17:in `map'
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]

এটি ফিক্স করতে, আমরা সিম্পলি এরর মেসেজের ইনস্ট্রাকশন ফলো করতে পারি এবং কোয়েরিতে .includes([:comments]) যোগ করতে পারি:

-@posts = Post.all
+@posts = Post.all.includes([:comments])

এটি ActiveRecord-কে শুধু ১ কোয়েরিতে সব কমেন্ট লোড করতে নির্দেশ করবে।

Processing by PostsController#index as HTML
Post Load (0.2ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:14
Comment Load (0.0ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?) [["post_id", 1], ["post_id", 2]]
↳ app/views/posts/index.html.erb:14

যাইহোক, bullet এক্সেপশন রেইজ করবে না একটি কন্ট্রোলার টেস্টে যেমন নিচেরটি, কারণ কন্ট্রোলার টেস্ট ডিফল্টে ভিউ রেন্ডার করে না, তাই N+1 কোয়েরি ট্রিগার হবে না।

নোট: কন্ট্রোলার টেস্ট রেইলস ৫ থেকে ডিসকourage করা হয়েছে:

# spec/controllers/posts_controller_spec.rb
require 'rails_helper'
RSpec.describe PostsController do
  describe 'GET index' do
    it 'lists all posts' do
      post1 = Post.create!
      post2 = Post.create!
      get :index
      expect(response.status).to eq(200)
    end
  end
end

Bullet যে এক্সেপশন রেইজ করবে না "N+1" ডিটেক্ট করার জন্য একটি ভিউ টেস্টের উদাহরণ, কারণ এই কেসে এটি ডাটাবেসে N+1 কোয়েরি রান করবে না:

# spec/views/posts/index.html.erb_spec.rb
require 'rails_helper'
describe "posts/index.html.erb" do
  it 'lists all posts' do
    post1 = Post.create!(name: 'post1')
    post2 = Post.create!(name: 'post2')
    assign(:posts, [post1, post2])
    render
    expect(rendered).to include('post1')
    expect(rendered).to include('post2')
  end
end

টেস্টে N+1 ডিটেক্ট করার চান্স বাড়ানোর একটি টিপ

আমি সুপারিশ করি প্রতিটি কন্ট্রোলার অ্যাকশনের জন্য কমপক্ষে ১টি রিকোয়েস্ট স্পেক তৈরি করা, শুধু সঠিক HTTP স্ট্যাটাস রিটার্ন করছে কিনা টেস্ট করার জন্য, তাহলে bullet এই ভিউ রেন্ডার করার সময় কোয়েরি ওয়াচ করবে।

Unused Eager Loading ডিটেক্ট করা

একটি basic_index অ্যাকশন দেওয়া আছে:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def basic_index
    @posts = Post.all.includes(:comments)
  end
end

এবং নিচের basic_index ভিউ:

# app/views/posts/basic_index.html.erb
<h1>Posts</h1>
<table>
  <thead>
    <tr>
      <th>Name</th>
    </tr>
  </thead>
  <tbody>
    <% @posts.each do |post| %>
    <tr>
      <td><%= post.name %></td>
    </tr>
    <% end %>
  </tbody>
</table>

যখন আমরা নিচের টেস্ট রান করি:

# spec/requests/posts_request_spec.rb
require 'rails_helper'
RSpec.describe "Posts", type: :request do
  describe "GET /basic_index" do
    it 'lists all posts' do
      post1 = Post.create!
      post2 = Post.create!
      get '/posts/basic_index'
      expect(response.status).to eq(200)
    end
  end
end

Bullet নিচের এরর রেইজ করবে:

  1) Posts GET /basic_index lists all posts
  Failure/Error: get '/posts/basic_index'
  Bullet::Notification::UnoptimizedQueryError: user: fabioperrella
  GET /posts/basic_index
  AVOID eager loading detected
  Post => [:comments]
  Remove from your query: .includes([:comments])
  Call stack
  /Users/fabioperrella/projects/bullet-test/spec/requests/posts_request_spec.rb:20:in `block (3 levels) in '

এটি ঘটে কারণ এই ভিউর জন্য কমেন্টের লিস্ট লোড করা প্রয়োজন নেই।

সমস্যা ফিক্স করতে, আমরা এররের উপরে ইনস্ট্রাকশন ফলো করে কোয়েরি .includes([:comments]) রিমুভ করতে পারি:

-@posts = Post.all.includes(:comments)
+@posts = Post.all

বলতে হবে এটি একই এরর রেইজ করবে না যদি আমরা শুধু কন্ট্রোলার টেস্ট রান করি, render_views ছাড়া, যেমন আগে দেখানো হয়েছিল।

Missing Counter Cache ডিটেক্ট করা

একটি কন্ট্রোলার দেওয়া আছে:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index_with_counter
    @posts = Post.all
  end
end

একটি ভিউ এমন:

# app/views/posts/index_with_counter.html.erb
<h1>Posts</h1>
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Number of comments</th>
    </tr>
  </thead>
  <tbody>
    <% @posts.each do |post| %>
    <tr>
      <td><%= post.name %></td>
      <td><%= post.comments.size %></td>
    </tr>
    <% end %>
  </tbody>
</table>

যদি আমরা নিচের রিকোয়েস্ট স্পেক রান করি:

describe "GET /index_with_counter" do
  it 'lists all posts' do
    post1 = Post.create!
    post2 = Post.create!
    get '/posts/index_with_counter'
    expect(response.status).to eq(200)
  end
end

bullet নিচের এরর রেইজ করবে:

1) Posts GET /index_with_counter lists all posts
Failure/Error: get '/posts/index_with_counter'
Bullet::Notification::UnoptimizedQueryError: user: fabioperrella
GET /posts/index_with_counter
Need Counter Cache
Post => [:comments]
# ./spec/requests/posts_request_spec.rb:31:in `block (3 levels) in '

এটি ঘটে কারণ এই ভিউ post.comments.size-এ প্রতিটি পোস্টের জন্য কমেন্টের সংখ্যা কাউন্ট করার জন্য ১ কোয়েরি এক্সিকিউট করছে।

Processing by PostsController#index_with_counter as HTML
↳ app/views/posts/index_with_counter.html.erb:14
Post Load (0.4ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index_with_counter.html.erb:14
(0.4ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
↳ app/views/posts/index_with_counter.html.erb:17
(0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]

এটি ফিক্স করতে, আমরা একটি কাউন্টার ক্যাশ তৈরি করতে পারি, যা কিছুটা জটিল হতে পারে, বিশেষ করে যদি প্রোডাকশন ডাটাবেসে ডেটা থাকে।

একটি কাউন্টার ক্যাশ হলো একটি কলাম যা আমরা একটি টেবিলে যোগ করতে পারি, যা ActiveRecord স্বয়ংক্রিয়ভাবে আপডেট করবে যখন আমরা অ্যাসোসিয়েটেড মডেল ইনসার্ট ও ডিলিট করব। এর ওপর আরও ডিটেইলস এই পোস্টে আছে। কাউন্টার ক্যাশ তৈরি ও সিঙ্ক করার জন্য সেটি পড়তে পরামর্শ দিচ্ছি।

ডেভেলপমেন্টে Bullet ব্যবহার

কभी কখনো টেস্টগুলো আগে উল্লেখ করা সমস্যা ডিটেক্ট করতে পারে না, উদাহরণস্বরূপ, যদি টেস্ট কভারেজ কম হয়, তাই অন্য এনভায়রনমেন্টে bullet ইনেবল করা সম্ভব ভিন্ন পদ্ধতিতে।

ডেভেলপমেন্ট এনভায়রনমেন্টে, আমরা নিচের কনফিগারেশন ইনেবল করতে পারি:

Bullet.alert = true

তবে, এটি ব্রাউজারে এলার্ট দেখাবে।

Bullet.add_footer = true

এটি পেজে একটি ফুটার যোগ করবে এররসহ।

ব্রাউজারের কনসোলে এরর লগ করতে পারেন:

Bullet.console = true

এটি একটি এরর যোগ করবে।

AppSignal-এ Staging-এ Bullet ব্যবহার

Staging এনভায়রনমেন্টে, আমরা এই এরর মেসেজ এন্ড-ইউজারকে দেখাতে চাই না, তবে অ্যাপ্লিকেশন যদি আগে উল্লিখিত সমস্যাগুলোর যেকোনো একটি নিয়ে শুরুর করতে, সেটি জানা দারুণ হবে।

একই সাথে, bullet অ্যাপ্লিকেশনের পারফরম্যান্স ডিগ্রেড করতে পারে এবং মেমরি কনসাম্পশন বাড়াতে পারে, তাই সেটি শুধু مؤقتভাবে staging-এ ইনেবল করা ভালো, কিন্তু production-এ ইনেবল করবেন না।

ধরে নিল staging এনভায়রনমেন্ট production এনভায়রনমেন্টের সাথে একই কনফিগারেশন ফাইল ব্যবহার করছে, যা সেগুলোর পার্থক্য কমাতে একটি ভালো প্র্যাকটিস। আমরা একটি এনভায়রনমেন্ট ভেরিয়েবল ব্যবহার করে bullet ইনেবল/ডিজএবল করতে পারি:

# config/environments/production.rb
config.after_initialize do
  Bullet.enabled = ENV.fetch('BULLET_ENABLED', false)
  Bullet.appsignal = true
end

Bullet যে ইস্যুগুলো খুঁজে পাচ্ছে সেগুলোর নোটিফিকেশন পেতে স্টেজিং এনভায়রনমেন্টে, আপনি AppSignal ব্যবহার করতে পারেন সেগুলো এরর হিসেবে রিপোর্ট করতে। আপনার প্রজেক্টে appsignal জেম ইনস্টলড ও কনফিগারড থাকতে হবে। রুবি জেম ডক্সে আরও ডিটেইলস দেখুন।

তব, যদি bullet কোনো সমস্যা ডিটেক্ট করে, এটি একটি এরর ইন্সিডেন্ট তৈরি করবে।

এই এরর uniform_notifier জেম দ্বারা রেইজ হয়েছে যা bullet- থেকে এক্সট্র্যাক্ট করা হয়েছিল।

দুর্ভাগ্যক্রমে, এরর মেসেজে পর্যাপ্ত ইনফরমেশন দেখায় না, কিন্তু আমি একটি Pull Request পাঠিয়েছি এটিকে উন্নত করতে!

উপসংহার

bullet জেম একটি চমৎকার টুল যা আমাদের পারফরম্যান্স ডিগ্রেড করতে পারে এমন সমস্যা ডিটেক্ট করতে সাহায্য করে।

প্রচেষ্টা করুন ভালো টেস্ট কভারেজ রাখা, যেমন আগে বললাম, এই সমস্যা প্রোডাকশনে যাওয়ার আগে ডিটেক্ট করার চান্স বাড়াতে।

এক্সট্রা টিপ হিসেবে, আপনি যদি ডাটাবেস-রিলেটেড পারফরম্যান্স প্রব্লেম থেকে আরও বেশি প্রোটেক্টেড থাকতে চান, wt-activerecord-index-spy জেমটি দেখুন, যা প্রোপার ইনডেক্স ব্যবহার না করা কোয়েরি ডিটেক্ট করতে সাহায্য করে।

P.S. Ruby Magic পোস্টগুলো প্রেস থেকে বের होतेই পেতে চাইলে, আমাদের Ruby Magic নিউজলেটার সাবস্ক্রাইব করুন এবং কোনো পোস্ট মিস করবেন না!

  1. রেইলসের লুকানো রত্ন আবিষ্কার: ActiveSupport-এর StringInquirer দক্ষতা অর্জন

  2. ৭টি শক্তিশালী রুবি জেম যা আপনি সম্ভবত শুনেননি

  3. রুবি ২.৬-এ ৯টি গুরুত্বপূর্ণ উন্নতির অন্বেষণ

  4. Windows 11 মাস্টার করুন: এক্সপার্ট টিপস ও হিডেন ফিচারস প্রকাশিত