12/08/2018, 14:46

Skinny controllers through refactoring

Controller có thể mất đi kiểm soát khi development. Skinny controller through refactoring - hay có thể hiểu là làm cho controller trong mô hình MVC như rails framework đang áp dụng trở nên đơn giản và đúng vai trò hơn trong việc điều khiển nhận và trả về dữ liệu trên server. Công việc của ...

Controller có thể mất đi kiểm soát khi development. Skinny controller through refactoring - hay có thể hiểu là làm cho controller trong mô hình MVC như rails framework đang áp dụng trở nên đơn giản và đúng vai trò hơn trong việc điều khiển nhận và trả về dữ liệu trên server. Công việc của controller phải trở nên thật sự đơn giản. Trong một mô hình MVC, controller đóng vai trò được biết như làm thế nào để làm việc được với model theo thứ tự để lấy ra những thứ mà View cần. Theo cách hiểu thì controller nhận yêu cầu từ người dùng (thông qua params) và quyết định output có thể trả về (ví dụ như trả về một file JSON) Thông thường bạn thấy người điều hướng "traffic cop" sẽ đơn giản, nhưng thỉnh thoảng bạn có thể thấy controller ngày càng phình ra như phải cần nhiều phương thức private để giúp xử lý những input phức tạp từ user để trả về output hay query phức tạp. Nếu bạn thấy controller ngày càng trở nên phình ra, hoặc khó để test tất cả các viễn cảnh mà chúng ta phải sử lý, thì đó là lúc mà bạn cần phải refactor code.

Xử lý những query logic phức tạp

Lấy ví dụ về một controller mà yêu cầu chúng ta phải xử lý một số cá logic để handle việc sắp xếp sorting, phân trang paginage và có khả năng lọc filter dữ liệu trong cùng một action index. Chúng ta xử lý chúng trong một class được gọi là RetalUnitsIndex. Class này sẽ có một trách nhiệm đơn giản, và trách nhiệm đó là xử lý params của người dùng, và tạo một query từ chúng. Và một phần trong công việc đó là xây dựng các liên kết phân trang tương ứng với các query để truy xuất dữ liệu từ trang này sang trang khác. Và đó là cái mà chúng ta mong đợi từ phương thức index xử lý, trông có vẻ khá đơn giản phải không?

def index
  rental_units_index = RentalUnitsIndex.new(self)
  render json: rental_units_index.rental_units, links: rental_units_index.links
end

Để RetalUnitsIndex có thể làm công việc của chúng, chúng ta truyền vào chính đối tượng controller self. Bằng cách truy cập tới controller, chúng ta không chỉ truy cập tới params mà cũng đồng thời truy cập tới URLs helper mà Rails tạo ra như một phần routing system. Và đây là class được mô tả, chúng ta sẽ đi vào một số phương thức cụ thể mà chúng ta sẽ viết test để chắc rằng chúng sẽ hoạt động như mong muốn:

class RentalUnitsIndex

  DEFAULT_SORTING = {created_at: :desc}
  SORTABLE_FIELDS = [:rooms, :price_cents, :created_at]
  PER_PAGE = 10

  delegate :params, to: :controller
  delegate :rental_units_url, to: :controller

  attr_reader :controller

  def initialize(controller)
    @controller = controller
  end

  def rental_units
    @rental_units ||= RentalUnit.includes(:user).
      order(sort_params).
      paginate(page: current_page, per_page: PER_PAGE)
  end

  def links
    {
      self:  rental_units_url(rebuild_params),
      first: rental_units_url(rebuild_params.merge(first_page)),
      prev:  rental_units_url(rebuild_params.merge(prev_page)),
      next:  rental_units_url(rebuild_params.merge(next_page)),
      last:  rental_units_url(rebuild_params.merge(last_page))
    }
  end

  private

    def current_page
      (params.to_unsafe_h.dig(:page, :number) || 1).to_i
    end

    def first_page
      {page: {number: 1}}
    end

    def next_page
      {page: {number: next_page_number}}
    end

    def prev_page
      {page: {number: prev_page_number}}
    end

    def last_page
      {page: {number: total_pages}}
    end

    def total_pages
      @total_pages ||= rental_units.total_pages
    end

    def next_page_number
      [total_pages, current_page + 1].min
    end

    def prev_page_number
      [1, current_page - 1].max
    end

    def sort_params
      SortParams.sorted_fields(params[:sort], SORTABLE_FIELDS, DEFAULT_SORTING)
    end

    def rebuild_params
      @rebuild_params ||= begin
        rejected = ['action', 'controller']
        params.to_unsafe_h.reject { |key, value| rejected.include?(key.to_s) }
      end
    end

