ActiveRecord refactoring (P1) - Concerns

Mở đầu

Trong Ruby, ActiveRecord cung cấp cho ta rất nhiều sức mạnh. VỚi sức mạnh đó thì ta có thể thêm vào các logic để thực hiện những công việc của mình để tạo ra những model lớn.

Tuy nhiên, càng ngày với càng nhiều xử logic hơn thì đồng nghĩa với việc model của chúng ta cũng sẽ dần phình to ra (mà người ta hay gọi là Fat models) và trở nên cồng kềnh, chậm chạp.

Cũng có nhiều cách để nhằm khắc phục vấn đề này, để giúp giảm bớt công việc cho các model.

Bạn có thể đọc thêm về bài viết 7 Patterns to Refactor Fat ActiveRecord Models.

Sau đây, mình xin gửi đến các bạn chuỗi bài viết ActiveRecord Refactoring của tác giả Luke Morton.

Tác giả có đề cập đến 3 hướng tiếp cận :

  • Concerns

  • Services (hay còn gọi là Interactors).

  • Presenters

Và ở bài viết này, mình xin đề cập đến hướng tiếp cận thứ nhất.


oop-model-too-fat.png


Phần 1. Concerns

Design pattern đầu tiên để nhằm giảm tải cho model ActiveRecord đó chính là concerns. Concerns có thể giúp giảm bớt việc lặp đi lặp lại logic thông qua các model. Nó cũng giúp nhóm các vấn đề cụ thể của logic cùng với nhau bằng cách di chuyển logic vào một file khác.

Hãy tưởng tượng là bạn có 2 modelBlog::PostBlog::Comment. Cả hai đều có một hàm long_date để hiển thị ngày như sau :

# app/models/blog/post.rb
module Blog
  class Post < ActiveRecord::Base
    def long_date
      date.strftime("%A, #{date.day.ordinalize} %B %Y")
    end
  end
end

# app/models/blog/comment.rb
module Blog
  class Comment < ActiveRecord::Base
    def long_date
      date.strftime("%A, #{date.day.ordinalize} %B %Y")
    end
  end
end

Rõ ràng là ở đây đã vi phạm nguyên tắc DRY - không lặp lại chính mình. DRY là một nguyên tắc tốt và tôi không bất ngờ nếu như bạn đã quen thuộc với việc sử dụng mixins trong trường hợp này.

# app/models/concerns/blog/date_concern.rb
module Blog
  module DateFormattable
    def long_date
      date.strftime("%A, #{date.day.ordinalize} %B %Y")
    end
  end
end
# app/models/blog/post.rb
module Blog
  class Post < ActiveRecord::Base
    include Blog::DateFormattable
  end
end
# app/models/blog/comment.rb
module Blog
  class Comment < ActiveRecord::Base
    include Blog::DateFormattable
  end
end

Và ở đây, bạn đã loại bỏ sự trùng lặp bằng cách di chuyển hàm long_date vào trong Blog::DateFormattable và sau đó sẽ sử dụng nó bằng cách include nó vào trong cả 2 model Blog::PostBlog::Comment.

Nhóm method một cách hợp lý và đặt tên cho các concern

Có một vài điểm cần bàn về concern.

Thứ nhất là nhóm các method một cách hợp lý. Ở ví dụ trên, chúng ta đã tạo ra module Blog::DateFormattable, module này tất nhiên sẽ chứa các method cụ thể về date. Nếu mà ví dụ bạn muốn thêm một loạt các method để định dạng tên tác giả cho cả 2 model Blog::PostBlog::Comment thì bạn sẽ sử dụng một concern khác như sau :

# app/models/concerns/blog/authorable.rb
module Blog
  module Authorable
    def author_full_name
      "#{author.first_name} #{author.last_name}"
    end
  end
end
# app/models/blog/post.rb
module Blog
  class Post < ActiveRecord::Base
    include Blog::DateFormattable
    include Blog::Authorable
  end
end
# app/models/blog/comment.rb
module Blog
  class Comment < ActiveRecord::Base
    include Blog::DateFormattable
    include Blog::Authorable
  end
end

