Làm gầy Model và Controller
Bài đăng này đã không được cập nhật trong 7 năm
Chúng ta hay có thói quen viết quá nhiều logic tính toán hay truy vấn cơ sở dữ liệu vào một Model, một Controller duy nhất hoặc gọi trực tiếp các truy vấn cơ sở dữ liệu ngay trên Controller mà không thông qua việc đóng gói dữ liệu. Khi chúng ta làm như vậy thì sẽ gây khó khăn:
- Cho việc tái sử dụng lại mã nguồn.
- Sửa chữa mã nguồn.
- Mở rộng các chức năng.
- Những người muốn đọc hiểu lại mã nguồn của chúng ta khi maintain.
Như vậy là chúng ta đã đang tạo nên những fat Model
, fat Controller
. Vậy làm thế nào để Model và Controller trở nên gầy gò hơn (skinny Model
, skinny Controller
), đây là những gì mà tôi sẽ trình bày trong bài viết này
Service Objects (and Interactor Objects)
Các service object được tạo ra khi có một sự kiện:
- Phức tạp (chẳng hạn như tính lương của nhân viên)
- Sử dụng API của các service bên ngoài
- Không thuộc vào model (ví dụ xóa các dữ liệu không dùng đến)
- Sử dụng nhiều model (ví dụ bạn nhập dữ liệu từ file vào nhiều model)
Ví dụ
Trong ví dụ bên dưới, công việc được thực hiện bởi Stripe service bên ngoài. Stripe service tạo ra một Stripe customer dựa trên địa chỉ email và mã nguồn (mã thông báo) và gắn bất kỳ dịch vụ thanh toán nào vào tài khoản.
Vấn đề
- Logic của hoạt động cùng với một dịch vụ bên ngoài được đặt trong Controller.
- Dữ liệu lấy từ form thông qua các param là để dùng cho service bên ngoài.
- Khó khăn trong việc bảo trì và quản lý Controller.
class ChargesController < ApplicationController
def create
amount = params[:amount].to_i * 100
customer = Stripe::Customer.create email: params[:email], source: params[:source]
charge = Stripe::Charge.create customer: customer.id, amount: amount,
description: params[:description], currency: params[:currency] || "USD"
redirect_to charges_path
rescue Stripe::CardError => exception
flash[:error] = exception.message
redirect_to new_charge_path
end
end
Để giải quyết vấn đề này, chúng ta đóng gói công việc của chúng ta cùng 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à một CheckoutService
chịu trách nhiệm tạo tài khoản khách hàng và thanh toán. Tuy nhiên khi giải quyết được vấn đề quá nhiều logic trong Controller, chúng ta vẫn còn một vấn đề cần giải quyết. Điều gì sẽ xảy ra nếu service bên ngoài đưa ra một ngoại lệ (ví dụ thẻ tín dụng không hợp lệ) và chúng ta phải chuyển hướng người dùng đến một trang 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
Để xử lý vấn đề này, chúng ta gói lời gọi CheckoutService và ngoại lệ trong đối tượng interactor. Interactor được sử dụng để gói logic nghiệp vụ. Mỗi một interactor thường đại diện cho một nguyên tắc nghiệp vụ. Mô hình interactor giúp chúng ta đạt được nguyên tắc đơn nhiệm (SRP) bằng cách sử dụng những đối tượng Ruby cũ (POROs). Các interactor cũng tương tự như các đối tượng service, nhưng thường tả về một số giá trị cho thấy trạng thái thực thi và các thông tin khác (thêm vào các hành động được thực hiện). Chúng ta cũng thường sử dụng các đối tượng service bên trong các đối tượng interactor. Dưới đây là cách sử dụng design pattern:
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
Đưa tất cả các ngoại lệ có liên quan vào CardError, chúng ta có thể có được skinny Controller. Bây giờ, ChargesController chỉ có trách nhiệm chuyển người dùng đến trang thanh toán thành công hoặc thanh toán không thành công.
Value Objects
Value object trong design pattern khuyến khích tạo ra những đối tượng nhỏ, đơn giản (thường chỉ chứa giá trị nhất định) và cho phép bạn so sánh các đối tượng 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í dụ, các value object là 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, chúng ta có thể so sánh các giá trị đối tượng này bằng một đơn vị tiền tệ (ví dụ: USD). Các value object cũng có thể biểu diễn nhiệt độ và so sánh bằng thang đo Kelvin.
Ví dụ
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 một giao diện web. Vai trò của Controller là nhận các parameter cho lò sưởi (heater) từ một cảm ứng nhiệt độ: nhiệt độ (một giá trị) và thang nhiệt độ (Fahrenheit, Celsius hoặc Kelvin). Nhiệt độ này sẽ chuyển về thang đo Kelvin nếu được cung cấp theo thang đo khác, controller sẽ kiểm tra xem nhiệt độ hiện tại nhỏ hơn, lớn hơn hay bằng nhiệt độ hiện tại.
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 chuyển logic so sánh nhiệt độ vào model, do đó Controller chỉ truyền tham số đến phương thức update. Nhưng Model vẫn chưa phải là tối ưu, nó vẫn quá nhiều các xử lý về 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"
attr_accessor :next_degrees, :next_scale
attr_reader :was_heat_up
before_validation :check_next_temperature, if: :next_temperature
after_save :launch_heater, if: :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
Để Model trở nên gầy (skinny), chúng ta tạo ra những đối tượng. Khi khởi tạo, đối tượng sẽ lấy giá trị của nhiệt độ và thang đo. Khi so sánh các đối tượng (<=>) chúng ta chuyển về Kelvin.
Các đối tượng chứa phương thức to_h cho phép gán giá trị vào thuộc tính. Đối tượng Temperature
dễ dàng được tạo ra bởi một trong số các hàm from_kelvin
, from_celsius
và from_fahrenheit
tùy vào thang đo nhiệt độ. Ví dụ: Temperature.from_celsius(0) sẽ tạo ra một đối tượng có nhiệt độ 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
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 skinny Controller và một skinny Model. Controller không biết bất kỳ logic nào liên quan đến nhiệt độ, Model không biết bất kỳ điều gì về chuyển đổi nhiệt độ và chỉ sử dụng các phương thức của đối tượng Temperature.
Form Objects
Form object là một design pattern đóng gói liên quan đến logic validating và tồn tại dữ liệu.
Ví dụ
Giả sử chúng ta có một Model và Controller điển hình để tạo mới nhiều người dùng.
Vấn đề
Model có chứa tất validation logic, do đó nó không thể dùng lại cho 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 = /\A[^@\s]+@([^@.\s]+\.)+[^@.\s]+\z/
validates :full_name, presence: true
validates :email, presence: true, format: {with: EMAIL_REGEX}
validates :password, presence: true, confirmation: true
end
Một giải pháp là chuyển hết validation logic đến một lớp riêng biệt và lớp này là UserForm
class UserForm
EMAIL_REGEX = /\A[^@\s]+@([^@.\s]+\.)+[^@.\s]+\z/
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: {with: 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 di chuyển validation logic vào UserForm, chúng ta sử dụng nó bê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 phải validate dữ liệu nữa
class User < ActiveRecord::Base
end
Query Objects
Query object là một dessign pattern cho phép chúng ta có thể lấy ra các query logic từ Controller và Model cho vào các lớp khác và có thể sử dụng nhiều lần.
Ví dụ
Chúng ta muốn lấy ra một danh sách các bài viết thuộc thể loại video
có số lượng 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ả các truy vấn đều đặt trong Controller (tất cả các điều kiện truy vấn cũng đặt trong Controller)
- Logic này sẻ không được sử dụng lại
- Rất khó để kiểm tra
- Bất kỳ những thay đổi nào của bài viết cũng có thể phải sửa đổi lại 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 của chúng ta là tái cấu trúc lại Controller bằng cách ẩn và đóng gói các điều kiện truy vấn cơ bản thông qua việc cung cấp một API đơn giản cho việc truy vấn Model. Trong Rails chúng ta có thể làm điều này bằng cách tạo ra các 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ể dử 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 thực hiện. Nếu Article của chúng ta có bất kỳ thay đổi gì, chúng ta chỉ cần thực hiện thay đổi trong class Article
class ArticlesController < ApplicationController
def index
@articles = Article.accessible_by(current_ability).popular_with_video_type
end
end
Có vẻ tốt hơn, nhưng bây giờ có một vấn đề mới phát sinh. Chúng ta phải tạo ra những scope cho mọi điều kiện truy vấn mà chúng ta muốn đóng gói, cho những Model với scope khác nhau. Một vấn đề nữa là, scope không thể sử dụng lại trên các model khác nhau, điều đó có nghĩa là bạn không thể sử dụng scope từ class Article để truy vấn trên class Attachment. Chúng ta không thể phá vỡ nguyên tắc đơn nhiệm (SRP) bằng cách cho toàn bộ truy vấn vào class Article. Giải pháp cho vấn đề này là sử dụng đối tượng truy vấn
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
Tuyệt vời, nó có thể dùng lại được. Bây giờ chúng ta có thể sử dụng class này để truy vấn cho các class khác có cùng schema.
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 muốn liên kết chúng với nhau thì thật đơn giản. Điều duy nhất chúng ta phải nghỉ đến là lời gọi phương thức nên phù hợp với các interface của 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ó một class để tái sử dụng cùng với tất cả logic đã được đóng gói, một interface đơn giản và dễ dàng để kiểm tra
Kết luận
Bài viết này quá dài nên tôi mới chỉ tập trung được vào 2 vấn đề chính là làm thế nào để có được skinny Model, skinny Controller, còn vấn đề skinny View (View Objects, Policy Objects và Decorators) xin hẹn gặp lại các bạn trong một bài viết nào đó của tôi Rất mong nhận được sự đóng góp ý kiến chân thành của các bạn. Bài viết này có tham khảo từ nguồn https://www.sitepoint.com/7-design-patterns-to-refactor-mvc-components-in-rails/
All rights reserved