end

Chúng ta sử dụng delegate phương thức trong trường hợp này để có thể truy cập dễ dàng params khi gọi params thay vì controller.params.

Viết test cho class

Bằng cách tạo ra class trích xuất xử lý từ controller, nó cho phép chúng ta có thể dễ dàng viết test cho các phương thức xử lý. Chúng ta không phải tạo đầy đủ request tới controller mỗi lần, thay vào đó chúng ta chỉ cần test riêng mỗi phương thức và output của phương thức đó. Chúng ta sẽ tạo ra thêm một class để hỗ trợ cho việc viết test. Nó rất đơn giản và cơ bản để tạo ra fake/stubbed thực thể của controller và params. Chúng ta sử dụng thêm ActionController::Parameters hay strong params class để cho phép chúng ta xử cho phép và yêu cầu những key cụ thể trong params.

RSpec.describe RentalUnitsIndex, :type => :model do

  class FakeController
    attr_accessor :params

    def initialize(params)
      @params = params
    end

    def rental_units_url(*args)
      "http://www.fake.com"
    end
  end

  let(:controller) { FakeController.new(params) }
  let(:params) do
    ActionController::Parameters.new({
      sort: sort,
      page: page
    })
  end
  let(:sort) { "-rooms" }
  let(:page) { {number: "1"} }
  let(:rui) { RentalUnitsIndex.new(controller) }

Bằng cách đặt params trong câu lệnh let, chúng ta có thể tạo riêng mỗi params riêng biệt với mỗi test case với các phương thức. Đầu tiên chúng ta sẽ test phương thức rental_units phương thức để chắc chắn rằng nó có thể truy xuất data chính xác:

describe ".rental_units" do
it "queries rental units" do
  rental_unit = create(:rental_unit)
  expect(rui.rental_units).to include(rental_unit)
end

it "sorts by sort param" do
  rental_units = (1..5).to_a.map { create(:rental_unit, rooms: Random.rand(1..10)) }
  expect(rui.rental_units.map(&:rooms)).to eq(rental_units.map(&:rooms).sort.reverse)
end

it "paginates results" do
  (described_class::PER_PAGE + 1).times { create(:rental_unit) }
  expect(rui.rental_units.size).to eq(described_class::PER_PAGE)
end
end

Tiếp theo chúng ta test links. Bằng cách sau chúng ta có thể chắc chắn rằng link được tạo ra với các keys là đúng.

describe ".links" do
it "builds link hash" do
  expect(rui.links.keys).to eq([:self, :first, :prev, :next, :last])
end
end

Cuối cùng chúng ta test cho private phương thức current_page bởi vì nó thường xuyên được sử dụng khi tạo ra các liên kết links.

describe ".current_page" do
  context "present" do
    let(:page) { {number: "2"} }
    it "finds current page as integer" do
      expect(rui.send(:current_page)).to eq(2)
    end
  end

  context "missing" do
    let(:page) { {} }
    it "sets default page to 1" do
      expect(rui.send(:current_page)).to eq(1)
    end
  end
end

Kết luận

Đừng để bị giới hạn khi sử dụng với ActiveRecord::Base models trong ứng dụng Rails của bạn. Nó dễ dàng quên đi bằng cách nghĩ sự thật bạn đang viết Ruby code, do đó bạn có thể thoải mái sử dụng class riêng, điều đó có thể giúp bạn viết ra một ứng dụng tốt hơn. Bằng cách tách một vài chức năng được viết trong controller ra class riêng của bạn, chúng ta hoàn toàn có thể giúp controller đơn giản hơn. và đồng thời cũng giúp chúng ta có thể test đơn giản hơn các chức năng trong một index action.

Refs

Skinny controllers through refactoring

0