Rails/多租户:基于不同模型的数据库值/全局设置的条件默认范围?

Rails / Multi-Tenancy: Conditional default scope based on a different model's db value / global setting?

我有一个 Rails 多租户应用程序。每个模型都有一个 account_id,属于一个帐户,并且具有当前帐户 ID 的默认范围:

class Derp < ApplicationRecord
  default_scope { where(account_id: Account.current_id) }
  belongs_to :account
end

这很好用,我已经在其他应用程序的生产中使用了这种模式(我知道默认范围不受欢迎,但这是一种可接受的模式。参见:https://leanpub.com/multi-tenancy-rails)。

现在更重要的是 - 我有一个客户(可能还有更多的客户,谁知道呢),他想 运行 在他们自己的服务器上安装该软件。为了解决这个问题,我简单地制作了一个带有类型属性的服务器模型:

class Server < ApplicationRecord
  enum server_type: { multitenant: 0, standalone: 1 }
end

现在在我的多租户服务器实例上,我只需创建一个服务器记录并将 server_type 设置为 0,在我的独立实例上我将其设置为 1。然后我有一些辅助方法在我的应用程序控制器中帮助解决这个问题,即:

class ApplicationController < ActionController::Base
  around_action :scope_current_account

  ...

  def server
    @server ||= Server.first
  end

  def current_account
    if server.standalone?
      @current_account ||= Account.first
    elsif server.first.multitenant?
      @current_account ||= Account.find_by_subdomain(subdomain) if subdomain
    end
  end

  def scope_current_account
    Account.current_id = current_account.id
    yield
  rescue ActiveRecord::RecordNotFound
    redirect_to not_found_path
  ensure
    Account.current_id = nil 
  end
end

这行得通,但是我在这个特定的独立客户端上查询了很大的记录集(70,000 条记录)。我在 account_id 上有一个索引,但我的主要客户 table 在我的开发机器上用了 100 毫秒到 400 毫秒。

然后我意识到:独立服务器根本不需要关心帐户 ID,特别是如果它会影响性能。

所以我真正要做的就是使这一行成为条件:

default_scope { where(account_id: Account.current_id) }

我想做这样的事情:

class Derp < ApplicationRecord
  if Server.first.multitenant?
    default_scope { where(account_id: Account.current_id) }
  end
end

但显然语法错误。我在 Stack Overflow 上看到了一些关于条件范围的其他示例,但是 none 似乎可以使用基于完全独立模型的条件语句。在 Ruby 中有没有办法完成类似的事情?

编辑:我刚刚意识到的问题是,这只会解决一个独立服务器的速度问题,所有多租户帐户仍然需要处理 account_id 的查询。也许我应该专注于此...

这是我的解决方案:

首先,将任何帐户范围模型必须的帐户范围内容抽象为一个从 ApplicationRecord 继承的抽象基础 class:

class AccountScopedRecord < ApplicationRecord
  self.abstract_class = true

  default_scope { where(account_id: Account.current_id) }

  belongs_to :account
end

现在任何模型都可以干净地划分为帐户范围,例如:

class Job < AccountScopedRecord
  ...
end

要解决条件问题,将进一步抽象为 ActiveRecord 关注点:

module AccountScoped
  extend ActiveSupport::Concern

  included do
    default_scope { where(account_id: Account.current_id) }
    belongs_to :account
  end
end

那么 AccountScopedRecord 可以做:

class AccountScopedRecord < ApplicationRecord
  self.abstract_class = true

  if Server.first.multitenant?
    send(:include, AccountScoped)
  end
end

现在独立帐户可以忽略任何与帐户相关的内容:

# Don't need this callback on standalone anymore
around_action :scope_current_account, if: multitenant?

# Method gets simplified
def current_account
  @current_account ||= Account.find_by_subdomain(subdomain) if subdomain
end

我会避免使用 default_scope,因为我过去曾被它咬过。特别是,我在应用程序中有一些地方我想 明确地 有它的范围,而其他地方我不想。我想要范围界定的地方通常最终成为控制器/后台作业,而我不想要/不需要它的地方最终成为测试。

考虑到这一点,我会选择控制器中的显式方法,而不是模型中的隐式范围:

而你有:

class Derp < ApplicationRecord
  if Server.first.multitenant?
    default_scope { where(account_id: Account.current_id) }
  end
end

我会在控制器中有一个名为 account_derps:

的方法
def account_derps
  Derp.for_account(current_account)
end

然后,无论我想只为给定帐户加载 derps,我都会使用 account_derps。如果我需要这样做,我可以自由地使用 Derp 进行无范围查找。

这个方法最好的部分是你也可以在这里放弃你的 Server.first.multitenant? 逻辑。


你在这里提到另一个问题:

This works, but I've got large record sets that I'm querying on this particular standalone client (70,000 records). I've got an index on the account_id, but it took my main customers table from 100ms to 400ms on my development machine.

我认为这很可能是因为缺少索引。但是我在这里没有看到 table 架构或查询,所以我不确定。可能是您正在对 account_id 和其他一些字段执行 where 查询,但您只将索引添加到 account_id。如果您使用的是 PostgreSQL,那么查询前的 EXPLAIN ANALYZE 将为您指明正确的方向。如果您不确定如何破译其结果(有时它们可​​能很棘手),那么我建议您使用精彩的 pev (Postgres EXPLAIN Visualizer),它会将您指向查询中最慢的部分图形格式。


最后,感谢您花时间阅读我的书,并就 SO 上的相关主题提出如此详细的问题 :)