Ruby 利用 Rails 内置功能解决当前用户状态 N+1 问题

lolychee · April 21, 2021 · Last by lolychee replied at April 22, 2021 · 610 hits

这段时间一直在研究优化各种场景 N+1 问题的方法,然后最近摸索出一种简单的解决当前用户状态 N+1 问题的方案,而且使用的都是 Rails 内置的功能,不需要依赖其他 Gem。

首先我们先描述一下场景,就用现在这个页面举例好了,显示当前用户是否点赞了每条评论,先来看看 homeland 的是如何做的:

https://github.com/ruby-china/homeland/blob/ae3296f6cc31f83384bc1f2515278bb7838f0717/app/controllers/topics_controller.rb#L53

https://github.com/ruby-china/homeland/blob/ae3296f6cc31f83384bc1f2515278bb7838f0717/app/views/topics/show.html.erb#L6

https://github.com/ruby-china/homeland/blob/ae3296f6cc31f83384bc1f2515278bb7838f0717/app/javascript/front/topics.js#L98

homeland 先是在 controller 里面查出所有点赞过的评论 ID,然后在 JS 里修改点赞评论的显示状态。

其实解决 N+1 问题的思路是一样的,只是这里的实现有些冗余,不够通用。

下面来看一下同样的需求我们如何用 rails 内置功能实现:

# 以下代码基于 Rails 6.1 版本

class Current < ActiveSupport::CurrentAttributes
  attribute :user, :stateful

  def self.with_stateful
    Current.stateful = true
    yield
  ensure
    Current.stateful = false
  end
end

module Authorable
  extend ActiveSupport::Concern

  included do
    belongs_to :author, class_name: "User", default: -> { Current.user }, strict_loading: true
  end
end

module Commentable
  extend ActiveSupport::Concern

  included do
    has_many :comments, as: :commentable, dependent: :delete_all
    has_many :commented_users, through: :comments, source: :author
  end
end

module Likeable
  extend ActiveSupport::Concern

  included do
    has_many :likes, as: :likeable, dependent: :delete_all
    has_many :liked_users, through: :likes, source: :user

    has_one :current_like, -> { Current.user ? where(user: Current.user) : none }, class_name: "Like", as: :likeable, strict_loading: true

    default_scope { Current.stateful ? includes(:current_like) : all }
  end

  def liked
    current_like.present? && current_like.persisted?
  end

  def liked=(value)
    return unless Current.user

    value = ActiveModel::Type::Boolean.new.cast(value)
    if value
      liked || create_current_like
    else
      current_like.destroy
    end
    value
  end

  def liked_at
    current_like&.created_at
  end

  def serializable_hash(options = nil)
    if Current.stateful
      options ||= {}
      options[:methods] = Array(options[:methods]).compact | %i[liked liked_at]
    end

    super(options)
  end
end

class User < ApplicationRecord
end

class Post < ApplicationRecord
  include Authorable
  include Commentable
  include Likeable
end

class Comment < ApplicationRecord
  include Authorable
  include Commentable
  include Likeable

  delegated_type :commentable, types: %w[ Post Comment ]
end

class Like < ApplicationRecord
  belongs_to :user, default: -> { Current.user }, strict_loading: true
  delegated_type :likeable, types: %w[ Post Comment ]
end

class PostsController < ApplicationController
  around_action :with_stateful

  def show
    @post = Post.find(params[:id])
  end

  private

    def with_stateful
      Current.user = current_user
      Current.with_stateful { yield }
    end
end

# app/views/comment/_comment.html.erb

<% if @comment.liked %>
    <%= button_to "unlike", comment_path(post: { liked: false }), method: :patch) %>
<% else %>
  <%= button_to "like", comment_path(post: { liked: true }), method: :patch) %>
<% end %>

其中最核心的就是定义 current_like 的那行代码:

has_one :current_like, -> { Current.user ? where(user: Current.user) : none }, class_name: "Like", as: :likeable, strict_loading: true

https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_one

Rails 在 4.0 的时候就开始支持定义 association 的时候附带一个 scope 参数,这个参数让我们可以自定义查询条件。

利用这个参数搭配ActiveSupport::CurrentAttributes,我们定义出了一个当前用户点赞记录的 association。而且这个有点特别的 association 同样也能享受 AR 的 preload 优化。

剩下的就是在我们需要时加上includes(:current_like),然后就可以随意访问 liked 字段,而不用担心 N+1 问题啦!

在 mongoid 中如何实现上面的功能呢?

mongoid 因为不支持定义 association 时传 scope 参数,所以要用一个有点 trick 的方法:


class Post
  include Mongoid::Document

  has_one :current_like, class_name: "Like", as: :likeable do
    def criteria
      super.where(user: Current.user)
    end
  end
end

通过定义一个 extension module,覆盖 criteria 方法,加入我们的自定义查询条件。

You need to Sign in before reply, if you don't have an account, please Sign up first.