Vấn đề của Rails Callback

Nếu bạn tìm kiếm trên Stackoverflow với từ khóa Rails callbacks , phần lớn các kết quả tìm kiếm đều khuyên tránh sử dụng callback trong một số trường hợp. Nó dường như đã bị xa lánh ngay khi các lập trình viên phát hiện ra 😦. Tại sao lại vậy ? Callback là gì ?

Callback là gì?

Callbackhooks trong vòng đời của một đối tượng ActiveRecord, Các action: before, after, hoặc around Các event: save, validate, create, update,... Ta có thể có 2 actions before_update trong 1 object ActiveRecord, và các actions này sẽ được thực hiện theo tứ thự đã được khai báo. Bạn có thể tìm hiểu thêm về callback qua đây.

Vấn đề đầu tiên

Các lập trình viên thường sẽ thấy vấn đề ngay khi bắt đầu với việc viết test rspec. Bởi vì bạn thử tưởng tượng xem, khi viết test bạn sẽ cần khởi tạo object, nhưng mỗi lần khởi tạo bạn lại phải quan tâm tới hàng tá callback khác, và phải thêm cơ số data để phục vụ cho việc khởi tạo này. Hay thậm chí khi bạn đã có thể viết được suôn sẻ lần đầu thì khi có một logic mới được thêm vào => chúng ta thêm callback => phải sửa lại tất cả các rspec liên quan, bởi rất có thể logic thêm vào sẽ khiến việc khởi tạo object khi test gặp vấn đề. Đương nhiên chúng ta có thể sử dụng stub cho các callback giống như ví dụ sau:

#Model
class Post < ActiveRecord::Base
  has_many :followers
  after_save :notify_followers
  
  def publish!
    self.published_at = Time.now
    save
  end
  
  private
  
  def notify_followers
    Notifier.post_mailer.deliver
  end
end

#Rspec
describe "publishing the article" do
  it "saves the object with a defined published_at value" do
    Post.any_instance.stub(:notify_followers) # Codey McSmellsalot
    post = Post.new(:title => "The Problem with Callbacks in Rails")
    post.publish!
    expect(post.published_at).to be_an_kind_of(Time)
    expect(post).to_not be_a_new_record
  end
end

Bạn có thấy để rspec pass thì chúng ta phải sử dụng Post.any_instance.stub(:notify_followers) không, nếu không có stub hẳn sẽ rất vất vả để viết được rspec này đây. Có vẻ nếu dùng stub thì vấn đề rspec sẽ được giải quyết. Không hẳn vậy, hãy thử tưởng tượng tất cả các rspec có save Post đều phải thêm stub xem, thật phiền phức, hơn nữa nếu ta chỉ cần thêm 1 callback thôi thì tất cả các rspec khác sẽ đều phải thêm stub cho callback mới nữa. Bạn sẽ vấn muốn dùng callback chứ ?

Observers liệu có thay thế được Callback ?

Đây có lẽ là hướng đi đúng, bởi vì bằng cách tạo ra một observer, tức là bạn đã di chuyển những trách nhiệm xử lý không thuộc object chính cho observer xử lý.

Thế nhưng vấn đề ở chỗ observer là một loại callback ẩn, tức là nó hoạt động giống callback nhưng các callback này được chuyển sang một class khác mà thôi. Các class observer này sẽ được gán cho class tương ứng khi ứng dụng của bạn khởi động vì thế khi bạn test rspec, các callback sẽ vẫn hoạt động, và vấn đề sẽ vẫn tồn tại. Tuy nhiên nếu bạn dùng observer thì có một cách để disable observer khi test với gem no-peeping-toms

#Thêm dòng sau vào file spec_helper.rb
ActiveRecord::Observer.disable_observers

Tại sao Callback lại có vấn đề ?

before_ callback thường được sử dụng để chuẩn bị cho một object, như việc cập nhật timestamps hoặc tăng biến counters của object đó trước khi object được lưu lại. after_ callback chủ yếu được sử dụng liên quan tới việc lưu object. Khi object được lưu, mục đích và trách nhiệm của object đã được hoàn thành, nhưng với callback thì chúng ta thường thấy nó phải xử lý cả những vấn đề vượt ra ngoài phạm vi trách nhiệm của mình và đó là lúc chúng ta gặp phải vấn đề.

Hãy xem ví dụ sau:

class Order < ActiveRecord::Base
  belongs_to :user
  has_many :line_items
  has_many :products, :through => :line_items
  
  after_create :purchase_completion_notification
  
  private
  
  def purchase_completion_notification
    Notifier.purchase_notifier(self).deliver
  end
end

class Notifier < ActionMailer...
  def purchase_notifier(order)
    @order = order
    @user = order.user
    @products = order.products

    rest of the action mailer logic
  end
end

Khi Order được lưu, nó sẽ gửi một email tới cho khách hàng. Mailer sẽ sử dụng object order để lấy ra userproducts, sau đó dùng chúng để gửi email. Chúng ta có thể thấy trách nhiệm gửi mail là của class Notifier, tuy nhiên khi sử dụng after_create, chúng ta lại liên kết chặt object order với trách nhiệm của Notifier, việc liên kết này nhiều khi sẽ gây ra những vấn đề về sau mà chúng ta không thể kiểm soát được.

Làm sao để giải quyết ?

Có một nguyên tắc đơn giản như sau: Chỉ sử dụng callback khi logic liên quan với bản thân đối tượng. Nguyên tắc này sẽ tránh việc object phải xử lý những vấn đề nằm ngoài phạm vi kiểm soát của chính nó, tuy nhiên nếu muốn xử lý cả những vấn đề nằm ngoài phạm vi thì phải làm thế nào ?

Tất nhiên là không sử dụng callback rồi, chúng ta sẽ xử lý như sau:

#Model Order
class Order < ActiveRecord::Base
  belongs_to :user
  has_many :line_items
  has_many :products, :through => :line_items
end

#New class
class OrderCompletion
  attr_accessor :order
  
  def initialize(order)
    @order = order
  end
  
  def create
    if self.order.save
      self.purchase_completion_notification
    end
  end
  
  def purchase_completion_notification
    Notifier.purchase_notifier.deliver(self.order)
  end
end

#Controller
def create
  @order = Order.new(params[:order])
  @order_completion = OrderCompletion.new(@order)
  
  if @order_completion.create
    redirect_to root_path, notice: 'Your order is being processed.'
  else
    @order = @order_completion.order
    render action: "new"
  end
end

Với cách này, chúng ta chuyển việc lưu order và gửi notification sang một class mới. Lúc này, việc viết test sẽ dễ dàng hơn nhiều, chúng ta không cần phải dùng stub nữa, thật tuyệt phải không 😄

Tham khảo

http://samuelmullen.com/2013/05/the-problem-with-rails-callbacks/