Rails.app.creds 的 5 种意想不到的用法

Rails 8.2 引入了 Rails.app.creds 作为从 ENV 和加密文件访问凭证的统一方式。但隐藏在实现中的是更强大的东西:CombinedConfiguration 是配置的责任链模式。每个后端要么返回一个值,要么传递给下一个。

这不仅仅是凭证查找。这是可组合的配置中间件。

以下是利用这种架构的五种模式。

1. 无需服务的功能开关

大多数应用不需要 LaunchDarkly。你需要的是一种版本控制的、在开发环境中可覆盖的功能切换方式。

# config/credentials.yml.enc
features:
  new_checkout: false
  dark_mode: true
  ai_summaries: false

像任何凭证一样访问它们:

if Rails.app.creds.option(:features, :new_checkout, default: false)
  render_new_checkout
else
  render_legacy_checkout
end

神奇之处:ENV 会覆盖凭证。无需触碰加密文件即可在本地启用功能:

FEATURES__NEW_CHECKOUT=true bin/rails server

需要在预发布环境测试功能?在部署配置中设置 ENV 变量即可。无需代码更改,无需编辑凭证,无需部署。

使其更易用

用 helper 封装它:

# app/helpers/feature_flags_helper.rb
module FeatureFlagsHelper
  def feature?(name)
    Rails.app.creds.option(:features, name, default: false).to_s == "true"
  end
end

# 使用方法
<% if feature?(:new_checkout) %>
  <%= render "checkout/new" %>
<% end %>

现在你的功能开关是:

  • 版本控制的(在 credentials 中)
  • 可按环境覆盖(通过 ENV)
  • 零外部依赖
  • 免费

2. 开发者覆盖文件

每个团队都有这个问题:“如何在本地使用不同的凭证进行测试而不提交它们?”

创建一个被 git 忽略的本地覆盖文件:

# config/initializers/developer_overrides.rb
class DeveloperOverrides
  def initialize
    path = Rails.root.join(".secrets.local.yml")
    @config = path.exist? ? YAML.load_file(path).deep_symbolize_keys : {}
  end

  def require(*keys)
    option(*keys) || raise(KeyError, "缺失: #{keys.join('.')}")
  end

  def option(*keys, default: nil)
    value = keys.reduce(@config) { |h, k| h.is_a?(Hash) ? h[k] : nil }
    value.nil? ? default : value
  end

  def keys
    flatten_keys(@config)
  end

  def reload
    @config = YAML.load_file(Rails.root.join(".secrets.local.yml")).deep_symbolize_keys
  rescue Errno::ENOENT
    @config = {}
  end

  private

  def flatten_keys(hash, prefix = [])
    hash.flat_map do |k, v|
      v.is_a?(Hash) ? flatten_keys(v, prefix + [k]) : [prefix + [k]]
    end
  end
end

# 以最高优先级插入
if Rails.env.development?
  Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
    DeveloperOverrides.new,
    Rails.app.envs,
    Rails.app.credentials
  )
end

添加到 .gitignore

.secrets.local.yml

现在任何开发者都可以创建自己的覆盖文件:

# .secrets.local.yml(被 git 忽略)
stripe:
  secret_key: sk_test_my_personal_test_key
openai:
  api_key: sk-my-own-openai-key
features:
  experimental_ui: true

不再有”我不小心提交了我的 API 密钥”事故。不再需要”让我临时编辑 credentials.yml.enc”。每个开发者都有自己的沙箱。

3. 请求作用域配置

如果管理员可以按请求覆盖配置会怎样?对调试、客户支持或 A/B 测试很有用。

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :config_overrides
end

# lib/request_scoped_configuration.rb
class RequestScopedConfiguration
  def require(*keys)
    option(*keys) # 如果未找到则始终返回 nil 以继续链
  end

  def option(*keys, default: nil)
    Current.config_overrides&.dig(*keys)
  end

  def keys
    Current.config_overrides&.keys || []
  end

  def reload
    # 无操作,每个请求清除
  end
end

连接它:

# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  RequestScopedConfiguration.new,
  Rails.app.envs,
  Rails.app.credentials
)

现在在控制器中:

class Admin::DebugController < ApplicationController
  before_action :require_admin

  def impersonate_config
    # 为此管理员会话临时使用 Stripe 测试模式
    Current.config_overrides = {
      stripe: { test_mode: true },
      features: { debug_panel: true }
    }
    redirect_back fallback_location: root_path, notice: "调试模式已启用"
  end
end

应用的其余部分不知道也不关心。它只是调用 Rails.app.creds.option(:stripe, :test_mode) 并获取当前请求的正确值。

