12/08/2018, 15:30

Sử dụng domain model events để làm gọn controller và model trong Rails

Nếu như đã làm nhiều với Rails chắc hẳn bạn đã tưng nghe thấy cụm từ: "Skinny controller, fat model". Việc này xảy ra rất thường xuyên. Đặc biệt là khi controller của bạn đang cồng kềnh với các trách nhiệm HTTP-centric, chẳng hạn như thông báo websocket, dường như không thuộc về các domain model ...

Nếu như đã làm nhiều với Rails chắc hẳn bạn đã tưng nghe thấy cụm từ: "Skinny controller, fat model". Việc này xảy ra rất thường xuyên. Đặc biệt là khi controller của bạn đang cồng kềnh với các trách nhiệm HTTP-centric, chẳng hạn như thông báo websocket, dường như không thuộc về các domain model object của bạn.

Trong bài viết này, bạn sẽ học một kỹ thuật refactor đặt các controller vào chế độ ăn kiêng, mà không cần phải gom một loạt các mã máy chủ web vào các lớp mô hình hỗ trợ cơ sở dữ liệu của bạn (database-backed model classes.).

Chúng ta hãy nhìn vào một action controller Rails điển hình. Action này là từ một ứng dụng quản lý dự án.

class TasksController < ApplicationController
  # ...
  def update
    old_project_id = @task.project && @task.project.id
 
    previous_status = @task.status
    if @task.update_attributes(params[:task])
      if previous_status != @task.status
        notifiee = task.readers(false) - [current_user]
        if notifiee
          mail_completion_notice(notifiee) if new_status == Status::COMPLETED
          mail_uncomplete_notice(notifiee) if previous_status == Status::COMPLETED
        end
      end
      if old_project_id != (@task.project && @task.project.id)
        push_project_update(old_project_id)
        push_project_update(@task.project.id) if @task.project
      end
      if @task.project
        push_task('update_task')
      else
        push_task('create_task', assignee) if assignee
        push_task('delete_task', previous_assignee) if previous_assignee
        update_users = @task.readers
        update_users = update_users - [assignee] if assignee
        push_task('update_task', update_users)
      end
 
      mail_assignment if assignee
      mail_assignment_removal(previous_assignee) if previous_assignee
 
      #respond_with defaults to a blank response, we need the object sent back so that the id can be read
      respond_to do |format|
        format.json {render 'show', status: :accepted}
      end
    else
      respond_with @task do |format|
        format.json {render @task.errors.messages, status: :unprocessable_entity}
      end
    end
  end
  # ...
end

Ở ví dụ trên, ta đang cập nhật một task trong một project. Trước tiên, chúng ta lưu ý các giá trị hiện tại của một số thuộc tính của task, để sử dụng sau này. Sau đó cập nhật task với các tham số nhất định. Nếu thành công, thực hiện một số hành động sau:

  1. Gửi mail cho người dùng, nếu trạng thái của task đã thay đổi, ví dụ: "in progress" thành "complete".
  2. Nếu task đã được chuyển sang một project khác, cập nhật trực tiếp tới người dùng, sử dụng websockets hoặc một dạng thông báo không đồng bộ khác.
  3. Sau đó đẩy ra một hoặc nhiều thông báo liên quan đến task, cho phép người dùng quan tâm biết rằng task đã được tạo, cập nhật hoặc xóa.
  4. Nếu người được giao task đã thay đổi, gửi thông báo qua email để cho những người được ủy quyền mới và người nhận trước biết về sự thay đổi này.

Đây là ví dụ điển hình về một fat controller. Nhưng khi chúng ta cố gắng đưa nó vào chế độ ăn kiêng, bằng cách chuyển logic vào mô hình, vấn đề sẽ xảy ra như sau: tất cả các hoạt động mà action này thực hiện đều phụ thuộc vào kiến thức cụ thể của từng phiên. Các method như #push_task hay #mail_assignment cần phải biết những điều như current user , hoặc asynchronous socket ID của trình duyệt. Bạn sẽ không muốn đẩy các thông tin đó vào trong domain model.

Ở ví dụ trên, chúng ta nhận ra rằng: ẩn trong tất cả các điều kiện này là một loạt sự kiện vòng đời mô hình tên miền (domain-model lifecycle events), mỗi hành động cụ thể khác nhau kích hoạt những sự kiện đó:

  1. Có một số hành động để thực hiện bất cứ khi nào nhiệm vụ được cập nhật thành công.
  2. Có những hành động cần thực hiện khi nhiệm vụ chuyển từ dự án này sang dự án khác.
  3. Có các hành động để thực hiện khi nhiệm vụ được tạo mới.
  4. Có những hành động xảy ra khi tình trạng của tác vụ đã thay đổi.
  5. Có những hành động cho khi nhiệm vụ đã được chỉ định lại.

