mcpField
A
activerecord-tenanted

activerecord-tenanted

github.com/thinkoodle开发7165 次安装8 周前更新
代码仓库

关于 activerecord-tenanted

这份技能指南介绍如何用 Basecamp 的 `activerecord-tenanted` gem 将 Rails 租户数据隔离到各自的 SQLite 数据库,同时把身份和租户记录留在全局数据库。内容涵盖分片选择、请求作用域、后台任务、迁移与测试。

主要功能

  1. 01区分全局记录与租户记录
  2. 02为每个租户配置 SQLite 数据库
  3. 03根据请求路径设置租户作用域
  4. 04在后台任务中重新进入租户作用域
  5. 05安全迁移和测试租户数据库

适用场景

  1. 01设计基于路径的 Rails 多租户模式
  2. 02分离身份记录与工作区数据
  3. 03让后台任务访问租户记录
  4. 04测试需要活动租户的代码

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/thinkoodle/rails-skills --skill activerecord-tenanted

安装后在 Claude Code 中输入/activerecord-tenanted确认已加载。

附带文件reference.md

详细介绍

activerecord-tenanted(每个租户使用一个 SQLite 数据库)

通过 Basecamp 的 activerecord-tenanted gem 为每个租户使用一个 SQLite 文件。身份、会话和租户记录存放在全局数据库中。产品数据存放在 storage/tenants/{env}/{tenant_key}/main.sqlite3。

这是 Herald / Cortex / Innkeeper 使用的模式。它不是:

  • Nebula acts_as_tenant——一个数据库,每行都有 workspace_id
  • Fizzy 路径租户模式——一个数据库,使用 Current.account + SCRIPT_NAME URL 前缀

如果应用已在共享架构中使用 acts_as_tenant 或 Fizzy 风格的账户 slug,请勿引入此 gem。

设计理念

  1. 两个基类,两个数据库。 GlobalRecord 永远不会访问租户数据。ApplicationRecord 永远不会访问身份数据。
  2. 没有租户就不能查询租户数据。 在 with_tenant / Current.tenant= 之外调用 User.find 是一个 bug,即使是在任务和 rake 任务中也一样。
  3. URL(或 Current)决定分片。 gem 默认的子域名解析器不适用于基于路径的应用——请禁用它。
  4. 作业重新进入租户上下文。 Active Job 不会自动传递分片。传入一个全局 ID(account/tenant/project),并在 perform 中使用 with_tenant 包裹。

数据放在哪里

全局(GlobalRecord)租户级(ApplicationRecord)
Identity、Session、MagicLink实际的产品记录
Tenant / AccountUser(Cortex/Innkeeper——租户内的个人资料)
Membership(Identity ↔ tenant)该工作区中的其他所有内容
JoinCode、API tokensActive Storage blobs(启用 tenanted_rails_records 时)

Herald 将 Project 保留在全局数据库中(这样在选择分片之前就有 slug/API key),并将 releases/subscribers 存储在账户租户数据库中。Cortex/Innkeeper 则将几乎所有产品表放在租户数据库中。两种方式都可以——选择一种并保持一致。

步骤 1:Gem + database.yml

# Gemfile
gem "activerecord-tenanted"
# config/database.yml — every environment
primary:
  adapter: sqlite3
  database: storage/tenants/<%= Rails.env %>/%{tenant}/main.sqlite3
  tenanted: true
  max_connection_pools: 50
global:
  adapter: sqlite3
  database: storage/<%= Rails.env %>.sqlite3
  migrations_paths: db/global_migrate
queue:
  adapter: sqlite3
  database: storage/<%= Rails.env %>_queue.sqlite3
  migrations_paths: db/queue_migrate
# cache + cable the same way

%{tenant} 是 gem 的占位符。在 primary 上设置 database_tasks: false 是可选的(Herald 设置此项,以免 Rails 尝试转储单个主架构)。

迁移:

  • db/global_migrate/——Identity、Tenant/Account、Membership、Session。运行一次。
  • db/migrate/——租户级表。gem 会为每个租户运行这些迁移。
bin/rails db:migrate          # tenanted
bin/rails db:migrate:global    # global — check the gem's task name in the app

步骤 2:两个抽象类

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  tenanted
end

# app/models/global_record.rb
class GlobalRecord < ActiveRecord::Base
  self.abstract_class = true
  connects_to database: { writing: :global, reading: :global }
end

新模型:如果是个人、会话或租户记录本身 → GlobalRecord。如果是工作区内的数据 → ApplicationRecord。弄错这一点是头号 bug。

步骤 3:禁用子域名解析器 + 默认租户

基于路径的应用(我们所有的应用)都不能使用 gem 的子域名解析器:

