12/08/2018, 12:35

Tìm hiểu một vài cách Refractor trong Controller

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 ...

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

0