Tìm hiểu một vài cách Refractor trong Controller
Bài đăng này đã không được cập nhật trong 9 năm
Tìm hiểu một vài cách Refractor trong Controller
I. Giới thiệu
Tiếp nối phần trước kỹ thuật Refractor model trong sách Rails Antipatterns, trong phần này chúng ta sẽ tập trung tìm hiểu một vài kỹ thuật Refractor được áp dụng trong Controller.
Controller là 1 trong 3 thành phần cơ bản của Rails. Tuy nhiên, trong nhiều ứng dụng Rails, controller là thành phần xử lý phức tạp nhất, và ngày càng trở nên phình to, rất khó trong việc tối ưu code và bảo trì sau này.
Cuốn sách Rails Antipatterns
sẽ đề cập đến một vài cạm bẫy mà chúng ta hay gặp phải trong các controllers.
II. Các Antipatterns
1. Antipattern: Fat Controller
Fat Controller
là một trong những vấn đề phổ biến nhất trong cộng đồng Rails cũng như là vấn đề hay gặp nhất ảnh hưởng đến nhiều ứng dụng Rails
Fat Controller
bao gồm các xử lý logic mà đáng lẽ nên nằm trong model. Thêm vào đó, nếu chuyển các xử lý đấy vào model thì ta có thể dễ dàng thực hiện unit test, điều đấy sẽ tuyệt hơn là các function test cho controller. Và từ đấy dẫn tới một khái niệm mới skinny controller.
Các giải pháp để refractor controller, chuyển xử lý logic vào model đã được hỗ trợ bời Active Record ví dụ như callbacks, setters, database default, hay là các pattern khác như Present Pattern.
Sử dụng Active Record Callbacks
và Setters
Ví dụ 1 đoạn code trong controller không được xử lý tốt
class ArticlesController < ApplicationController
def create
@article = Article.new(params[:article])
@article.reporter_id = current_user.id
begin
Article.transaction do
@version = @article.create_version! params[:version], current_user
end
rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordInvalid
render :action => :index and return false
end
redirect_to article_path(@article)
end
với hàm create_version
được định nghĩa:
def create_version!(attributes, user)
if self.versions.empty?
return create_first_version!(attributes, user)
end
if self.current_version.relateds.any?
self.current_version.relateds.each do |rel|
rel.update_attribute :current, false
end
end
version = self.versions.build(attributes)
version.article_id = self.id
version.written_at = Time.now
version.writer_id = user.id
version.version = self.current_verison.version + 1
self.save!
self.update_attribute :current_version_id, version.id
version
end
create_first_version
def create_first_version!(attributes, user)
version = self.versions.build attributes
version.written_at = Time.now
version.writer_id = user.id
version.state ||= "Raw"
version.version = 1
self.save!
self.update_attribute :current_version_id, version.id
version
end
Trên đây là đoạn code để tạo object và lưu vào trong database. Tuy nhiên, nó được thiết kế quá tồi, vi phạm vào các quy ước MVC, lạm dụng quá nhiều kỹ thuật không đúng hay thiếu thực tế, có quá nhiều code bị DRY.
Trong đoạn code trên đã sử dụng transaction
. Database transaction
thường được dùng để đảm bảo tất cả các statements được thực thi sẽ rollback, hay quay trở lại trạng thái ban đầu nếu như có một statement bị lỗi.
Transaction
không nên nằm trong controller. Thứ nhất, không nên có quá nhiều statements phụ thuộc lẫn nhau nằm trong controller. Thứ hai là các hàm thông thường trong Active Record đã có sẵn transaction
của chính nó. Ví dụ như trên là hàm save
.
Giải pháp đưa ra là sử dụng callback
và setters
trong model.
version.state ||= "Raw"
được dùng để set giá trị cho state
, và chỉ sử dụng cho version đầu tiên, các lần khác sẽ sử dụng lại giá trị đó. Do vậy, ta nên set giá trị default bằng cách sử dụng database default
.
class AddRawDefaultToState < ActiveRecord::Migration
def self.up
change_column_default :article_versions, :state, "Raw"
end
def self.down
change_column_default :article_versions, :state, nil
end
end
Chuyển các logic vào callback trong model Version
class Version < ActiveRecord::Base
before_validation_on_create :set_version_number
before_create :mark_related_links_not_current
private
def set_version_number
self.version = (article.current_version ?
article.current_version.version : 0) + 1
end
def mark_related_links_not_current
unless article.versions.empty?
if article.current_version.relateds.any?
article.current_version.relateds.each do |rel|
rel.update_attribute(:current, false)
end
end
end
end
-
Các collection trong quan hệ Active Records của model không bao giờ trả về nil nếu không có record được tìm thấy, mà sẽ trả về collection rỗng. Chúng ta có thể bỏ đi
-
article.current_version
đã được lặp quá nhiều lần, vi phạm nguyên tắc DRY, ta nên tách ra
def current_version
article.current_version
end
với những đoạn code này @article.reporter_id = current_user.id
nên được thay thế bằng quan hệ trong Active Record hơn là dùng _id, do vậy ta có:
@article.reporter = current_user
Với một vài bước refractor, loại bỏ các DRY, remove các code không cần thiết, kết hợp sử dụng điều kiện trong callback hợp lý ta có kết quả cuối cùng:
Model Version
class Version < ActiveRecord::Base
before_validation :set_version_number, :on => :create
before_create :mark_related_links_not_current, if: :current_version
after_create :set_current_version_on_article
private
def set_current_version_on_article
article.update_attribute :current_version_id, self.id
end
end
và Controller đã trở thành 1 controller theo chuẩn thông thường
class ArticlesController < ApplicationController
def create
@article = Article.new(params[:article])
@article.reporter = current_user
@article.new_version.writer = current_user
if @article.save
render :action => :index
else
redirect_to article_path(@article)
end
end
end
2 Antipattern: Monolithic Controllers
Rails dựa theo cấu trúc RESTful
, ánh xạ các action cơ bản trong controller như index, new, create, edit, update, destroy tương ứng với các động từ HTTP như POST, GET, PUT và DELETE
Có 2 dấu hiệu mà một ứng dụng không theo chuẩn RESTful là thứ nhất các bổ sung các param trong URLs để thêm các hành động mới được thực hiện bởi controller. Ngoài ra, đó là các action không theo chuẩn, không nằm trong 7 action của RESTful.
Ví dụ ta có 1 phần của monolithic controller _ controller chỉ có 1 khối
AdminController
def users
per_page = Variable::default_pagination_value
@users = User.find(:all)
if not params[:operation].nil?
if (params[:operation] == "reset_password")
user = User.find(params[:id])
user.generate_password_reset_access_key
user.password_confirmation = user.password
user.email_confirmation = user.email
user.save!
flash[:notice] = user.first_name + " " + user.last_name + "'s password has been reset."
end
end
end
chỉ có một action user, và sử dụng parameter operation để phân biệt các chức năng xử lý URLs sẽ trông giống như thế này
/admin/users?operation=reset_password?id=x
/admin/users?operation=delete_user?id=x
/admin/users?operation=activate_user?id=x
/admin/users?operation=show_user?id=x
/admin/users
Giải pháp cho vấn đề này chỉ đơn thuần là chuyển về chuẩn RESTful, tách các chức năng khác nhau cho các controller khác nhau, UserController chỉ xử lý các chức năng liên quan để User, PasswordsController
, và ActivationsController
thực hiện các chức năng theo đúng ý nghĩa tên gọi của nó
3. AntiPattern: Controller of Many faces
Khi ứng dụng lớn dần, thì các RESTful controller sẽ phải thực hiện một vài action non-RESTful.
Điều đó khó tránh khỏi, để giải quyết vấn đề này, giải pháp được đưa ra là tách các action non-RESTful thành các controller riêng biệt
Ví dụ controller về authentication
class UsersController < ApplicationController
def login
if request.post?
if session[:user_id] = User.authenticate(params[:user][:login],
params[:user][:password])
flash[:message] = "Login successful"
redirect_to root_url
else
flash[:warning] = "Login unsuccessful"
end
end
end
def logout
session[:user_id] = nil
flash[:message] = 'Logged out'
redirect_to :action => 'login'
end
end
Một vấn đề xảy là resource của UsersController là gì, login logout chỉ liên quan đến user session
và không có quan hệ trực tiếp với một Active Record model.
Để đảm bảo chuẩn RESTful, ta sẽ tách riêng một controller session riêng biệt
class SessionsController < ApplicationController
def create
if session[:user_id] = User.authenticate(params[:user][:login],
params[:user][:password])
flash[:message] = "Login successful"
redirect_to root_url
else
flash.now[:warning] = "Login unsuccessful"
render :action => "new"
end
end
def destroy
session[:user_id] = nil
flash[:message] = 'Logged out'
redirect_to login_url
end
end
Thiết kế này sẽ dễ bảo trì hơn bằng cách nhóm các hành động liên quan đến session vào controller riêng.
4. Antipattern: Rat's Nest Resources
Tình huống đặt ra là một ứng dụng lấy ra tất cả các messages được tạo bởi tất cả user, các thể lấy ra cả các messages được tạo bởi 1 user, nếu 2 danh sách đấy được thực hiện bởi 1 controller ví dụ như:
class MessagesController < ApplicationController
def index
if params[:user_id]
@user = User.find(params[:user_id])
@messages = @user.messages
else
@messages = Message.all
end
end
end
thì routes cho controller đó sẽ là
resources :messages
resources :users do
resources :messages
end
và trong view để có thể hiện thị chính xác từng danh sách thì phải sử dụng các điều kiện logic, giả sử như nếu các messages được nhóm trong Project
thì một user sẽ có view cho tất cả message trong project, message cho một project, tất cả message của một user trong tất cả project hay message của chỉ một user trong một project. Khi đó điều kiện logic trong view và controller sẽ trở nên rất phức tạp.
Giải pháp đưa ra là tách các controller cho mỗi một nest resource
ví dụ như
controllers/messages_controller.rb
controllers/users/messages_controller.rb
ta sẽ có route tương ứng:
resources :messages
resources :users do
resources :messages, :controller => ‘users/messages’
end
công việc của view và controller sẽ trở nên đơn giản hơn rất nhiều
III Tổng kết
-
Trên đây là vài vấn đề hay gặp phải trong controller và một vài giải pháp tương ứng. Nhìn chung, để xây dựng một controller đảm bảo hợp logic, đơn giản, dễ hiểu, dễ bảo trì thì nguyên tắc cơ bản là chia nhỏ vấn đề và xử lý riêng từng vấn đề đấy.
-
Thông tin tham khảo: sách AntiPatterns_ Best practice Ruby on Rails Refractoring
All rights reserved