Công việc của controller là biết những thứ như current_user và cách đẩy ra một thông báo cho trình duyệt. Nhưng đó thực sự là công việc của model để biết khi các sự kiện xảy ra trong vòng đời của đối tượng.

Theo tư tưởng đó, chúng tôi đặt ra cho model Task khả năng thông báo tới các đối tượng quan tâm khi xảy ra những sự kiện này. Tạo ra một phương thức #add_listeners, để thêm một đối tượng quan tâm vào một danh sách nội bộ.

class Task < ApplicationRecord
  # ...
  def add_listener(listener)
    (@listeners ||= []) << listener
  end
  # ...
end

Phương thức #notify_listeners, có thể lặp lại danh sách người nghe ...

def notify_listeners(event_name, *args)
  @listeners && @listeners.each do |listener|
     # ...
  end
end

Và gửi một thông báo cho các đói tượng, được đặt tên cho một sự kiện cụ thể, chẳng hạn như :on_create hoặc :on_status_changed

    if listener.respond_to?(event_name)
      listener.public_send(event_name, self, *args)
    end

Sau đó thêm một ActiveModel "around" callback, để gọi tới phương thức #notify_on_save bất cứ khi nào mà Task được lưu lại

around_save :notify_on_save

Cuối cùng là thực hiện phương thức #notify_on_save

def notify_on_save
  # ...
end

Đầu tiên, phương thức này xác định kiểu lưu là gì. Đó là, cho dù Task đang được tạo ra hay cập nhật, cho dù đó là chuyển từ project này sang project khác, v.v ... Chúng tôi sử dụng nhiều tính năng "thuộc tính bẩn" (dirty attributes) của ActiveModel ở đây, sử dụng các phương thức như project_id_changed? để xem nếu project_id đã được thay đổi từ giá trị cơ sở dữ liệu của nó.

  is_create_save   = !persisted?
  project_changed  = project_id_changed?
  status_changed   = status_changed?
  assignee_changed = assignee_id_changed?
  old_project_id   = project_id_was
  old_status       = status_was
  old_assignee     = User.find_by_id(assignee_id_was)
  # ...

Khi mà yields gọi tới - bởi vì nó được sử dụng như một "around" callback, đây là điểm để xác định hành động save xảy ra

Sau khi Task được lưu, phương thức này tiến hành gửi các thông báo khác nhau đến bất kỳ người nghe nào đã đăng ký. Nếu task mới được tạo, người nghe sẽ nhận được thông báo #on_create:

if is_create_save
    notify_listeners(:on_create)
else
    notify_listeners(:on_project_change, old_project_id, project_id) if project_changed
    notify_listeners(:on_status_change, old_status, status) if status_changed
    notify_listeners(:on_assignment_change, old_assignee, assignee) if assignee_changed
    new_assignee = assignee if assignee_changed
    notify_listeners(:on_update, new_assignee)
end

Nếu trạng thái đã thay đổi, họ sẽ nhận được #on_status_change, v.v ...

Một số trong những thông điệp này cũng có một số arguments đi kèm. Ví dụ, trong trường hợp task được chuyển đến một project mới, thông báo sẽ gửi tới cả dự án ban đầu và dự án mới.

Bây giờ model của chúng ta đã có thể quản sát được, chúng ta sẽ quay lại với action #update trong controller. Hãy tập trung vào các bước nhỏ, để đơn giản hóa chính controller là người lắng nghe Task. Trước khi bắt đầu chúng ta cần bổ sung thêm vào danh sách người lắng nghe của @task

class TasksController < ApplicationController
  def update
    @task.add_listener self
  # ...
end

Từ if else end khá dài dòng bây giờ mọi thứ trở nên slim hơn

    if @task.update_attributes(params[:task])
      respond_to do |format|
        format.json {render 'show', status: :accepted}
      end
    else
      respond_with @task do |format|
        format.json {render @task.errors.messages, status: :unprocessable_entity}
      end
    end

Sau đó chúng tôi tiếp tục thực hiện theo phương thức, đưa code ra thành các phương pháp được đặt tên cho sự kiện vòng đời (lifecycle events) Task .

Ví dụ như khi có task mới được tạo ra

  def on_create(task)
    push_task('create_task')
    push_project_update(task.project.id) if task.project
    mail_assignment if @assignee && @assignee != current_user
  end

Task được chuyển tới project mới

  def on_project_change(task, previous_project_id, new_project_id)
    push_project_update(previous_project_id)
    push_project_update(new_project_id) if new_project_id
  end

Khi cập nhật trạng thái của task

  def on_status_change(task, previous_status, new_status)
    notifiee = task.readers(false) - [current_user]
    if notifiee
      mail_completion_notice(notifiee) if new_status == Status::COMPLETED
      mail_uncomplete_notice(notifiee) if previous_status == Status::COMPLETED
    end
  end

