10/11/2018, 00:00

7 design pattern để tái cấu trúc MVC components trong Rails △

Để các Model, View, Controller trong rails được gọn gàng, chúng ta phải liên tục tái cấu trúc lại code. Tái cấu trúc là một tiến trình tái cơ cấu lại code hiện có. Trong khi tái cấu trúc không làm thay đổi bất cứ cái gì từ phía góc nhìn của end user, nó giúp cho code được sạch sẽ, dễ dàng bảo ...

Để các Model, View, Controller trong rails được gọn gàng, chúng ta phải liên tục tái cấu trúc lại code. Tái cấu trúc là một tiến trình tái cơ cấu lại code hiện có. Trong khi tái cấu trúc không làm thay đổi bất cứ cái gì từ phía góc nhìn của end user, nó giúp cho code được sạch sẽ, dễ dàng bảo trì, test, đem lại nhiều ích lợi cho developer.

Tái cấu trúc tuân theo một quy tắc đơn giản là nếu bạn tạo ra một mớ hỗn độn thì chính bạn nên là người tự dọn dẹp nó. Tái cấu trúc là việc liên tục dọn dẹp những thứ xảy ra sau khi code thay đổi. Bạn không thể xây dựng 1 tòa nhà chọc trời hay vẽ 1 bức tranh kiệt tác mà không có nhiều mớ hỗn độn trong quá trình này, và cũng giống như việc viết ra code chất lượng. Đó chính là lý do tại sao chúng ta cần phải tái cấu trúc lại code mỗi khi implement một tính năng mới nào đó.

Ở đây mình sẽ giới thiệu 7 design pattern để tái cấu trúc lại code:

  • Service Objects.
  • Value Objects.
  • Form Objects.
  • Query Objects.
  • View Objects.
  • Policy Objects.
  • Decorators.

Service Object được sử dụng khi một action có các tính chất:

  • phức tạp (ví dụ như tính tiền lương).
  • sử dụng API từ bên ngoài.
  • không thuộc về riêng 1 model nào đó (ví dụ như xóa các outdated data).
  • sử dụng nhiều model (ví dụ như import data từ 1 file ra nhiều model khác nhau).

Ví dụ
Trong ví dụ bên dưới, hành động sẽ được thực hiện bởi Stripe service. Stripe service này sẽ tạo 1 Stripe customer dựa trên địa chỉ email và 1 nguồn khác (ví dụ như token) và ràng buộc bất kỳ dịch vụ thanh toán nào cho tài khoản này.

Vấn đề

  • Logic của hoạt động này với service bên ngoài được đặt bên trong controller.
  • Controller tạo dữ liệu cho 1 service bên ngoài.
  • Rất khó để duy trì và mở rộng controller.
class ChargesController  exception
    flash[:error] = exception.message
    redirect_to new_charge_path
  end
end

Để giải quyết những vấn đề này, chúng ta đóng gói hoạt động lại 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à CheckoutService chịu trách nhiệm về việc tạo và thanh toán tài khoản customer. Nhưng sau khi giải quyết được việc có qua nhiều logic trong controller như trên, ta lại gặp 1 vấn đề khác đó là chuyện gì sẽ xảy ra nếu service bên ngoài kia throw 1 exception (ví dụ như credit card không hợp lệ) và chúng ta phải điều hướng user tới 1 page 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

Để giải quyết việc này chúng ta thêm 1 CheckoutService call và chặn các exception với Interactor Object. Interactor được sử dụng để gói gọn các logic nghiệp vụ. Mỗi interactor thường mô tả 1 quy tắc nghiệp vụ.

Mô hình Interactor giúp chúng ta đạt được Nguyên tắc Trách nhiệm Duy nhất (SRP) bằng cách sử dụng plain old Ruby objects (POROs) - để lại các model chỉ chịu trách nhiệm ở mức ổn định. Interactors gần giống với Service Object nhưng thường trả về một số giá trị cho biết trạng thái thực thi và các thông tin khác (ngoài các hành động thực thi). Dưới đây là 1 ví dụ:

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

Bằng cách chuyển tất cả ngoại lệ liên quan đến card ra ngoài, controller của chúng ta đã trở nên gọn gàng hơn, chỉ chịu trách nhiệm chuyển hướng người dùng đến các trang thanh toán thành công hay không thành công.

Value Object khuyến khích các đối tượng đơn giản, nhỏ (thường chỉ chứa các value cho trước) 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 dựa trên các thuộc tính cụ thể (và không dựa trên danh tính của chúng). Một ví dụ về value object là các đối tượng biểu diễn các giá trị tiền bằng các loại tiền tệ khác nhau. Sau đó chúng ta có thể so sánh các value object này theo 1 loại tiền cụ thể (ví dụ USD). Ví dụ, value object cũng có thể biểu thị nhiệt độ và được so sánh bằng thang Kelvin.

