构建功能开关:从零搭建的经验分享

本文分享了作者在实际项目中构建功能开关系统的经验,包括解决开发与测试节奏不匹配的问题、实现用户级配置控制、以及构建动态配置系统的技术实现细节。

构建功能开关:从零搭建的经验分享

我一直在进行一个项目,其中的开发速度远快于我们的QA流程。你知道那种感觉吗?每天都在部署代码,但QA和客户验收测试却是批量进行的。这就是我的现实。

当我意识到自己在避免部署一两周前编写的更改时,问题变得严重了。如果在测试后出现问题,而我已经合并了其他内容,该怎么办?是回滚所有内容?挑选提交?在生产环境着火时处理合并冲突?

功能开关似乎是解决这些头痛问题的最佳方案。

但真正的关键在于 - 我还希望某些功能能在用户级别进行配置。不仅仅是全局开关,还包括用户特定的配置。比如,我不想改变所有用户的主要行为,只想为特定测试人员调整。

那时我意识到我需要两个级别:全局控制和用户特定控制。

🎯 为什么这很重要

让我举些具体例子:

支付测试噩梦 🤑 我们想在生产环境中测试支付功能。最低时薪设置为60美元。但我不希望仅仅为了测试支付流程(可能需要多次测试)就支付那么多钱。因此我构建了用户级配置来设置最低时薪。现在我可以将其设置为1美元进行测试。这更像是动态配置而非功能开关,但机制相同。

新积分系统 💳 我们引入了应用内积分,因此支付不再仅限于Stripe。由于我将资金视为任何应用中最关键的部分,我想要一个即时关闭开关。当出现问题时关闭它,当我们有信心时再开启。

回滚地狱 🔥 在匆忙时,你总是更容易遇到错误。发布到生产环境,出现错误,你跳过了一些配置,某些功能工作方式不同。你需要回滚,但你已经合并了一堆更改。在生产事件期间处理合并冲突?这是制造更多错误的配方。

使用功能开关?只需翻转开关。不需要发布流程。

🙄 问题:发布地狱

没有功能开关的主要问题不是你不够灵活。问题是你的发布过程与部署过程紧密耦合。

要更改任何内容,你需要发布完全不同的代码版本。这意味着要经历整个测试过程。时间就是金钱,而这正在消耗两者。

这是我的日常现实:我想每天发布代码。我正在开发功能A - 比如影响用户可用性的外部日历集成。由于可用性是业务流程的核心,这很关键。

在完成此功能之前,没有安全的选项来发布每个提交。因此我堆叠所有更改,完成所有工作,按下部署按钮,祈祷它不会爆炸。

如果它真的爆炸了呢?

代码更改 → 提交 → 测试 → 发布(开发 → 测试 → 生产)→ 回到起点

我构建的替代方案是: 从一开始就将影响隐藏在功能开关后面。

1
2
3
4
5
def is_available(self, user_id, datetime):
    if feature_flag_check('EXTERNAL_CALENDAR_INTEGRATION'):
        return check_availability_in_external_calendar(user_id, datetime)

    return check_basic_availability(user_id, datetime)

从第一个提交开始,我就可以推送到生产环境,即使这个函数看起来像:

1
2
def check_availability_in_external_calendar(user_id, datetime):
    return True  # TODO: 实现实际逻辑

因为我只需将EXTERNAL_CALENDAR_INTEGRATION设置为False。

下一步?为特定用户将其切换为True,进行测试,如果出现问题 - 立即将其翻转回False。无需部署,无需CI,无需QA流程。只是更快更安全。

💡 我的解决方案:保持简单,保持快速

现在让我们谈谈我是如何构建这个系统的。开始时这是一个简单的机制。你可以使用外部解决方案,但让我们构建一个真正有效的自定义方案。

你需要:

  • 功能开关的存储
  • 用于CRUD操作的管理面板
  • 在代码中使用它们的工具

在我的应用中,我使用内存事件总线和我在这里描述过的上下文模式。关键见解:功能开关是开启还是关闭?取决于谁在询问。

我将标志存储在请求全局的Context变量中。对于每个请求,在构建上下文时,我获取全局启用的所有功能开关以及特定用户的功能开关。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class FeatureFlagsModel(BaseModel):
    __tablename__ = 'feature_flags'
    __table_args__ = (
        UniqueConstraint('key', 'user_id', name='uq_feature_flag_key_user'),
        Index('ix_feature_flag_user_id', 'user_id'),
        Index('ix_feature_flag_key', 'key'),
    )

    key: Mapped[ConfigKey] = mapped_column(String, nullable=False)
    enabled: Mapped[bool] = mapped_column(nullable=False, default=False)
    value: Mapped[Optional[str]] = mapped_column(nullable=True)

    user_id: Mapped[Optional[str]] = mapped_column(
        ForeignKey('[users.id](http://users.id)', ondelete='CASCADE'), 
        nullable=True
    )
    user: Mapped[Optional['UserModel']] = relationship(back_populates='configuration')

    enabled_globally: Mapped[bool] = mapped_column(nullable=False, default=False)

检查逻辑非常简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def check_ff(self, key: ConfigKey) -> bool:
    if key in self.feature_flags:
        flag = self.feature_flags[key]

        # 用户特定标志覆盖全局设置
        if flag.user_id is not None:
            return flag.enabled

        # 全局标志
        return flag.enabled_globally

    return False

就是这样。用户特定标志覆盖全局标志。清晰的优先级,可预测的行为。

🎉 我实际构建的内容(诚实版)

让我实话实说 - 我称之为"功能开关",但我主要构建了一个动态配置系统。

以下是我在生产中实际使用的3个配置:

  • MINIMUM_TIME_REQUIRED_BEFORE_BOOKING_IN_HOURS - 控制预订提前时间。我们设置为24小时,但测试时可以设为0
  • CAPTIONER_HOURLY_RATE_MIN - 允许的最低时薪
  • CAPTIONER_HOURLY_RATE_MAX - 允许的最高时薪

这些不是布尔标志 - 它们是类型化的配置值,我可以在不部署的情况下更改。

1
2
3
4
5
6
7
8
# 在我的预订策略中
min_booking_time = now + timedelta(
    hours=Current.context.config.minimum_time_required_before_booking_in_hours
)

# 在费率验证中  
if not (config.captioner_hourly_rate_min <= rate <= config.captioner_hourly_rate_max):
    raise ValidationError("费率超出允许范围")

💭 经验教训与下一步计划

构建与购买:从自定义开始,因为它更快且实现起来不那么复杂。

缺少TTL的问题:这是我应该从一开始就实现的东西 - 自动标志过期。功能开关应该是临时的。没有TTL,它们会成为永久的技术债务。你最终会有数十个旧标志杂乱地充斥你的代码库和数据库。

在真正的功能开关系统中,每个标志都应该有过期日期。当标志过期时,它们应该:

  • 自动禁用并提醒团队
  • 强制进行代码清理决策
  • 完全自行删除

这防止了"标志墓地"问题,即你有50多个标志,但没人记得其中一半是做什么的。

我接下来要添加的

  • 带有自动清理功能的TTL实现
  • 重命名为"具有功能切换的动态配置"

🤔 轮到你了

你的配置管理是什么样的?你还在通过部署来更改单个值吗?或者你已经构建了类似的东西?

我很好奇你对用户特定配置的方法以及如何处理性能影响。

留下评论 - 我很想听听你的功能开关成功经验和灾难经历。

想要更多类似的帖子吗?我写关于实用软件架构和我们在生产中解决的实际问题。查看我关于构建事件系统或修复FastAPI中属性传递的帖子。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计