Khi task được assign lại

  def on_assignment_change(task, previous_assignee, new_assignee)
    push_task('create_task', new_assignee) if new_assignee
    mail_assignment if new_assignee
 
    push_task('delete_task', previous_assignee) if previous_assignee
    mail_assignment_removal(previous_assignee) if previous_assignee
  end

Khi task được update

  def on_update(task, new_assignee)
    update_users = task.readers - [new_assignee] if task.project
    push_task('update_task', update_users)
  end

Một khi chúng ta đảm bảo rằng controller vẫn hoạt động theo đúng như trước khi refactor, ta sẽ bắt đầu đi sâu hơn nữa vào các phương thức, chia thành các class "listener" mới mà chúng tương ứng với một khía cạnh trách nhiệm cụ thể trước đây của controller. Ví dụ, môt Task listener chỉ xử lý các thông báo đẩy trình duyệt chứ không phải thông báo qua email.

class PusherTaskListener
  def initialize(socket_id, queue=QC, worker=PusherWorker)
    @socket_id = socket_id
    @worker = worker
    @queue = queue
  end

  def on_create(task)
    push_task(task, 'create_task')
    push_project_update(task.project.id) if task.project
  end

  def on_project_change(task, previous_project_id, new_project_id)
    push_project_update(previous_project_id) if previous_project_id
    push_project_update(new_project_id) if new_project_id
  end

  def on_assignment_change(task, previous_assignee, new_assignee)
    push_task(task, 'create_task', new_assignee) if new_assignee
    push_task(task, 'delete_task', previous_assignee) if previous_assignee
  end

  def on_update(task, new_assignee)
    update_users = task.readers - [new_assignee] if task.project
    push_task(task, 'update_task', update_users)
  end

  # ...details of how notifications are pushed omitted...
end

Quay lại với controller, thay vì truyền self vào phương thức #add_listener ta có thể thêm một loạt các đối tượng listener, một trong số đó liên quan đến việc đẩy thông báo trình duyệt, một người khác gửi email. Các đối tượng này gói gọn các chi tiết về phương thức truyền vào cụ thể được thực hiện như thế nào

class TasksController < ApplicationController
  def update
    @task.add_listener TaskPusherListener.new(@socket_id)
    @task.add_listener TaskEmailListener.new(current_user)
    # ...
  end
  # ...
end

Nếu như bạn đã làm việc với Rails một thời gian, bạn có thể tự hỏi làm thế nào cách tiếp cận này khác với Rails 3-era Observers.

Khi bạn tạo một Rails ActiveRecord Observer, nó sẽ tự động được nối vào mỗi lần gọi tới hành động model mà nó áp dụng. Điều này dẫn đến "Hành động kỳ quặc ở khoảng cách"(spooky action at a distance), nơi gọi một phương thức model gây ra các code trong các lớp khác được thực hiện bất ngờ, thường có kết quả đáng ngạc nhiên và không mong muốn. Trong những năm qua, các lập trình Rails đã phát hiện ra rằng những implicit observers này gây ra nhiều vấn đề mà nhiều hướng dẫn của dự án đã cấm việc sử dụng chúng, và thư viện cuối cùng đã được gỡ bỏ khỏi Rails.

Ngược lại, loại observability được chúng tôi sử dụng ở đây là hoàn toàn rõ ràng. Chúng tôi thêm listener vào đầu hành động của controller mà chúng tôi quan sát. Bất cứ ai đọc code controller có thể thấy những listener nào được thêm vào model objects nào, và có thể biết để mong đợi callback vào những listener đó.

Bây giờ chúng ta đã chia một fat controller thành ba lĩnh vực khác nhau của kiến thức:

  1. Thông tin về phiên hiện tại và request, bao gồm cả ID người dùng và các tham số. Controller chịu trách nhiệm về điều này.
  2. Các sự kiện có thể xảy ra trong vòng đời của Task. Model Task hiện nay chịu trách nhiệm về kiến thức này.
  3. Ai cần được thông báo về các sự kiện vòng đời Task khác nhau và cách họ được thông báo. Nhiều class listener cụ thể đóng gói kiến thức này.

Chúng tôi đã xem xét điều này trong ngữ cảnh của một action controller Rails, nhưng nó thực sự là một kỹ thuật áp dụng cho bất kỳ loại ứng dụng nào, dựa trên web hoặc bằng cách khác. Chia logic thành các sự kiện và quan sát viên là một kỹ thuật cơ bản để gỡ lỗi các mô hình miền và các trách nhiệm giao diện người dùng.

Bài việt được dịch từ Slim down hefty Rails controllers AND models, using domain model events.

0