这篇文章展示了如何定义依赖于一些全局对象的关联,如当前用户(我们也可以把这些东西称为多租户关联)。
问题
想象一个有三个模型的Rails项目。用户、艺术家、歌曲。匿名用户只能看到已发布的歌曲,认证用户可以看到所有歌曲:
class User < ApplicationRecord
end
class Song < ApplicationRecord
enum :status, {draft: 'draft', published: 'published'}
belongs_to :artist
end
class Artist < ApplicationRecord
has_many :songs
end
在控制器的某个地方,有一段代码可以优化N+1问题:
@artists = Artist.includes(:song)
songs 但是在Artist 上定义的关联没有意识到我们的要求,即它需要只为未登录的用户列出已发表的歌曲。所以它总是会返回所有的歌曲。
请看下面如何在不改变关联名称的情况下解决这个问题。
保持关联名称是一个重要的要求。它不需要在现有应用程序有
.includes(:song)的地方改变代码。这样,它就消除了回归和被遗忘的地方可能出现的错误。
解决方案
ActiveRecord没有任何允许参数化关联的功能。幸运的是,它允许指定一个可选的lambda,可以减少返回对象的范围。听起来正是我们所需要的。 不幸的是,它不知道当前用户是否存在(已认证)。 但我们可以定义一个全局线程安全变量并在那里使用它。
这个全局变量可以通过ApplicationRecord ,在around_filter:
class ApplicationController < ActionController::Base
around_action :set_current_user_globally
private
def set_current_user_globally
Thread.current[:current_user] = current_user
yield
ensure
Thread.current[:current_user] = nil
end
end
然后在关联中使用它就变得很容易了:
class Artist < ApplicationRecord
has_many :songs, -> { Thread.current[:current_user] ? all : where(status: :published) }
end
应用程序开始按照新的要求工作,即匿名者只能看到已发布的歌曲。 注意,应用程序的其他地方保持不变,不需要任何改变。
这就是整个解决方案。就这么简单。
让我们检查一下它的操作:
irb(main):001:0> Artist.includes(:songs).map(&:songs)
(0.9ms) SELECT sqlite_version(*)
Artist Load (0.5ms) SELECT "artists".* FROM "artists"
Song Load (0.7ms) SELECT "songs".* FROM "songs" WHERE "songs"."status" = ? AND "songs"."artist_id" IN (?, ?) [["status", "published"], ["artist_id", 1], ["artist_id", 2]]
=>
[[#<Song:0x00000001100331d8 id: 2, status: "published", artist_id: 1, ...],
[#<Song:0x000000011008be28 id: 3, status: "published", artist_id: 2, ...]]
它没有设置Thread.current[:current_user] - 假设它是一个匿名用户的请求。这就是为什么结果中只有已发表的歌曲。
在下一个测试中,我们指定Thread.current[:current_user] - 在控制台中这样做,我们有点模拟在控制器中运行上面的周围过滤器:
irb(main):002:0> Thread.current[:current_user] = User.first
irb(main):003:0> Artist.includes(:songs).map(&:songs)
Artist Load (0.2ms) SELECT "artists".* FROM "artists"
Song Load (0.4ms) SELECT "songs".* FROM "songs" WHERE "songs"."artist_id" IN (?, ?) [["artist_id", 1], ["artist_id", 2]]
=>
[[#<Song:0x00000001108c82d0 id: 1, status: "draft", artist_id: 1, ...>,
#<Song:0x00000001108c8140 id: 2, status: "published", artist_id: 1, ...>],
[#<Song:0x0000000110923f90 id: 3, status: "published", artist_id: 2, ...>,
#<Song:0x0000000110923e28 id: 4, status: "draft", artist_id: 2, ...>]]
现在它返回所有歌曲的状态。你也可以在生成的SQL中看到这一点。
关联变得非常聪明,非常灵活。
缺点
-
这个解决方案在你面临的情况下是有效的,因为当一个地方需要减少结果,同时另一个地方需要结果中的所有项目。 在这种情况下,这个解决方案应该是先进的(是的,有可能使它更聪明)或被其他东西取代。
-
我应用这个解决方案的项目在测试中使用了数据库清洁器gem。 尽管,下面的 RSpec 示例看起来不错,但它会破坏这个 gem 的工作。
around do |example|
Thread.current[:current_user] = user
example.run
ensure
Thread.current[:current_user] = nil
end
在上面的ensure 块中,代码清除了全局var,并在数据库清洁器钩子后运行。但所有保存在Thread.current 中的对象并没有被数据库清洁器清理。因此,如果项目中有一些规格使用上述的周围块,测试可能开始随机失败,因为DB中保存的用户跨越了测试(测试实例通常依赖于一个空的DB)。
我想出了以下的解决方法:
- 使用
before块而不是around:
before { Thread.current[:current_user] = user }
- 改变数据库清洁器的设置:
config.after(:each) do
# Thread.current[:current_user] is set in some tests
# it should be cleaned before database cleaner runs, otherwise it won't be dropped from DB.
Thread.current[:current_user] = nil
DatabaseCleaner.clean
end
这不是一个非常优雅的解决方案,但它很有效。
总结
在这里可以看到一个工作中的Rails应用程序作为一个例子。
在处理一个问题时,尽量在 "本地 "解决它。这可以让我们找到一个解决方案,避免不必要的全局变化、回归和bug。编码愉快!