Làm gầy Model và Controller

Chúng ta hay có thói quen viết quá nhiều logic tính toán hay truy vấn cơ sở dữ liệu vào một Model, một Controller duy nhất hoặc gọi trực tiếp các truy vấn cơ sở dữ liệu ngay trên Controller mà không thông qua việc đóng gói dữ liệu. Khi chúng ta làm như vậy thì sẽ gây khó khăn:

  • Cho việc tái sử dụng lại mã nguồn.
  • Sửa chữa mã nguồn.
  • Mở rộng các chức năng.
  • Những người muốn đọc hiểu lại mã nguồn của chúng ta khi maintain.

Như vậy là chúng ta đã đang tạo nên những fat Model, fat Controller. Vậy làm thế nào để Model và Controller trở nên gầy gò hơn (skinny Model, skinny Controller), đây là những gì mà tôi sẽ trình bày trong bài viết này

Service Objects (and Interactor Objects)

Các service object được tạo ra khi có một sự kiện:

  • Phức tạp (chẳng hạn như tính lương của nhân viên)
  • Sử dụng API của các service bên ngoài
  • Không thuộc vào model (ví dụ xóa các dữ liệu không dùng đến)
  • Sử dụng nhiều model (ví dụ bạn nhập dữ liệu từ file vào nhiều model)

Ví dụ

Trong ví dụ bên dưới, công việc được thực hiện bởi Stripe service bên ngoài. Stripe service tạo ra một Stripe customer dựa trên địa chỉ email và mã nguồn (mã thông báo) và gắn bất kỳ dịch vụ thanh toán nào vào tài khoản.

Vấn đề

  • Logic của hoạt động cùng với một dịch vụ bên ngoài được đặt trong Controller.
  • Dữ liệu lấy từ form thông qua các param là để dùng cho service bên ngoài.
  • Khó khăn trong việc bảo trì và quản lý Controller.
class ChargesController < ApplicationController
  def create
    amount = params[:amount].to_i * 100
    customer = Stripe::Customer.create email: params[:email], source: params[:source]
    charge = Stripe::Charge.create customer: customer.id, amount: amount, 
      description: params[:description], currency: params[:currency] || "USD"
    redirect_to charges_path
  rescue Stripe::CardError => exception
    flash[:error] = exception.message
    redirect_to new_charge_path
  end
end

Để giải quyết vấn đề này, chúng ta đóng gói công việc của chúng ta cùng với một service bên ngoài.

class ChargesController < ApplicationController
 def create
   CheckoutService.new(params).call
   redirect_to charges_path
 rescue Stripe::CardError => exception
   flash[:error] = exception.message
   redirect_to new_charge_path
 end
end

class CheckoutService
 DEFAULT_CURRENCY = "USD".freeze

 def initialize options = {}
   options.each_pair do |key, value|
     instance_variable_set "@#{key}", value
   end
 end

 def call
   Stripe::Charge.create charge_attributes
 end

 private
 attr_reader :email, :source, :amount, :description

 def currency
   @currency || DEFAULT_CURRENCY
 end

 def amount
   @amount.to_i * 100
 end

 def customer
   @customer ||= Stripe::Customer.create customer_attributes
 end

 def customer_attributes
   {email: email, source: source}
 end

 def charge_attributes
   {
     customer: customer.id,
     amount: amount,
     description: description,
     currency: currency
   }
 end
end

Kết quả là một CheckoutService chịu trách nhiệm tạo tài khoản khách hàng và thanh toán. Tuy nhiên khi giải quyết được vấn đề quá nhiều logic trong Controller, chúng ta vẫn còn một vấn đề cần giải quyết. Điều gì sẽ xảy ra nếu service bên ngoài đưa ra một ngoại lệ (ví dụ thẻ tín dụng không hợp lệ) và chúng ta phải chuyển hướng người dùng đến một trang khác.

class ChargesController < ApplicationController
 def create
   CheckoutService.new(params).call
   redirect_to charges_path
 rescue Stripe::CardError => exception
   flash[:error] = exception.error
   redirect_to new_charge_path
 end
end

