+3

Fat Model - Skinny Controller and The Patterns to Refactor Fat ActiveRecord Model

Chắc hẳn các bạn lập trình viên đã từng ít nhiều nghe qua khái niệm Fat model - Skinny Controller khi nói về Framework MVC. Vậy như thế nào được gọi là Fat model hay Skinny Controller???

Keep as much business logic in the models as

Rõ là khi nghe cái tên như thế, chúng ta cũng mường tượng ra được phần nào: kiểu như là, Fat model thì làm béo, làm phình model trong khi Skinny controller là làm gọn, thu nhỏ controller lại, giống với việc chúng ta làm đẹp Controller nhưng lại làm xấu Model vậy. Chúng ta cố gắng làm gọn logic ở Controller, cố làm thật clear-code, trong khi tống khứ tất cả những phần xử lý khác vào model, và model của chúng ta ngày một PHÌNH to ra như con voi.

Và việc làm này thực sự có tốt hay không? Khi chúng ta đặt ra câu hỏi như này, chẳng khác nào chúng ta hỏi một người mập rằng bạn cảm thấy thế nào cả. (hihi)

Khi chúng ta muốn nâng cao chất lượng ứng dụng Rails, hẳn chúng ta sẽ muốn học cách phá vỡ thói quen cho phép các mô hình được phép phình ra. Bởi FAT MODEL thực sự gây ra nhiều vấn đề bảo trì trong các ứng dụng lớn.

Chính vì thế ở bài viết này, chúng ta sẽ cùng nhau tìm hiểu về một số cách trong 7 Patterns to Refactor Fat ActiveRecord Models - 7 cách để làm đẹp model như các bạn đã cố gắng làm đẹp Controller được viết bởi Bryan Helmkamp, người sáng lập Code Climate vào ngày 17 tháng 10 năm 2012.

It’s just you and a skinny, attractive model. It’s going to be a good day.

1. Extract Value Objects

Value Objects chính là những Object rất rất simple mà bản thân Object đó phụ thuộc vào gía trị của mình hơn là phụ thuộc vào cách mà nó được định nghĩa (indentify). 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ể(chủ yếu dựa vào gía trị của chúng là chính.).

Ví dụ về Value Objects: ta có 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 và sau đó, ta có thể so sánh các đối tượng giá trị này bằng một đơn vị tiền tệ (ví dụ: USD). Hoặc, các Value Object cũng có thể biểu diễn nhiệt độ và được so sánh bằng thang đo Kelvin,...

Ta sẽ hiểu hơn về Value Object thông 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ưởi được điều khiển thông qua giao diện web. Một action của Controller nhận các thông số nhất định cho một lò sưởi từ một cảm biến nhiệt độ: nhiệt độ(type: number) và thang nhiệt độ (Fahrenheit, Celsius, hoặc Kelvin). Nhiệt độ này được chuyển thành Kelvin nếu được cung cấp theo thang đo khác, và action ở Controller phải kiểm tra xem nhiệt độ dưới 25 ° C và liệu nó có bằng hoặc cao hơn nhiệt độ hiện tại.

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 = AutomatedThermostaticValve.find params[:id]
      @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 kelvin_degrees_by_scale degrees, scale
    degrees = degrees.to_f
    case scale.to_sym
    when :kelvin
      degrees
    when :celsius
      degrees + 273.15
    when :fahrenheit
      (degrees - 32) * 5 / 9 + 273.15
    end
  end
end

Vấn đề ở đây chính là Controller chứa qúa nhiều các logic so sánh và chuyển đổi các gía trị nhiệt độ. Controller hiện tại đang PHÌNH rất khủng khiếp. Do đó, chúng ta sẽ move tất cả những logic so sánh nhiệt độ sang bên Model, và như thế việc còn lại của Controller chỉ là chuyển các param cho method update mà thôi.

class AutomatedThermostaticValvesController < ApplicationController
  def heat_up
   @valve = AutomatedThermostaticValve.find params[:id]
    @valve.update(next_degrees: params[:degrees], next_scale: params[:scale])
    render json: { was_heat_up: valve.was_heat_up }
  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_sym
    when :kelvin
      degrees
    when :celsius
      degrees + 273.15
    when :fahrenheit
      (degrees - 32) * 5 / 9 + 273.15
    end
  end
end

