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