Computer >> คอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

Ruby's Hidden Gems:Bullet

ฐานข้อมูลเป็นหัวใจของแอปพลิเคชันจำนวนมาก และการมีปัญหากับฐานข้อมูลอาจส่งผลให้เกิดปัญหาด้านประสิทธิภาพที่ร้ายแรง

ORM เช่น ActiveRecord และ Mongoid ช่วยให้เราใช้งานนามธรรมและส่งมอบโค้ดได้เร็วขึ้น แต่บางครั้ง เราลืมตรวจสอบว่ามีการสืบค้นข้อมูลใดบ้างที่กำลังทำงานอยู่

สัญลักษณ์แสดงหัวข้อย่อยช่วยให้เราระบุปัญหาที่เกี่ยวข้องกับฐานข้อมูลที่รู้จักกันดี:

  1. "N+1 Queries":เมื่อแอปพลิเคชันเรียกใช้แบบสอบถามเพื่อโหลดแต่ละรายการของรายการ
  2. "Unused Eager Loading":เมื่อแอปพลิเคชันโหลดข้อมูล โดยปกติเพื่อหลีกเลี่ยงการสืบค้น N+1 แต่ไม่ได้ใช้
  3. "Missing Counter Cache":เมื่อแอปพลิเคชันจำเป็นต้องดำเนินการค้นหาการนับเพื่อรับจำนวนรายการที่เกี่ยวข้อง

ในโพสต์นี้ ผมจะแสดง:

  • วิธีกำหนดค่า bullet อัญมณีในโปรเจ็กต์ Ruby
  • ตัวอย่างของแต่ละปัญหาที่กล่าวถึงก่อนหน้านี้
  • อย่างไร bullet ตรวจจับแต่ละตัว
  • วิธีแก้ไขปัญหาแต่ละข้อและ
  • วิธีผสาน bullet ด้วย AppSignal

ฉันจะใช้ตัวอย่างบางส่วนจากโครงการที่ฉันสร้างขึ้นสำหรับโพสต์นี้

วิธีกำหนดค่า Bullet ในโครงการ Ruby

ขั้นแรก เพิ่มอัญมณีลงใน Gemfile .

เราสามารถเพิ่มไปยังทุกสภาพแวดล้อมที่กำหนด เราสามารถเปิดหรือปิดการใช้งานมัน และใช้วิธีการที่แตกต่างกันในแต่ละสภาพแวดล้อม:

gem 'bullet'

ถัดไป จำเป็นต้องกำหนดค่า

หากคุณอยู่ในโครงการ Rails คุณสามารถเรียกใช้คำสั่งต่อไปนี้เพื่อสร้างรหัสการกำหนดค่าโดยอัตโนมัติ:

bundle exec rails g bullet:install

หากคุณอยู่ในโครงการที่ไม่ใช่ Rails คุณสามารถเพิ่มได้ด้วยตนเอง เช่น โดยการเพิ่มโค้ดต่อไปนี้ใน spec_helper.rb หลังจากโหลดโค้ดของแอปพลิเคชัน:

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

และเพิ่มโค้ดต่อไปนี้ในไฟล์หลักหลังจากโหลดโค้ดของแอปพลิเคชัน:

Bullet.enable = true

ฉันจะแบ่งปันรายละเอียดเพิ่มเติมเกี่ยวกับการกำหนดค่าในโพสต์นี้ หากต้องการดูทั้งหมด ให้ไปที่หน้า README ของสัญลักษณ์แสดงหัวข้อย่อย

การใช้สัญลักษณ์แสดงหัวข้อย่อยในการทดสอบ

ด้วยการกำหนดค่าที่แนะนำก่อนหน้านี้ 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 <top (required)>'

สิ่งนี้เกิดขึ้นเนื่องจากมุมมองดำเนินการค้นหาหนึ่งรายการเพื่อโหลดชื่อความคิดเห็นแต่ละรายการใน 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 โหลดความคิดเห็นทั้งหมดโดยมีเพียง 1 แบบสอบถาม

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 จะไม่ถูกทริกเกอร์

หมายเหตุ:ไม่แนะนำให้ทดสอบคอนโทรลเลอร์เนื่องจาก Rails 5:

# 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 ในการทดสอบ

ฉันแนะนำให้สร้างข้อกำหนดคำขออย่างน้อย 1 รายการสำหรับแต่ละการกระทำของตัวควบคุม เพื่อทดสอบว่าส่งคืนสถานะ HTTP ที่ถูกต้องหรือไม่ จากนั้น bullet จะคอยดูข้อความค้นหาเมื่อแสดงมุมมองเหล่านี้

ตรวจพบการโหลดอย่างกระตือรือร้นที่ไม่ได้ใช้

รับ 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 <top (required)>'