Để xử lý vấn đề này, chúng ta gói lời gọi CheckoutService và ngoại lệ trong đối tượng interactor. Interactor được sử dụng để gói logic nghiệp vụ. Mỗi một interactor thường đại diện cho một nguyên tắc nghiệp vụ. Mô hình interactor giúp chúng ta đạt được nguyên tắc đơn nhiệm (SRP) bằng cách sử dụng những đối tượng Ruby cũ (POROs). Các interactor cũng tương tự như các đối tượng service, nhưng thường tả về một số giá trị cho thấy trạng thái thực thi và các thông tin khác (thêm vào các hành động được thực hiện). Chúng ta cũng thường sử dụng các đối tượng service bên trong các đối tượng interactor. Dưới đây là cách sử dụng design pattern:

class ChargesController < ApplicationController
 def create
   interactor = CheckoutInteractor.call self

   if interactor.success?
     redirect_to charges_path
   else
     flash[:error] = interactor.error
     redirect_to new_charge_path
   end
 end
end

class CheckoutInteractor
 def self.call context
   interactor = new context
   interactor.run
   interactor
 end

 attr_reader :error

 def initialize context
   @context = context
 end

 def success?
   @error.nil?
 end

 def run
   CheckoutService.new context.params
 rescue Stripe::CardError => exception
   fail! exception.message 
 end

 private
 attr_reader :context

 def fail! error
   @error = error
 end
end

Đưa tất cả các ngoại lệ có liên quan vào CardError, chúng ta có thể có được skinny Controller. Bây giờ, ChargesController chỉ có trách nhiệm chuyển người dùng đến trang thanh toán thành công hoặc thanh toán không thành công.

Value Objects

Value object trong design pattern khuyến khích tạo ra những đối tượng nhỏ, đơn giản (thường chỉ chứa giá trị nhất định) và cho phép bạn so sánh các đối tượng theo một logic nhất định hoặc đơn giản dựa trên các thuộc tính cụ thể. Ví dụ, các value object là các đối tượng thể hiện giá trị tiền tệ bằng nhiều loại tiền tệ khác nhau, chúng ta có thể so sánh các giá trị đối tượng này bằng một đơn vị tiền tệ (ví dụ: USD). Các value object cũng có thể biểu diễn nhiệt độ và so sánh bằng thang đo Kelvin.

Ví dụ

Giả sử chúng ta có một ngôi nhà thông minh với nhiệt điện và lò sưởi được điều khiển thông qua một giao diện web. Vai trò của Controller là nhận các parameter cho lò sưởi (heater) từ một cảm ứng nhiệt độ: nhiệt độ (một giá trị) và thang nhiệt độ (Fahrenheit, Celsius hoặc Kelvin). Nhiệt độ này sẽ chuyển về thang đo Kelvin nếu được cung cấp theo thang đo khác, controller sẽ kiểm tra xem nhiệt độ hiện tại nhỏ hơn, lớn hơn hay bằng nhiệt độ hiện tại.

Vấn đề

Controller chứa quá nhiều logic liên quan đến việc chuyển đổi và so sánh các giá trị nhiệt độ

class AutomatedThermostaticValvesController < ApplicationController
  SCALES = %w(kelvin celsius fahrenheit)
  DEFAULT_SCALE = "kelvin"
  MAX_TEMPERATURE = 25 + 273.15

  before_action :set_scale

  def heat_up
    was_heat_up = false
    if previous_temperature < next_temperature && next_temperature < MAX_TEMPERATURE
      valve.update degrees: params[:degrees], scale: params[:scale]
      Heater.call next_temperature
      was_heat_up = true
    end
    render json: {was_heat_up: was_heat_up}
  end

  private
  def previous_temperature
    kelvin_degrees_by_scale valve.degrees, valve.scale
  end

  def next_temperature
    kelvin_degrees_by_scale params[:degrees], @scale
  end

  def set_scale
    @scale = SCALES.include?(params[:scale]) ? params[:scale] : DEFAULT_SCALE
  end

  def valve
    @valve ||= AutomatedThermostaticValve.find params[:id]
  end

  def kelvin_degrees_by_scale degrees, scale
    degrees = degrees.to_f
    case scale.to_s
    when "kelvin"
      degrees
    when "celsius"
      degrees + 273.15
    when "fahrenheit"
      (degrees - 32) * 5 / 9 + 273.15
    end
  end
