Tìm hiểu về callback function trong Rails

1. Active record callbacks là gì? Tại sao phải dùng nó?

Callback là các method được gọi tại 1 khoảnh khắc nhất định trong vòng đời của 1 ActiveRecord Object. Với callbacks ta có thể xử lý logic ở mỗi thời điểm mà object được created, saved, updated, deleted, validated, hoặc loaded từ database.

Tại sao phải dùng nó? Chúng ta sẽ thử với 1 bài toán thực tế. Giả sử khi user đăng ký tài khoản, họ sẽ nhập vào form đăng ký, tên của mình như thế này: Tuy nhiên, trong cơ sở dữ liệu, chúng ta muốn thuộc tính "name" luôn được lưu ở định dạng này:

Hoang Trong Hieu

Để giải quyết vấn đề này, ta sử dụng 1 hàm callback để định dạng lại thuộc tính name trước khi nó được lưu vào cơ sở dữ liệu như sau:

class User < ApplicationRecord
  before_save :normalize_name
  private
    def normalize_name
      self.name = name.downcase.titleize
    end
end

Và xem cách nó chạy:

$ User.create name: "hoang trong hieu"
   (0.1ms)  begin transaction
"Vao callback before_save"
  User Create (0.3ms)  INSERT INTO "users" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Hoang Trong Hieu"], ["created_at", "2019-09-12 02:40:58.419078"], ["updated_at", "2019-09-12 02:40:58.419078"]]
   (60.5ms)  commit transaction
=> #<User id: 19, name: "Hoang Trong Hieu", location: nil, age: nil, image_path: nil, created_at: "2019-09-12 02:40:58", updated_at: "2019-09-19

$ User.last.name
=> "Hoang Trong Hieu"

2. Active record callbacks dùng dư nào?

Để dùng các callbacks, ta phải gọi và đăng ký chúng dưới dạng class_method.

class User < ApplicationRecord
  before_validation { p "Vào callback before_validation rồi nè" }
end

3 Các loại callbacks có thể sử dụng.

3.1 Creating an Object

before_validation
after_validation
before_save
around_save
before_create
around_create
after_create
after_save
after_commit/after_rollback

3.2 Updating an Object

before_validation
after_validation
before_save
around_save
before_update
around_update
after_update
after_save
after_commit/after_rollback

3.3 Destroying an Object

before_destroy
around_destroy
after_destroy
after_commit/after_rollback

Các callbacks bản chất là các class_method, mỗi khi khởi tạo, chúng sẽ được xếp vào 1 hàng đợi (gọi là chuỗi callbacks) và thực hiện theo 1 thứ tự nhất định. Ví dụ:

class User < ApplicationRecord
  before_save { p " Callback số before_save số 1 " }
  before_validation { p " Callback before_validation số 1 " }
  before_validation { p " Callback before_validation số 2 " }
  before_save { p " Callback số before_save số 2 " }
end

Mình cố tình sắp xếp các callback hơi lộn xộn, và cùng xem thứ tự thực hiện của nó:

User.create name: "Hieu dep trai qua", age: 50
   (0.0ms)  begin transaction
" Callback before_validation số 1 "
" Callback before_validation số 2 "
" Callback số before_save số 1 "
" Callback số before_save số 2 "
  User Create (0.3ms)  INSERT INTO "users" ("name", "age", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "Hieu dep trai qua"], ["age", 50], ["created_at", "2019-09-10 07:04:47.732217"], ["updated_at", "2019-09-10 07:04:47.732217"]]
   (86.2ms)  commit transaction
=> #<User id: 6, name: "Hieu dep trai qua", location: nil, age: 50, created_at: "2019-09-10 07:04:47", updated_at: "2019-09-10 07:04:47">

Như vậy các bạn có thể thấy, nếu callback là cùng loại thì thứ tự thực hiện của chúng phụ thuộc vào thứ tự xuất hiện trong code. Nếu callback là khác loại thì thứ tự thực hiện của chúng giống như mình đã nêu ở phần 3 ( như ví dụ ở trên thì ta có thể thấy before_save luôn chạy sau before_validation).

3.4 after_initialize and after_find

Callback after_find được gọi khi ActiveRecord load 1 bản ghi từ database

User.find 2
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
" Vào callback after_find rồi nè "

Callback after_initialize được gọi khi 1 ActiveRecord object được khởi tạo( hàm new ) hoặc khi ActiveRecord load 1 bản ghi từ database. Callback này tạo ra để tránh việc bạn phải ghi đề lại method initialize của model.

User.find 2
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
" Vào callback after_find rồi nè "
" Vào callback after_initialize rồi nè "
User.new
" Vào callback after_initialize rồi nè "
=> #<User id: nil, name: nil, location: nil, age: nil, image_path: nil, created_at: nil, updated_at: nil>

after_find luôn được load trước after_initialize nếu cả 2 cùng được đăng ký.

3.5 after_touch

Chỉ được gọi ra khi có 1 Active Record objects được touch.

User.find_by(id: 2).touch

4. Các method giúp gọi callbacks

Đây là danh sách các method giúp chúng ta gọi vào callback.

create
create!
destroy
destroy!
destroy_all
save
save!
save(validate: false)
toggle!
touch
update_attribute
update
update!
valid?

after_find sẽ được gọi bởi các method sau

all
first
find
find_by
find_by_*
find_by_*!
find_by_sql
last

