Query Object trong Ruby on Rails
Truy vấn cơ sở dữ liệu là việc thường gặp khi bạn phát triển một ứng web. Ruby on Rails và ActiveRecord giải phóng bạn khỏi việc phải viết hàng tấn các câu lệnh SQL kiểu mẫu và kết quả là tạo ra các câu truy vấn khổng lồ theo Ruby thuần. Nhưng thật không may là có vô số các tính năng bao la rộng ...
Truy vấn cơ sở dữ liệu là việc thường gặp khi bạn phát triển một ứng web. Ruby on Rails và ActiveRecord giải phóng bạn khỏi việc phải viết hàng tấn các câu lệnh SQL kiểu mẫu và kết quả là tạo ra các câu truy vấn khổng lồ theo Ruby thuần. Nhưng thật không may là có vô số các tính năng bao la rộng lớn được cung cấp bởi Ruby và ActiveRecord thậm chí chưa được sử dụng đến. Tôi cá là bạn thường thấy rất nhiều các scopes khổng lồ trong model của Ruby on Rails, hàng loạt chuỗi câu truy vấn nối nhau trong controller và thậm chí cả các khối lệnh SQL thuần cồng kềnh nữa.
@articles = Article .includes(:user) .order("created_at DESC") .where("text IS NOT NULL") .page(page) @articles = Articles .connection .select_all(%Q{SELECT articles.* FROM articles WHERE (text IS NOT NULL) ORDER BY created_at DESC LIMIT 5 OFFSET 0})
Một trường hợp không tốt khi sử dụng truy vấn của ActiveRecord
Những thói quen xấu này có thể sẽ tạo ra các vật cản lớn và trở thành lý do cho sự đau dầu của các lập trình viên trong việc xây dựng các ứng dụng web trên thế giới.
- Những khối lệnh truy vấn cơ trong controllers/models/services gây rối loạn code của bạn.
- Thực sự rất khó để có thể hiểu các yêu cầu phức tạp để truy nhập cơ sở dữ liệu.
- Việc thêm các dòng lệnh SQL thuần là không đồng nhất và thường bị lẫn với các câu truy vấn của ActiveRecord.
- Việc thử và kiểm tra từng câu truy vấn một cách độc lập là một vấn đề nan giải.
- Rất khó để có thể gộp lại, mở rộng hoặc thừa kế các câu truy vấn.
- Thường sẽ vi phạm quy tắc Single Responsibility Principle - mỗi khối lệnh, module hay class chỉ nên chịu trách nhiệm thực hiện 1 chức năng cụ thể nào đó.
Những vấn đề trên có thể được giải quyết bằng cách sử dụng Query Object - một kĩ thuật phổ biến để cô lập các truy vấn cơ sở dữ liệu phức tạp. Query Object theo trường hợp lý tưởng có thể hiểu là một class riêng biệt chứa một câu truy vấn cụ thể, và chỉ thực hiện một quy tắc logic nghiệp vụ.
Trong hầu hết các trường hợp Query Object là một PORO ( Plain Old Ruby Ọbject) - một object thuần trong ruby chấp nhận các quan hệ trong hàm khởi tạo và định nghĩa các truy vấn được đặt tên như các hàm của ActiveRecord:
# app/models/article.rb class Article < ActiveRecord::Base scope :by_title, ->(direction) { order title: direction } scope :by_date, ->(direction) { order created_at: direction } scope :by_author, ->(direction) { order "users.full_name #{direction}" } end # app/queries/ordered_articles_query.rb class OrderedArticlesQuery SORT_OPTIONS = %w(by_date by_title by_author).freeze def initialize(params = {}, relation = Article.includes(:user)) @relation = relation @params = params end def all @relation.public_send(sort_by, direction) end private def sort_by @params[:sort].presence_in(SORT_OPTIONS) || :by_date end def direction @params[:direction] == "asc" ? :asc : :desc end end # app/controllers/articles_controller.rb class ArticlesController def index @articles = OrderedArticlesQuery.new(sort_query_params).all.page(params[:page]) end private def sort_query_params params.slice(:sort_by, :direction) end end
Cách triển khai Query Object và sử dụng trong controller
Cú pháp HEREDOC cho các câu lệnh SQL thuần:
Trong trường hợp bạn thực sự cần phải sử dụng các câu lệnh SQL thuần, hãy cố cô lập chúng bằng cách sử dụng cú pháp HEREDOC của Ruby:
class PopularArticlesQuery POPULAR_TRESHOLD = 5 def initialize(subscriptions = Subscription.all) @subscriptions = subscriptions end def all @subscriptions.where(query) end private def query <<-SQL articles.comments_count >= #{POPULAR_TRESHOLD} AND articles.content IS NOT NULL AND articles.status = #{Article::STATUSES[:published]} ORDER BY articles.comments_count DESC SQL end end
Ví dụ sử dụng cú pháp HEREDOC cho việc thêm các câu lệnh SQL thuần
Mở rộng scope:
Nếu scope của bạn liên quan đến một QueryỌbject đã có sẵn, bạn có thể dễ dàng mở rộng mối quan hệ của chúng hơn là việc làm lộn xộn các model. Cú pháp ActiveRecord::QueryMethods.extending sẽ giúp bạn giải quyết vấn đề này:
class OrderedArticlesQuery SORT_OPTIONS = %w(by_date by_title by_author).freeze def initialize(params = {}, relation = Article.includes(:user)) @relation = relation.extending(Scopes) @params = params end def all @relation.public_send(sort_by, direction) end private def sort_by @params[:sort].presence_in(SORT_OPTIONS) || :by_date end def direction @params[:direction] == "asc" ? :asc : :desc end # Group additional scope methods in module in order to extend relation module Scopes def by_title(direction) order(title: direction) end def by_date(direction) order(created_at: direction) end def by_author order("users.full_name #{direction}") end end end
Mở rộng scope các quan hệ của Query Object
Gộp các Querry Object với nhau:
Các Query Object nên được viết để có thể hỗ trợ việc gộp với các Query Object khác và các quan hệ từ ActiveRecord. Ví dụ dưới đây minh họa việc gộp hai Query Objects lại để đại diện cho một câu truy vấn SQL:
class FeaturedQuery def initialize(relation = Article.all) @relation = relation end def all @relation.where(featured: true).where("views_count > ?", 100) end end class ArticlesController def index @articles = FeaturedArticlesQuery.new(sorted_articles).all # SELECT "articles".* FROM "articles" WHERE "articles"."featured" = $1 # AND (views_count > 100) ORDER BY "articles"."created_at" DESC LIMIT 10 OFFSET 0 [["featured", "t"]] end private def sorted_articles SortedArticlesQuery.new(sort_query_params).all end def sort_query_params { sort: :by_title, direction: :desc } end end
Gộp hai Query Object với nhau
Kế thừa một Query Objects:
Nếu bạn có nhiều các truy vấn tương tự nhau, bạn có thể sẽ muốn chúng được thừa kế để có thể tránh, giảm việc lặp code và có thể tuân theo nguyên tắc DRY:
class ArticlesQuery TEXT_LENGTH = 3 def initialize(comments = Comment.all) @comments = comments end def all comments .where("user_id IS NOT NULL") .where("LENGTH(content) #{condition}") end def condition "> #{TEXT_LENGTH}" end end class LongArticlesQuery < ArticlesQuery TEXT_LENGTH = 5 def condition ">= #{TEXT_LENGTH}" end end
Kế thừa một Query Object
Viết test cho Query Objects:
Query Objects nên được thiết kế để có thể thuận tiện cho việc Test được dễ dàng hơn. Trong hầu hết các trường hợp bạn chỉ cần Test kết quả trả về của các hàm chính định nghĩa trong truy vấn:
require "rails_helper" describe LongArticlesQuery do describe "#all" do subject(:all) { described_class.new.all } before do create :article, text: "abc" create :article, text: "this is long article" end it "returns one short comment" do expect(all.size).to eq(1) end end end
Test một Query Object
Một Query Object chuẩn:
- Tuân theo quy tắc Single Responsibility Principle.
- Có thể dễ dàng được Test một cách độc lập.
- Có thể dễ dàng kết hợp hay mở rộng với các Query Object khác.
- Không cần tốn nhiều sức để có thể sử dụng lại ở các phần khác trong ứng dụng.
- Trả về ActiveRecord::Relation chứ không phải Array.
- Chỉ đại diện cho truy vấn cơ sở dữ liệu, không phải logic nghiệp vụ hay thao tác nào đó.
- Các hàm của Query Object được đặt tên như hàm của ActiveRecord (all, last, count...).
Sử dụng Query Object khi:
- Bạn cần sử dụng lại một câu truy vấn ở nhiều chỗ trong ứng dụng.
- Bạn cần mở rộng, gộp hay thừa kế các câu truy vấn và các quan hệ của chúng.
- Bạn cần viết rất nhiều các câu lệnh SQL thuần nhưng không muốn làm rối code.
- Các câu truy vấn của bạn quá phức tạp, lớn cho một hàm hay scope.
- Câu truy vấn của bạn gây ra hiện tượng Feature Envy - một hàm truy nhập đến dữ liệu của các object khác nhiều hơn cả dữ liệu của nó.
Không nên sử dụng Query Object khi:
- Câu truy vấn của bạn đơn giản chỉ cần một hàm hay scope là đủ.
- Bạn không cần mở rộng, gộp hay kế thừa câu truy vấn đó.
- Câu truy vấn của bạn là duy nhất không sử dụng lại ở bất cứ đâu.