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 ...
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, CreditCard và Customer).
- 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à: User và Company.
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.