5. Các method không gọi callbacks (skip callback)

Với các method dưới đây, callback sẽ không được gọi

decrement!
decrement_counter
delete
delete_all
increment!
increment_counter
update_column
update_columns
update_all
update_counters

Trong đó, after_findafter_initialize không thể bị skip vì 2 callback này được gọi khi dữ liệu được load từ DB.

Ngoài ra để skip 1 callback cụ thể, ta có thể đặt trạng thái cho callback. Ví dụ, ta muốn có 1 callback before_validation :show_age như sau.

class User < ApplicationRecord
 before_validation :show_age 
 
 private
   def show_age
     p "User is #{age} years old"
   end
end


##
User.first.update name: "Hieu dep trai qua 2", age: 50
  User Load (0.1ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
   (0.1ms)  begin transaction
"User is 50 years old"
   (0.0ms)  commit transaction
=> true

Ta có thể skip callback nói trên với điều kiện tuổi của user lớn hơn 30 bằng cách sau:

class User < ApplicationRecord
 before_validation :show_age, unless: ->{ self.age > 30 } 
 
 private
   def show_age
     p "User is #{age} years old"
   end
end

User.first.update name: "Hieu dep trai qua 2", age: 50
  User Load (0.1ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
   (0.1ms)  begin transaction
"User is 50 years old"
   (0.0ms)  commit transaction
=> true

6. Halting Execution

Toàn bộ chuỗi callback được gói trong 1 transaction. Nếu có 1 callback trong chuỗi raise exception, toàn bộ chuỗi sẽ dừng lại và transaction sẽ bị rollback. Ví dụ:

class User < ApplicationRecord
   before_validation { p "Vào trong callback before_validation" }
   before_validation { a }
end

######
User.create name: "Hieu dep trai", age: 50
   (0.0ms)  begin transaction
" Vào trong callback before_validation"
   (0.0ms)  rollback transaction
Traceback (most recent call last):
        2: from (irb):39
        1: from app/models/user.rb:8:in `block in <class:User>'
NameError (undefined local variable or method `a' for #<User:0x00005571eec85e00>) 

Ở trong ví dụ nói trên, do a không được định nghĩa, nên callback before_validation { a } đã trả về 1 exceptiontransaction bị rollback. Để chủ động dừng chuỗi callback và tạo ra 1 rollback, ta có thể sử dụng throw :abort

class User < ApplicationRecord
   before_validation { p "Vào trong callback before_validation" }
   before_validation { throw :abort }
end

###############
User.create name: "Hieu dep trai", age: 50
   (0.1ms)  begin transaction
" Vào trong callback before_validation"
   (0.0ms)  rollback transaction
=> #<User id: nil, name: "Hieu dep trai", location: nil, age: 50, created_at: nil, updated_at: nil>

7. Relational callback

Callback được thực hiện kể cả qua relationship giữa các model. Ví dụ với 1 user có nhiều articles , khi xóa user thì các articles thuộc về nó cũng bị xóa theo. Vì vậy , các callback thuộc về bảng Article cũng sẽ được gọi.

class User < ApplicationRecord
  has_many :articles, dependent: :destroy
end
 
class Article < ApplicationRecord
  after_destroy :log_destroy_action
 
  def log_destroy_action
    puts 'Article destroyed'
  end
end
 
>> user = User.first
=> #<User id: 1>
>> user.articles.create!
=> #<Article id: 1, user_id: 1>
>> user.destroy
Article destroyed
=> #<User id: 1>

8. Conditional callback

Có thể đặt trạng thái để xảy ra callback với symbol :if:unless. Value truyền vào sau key :if hoặc :unless có thể là Symbol hoặc Proc .

class User < ApplicationRecord
  before_validation :show_age, if: :is_teen?
  
  private
    def is_teen?
      age < 20
    end
end
class User < ApplicationRecord
  before_validation :show_age, if: Proc.new { age < 20 }
end

Cho trường hợp cần sử dụng nhiều trạng thái cho callback, chúng ta có thể truyền mảng các Symbol, Proc vào sau key :if (tương tự với :unless )

class User < ApplicationRecord
  before_validation :show_age, if: [:is_teen?, :is_integer?, Proc.new { age > 10 }]
  
  private
    def is_teen?
      age < 20
    end
    
    def is_integer?
      age.integer?
    end
end

Callback sẽ được chạy khi tất cả các phần tử trong mảng đứng sau :if đều trả về giá trị true ( tương tự các phần tử trong mảng đứng sau:unless cần trả về giá trị false ) .

Ngoài ra, Rails cũng tạo ra cú pháp để mix :unless:if, tuy nhiên mình không thích dùng cú pháp này vì nó khiến cho code không được clean.

class User < ApplicationRecord
  before_validation :show_age, if: [:is_teen?, :is_integer?], unless: Proc.new { age < 10 }
  
  private
    def is_teen?
      age < 20
    end
    
    def is_integer?
      age.integer?
    end
end

Với cú pháp mix nói trên, callback sẽ chạy nếu tất cả phần tử trong mảng sau :if trả về giá trị true tất cả phần tử trong mảng sau :unless trả về giá trị false . ( Dở hơi nhỉ . Với cá nhân mình thì mình sẽ chỉ dùng 1 trong 2 key :if hoặc :unless )

Đó là toàn bộ bài report của mình về vấn đề này.


References:

https://guides.rubyonrails.org/active_record_callbacks.html