这段时间一直在研究优化各种场景 N+1 问题的方法,然后最近摸索出一种简单的解决当前用户状态 N+1 问题的方案,而且使用的都是 Rails 内置的功能,不需要依赖其他 Gem。
首先我们先描述一下场景,就用现在这个页面举例好了,显示当前用户是否点赞了每条评论,先来看看 homeland 的是如何做的:
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 问题啦!