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.
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.
Referenceshttps://www.sitepoint.com/7-design-patterns-to-refactor-mvc-components-in-rails/