+4

Query Object trong Ruby on Rails

Tổng quan:

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 vấn đề thường gặp khi truy vấn cơ sở dữ liệu trong ứng dụng:

  • 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 đó.

Giải pháp cho vấn đề:

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ụ.

Các cách triển khai:

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

Kết Luận:

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.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.