用 Rails.app.creds 构建 50 行代码的功能开关系统
你不需要 LaunchDarkly。你不需要 Flipper。对于大多数 Rails 应用,你需要的功能开关是版本控制的、在开发中易于覆盖的、不需要另一个服务来管理。
Rails 8.2 的 Rails.app.creds 正好提供这些。这是一个不到 50 行代码的完整功能开关系统。
核心思想
Rails.app.creds 首先检查 ENV,然后检查加密凭证。这意味着:
- 将标志存储在
credentials.yml.enc中(版本控制,在 PR 中审查) - 随时用 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
- "[email protected]" # 或邮箱
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.creds 和 CombinedConfiguration 是功能开关的完美基础:
- 在加密凭证中存储默认值(版本控制)
- 用 ENV 覆盖(无需部署)
- 按请求覆盖(测试、管理工具)
- 根据需要添加百分比发布和用户定向
无外部依赖。无 API 延迟。无月费。
查看 PR #56404 了解 Rails.app.creds 的实现。