Tuy nhiên, cách làm ở phía trên chưa phải là phương pháp tối ưu và hiệu qủa nhất. Để làm cho Model skinny chúng ta sẽ khởi tạo các Value Objects. Khi khởi tạo, các value object sẽ nhận gía trị của nhiệt độ (degree) và thang đo (scale). Và khi so sánh những đối tượng này, phương thức spaceship(<=>) sẽ so sánh nhiệt độ của chúng mà đã chuyển đổi sang thang đo Kelvin.

class AutomatedThermostaticValvesController < ApplicationController
  def heat_up
    @valve = AutomatedThermostaticValve.find params[:id]
    @valve.update next_degrees: params[:degrees], next_scale: params[:scale]
    render json: { was_heat_up: valve.was_heat_up }
  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 class < sefl
      def from_celsius degrees_celsius
        new degrees_celsius, "celsius"
      end

      def from_fahrenheit degrees_fahrenheit
        new degrees_celsius, "fahrenheit"
      end

      def from_kelvin degrees_kelvin
        new degrees_kelvin, "kelvin"
      end
  end
  
  def <=>(other)
    kelvin_degrees <=> other.kelvin_degrees
  end

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

  MAX = from_celsius(25)
end

Từ việc refactor theo Value Object ở trên, kết qủa là ta có một skinny Controller và một skinny Model.

2. Extract Service Objects

Ta có thể hiểu service object như sau:

Service Objects are objects that perform a discrete operation or action. When a process becomes complex, hard to test, or touches more than one type of model, a Service Object can come in handy for cleaning up your code base.

Một vài action có thể cần phải có những Service Object riêng để đóng gói những thực thi của nó. Và ta thường tách ra service object khi một action đáp ứng ít nhất một trong những tiêu chí sau:

  • action đó rất phức tạp.
  • action đó đụng tới qúa nhiều model. (Chẳng hạn như, chức năng Thanh toán sẽ dùng tới các model Order, CreditCardCustomer).
  • action đó tương tác với một service ngoài (chẳng hạn như share lên các mạng xã hội...)
  • action đó không phải là một core concern của model bên dưới.
  • có nhiều cách để thực hiện action đó

Ví dụ , ta có thể tách method User#authenticate ra thành một class UserAuthenticator

class UserAuthenticator
  def initialize user
    @user = user
  end

  def authenticate unencrypted_password
    return false unless @user

    if BCrypt::Password.new(@user.password_digest) == unencrypted_password
      @user
    else
      false
    end
  end
end

Và trong SessionsController sẽ gọi như sau:

class SessionsController < ApplicationController
  def create
    user = User.where(email: params[:email]).first

    if UserAuthenticator.new(user).authenticate params[:password]
      self.current_user = user
      redirect_to dashboard_path
    else
      flash[:alert] = "Login failed."
      render "new"
    end
  end
end

3. Extract Form Objects

Form Object is a design pattern that encapsulates logic related to validating and persisting data.

Khi ta có nhiều ActiveRecord models cùng được update trong một lần submit form, sử dụng Form Object có thể đóng gói rất tốt những thực thi này. Cách làm này gọn gàng hơn rất nhiều so với việc sử dụng accepts_nested_attributes_for.

Ví dụ: chúng ta có một form để người dùng đăng kí sử dụng hệ thống của mình với yêu cầu nhập thông tin cơ bản như: email, full_name, address ngoài ra, yêu cầu nhập thêm thông tin của company của họ như company_name, company_address. Ở đây, chúng ta đồng thời phải xử lý 2 đối tượng đó là: UserCompany.

app/forms/registration.rb
class Registration
  include ActiveModel::Model

  attr_accessor :company_name, :company_address, :email, :full_name

  validates :company_name, presence: true
  validates :company_address, presence: true
  validates :email, presence: true, email: true
  validates :full_name, presence: true

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

  private
  def persist!
    @company = Company.create! name: company_name
    @user = @company.users.create! name: full_name, email: email
  end
end

Object này vẫn giữ những chức năng hoạt động tương tự như attribute của ActiveRecord, nên thật sự không mấy khác biệt khi sử dụng trên Controller.

class RegistrationsController < ApplicationController
  def create
    @registration = Registration.new registration_params
    if @registration.save
      # do something
    else
      render :new
    end
  end

  private
  def registration_params
    params.require(:registration).permit :company_name, :company_address, :email, :full_name
  end
end

Trên đây là một số phương pháp refactor Fat Models. Các bạn có thể tham khảo tiếp 4 phương pháp refactor còn lại tại đây

Cảm ơn các bạn đã đọc bài viết này! Hi vọng bài viết sẽ hữu ích với các bạn.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí