07/09/2018, 17:38

Những Design Patterns cho việc Refactor MVC Components trong Rails

Chúng ta đã biết mô hình MVC trong Rails, nhưng ít ai biết được cách để giữ cho chúng gọn nhẹ (skinny). Để làm được điều đó, đòi hỏi chúng ta cần phải refactor code thường xuyên, để làm điều đó chúng ta cần vận dụng các design patterns để hỗ trợ trong quá trình refactor. Dưới đây sẽ giới thiệu cho ...

Chúng ta đã biết mô hình MVC trong Rails, nhưng ít ai biết được cách để giữ cho chúng gọn nhẹ (skinny). Để làm được điều đó, đòi hỏi chúng ta cần phải refactor code thường xuyên, để làm điều đó chúng ta cần vận dụng các design patterns để hỗ trợ trong quá trình refactor. Dưới đây sẽ giới thiệu cho các bạn về 7 design patterns để hỗ trợ trong việc refactor MVC components trong Rails

Về cơ bản chúng ta đã biết một số pattern như là View Objects (Serializer/Presenter), Policy Objects hay Decorators và Service Objects. bài dưới sẽ giới thiệu một số patterns mà ít khi được áp dụng tới như là Form Object, Query Object, Value Object.

1. Service Objects (and Interactor Objects)

Service Object được sử dụng khi chúng ta có những action:

  • Phức tạp (như là tính mức lương của nhân viên ...)
  • Sử dụng API của các dịch vụ bên ngoài
  • không phụ thuộc vào một model nào cụ thể
  • hoặc là sử dụng nhều models

Cụ thể chi tiết về service object các bạn có thể tham khảo thêm về bài trước mình từng viết Service Object là gì? Sử dụng nó như thế nào?

Việc sử dụng Service Object sẽ làm cho controller trở nên nhẹ nhàng hơn rất nhiều, controller giờ chỉ còn việc điều hướng nó đến trang thành công hoặc thất bại dựa vào service của bạn.

2. Value Object

Value Object design pattern thường được dùng cho những object đơn giản, nhỏ (thường là object đã chưa những thông tin cần thiết), và cho phép bạn so sánh các đối tượng này theo một logic nhất định hoặc đơn giản là dựa trên các thuộc tính cụ thể. Ví dụ như về tiền tệ, chúng ta quy đổi ra các đơn vị tiền tệ khác nhau, hoặc nhiệt độ có thể đo theo Kelvin hoặc Celsius..
Để hiểu sâu hơn về nó chúng ta đi qua ví dụ sau:
Giả sử chúng ta có một ngôi nhà thông minh, với nhiệt điện và lò sửa được điều khiển qua giao diện trang web. Chúng ta sẽ có một controller nhận params là thông số từ một cảm biến nhiệt độ và thang đo nhiệt độ khác nhau (Fahrenheit, Celsius, or Kelvin). Nhiệt độ này sẽ được chuyển đổi qua kelvin nếu như đang truyền vào theo thang đo khác. Và controller sẽ kiểm tra xem nếu nhiệt độ nhỏ hơn 25°C và so sánh với nhiệt độ hiện tại xem lớn hơn hay nhỏ hơn.

Vấn đề

Bộ điều khiển chứa quá nhiều logic liên quan đến việc chuyển đổi và so sánh giữa các 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

Có quá nhiều logic được gán trong controller, chúng ta chuyển bớt logic so sánh nhiệt độ vào trong model và trong controller thì sẽ truyền tham số so sánh. Nhưng mà model cũng không phải là một nơi lý tưởng để làm việc này. Model bị phình to ra và biết quá nhiều logic liên quan đến việc 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'

  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
    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

Để làm model gọn nhẹ hơn, chúng ta tạo ra một value objects. Khi khởi tạo các đối tượng có giá trị nhiệt độ và thang đo. Chúng ta sẽ chuyển nhiệt độ và so sánh theo Kelvin

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 then 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 controller, model gọn nhẹ. Controller không biết bất cứ logic nào liên quan đến nhiệt độ và model cũng không hề biết bất cứ điều gì liên quan dến việc chuyển đổi nhiệt độ.

3. Form Object

Form Object là một pattern gói gọn việc validating và persiting data.

Ví dụ: Chúng ta có một rails model và controller với action tạo mới một user

Vấn đề: Model chứa tất cả các logic và nó không thể sử dụng lại được với tất cả các thực thể khác, như là với admin chúng ta sẽ validate một kiểu, manager chúng ta sẽ validate kiểu khác.

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 = /@/ # Some fancy email regex

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

Một giải pháp là chúng ta chuyển các logic validate sang một lớp riêng biệt gọi là UserForm

class UserForm
  EMAIL_REGEX = // # Some fancy email regex

  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: 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 chuyển các logic sang form object chúng ta có thể sử dụng chúng 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à không còn bất cứ validate nào trong model nữa

class User < ActiveRecord::Base
end

4. Query Objects

Query Object là một pattern cho phép chúng ta trích ra logic query từ controller và model vào một class có thể sử dụng lại được.

Ví dụ: Chúng ta muốn yêu cầu một danh sách các article thuộc loại video có lượt 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ả logic đang đặt trong controller

  • Logic này sẽ không sử dụng lại được
  • Rất khó để test khi chúng đặt trong model
  • Nếu Article thay đổi thì có thể sẽ ảnh hưởng đến code này
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 trong việc refactor là cho tất cả query vào trong model và sử dụng tại controller

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

Và bây giờ chúng ta có thể dễ dàng gọi nó ra

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

Nó trông đã tốt hơn rất nhiều, tuy nhiên có một số vấn đề mới phát sinh. Chúng ta cần tạo scope tương tự cho những model khác, và vì nó không thể tái sự dụng được cho những model khác nên giải pháp cho vấn đề này là sử dụng query object. Chúng ta sẽ tạo một Query object để lấy ra những record với type là video và số lượng view lớn hơn 100 => Object này sẽ sự dụng được cho tất cả các relation với field tương tự như vậy

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

Nhìn vào đây chúng ta có thể dễ dàng sử dụng lại cho các model có chung thuộc tính

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 có thể dễ dàng kết nối chúng với nhau với 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ó được một class có thể dễ dàng sử dụng lại được, chứa tất cả logic query và dễ dàng kiểm tra.

Trên là 7 design patterns hữu ích trong quá trình refactor, tuy nhiên bạn nên sử dụng nó ngay từ khi bắt đầu dự án để có thể quản lý code dễ dàng hơn.

References

https://www.sitepoint.com/7-design-patterns-to-refactor-mvc-components-in-rails/

0