# config/initializers/tenanted.rb
Rails.application.configure do
  config.active_record_tenanted.tenant_resolver = ->(request) { nil }
  config.active_record_tenanted.default_tenant =
    Rails.env.local? ? "#{Rails.env}-tenant" : nil
  config.active_record_tenanted.tenanted_rails_records = true
  config.active_record_tenanted.connection_class = "ApplicationRecord"
end

生产环境中的 default_tenant 必须为 nil。 否则启动可能会因 ARTENANT not set 而失败;更糟的是,每个请求都可能共用一个遗留分片。在本地,指定一个默认租户可让控制台/测试正常启动。

tenanted_rails_records = true 会将 Active Storage / Action Text 存储在租户数据库中,并使用带前缀的键。请搭配 active-storage-multitenant skill 使用。

步骤 4:Current 切换分片

class Current < ActiveSupport::CurrentAttributes
  attribute :identity, :session
  attribute :tenant  # or :account — pick one name and use it everywhere
  attribute :user

  def tenant=(tenant)
    super
    return if ActiveRecord::Base.shard_swapping_prohibited?

    ApplicationRecord.current_tenant =
      tenant ? tenant.external_id : ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
  end
end

Herald 使用 Current.account= 和 account.tenant_key("account-#{id}")。Cortex/Innkeeper 使用 Current.tenant= 和 tenant.external_id。机制相同。

在请求结束时重置(Cortex):

resets do
  next if ActiveRecord::Base.shard_swapping_prohibited?
  ApplicationRecord.current_tenant = ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
end

步骤 5:从 URL 解析租户

在控制器 concern 中执行,而不是使用 gem 的解析器:

module TenantScoping
  extend ActiveSupport::Concern

  included do
    before_action :set_current_tenant
    before_action :set_current_user
  end

  def set_current_tenant
    record = Tenant.find_by(external_id: params[:tenant_external_id])
    Current.tenant = record if record
  end

  def set_current_user
    return unless Current.identity && Current.tenant
    Current.user = User.find_by(identity_id: Current.identity.id)
  end
end

路由嵌套在租户 key 下(/:tenant_external_id/...,或会加载 Account 的项目 slug 下)。未认证页面(登录、来自供应商的 webhooks)会跳过此步骤,并且只对租户级操作进行封装。

创建 Tenant/Account 时创建租户数据库:

after_create :provision_tenant_database

def ensure_tenant_database!
  ApplicationRecord.create_tenant(tenant_key) unless database_exists?
rescue ActiveRecord::Tenanted::TenantExistsError
  # parallel tests / retries
end

步骤 6:作业、rake、控制台

def perform(tenant_external_id, record_id)
  ApplicationRecord.with_tenant(tenant_external_id) do
    Current.tenant = Tenant.find_by(external_id: tenant_external_id)
    Record.find(record_id).do_work
  end
end

永远不要将属于租户的 Active Record 序列化为 GID,并指望 perform 能加载它——作业运行时没有租户上下文。请传入全局租户键和记录 ID。

Rake / 单次任务:

Tenant.find_each do |tenant|
  ApplicationRecord.with_tenant(tenant.external_id) do
    puts Project.count
  end
end

对于 URL 中不含租户信息的公开入站 Webhook:遍历租户(Innkeeper),或查找指向某个租户的全局令牌,然后调用 with_tenant。不要在作业顶层查询 User。

测试

每个涉及租户模型的测试都必须设置租户。使用以下辅助方法:

def with_tenant(tenant = tenants(:acme), &block)
  ApplicationRecord.with_tenant(tenant.external_id, &block)
end

集成测试:访问包含租户前缀的 URL,以便运行 TenantScoping。不要 stub Current.tenant 后再断言 HTTP 结果——分片与请求不匹配。

反模式

  1. 在已经采用租户隔离的应用中使用 acts_as_tenant :workspace——这会引入两套租户系统。Nebula 是另一种模型;不要混用。
  2. 将 Identity 或 Session 放在 ApplicationRecord 上——切换分片时,这些表必须继续可用。
  3. 在作业中不调用 with_tenant 就执行 User.find——结果会静默为空,或出现 TenantNotSet。
  4. 保留子域名解析器——基于路径的应用会解析出错误的租户,或无法解析租户。
  5. 在生产环境中使用 default_tenant——所有进程都会共享同一个数据库文件。
  6. 假设租户数据库使用整数主键——我们的租户表使用 UUID。请参阅 uuid-primary-keys。
  7. 未设置当前租户就创建 blob——“Cannot generate a Blob key without a tenant.”请使用 with_tenant 和 Active Storage 技能。

相关内容

  • Identity 与 User、成员关系、加入码:identity-membership
  • 租户隔离的 Active Storage / Lexxy:active-storage-multitenant
  • 在租户上下文中运行的出站 Webhook:outgoing-webhooks
  • 无密码登录(全局 Identity):magic-link-auth

有关 database.yml、Herald 与 Cortex 的命名,以及分片切换的注意事项,请参阅 reference.md。

评论

支持 Markdown