end

Chúng ta chuyển logic so sánh nhiệt độ vào model, do đó Controller chỉ truyền tham số đến phương thức update. Nhưng Model vẫn chưa phải là tối ưu, nó vẫn quá nhiều các xử lý về chuyển đổi nhiệt độ.

class AutomatedThermostaticValvesController < ApplicationController
  def heat_up
    valve.update next_degrees: params[:degrees], next_scale: params[:scale]
    render json: {was_heat_up: valve.was_heat_up}
  end

  private
  def valve
    @valve ||= AutomatedThermostaticValve.find params[:id]
  end
end

class AutomatedThermostaticValve < ActiveRecord::Base
  SCALES = %w(kelvin celsius fahrenheit)
  DEFAULT_SCALE = "kelvin"

  attr_accessor :next_degrees, :next_scale
  attr_reader :was_heat_up

  before_validation :check_next_temperature, if: :next_temperature
  after_save :launch_heater, if: :was_heat_up

  def temperature
    kelvin_degrees_by_scale degrees, scale
  end

  def next_temperature
    kelvin_degrees_by_scale next_degrees, next_scale if next_degrees.present?
  end

  def max_temperature
    kelvin_degrees_by_scale 25, "celsius"
  end

  def next_scale=(scale)
    @next_scale = SCALES.include?(scale) ? scale : DEFAULT_SCALE
  end

  private
  def check_next_temperature
    @was_heat_up = false
    if temperature < next_temperature && next_temperature <= max_temperature
      @was_heat_up = true
      assign_attributes degrees: next_degrees, scale: next_scale
    end
    @was_heat_up
  end

  def launch_heater
    Heater.call temperature
  end

  def kelvin_degrees_by_scale degrees, scale
    degrees = degrees.to_f
    case scale.to_s
    when "kelvin"
      degrees
    when "celsius"
      degrees + 273.15
    when "fahrenheit"
      (degrees - 32) * 5 / 9 + 273.15
    end
  end
end

Để Model trở nên gầy (skinny), chúng ta tạo ra những đối tượng. Khi khởi tạo, đối tượng sẽ lấy giá trị của nhiệt độ và thang đo. Khi so sánh các đối tượng (<=>) chúng ta chuyển về Kelvin. Các đối tượng chứa phương thức to_h cho phép gán giá trị vào thuộc tính. Đối tượng Temperature dễ dàng được tạo ra bởi một trong số các hàm from_kelvin, from_celsiusfrom_fahrenheit tùy vào thang đo nhiệt độ. Ví dụ: Temperature.from_celsius(0) sẽ tạo ra một đối tượng có nhiệt độ 0°C hoặc 273°К.

class AutomatedThermostaticValvesController < ApplicationController
  def heat_up
    valve.update next_degrees: params[:degrees], next_scale: params[:scale]
    render json: {was_heat_up: valve.was_heat_up}
  end

  private
  def valve
    @valve ||= AutomatedThermostaticValve.find params[:id]
  end
end

class AutomatedThermostaticValve < ActiveRecord::Base
  before_validation :check_next_temperature, if: :next_temperature
  after_save :launch_heater, if: :was_heat_up

  attr_accessor :next_degrees, :next_scale
  attr_reader :was_heat_up

  def temperature
    Temperature.new degrees, scale
  end

  def temperature=(temperature)
    assign_attributes temperature.to_h
  end

  def next_temperature
    Temperature.new next_degrees, next_scale if next_degrees.present?
  end

  private
  def check_next_temperature
    @was_heat_up = false
    if temperature < next_temperature && next_temperature <= Temperature::MAX
      self.temperature = next_temperature
      @was_heat_up = true
    end
  end

  def launch_heater
    Heater.call temperature.kelvin_degrees
  end
end