4. 审计日志包装器

合规要求通常要求记录对敏感凭证的访问。使用 CombinedConfiguration,你可以用日志包装任何后端:

# lib/audited_configuration.rb
class AuditedConfiguration
  def initialize(backend, logger: Rails.logger)
    @backend = backend
    @logger = logger
  end

  def require(*keys)
    log_access("require", keys)
    @backend.require(*keys)
  end

  def option(*keys, default: nil)
    log_access("option", keys)
    @backend.option(*keys, default: default)
  end

  def keys
    @backend.keys
  end

  def reload
    @backend.reload
  end

  private

  def log_access(method, keys)
    location = caller_locations(2, 1).first
    @logger.info({
      event: "credential_access",
      method: method,
      keys: keys.join("."),
      file: location.path,
      line: location.lineno,
      timestamp: Time.current.iso8601
    }.to_json)
  end
end

包装你的生产凭证:

# config/initializers/credentials.rb
if Rails.env.production?
  audited_creds = AuditedConfiguration.new(Rails.app.credentials)

  Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
    Rails.app.envs,
    audited_creds
  )
end

每次凭证访问现在都会被记录:

{"event":"credential_access","method":"require","keys":"stripe.secret_key","file":"app/services/payment_service.rb","line":42,"timestamp":"2026-01-05T10:30:00Z"}

你可以将其发送到你的 SIEM,为异常访问模式设置警报,或满足需要访问控制证明的审计员。

5. 定时配置

如果配置可以根据时间变化会怎样?黑色星期五定价。节日主题。计划的维护窗口。

# lib/scheduled_configuration.rb
class ScheduledConfiguration
  Schedule = Data.define(:range, :config)

  SCHEDULES = [
    Schedule.new(
      range: Time.zone.parse("2026-11-27")..Time.zone.parse("2026-12-01"),
      config: {
        pricing: { discount_percent: 30 },
        features: { sale_banner: true },
        cache: { ttl: 60 } # 高流量期间较短的缓存
      }
    ),
    Schedule.new(
      range: Time.zone.parse("2026-12-24")..Time.zone.parse("2026-12-26"),
      config: {
        features: { holiday_theme: true },
        support: { auto_reply: true }
      }
    )
  ]

  def require(*keys)
    option(*keys) || raise(KeyError, "缺失: #{keys.join('.')}")
  end

  def option(*keys, default: nil)
    active = SCHEDULES.find { |s| s.range.cover?(Time.current) }
    return default unless active

    keys.reduce(active.config) { |h, k| h.is_a?(Hash) ? h[k] : nil }
  end

  def keys
    SCHEDULES.flat_map { |s| s.config.keys }.uniq
  end

  def reload
    # 可以从数据库或外部源重新加载
  end
end

将其连接到链中:

Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  ScheduledConfiguration.new,  # 基于时间的覆盖优先
  Rails.app.envs,
  Rails.app.credentials
)

在黑色星期五,Rails.app.creds.option(:pricing, :discount_percent) 自动返回 30。无需部署。当计划结束时,它会回退到默认值。

你的定价代码不需要知道黑色星期五:

def apply_discount(price)
  discount = Rails.app.creds.option(:pricing, :discount_percent, default: 0)
  price * (1 - discount / 100.0)
end

模式

所有五个示例都共享同一个洞察:CombinedConfiguration 是配置的中间件。责任链模式让你能够:

  1. 分层行为而不修改现有代码
  2. 通过排序后端来组合功能
  3. 在存储、访问控制和业务逻辑之间分离关注点

顺序很重要:

ActiveSupport::CombinedConfiguration.new(
  RequestScopedConfiguration.new,  # 最高优先级:按请求覆盖
  ScheduledConfiguration.new,       # 基于时间的规则
  DeveloperOverrides.new,           # 本地开发
  Rails.app.envs,                   # 环境变量
  AuditedConfiguration.new(         # 包装用于日志记录
    Rails.app.credentials
  )
)

第一个非 nil 值获胜。每个后端可以处理请求或将其传递到链的下游。

总结

Rails.app.creds 看起来像一个简单的便利 API。但 CombinedConfiguration 是构建复杂配置系统的基础:

  • 无需外部服务的功能开关
  • 无凭证冲突的开发者沙箱
  • 请求作用域的调试
  • 符合合规要求的审计日志
  • 无需部署的基于时间的配置

最好的部分?你的应用代码保持整洁。它只需调用 Rails.app.creds.option(:whatever),配置层处理复杂性。

查看 PR #56404 了解实现详情。