สิ่งนี้เกิดขึ้นเพราะไม่จำเป็นต้องโหลดรายการความคิดเห็นสำหรับมุมมองนี้

ในการแก้ไขปัญหา เราสามารถทำตามคำแนะนำในข้อผิดพลาดด้านบนและลบข้อความค้นหา .includes([:comments]) :

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

คุ้มที่จะบอกว่ามันจะไม่ทำให้เกิดข้อผิดพลาดแบบเดียวกันถ้าเรารันเฉพาะการทดสอบคอนโทรลเลอร์โดยไม่มี render_views ดังที่แสดงไว้ก่อนหน้านี้

ตรวจพบแคชตัวนับที่หายไป

รับคอนโทรลเลอร์ดังนี้:

# 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 <top (required)>'

สิ่งนี้เกิดขึ้นเนื่องจากมุมมองนี้ดำเนินการค้นหา 1 รายการเพื่อนับจำนวนความคิดเห็นใน 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.alert         = true

จากนั้นจะแสดงการแจ้งเตือนในลักษณะนี้ในเบราว์เซอร์:

Bullet.add_footer    = true

มันจะเพิ่มส่วนท้ายในหน้าที่มีข้อผิดพลาด:

นอกจากนี้ยังสามารถเปิดใช้งานข้อผิดพลาดเพื่อบันทึกในคอนโซลของเบราว์เซอร์:

Bullet.console    = true

มันจะเพิ่มข้อผิดพลาดเช่นนี้:

การใช้ Bullet ในการแสดงละครด้วย Appsignal

ใน การแสดงละคร เราไม่ต้องการให้ข้อความแสดงข้อผิดพลาดเหล่านี้แสดงต่อผู้ใช้ปลายทาง แต่จะเป็นการดีที่จะทราบว่าแอปพลิเคชันเริ่มมีปัญหาอย่างใดอย่างหนึ่งที่กล่าวถึงก่อนหน้านี้หรือไม่

ในขณะเดียวกัน bullet อาจลดประสิทธิภาพและเพิ่มการใช้หน่วยความจำในแอปพลิเคชัน ดังนั้นจึงควรเปิดใช้งานเพียงชั่วคราวใน staging แต่อย่าเปิดใช้งานใน การผลิต .

สมมติว่า การแสดงละคร สภาพแวดล้อมกำลังใช้ไฟล์การกำหนดค่าเดียวกันกับ การผลิต สภาพแวดล้อม ซึ่งเป็นแนวปฏิบัติที่ดีในการลดความแตกต่างระหว่างกัน เราสามารถใช้ตัวแปรสภาพแวดล้อมเพื่อเปิดใช้งานหรือปิดใช้งาน bullet ดังนี้

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

ในการรับการแจ้งเตือนเกี่ยวกับปัญหาที่ Bullet พบในสภาพแวดล้อมการแสดงละครของคุณ คุณสามารถใช้ AppSignal เพื่อรายงานการแจ้งเตือนเหล่านั้นว่าเป็นข้อผิดพลาด คุณจะต้องมี appsignal gem ติดตั้งและกำหนดค่าในโครงการของคุณ คุณสามารถดูรายละเอียดเพิ่มเติมได้ในเอกสาร Ruby gem

จากนั้น หากตรวจพบปัญหาโดย bullet , มันจะสร้างเหตุการณ์ข้อผิดพลาดเช่นนี้:

ข้อผิดพลาดนี้เกิดจากการที่ gem uniform_notifier ซึ่งดึงมาจาก bullet .

ขออภัย ข้อความแสดงข้อผิดพลาดแสดงข้อมูลไม่เพียงพอ แต่ฉันส่งคำขอดึงเพื่อปรับปรุงสิ่งนี้!

บทสรุป

bullet gem เป็นเครื่องมือที่ยอดเยี่ยมที่ช่วยให้เราตรวจพบปัญหาที่จะลดประสิทธิภาพในแอปพลิเคชัน

พยายามรักษาความครอบคลุมของการทดสอบให้ดีดังที่ได้กล่าวไว้ก่อนหน้านี้ เพื่อให้มีโอกาสตรวจพบปัญหาเหล่านี้มากขึ้นก่อนดำเนินการผลิต

สำหรับคำแนะนำเพิ่มเติม หากคุณต้องการได้รับการปกป้องจากปัญหาด้านประสิทธิภาพที่เกี่ยวข้องกับฐานข้อมูลมากขึ้น ให้ดูที่อัญมณี wt-activerecord-index-spy ซึ่งช่วยในการตรวจจับการสืบค้นที่ไม่ได้ใช้ดัชนีที่เหมาะสม

ป.ล. หากคุณต้องการอ่านโพสต์ Ruby Magic ทันทีที่ออกจากสื่อ สมัครรับจดหมายข่าว Ruby Magic ของเราและไม่พลาดแม้แต่โพสต์เดียว!