class Temperature
  include Comparable
  SCALES = %w(kelvin celsius fahrenheit)
  DEFAULT_SCALE = "kelvin"

  attr_reader :degrees, :scale, :kelvin_degrees

  def initialize degrees, scale = "kelvin"
    @degrees = degrees.to_f
    @scale = case scale
    when *SCALES 
      scale
    else 
      DEFAULT_SCALE
    end

    @kelvin_degrees = case @scale
    when "kelvin"
      @degrees
    when "celsius"
      @degrees + 273.15
    when "fahrenheit"
      (@degrees - 32) * 5 / 9 + 273.15
    end
  end

  def self.from_celsius degrees_celsius
    new degrees_celsius, "celsius"
  end

  def self.from_fahrenheit degrees_fahrenheit
    new degrees_celsius, "fahrenheit"
  end

  def self.from_kelvin degrees_kelvin
    new degrees_kelvin, "kelvin"
  end

  def <=>(other)
    kelvin_degrees <=> other.kelvin_degrees
  end

  def to_h
    {degrees: degrees, scale: scale}
  end

  MAX = from_celsius 25
end

Kết quả là chúng ta đã có một skinny Controller và một skinny Model. Controller không biết bất kỳ logic nào liên quan đến nhiệt độ, Model không biết bất kỳ điều gì về chuyển đổi nhiệt độ và chỉ sử dụng các phương thức của đối tượng Temperature.

Form Objects

Form object là một design pattern đóng gói liên quan đến logic validating và tồn tại dữ liệu.

Ví dụ

Giả sử chúng ta có một Model và Controller điển hình để tạo mới nhiều người dùng.

Vấn đề

Model có chứa tất validation logic, do đó nó không thể dùng lại cho cho các thực thế khác, ví dụ: admin

class UsersController < ApplicationController
  def create
    @user = User.new user_params

    if @user.save
      render json: @user
    else
      render json: @user.error, status: :unprocessable_entity
    end
  end

  private
  def user_params
    params.require(:user).permit :email, :full_name, :password, :password_confirmation
  end
end

class User < ActiveRecord::Base
  EMAIL_REGEX = /\A[^@\s][email protected]([^@.\s]+\.)+[^@.\s]+\z/

  validates :full_name, presence: true
  validates :email, presence: true, format: {with: EMAIL_REGEX}
  validates :password, presence: true, confirmation: true
end

Một giải pháp là chuyển hết validation logic đến một lớp riêng biệt và lớp này là UserForm

class UserForm
  EMAIL_REGEX = /\A[^@\s][email protected]([^@.\s]+\.)+[^@.\s]+\z/

  include ActiveModel::Model
  include Virtus.model

  attribute :id, Integer
  attribute :full_name, String
  attribute :email, String
  attribute :password, String
  attribute :password_confirmation, String

  validates :full_name, presence: true
  validates :email, presence: true, format: {with: EMAIL_REGEX}
  validates :password, presence: true, confirmation: true

  attr_reader :record

  def persist
    @record = id ? User.find(id) : User.new

    if valid?
      @record.attributes = attributes.except :password_confirmation, :id
      @record.save!
      true
    else
      false
    end
  end
end

Sau khi chúng ta di chuyển validation logic vào UserForm, chúng ta sử dụng nó bên trong Controller như sau

class UsersController < ApplicationController
  def create
    @form = UserForm.new user_params

    if @form.persist
      render json: @form.record
    else
      render json: @form.errors, status: :unpocessably_entity
    end
  end

  private
  def user_params
    params.require(:user).permit :email, :full_name, :password, :password_confirmation
  end
end

Kết quả là model User không còn phải validate dữ liệu nữa

class User < ActiveRecord::Base
end

Query Objects

Query object là một dessign pattern cho phép chúng ta có thể lấy ra các query logic từ Controller và Model cho vào các lớp khác và có thể sử dụng nhiều lần.

Ví dụ

Chúng ta muốn lấy ra một danh sách các bài viết thuộc thể loại video có số lượng xem lớn hơn 100 và người dùng hiện tại có thể truy cập được.

Vấn đề

Tất cả các truy vấn đều đặt trong Controller (tất cả các điều kiện truy vấn cũng đặt trong Controller)

  • Logic này sẻ không được sử dụng lại
  • Rất khó để kiểm tra
  • Bất kỳ những thay đổi nào của bài viết cũng có thể phải sửa đổi lại code
class Article < ActiveRecord::Base
  # t.string :status
  # t.string :type
  # t.integer :view_count
end

class ArticlesController < ApplicationController
  def index
    @articles = Article.accessible_by(current_ability).where(type: :video).where "view_count > ?", 100
  end