Giờ thì chúng ta vừa mới thêm method author_full_name vào cả 2 model của chúng ta. Vẫn đảm bảo DRYconcern của chúng ta được đặt tên một các sinh động.

Testing concern

Điểm thứ 2 tôi muốn bàn đến là trong lúc test concern. Bạn có 2 cách làm

  • Test trực tiếp cho từng concern riêng rẽ.
  • Test chức năng của concern đó trong mỗi class mà đã include đến nó.

Nếu như test trực tiếp cho từng concern như sau :

# spec/models/concerns/blog/authorable_spec.rb
describe Blog::Authorable do
  let(:authorable) {Class.new.extend(described_class)}

  before(:each) do
    authorable.stub(author: double(first_name: "Luke", last_name: "Morton"))
  end

  context "#author_full_name" do
    it "should return the full name of the author" do
      authorable.author_full_name.should eq("Luke Morton")
    end
  end
end

Trong ví dụ này, ta tạo mới một class ẩn danh với Class.newmixins concern của chúng ta. Sau đó ta stub một author tự trả về method first_namelast_name trong class ẩn danh đó. Và ta trả về giá trị của author_full_name.

Cách viết trên cũng tốt rồi nhưng cá nhân tôi thấy test concern này chỉ hữu ích khi sử dụng test để hướng dẫn thiết kế của bạn. Điều này rất khó xảy ra. Thông thương thì khi ta đã xác định các method trong model và sau đó, trong quá trình sử dụng mới nhận ra là chúng ta cần sử dụng nó ở một nơi khác nữa. Tại thời điểm này, bạn đã viết test cho việc thực hiện đầu tiên do đó viết test concern để thực hiện lại chức năng dường như là không cần thiết.

Ta có thể thay thế bằng cách sử dụng shared_examples_for. Hãy bắt đầu với test cho Blog::Post :

# spec/models/blog/post_spec.rb
require "spec_helper"

describe Blog::Authorable do
  let(:post) {described_class.new}
  context "#author_full_name" do
    it "should return the full name of the author" do
      post.stub(author: double(first_name: "Luke", last_name: "Morton"))
      post.author_full_name.should eq("Luke Morton")
    end
  end
end

Bây giờ bạn muốn sử dụng lại method này cho cả Blog::Comment, do đó đầu tiên chúng ta nên di chuyển testcase này vào shared_examples như sau :

# spec/support/shared_examples/authorable_example.rb
shared_example_for Blog::Authorable do
  before(:each) do
    authorable.stub(author: double(first_name: "Luke", last_name: "Morton"))
  end

  context "#author_full_name" do
    it "should return a full name of the author" do
      authorable.author_full_name.should eq("Luke Morton")
    end
  end
end

Và cập nhật lại rspec của Blog::Post để sử dụng :

# spec/models/blog/post_spec.rb
require "spec_helper"

describe Blog::Post do
  let(:post) {described_class.new}

  it_should_behave_like Blog::Authorable do
    let(:authorable) {post}
  end
end

Bạn cũng có thể tái sử dụng shared_examples trong lúc test Blog::Comment.

# spec/models/blog/post_spec.rb
describe Blog::Comment do
  let(:comment) {described_class.new}
  it_should_behave_like Blog::Authorable do
    let(:authorable) {comment}
  end
end

shared_examples không thêm nhiều thứ phức tạp vào test của bạn và đảm bảo rằng method làm việc trong đối tượng inlcude chúng hơn là làm việc độc lập. Thông thường, bạn muốn test độc lập một đơn vị của công việc. Nhưng với mixins trong Ruby là một cách cơ bản để sao chép và dán các method vào class thì tôi tin rằng chúng nên được test ở mức độ mà chúng đang được sử dụng.

Nếu bạn không thể sử dụng trực tiếp một method concern nào đó thì chúng ta viết test trực tiếp cho chúng để làm gì?

Lần tới, chúng ta sẽ thảo luận một ví dụ việc sử dụng services như là một cách thay thế cho concern

Tham khảo

Bài viết liên quan


Cảm ơn bạn đã theo dõi bài viết

tribeo