Fat Model - Skinny Controller and The Patterns to Refactor Fat ActiveRecord Model
Bài đăng này đã không được cập nhật trong 7 năm
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.
All rights reserved