Làm gầy Model và Controller
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ử ...
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
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 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 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 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
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/