用 Rails.app.creds 构建 50 行代码的功能开关系统

你不需要 LaunchDarkly。你不需要 Flipper。对于大多数 Rails 应用,你需要的功能开关是版本控制的、在开发中易于覆盖的、不需要另一个服务来管理。

Rails 8.2 的 Rails.app.creds 正好提供这些。这是一个不到 50 行代码的完整功能开关系统。

核心思想

Rails.app.creds 首先检查 ENV,然后检查加密凭证。这意味着:

  1. 将标志存储在 credentials.yml.enc 中(版本控制,在 PR 中审查)
  2. 随时用 ENV 变量覆盖(测试、调试、渐进式发布)

无需数据库。无需外部服务。无需 API 调用。

步骤 1:在 Credentials 中存储标志

bin/rails credentials:edit
# config/credentials.yml.enc
features:
  new_checkout: false
  dark_mode: false
  ai_summaries: false
  beta_dashboard: false

这些是你的默认值。它们是加密的、版本控制的,更改需要 PR。

步骤 2:创建功能开关模块

# app/models/concerns/feature_flags.rb
module FeatureFlags
  extend self

  def enabled?(flag_name)
    value = Rails.app.creds.option(:features, flag_name, default: false)
    ActiveModel::Type::Boolean.new.cast(value)
  end

  def disabled?(flag_name)
    !enabled?(flag_name)
  end

  def enable!(flag_name)
    Current.feature_overrides ||= {}
    Current.feature_overrides[flag_name] = true
  end

  def disable!(flag_name)
    Current.feature_overrides ||= {}
    Current.feature_overrides[flag_name] = false
  end

  def with(flag_name, value)
    old_value = Current.feature_overrides&.dig(flag_name)
    enable!(flag_name) if value
    disable!(flag_name) unless value
    yield
  ensure
    if old_value.nil?
      Current.feature_overrides&.delete(flag_name)
    else
      Current.feature_overrides[flag_name] = old_value
    end
  end
end

步骤 3:添加请求范围的覆盖

对于按请求的标志控制(测试、管理员覆盖),连接 Current

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

# lib/feature_override_configuration.rb
class FeatureOverrideConfiguration
  def require(*keys)
    option(*keys)
  end

  def option(*keys, default: nil)
    return default unless keys.first == :features && keys.length == 2
    Current.feature_overrides&.dig(keys.last)
  end

  def keys = []
  def reload = nil
end

更新凭证链:

# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  FeatureOverrideConfiguration.new,  # 请求范围优先
  Rails.app.envs,                     # 然后是 ENV
  Rails.app.credentials               # 然后是加密文件
)

步骤 4:到处使用

在控制器中

class CheckoutController < ApplicationController
  def show
    if FeatureFlags.enabled?(:new_checkout)
      render :new_checkout
    else
      render :legacy_checkout
    end
  end
end

在视图中

<% if FeatureFlags.enabled?(:dark_mode) %>
  <body class="dark">
<% else %>
  <body>
<% end %>

在模型中

class Order < ApplicationRecord
  def calculate_shipping
    if FeatureFlags.enabled?(:new_shipping_calculator)
      NewShippingCalculator.calculate(self)
    else
      legacy_shipping_calculation
    end
  end
end

在测试中

class CheckoutTest < ActionDispatch::IntegrationTest
  test "新结账流程" do
    FeatureFlags.with(:new_checkout, true) do
      get checkout_path
      assert_select ".new-checkout-form"
    end
  end

  test "旧结账流程" do
    FeatureFlags.with(:new_checkout, false) do
      get checkout_path
      assert_select ".legacy-checkout-form"
    end
  end
end

覆盖标志

在开发环境

无需编辑凭证即可覆盖任何标志:

# 启用一个标志
FEATURES__NEW_CHECKOUT=true bin/rails server

# 启用多个
FEATURES__NEW_CHECKOUT=true FEATURES__DARK_MODE=true bin/rails server

在预发布/生产环境

在部署配置中设置 ENV 变量:

# fly.toml, render.yaml, etc.
[env]
  FEATURES__BETA_DASHBOARD = "true"

按请求(管理工具)

class Admin::FeatureFlagsController < AdminController
  def toggle
    flag = params[:flag].to_sym

    if FeatureFlags.enabled?(flag)
      FeatureFlags.disable!(flag)
    else
      FeatureFlags.enable!(flag)
    end

    redirect_back fallback_location: admin_root_path
  end
end

进阶:百分比发布

想为 10% 的用户启用功能?

# app/models/concerns/feature_flags.rb
module FeatureFlags
  def enabled_for?(flag_name, user)
    # 首先检查是否明确启用/禁用
    return enabled?(flag_name) if explicitly_set?(flag_name)

    # 检查百分比发布
    percentage = Rails.app.creds.option(:features, :"#{flag_name}_percentage", default: nil)
    return enabled?(flag_name) unless percentage

    # 基于用户 ID 的一致性分桶
    bucket = Digest::MD5.hexdigest("#{flag_name}-#{user.id}").first(8).to_i(16) % 100
    bucket < percentage.to_i
  end

  private

  def explicitly_set?(flag_name)
    Current.feature_overrides&.key?(flag_name) ||
      ENV["FEATURES__#{flag_name.to_s.upcase}"].present?
  end
