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 ...
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.
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 model là Blog::Post và Blog::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::Post và Blog::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::Post và Blog::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 DRY và concern 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.new và mixins concern của chúng ta. Sau đó ta stub một author tự trả về method first_name và last_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
- ActiveRecord Refactoring
- 7 Patterns to Refactor Fat ActiveRecord Models
- ActiveRecord refactoring (P2) - Services
- ActiveRecord refactoring (P3) - Presenters