end

Bước đầu tiên của chúng ta là tái cấu trúc lại Controller bằng cách ẩn và đóng gói các điều kiện truy vấn cơ bản thông qua việc cung cấp một API đơn giản cho việc truy vấn Model. Trong Rails chúng ta có thể làm điều này bằng cách tạo ra các scope:

class Article < ActiveRecord::Base
  scope :with_video_type, -> {where type: :video}
  scope :popular, -> {where "view_count > ?", 100}
  scope :popular_with_video_type, -> {popular.with_video_type}
end

Bây giờ chúng ta có thể dử dụng API đơn giản này để truy vấn mọi thứ chúng ta cần mà không phải lo lắng về việc thực hiện. Nếu Article của chúng ta có bất kỳ thay đổi gì, chúng ta chỉ cần thực hiện thay đổi trong class Article

class ArticlesController < ApplicationController
  def index
    @articles = Article.accessible_by(current_ability).popular_with_video_type
  end
end

Có vẻ tốt hơn, nhưng bây giờ có một vấn đề mới phát sinh. Chúng ta phải tạo ra những scope cho mọi điều kiện truy vấn mà chúng ta muốn đóng gói, cho những Model với scope khác nhau. Một vấn đề nữa là, scope không thể sử dụng lại trên các model khác nhau, điều đó có nghĩa là bạn không thể sử dụng scope từ class Article để truy vấn trên class Attachment. Chúng ta không thể phá vỡ nguyên tắc đơn nhiệm (SRP) bằng cách cho toàn bộ truy vấn vào class Article. Giải pháp cho vấn đề này là sử dụng đối tượng truy vấn

class PopularVideoQuery
  def call relation
    relation.where(type: :video).where "view_count > ?", 100
  end
end

class ArticlesController < ApplicationController
  def index
    relation = Article.accessible_by current_ability
    @articles = PopularVideoQuery.new.call relation
  end
end

Tuyệt vời, nó có thể dùng lại được. Bây giờ chúng ta có thể sử dụng class này để truy vấn cho các class khác có cùng schema.

class Attachment < ActiveRecord::Base
  # t.string :type
  # t.integer :view_count
end

PopularVideoQuery.new.call(Attachment.all).to_sql
# "SELECT \"attachments\".* FROM \"attachments\" WHERE \"attachments\".\"type\" = 'video' AND (view_count > 100)"
PopularVideoQuery.new.call(Article.all).to_sql
# "SELECT \"articles\".* FROM \"articles\" WHERE \"articles\".\"type\" = 'video' AND (view_count > 100)"

Ngoài ra chúng ta muốn liên kết chúng với nhau thì thật đơn giản. Điều duy nhất chúng ta phải nghỉ đến là lời gọi phương thức nên phù hợp với các interface của ActiveRecord::Relation

class BaseQuery
  def |(other)
    ChainedQuery.new do |relation|
      other.call(call(relation))
    end
  end
end

class ChainedQuery < BaseQuery
  def initialize &block
    @block = block
  end

  def call relation
    @block.call relation
  end
end

class WithStatusQuery < BaseQuery
  def initialize status
    @status = status
  end

  def call relation
    relation.where status: @status
  end
end

query = WithStatusQuery.new(:published) | PopularVideoQuery.new
query.call(Article.all).to_sql
# "SELECT \"articles\".* FROM \"articles\" WHERE \"articles\".\"status\" = 'published' AND \"articles\".\"type\" = 'video' AND (view_count > 100)"

Bây giờ chúng ta đã có một class để tái sử dụng cùng với tất cả logic đã được đóng gói, một interface đơn giản và dễ dàng để kiểm tra

Kết luận

Bài viết này quá dài nên tôi mới chỉ tập trung được vào 2 vấn đề chính là làm thế nào để có được skinny Model, skinny Controller, còn vấn đề skinny View (View Objects, Policy Objects và Decorators) xin hẹn gặp lại các bạn trong một bài viết nào đó của tôi Rất mong nhận được sự đóng góp ý kiến chân thành của các bạn. Bài viết này có tham khảo từ nguồn https://www.sitepoint.com/7-design-patterns-to-refactor-mvc-components-in-rails/


All Rights Reserved