end

配置发布:

# credentials.yml.enc
features:
  new_checkout: false
  new_checkout_percentage: 10  # 10% 的用户

使用:

if FeatureFlags.enabled_for?(:new_checkout, current_user)
  # 10% 的用户看到这个
end

同一用户总是得到相同的结果(通过 MD5 哈希的一致性分桶)。

进阶:用户分群定向

为特定用户分群启用功能:

module FeatureFlags
  def enabled_for?(flag_name, user)
    return true if user_in_allowlist?(flag_name, user)
    return true if segment_enabled?(flag_name, user)
    return enabled_by_percentage?(flag_name, user)
  end

  private

  def user_in_allowlist?(flag_name, user)
    allowlist = Rails.app.creds.option(:features, :"#{flag_name}_users", default: [])
    allowlist.include?(user.id) || allowlist.include?(user.email)
  end

  def segment_enabled?(flag_name, user)
    segments = Rails.app.creds.option(:features, :"#{flag_name}_segments", default: [])
    segments.any? { |segment| user_in_segment?(user, segment) }
  end

  def user_in_segment?(user, segment)
    case segment.to_sym
    when :staff then user.staff?
    when :beta then user.beta_tester?
    when :premium then user.premium?
    else false
    end
  end
end
# credentials.yml.enc
features:
  new_checkout: false
  new_checkout_users:
    - 123  # 用户 ID
    - "cto@company.com"  # 或邮箱
  new_checkout_segments:
    - staff
    - beta
  new_checkout_percentage: 5  # 再加上其他所有人的 5%

发布策略:先是员工,然后是 beta 测试者,然后是所有人的 5%,随着信心增加再提高百分比。

完整实现

所有代码在一起:

# app/models/concerns/feature_flags.rb (46 行)
module FeatureFlags
  extend self

  def enabled?(flag_name)
    cast_boolean(Rails.app.creds.option(:features, flag_name, default: false))
  end

  def disabled?(flag_name) = !enabled?(flag_name)

  def enabled_for?(flag_name, user)
    return true if allowlisted?(flag_name, user)
    return true if segment_match?(flag_name, user)
    return percentage_match?(flag_name, user) if rollout_percentage(flag_name)
    enabled?(flag_name)
  end

  def enable!(flag_name)
    (Current.feature_overrides ||= {})[flag_name] = true
  end

  def disable!(flag_name)
    (Current.feature_overrides ||= {})[flag_name] = false
  end

  def with(flag_name, value)
    old = Current.feature_overrides&.dig(flag_name)
    value ? enable!(flag_name) : disable!(flag_name)
    yield
  ensure
    old.nil? ? Current.feature_overrides&.delete(flag_name) : Current.feature_overrides[flag_name] = old
  end

  private

  def cast_boolean(value) = ActiveModel::Type::Boolean.new.cast(value)
  def creds_option(flag, suffix) = Rails.app.creds.option(:features, :"#{flag}#{suffix}", default: nil)
  def rollout_percentage(flag) = creds_option(flag, :_percentage)&.to_i
  def allowlist(flag) = creds_option(flag, :_users) || []
  def segments(flag) = creds_option(flag, :_segments) || []

  def allowlisted?(flag, user)
    list = allowlist(flag)
    list.include?(user.id) || list.include?(user.email)
  end

  def segment_match?(flag, user)
    segments(flag).any? { |s| user.try(:"#{s}?") }
  end

  def percentage_match?(flag, user)
    Digest::MD5.hexdigest("#{flag}-#{user.id}").first(8).to_i(16) % 100 < rollout_percentage(flag)
  end
end

为什么这比 SaaS 解决方案更好

功能此方法SaaS
成本免费$20-500/月
延迟零(内存中)API 调用
版本控制是(credentials)通常没有
代码审查是(PRs)视情况而定
离线工作
复杂度中-高
用户定向基础高级
分析DIY内置

对于大多数应用,你不需要实时标志更新或复杂的定向规则。你需要的是易于管理、不增加延迟、不花钱的标志。

何时使用真正的功能开关服务

这种方法有局限性。当你需要以下功能时考虑专用服务:

  • 非技术人员管理标志(产品经理等)
  • 复杂的定向规则(地理位置、设备类型等)
  • 无需部署的实时更新
  • 内置实验和 A/B 测试分析
  • 审计日志和合规功能

但对于大多数 Rails 应用?46 行代码和 Rails.app.creds 就是你需要的全部。

总结

Rails 8.2 的 Rails.app.credsCombinedConfiguration 是功能开关的完美基础:

  1. 在加密凭证中存储默认值(版本控制)
  2. 用 ENV 覆盖(无需部署)
  3. 按请求覆盖(测试、管理工具)
  4. 根据需要添加百分比发布和用户定向

无外部依赖。无 API 延迟。无月费。

查看 PR #56404 了解 Rails.app.creds 的实现。