Ví dụ
Giả sử chúng ta có 1 ngôi nhà thông minh với máy sưởi điện, và cái máy này được điều khiện thông qua 1 giao diện web. Nhiệm vụ của controller là nhận các thông số cho máy sưởi thông qua 1 cảm biến nhiệt: nhiệt độ (ở dạng số) và thang nhiệt độ (Fahrenheit, Celsius, hoặc Kelvin). Thông số này sẽ được chuyển về thang Kelvin nếu ở nhưng thang khác và controller sẽ kiểm tra nhiệt độ này có nhỏ hơn 25 ° C hay không hay nó có bằng hoặc lớn hơn nhiệt độ hiện tại hay không.

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 có thể chuyển logic so sánh nhiệt độ sang Model, vậy nên Controller chỉ chuyển các tham số tới phương thức cập nhật. Nhưng Model vẫn không lý tưởng - nó biết quá nhiều về cách xử lý 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 cho model trở nên gọn hơn, chúng ta tạo Value Object. Khi khởi tạo, các đối tượng nhận các giá trị nhiệt độ và thang độ. Khi so sánh các đối tượng này, spaceship method (<=>) sẽ so sánh nhiệt độ của chúng, chuyển thành Kelvin.

Value Object này cũng chứa 1 method to_h để gán các thuộc tính khối. Value Object cung cấp các method from_kelvin, from_celsius, and from_fahrenheit để dễ dàng tạo các object từ nhưng thang độ khác nhau.(ví dụ Temperature.from_celsius(0) sẽ tạo 1 object với 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 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à ta có 1 model và controller khá gọn gàng. Controller (AutomatedThermostaticValvesController) không hề biết gì về các chuyển đổi nhiệt độ, Model (AutomatedThermostaticValve) thì cũng không biết gì luôn mà chỉ sử dụng duy nhất các method từ Temperature value object.

Form Object là một design pattern đóng gói logic liên quan đến việc xác thực và lưu trữ dữ liệu.

Ví dụ
Giả sử chúng ta có một Rails Model và Controller rất cơ bản để tạo user mới.

Vấn đề
Model chứa tất cả logic xác thực, vì vậy nó không thể sử dụng lại 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 = /@/ # 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à di chuyển logic xác nhận vào 1 class chịu trách nhiệm riêng biệt duy nhất 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 chúng ta chuyển logic xác thực sang UserForm, chúng ta có thể sử dụng 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 chịu trách nhiệm xác thực dữ liệu:

class User < ActiveRecord::Base
end

Query Object là một design pattern cho phép chúng ta trích xuất logic truy vấn từ Controller và Model thành các lớp có thể tái sử dụng.

Ví dụ
Chúng ta muốn request 1 list các article có type "video" có lượt xem lớn hơn 100 và người dùng hiện tại có thể truy cập.

Vấn đề
Tất cả logic truy vấn nằm trong Controller (tất cả các điều kiện truy vấn được áp đặt trong Controller).

  • Logic này không thể tái sử dụng được.
  • Khó để test.
  • Bất kì thay đổi nào với article schema cũng có thể break the 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 để refactor lại controller này là giấu và đóng gói các điều kiện truy vấn cơ bản và cung cấp một API đơn giản cho query models. Trong rails chúng ta có thể sử dụng 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ể sử 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 triển khai cơ bản. Nếu article schema bị thay đổi, ta chỉ cần thực hiện thay đổi đối với class article:

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

Cách làm trên cũng tốt, tuy nhiên lại phát sinh một số vấn đề mới. Chúng ta phải tạo scope cho mỗi điều kiện truy vấn ta muốn đóng gói. Làm cho model trở nên chật chội với các combination khác nhau của mỗi scope cho các use case khác nhau. Một vấn để khác là scope không thể tái sử dụng cho các model khác nhau. Không nhưng thế chúng ta còn phá vỡ quy tắc trách nhiệm duy nhất vì đã ném tất cả các trách nhiệm liên quan đến truy vấn vào class Article. Lời giải cho vấn để này là sử dụng Query Object

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

Bây giờ nó có thể tái sử dụng được, chúng ta có thể sử dụng lớp này để truy vấn bất kỳ kho lưu trữ nào khác có lược đồ tương tự:

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

Nếu chúng ta muốn chain chúng:

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
            
            
            
         
0