Ghost Labs Feature Flags 完全指南:从 Beta 开关到 GA 清理的完整生命周期
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
导读
Ghost 使用一组称为Labs flags(实验室功能开关)的 feature flags,将尚未就绪的代码提前合并到主干、向用户提供 beta 功能,或在不删除代码的情况下临时关闭某项功能。本文以 Ghost 官方实践文档 docs/practices/feature-flags.md 为核心骨架,结合仓库中 labs 注册表、远程覆盖服务、Admin 切换 UI 与各测试套件等源码实现,系统讲解 flag 的三个阶段(私有实验 / 公开 Beta / GA 过渡)、服务端与前端各读取入口、取值优先级、远程灰度机制、测试策略以及"提升为 GA→删除"的完整收尾流程。读完你将能够在 Ghost 代码库中正确新增、读取、测试、提升与移除一个 Labs flag。
什么是 Labs flag:用途与边界
Ghost 中的 feature flag 通常被称为Labs flags,是开发周期中的临时开关,而非永久产品配置。它们用于达成三类目标:
- 在工作尚未准备好面向所有人时提前合并进主线(例如 React 化迁移中,用运行时开关在 React 与 Ember 两套实现之间切换);
- 向用户提供 beta 功能(opt-in 性质);
- 在不删除代码的前提下禁用一个功能,作为紧急止血手段。
核心准则是:flag 是临时的。它只控制某条代码路径是否激活,绝不应当被用来替代以下这些"永久"语义:
- 产品设置项(product setting);
- 配置要求(configuration requirement);
- 权限(permission);
- 主机限额(host limit)。
如何选择正确的门控(Gate)
对应地,feature-flags.md 给出了三条选型建议:
- 当一个功能需要经历"开发 → beta → 受控发布 → 全面可用"的阶段时,用 Labs flag。
- 当某个功能的可用性永远取决于某个底层条件时,直接判断该条件本身。例如依赖已配置凭证的功能,即便其 Labs flag 被移除,也必须继续检查凭证是否存在。如果开发期间两个条件都需要满足,就显式地同时检查两者。
- 不要把 Labs flag 当作 Admin 与服务器之间的兼容性检查。Admin 与 Ghost Core 是独立部署的,flag 可能在 UI 依赖的 endpoint、setting 或响应字段存在之前就可见。Admin 必须自行探测后端能力,并对"旧服务器"场景单独处理。
从源码结构看,这一建议对应 use-feature-flag.ts 中config?.config.labs?.[flag] === true的判等逻辑:只有当服务端把该 key 显式计算为布尔true时才放行,响应缺失或加载中一律返回false,这天然要求 Admin 具备处理"后端尚不支持"的降级能力。
注册表:Flag 的三个阶段
所有 Labs flag 都是注册在ghost/core/core/shared/labs.js中的 camelCase key。仓库将其划分为三张列表,每张列表代表一个生命周期阶段:
| 列表 | 用途 | 正常 Admin 展示面 |
|---|---|---|
PRIVATE_FEATURES | 开发与私有实验 | 仅当开启开发者实验(developer experiments)时才显示"私有功能"(Private features) |
PUBLIC_BETA_FEATURES | 用户可自主加入的公开 beta | "Beta 功能"(Beta features) |
GA_FEATURES | 全面可用后的短暂过渡 | 无展示;取值默认强制为true |
以当前仓库为例(labs.js):
GA_FEATURES = ['automationAnalytics', 'tagDetailsReact']PUBLIC_BETA_FEATURES = ['superEditors', 'editorExcerpt', 'additionalPaymentMethods', 'navigationIcons']PRIVATE_FEATURES包含了automations、stripeAutomaticTax、themeTranslation、pictureImageFormats、membersCustomFields、paywallImprovements、postsListReact、editorReact、machinePayments等 21 个键
模块还导出两张派生列表(labs.js):
module.exports.GA_KEYS = [...GA_FEATURES]; module.exports.WRITABLE_KEYS_ALLOWLIST = [...PUBLIC_BETA_FEATURES, ...PRIVATE_FEATURES];其中WRITABLE_KEYS_ALLOWLIST决定 Settings API 会接受哪些键——私有与公开 beta 键一起存放在站点的labsJSON setting 中;GA_KEYS对应的 GA flag 则不再可写。
关于列表的几个关键事实
- 私有与公开 beta flag共同存储在同一个
labssetting 里,列表本身只控制 settings API 允许写入哪些 key。 - Admin 的开关列表是独立维护的(见 private-features.tsx 与 beta-features.tsx),因此把 flag 从一个阶段挪到另一个阶段,同时必须做显式的 UI 改动。
- GA flag 不再可写:它默认强制开启,作为从"默认关闭"到"彻底删除旧分支"之间的短过渡。
标准生命周期
private or public beta → GA → remove the flag and old branchGA_FEATURES的作用是让一个 flag 在不立即改动每个调用点的情况下默认开启——这是一个短期的清理步骤,而不是已发布 flag 的永久归宿。
新增一个 Flag 的完整步骤
官方流程共五步:
- 在 labs.js 中把 key 加入
PRIVATE_FEATURES或PUBLIC_BETA_FEATURES。 - 在 private-features.tsx 或 beta-features.tsx 中加入对应的开关。
- 对必须一起发布的服务器行为与浏览器行为进行门控。
- 为"启用"与"禁用"两种行为都编写测试。
- 更新并审查 Admin config 与 settings API 的 snapshots。
key 必须在所有地方完全一致。由于 Labs 的值存放在已有的 JSON setting 中,新增 flag 不需要数据库迁移。
Admin 开关的 UI 结构
从 private-features.tsx 可以看到每个开关由LabItem承载展示(标题 + 描述 + 操作区),由FeatureToggle提供实际开关动作。每个功能项形如:
{ title: 'Stripe Automatic Tax (private beta)', description: 'Use Stripe Automatic Tax at Stripe Checkout. Needs to be enabled in Stripe', flag: 'stripeAutomaticTax', }注意private-features.tsx还会用useLimiter()与HostLimitError过滤受订阅套餐限制的功能(见 private-features.tsx),也就是说 beta 功能作为可选功能,不应让受限站点的开关直接报硬错误。而 beta-features.tsx 中的 automations 一类"单向门"(一旦开启无法关闭)还会配置confirmation弹窗并置灰开关。部分非 Labs 工具(如 redirects / routes 上传编辑)也挂在同一 Labs 页面下,但属于永久功能,与本文的临时 flag 语义不同。
读取一个 Flag:各端口的正确姿势
Ghost Core(Node 服务端)
使用共享的 Labs 服务:
const labs = require('../../../shared/labs'); if (labs.isSet('myFeature')) { // flagged behavior }底层实现是module.exports.isSet在每次调用时通过getAll()计算当前全量 labs 对象,并要求目标值严格等于true(labs.js):
module.exports.isSet = function isSet(flag) { const labsConfig = module.exports.getAll(); return !!(labsConfig && labsConfig[flag] && labsConfig[flag] === true); };服务端还提供两个更上层的门控封装:
labs.enabledMiddleware('myFeature'):当 flag 关闭时,让整条 API 路由返回404(labs.js):
module.exports.enabledMiddleware = (flag) => function labsEnabledMw(req, res, next) { if (module.exports.isSet(flag) === true) { return next(); } else { return next(new errors.NotFoundError()); } };labs.enabledHelper(...):供主题(Theme)helper 使用——helper 在功能关闭时需要上报"功能被禁用"错误。启用时直接走 callback;禁用时记录DisabledFeatureError并在页面注入console.error脚本(labs.js)。
Theme helpers 本身可以直接从计算好的@labs.myFeature读取当前值。
React Admin(新一代 Admin)
在 React Admin 中从@tryghost/admin-x-framework/hooks引入useFeatureFlag。它读取 Admin config 响应中由服务端计算好的值,在响应缺失或加载期间返回false。完整实现见 use-feature-flag.ts:
export const useFeatureFlag = (flag: string): boolean => { const { data: config } = useBrowseConfig({ refetchOnMount: false }); return config?.config.labs?.[flag] === true; };只有显式的布尔true才算启用,加载中 / 缺失 / 失败一律视为关闭;refetchOnMount: false则避免功能门控组件挂载时反复拉取已过期的 config。
传统 Ember Admin
使用featureservice。已有 Ember 代码的读取方式是:
this.feature.get('myFeature');门控位置的黄金法则
把决策放在"拥有该行为的边界"上。隐藏一个按钮并不能保护服务端端点;同理,拒绝一个端点请求也不会让 Admin 得到可用的禁用态。服务端负责保护数据与路由,前端负责呈现可用的禁用/降级 UI,两者必须在各自的边界各司其职。
取值优先级:值是如何解析出来的
对于普通 Labs flag,后面来源覆盖前面来源(labs.js):
stored Labs setting → GA default → remote override → config.labs这意味着:
- 数据库里存的
labssetting 是地基; - 属于
GA_FEATURES的 key 被强制置true; - 若存在远程覆盖则叠加其上;
- 显式的
config.labs永远最后写入,优先级最高。
注释中的精炼总结是:config.labs > remote > GA > DB。
一个特殊值members不从这三张 flag 列表中推导,而是由会员注册设置派生(labs.js):
labs.members = settingsCache.get('members_signup_access') !== 'none';远程覆盖:可选的灰度来源
Ghost 还支持一个opt-in 的远程覆盖源。它默认处于非激活状态,只有运维方显式配置后才生效,因此普通自托管安装继续使用本地 setting 与配置文件。
远程覆盖在内存中的落地载体是独立的共享模块 labs-flag-overrides.ts,其要点包括:
- 它维护一个进程内的
FlagOverrides(Record<string, boolean>)存储,由远程 flags 服务通过replace()写入,Labs 服务通过getAll()读取叠加; - Labs 从不对外暴露写 API、也不 import 该服务,保持单向数据流;
- 自托管环境下没有任何写入方,
overrides恒为空对象,因此该叠加层是 no-op。
远程拉取与灰度的运行时实现位于 remote-flags/index.ts:
- 配置门控:
config.remoteFlags.enabled === true且提供合法url才启动,否则返回null(remote-flags/index.ts); - 默认配置见 defaults.json:
"remoteFlags": { "enabled": false, "url": null, "pollInterval": null }- 轮询间隔有下限保护:
MIN_POLL_INTERVAL_MS = 60 * 1000,过小或单位混淆(把秒当毫秒)的值会被拒绝而回退到默认(remote-flags/index.ts); - 首次拉取采用 fire-and-forget 方式,绝不让启动被首个 fetch 阻塞(remote-flags/index.ts)。
稀疏清单与百分比灰度
远程清单是稀疏的:不存在的 key 不表达任何意见(缺省即无覆盖)。清单中条目有两种形态:
- 布尔值:对使用该清单的每个实例都应用该覆盖;
{value, percent}对象:只对稳定近似百分比的实例生效。
百分比分桶使用flag 名称 + 站点 UUID(site_uuid,每个站点都会播种)作为确定性的桶键——因此调大百分比时,已经在灰度内的站点会继续留在灰度里,只会追加新站点,不会产生"抖动"。site_uuid只可能在边缘场景缺失,此时会跳过灰度但完整覆盖仍生效(remote-flags/index.ts)。
设计取舍:为什么容忍未知 key
未知的 flag 名称是被刻意接受的:Admin 与 Ghost Core 可能在不同时间点部署。读取某个新 key 的代码必须已经随代码部署完成,清单本身只负责提供它的值。无效条目会被忽略;一次拉取或解析失败会保留上次成功的 overrides(而不是清空),确保运行时稳定。
测试两个状态
测试应当证明的是"flag 所控制的行为",而不仅是"flag 可以被读取"。官方文档给出的测试手法:
- 在聚焦的 Ghost Core 单元测试中用 stub 替换
labs.isSet; - 在共享 Admin fixtures 中通过
configResponse({labs: {...}})或settingsResponse({labs: {...}})传入 Labs 值; - 在顶层 Playwright 测试中用
test.use({labs: {myFeature: true}})或显式false; - 在 Admin acceptance 测试中覆盖flag 关闭态以及任何older-server 状态。
各测试套件的默认值
不同测试系统对 Labs 的默认处理并不一致,官方文档整理如下:
| 测试 | setup 完成后的默认行为 |
|---|---|
| Ghost Core 单元测试 | 不强制开启任何 flag;由测试按需 stub 需要的值 |
使用testUtils.setup()的 Ghost Coreintegration/legacy测试 | 每个已注册的私有与公开 beta flag 都被强制开启 |
使用fixtureManager.init()的 Ghost Coree2e/e2e-api/e2e-isolated测试 | 每个已注册的私有与公开 beta flag 都被强制开启 |
| 使用共享 test-data fixtures 的 React Admin 单元 / acceptance 测试 | labsDefaults中的 key 默认关闭;被测场景需显式传labs覆盖 |
| 使用 Mirage 的 Ember Admin 测试 | Labs 默认为空对象;使用enableLabsFlag或disableLabsFlag |
e2e/下的顶层 Playwright 测试 | 使用新站点的真实值;只有通过test.use({labs: ...})传入的 flag 才被改变 |
为什么数据库类测试总是全开
源码证据在 fixture-utils.js:Ghost Core 的公共 fixture 初始化器会把labs:enabled附加到每一次fixture 初始化上。enableAllLabsFeatures()的具体做法是把WRITABLE_KEYS_ALLOWLIST(即PRIVATE_FEATURES加PUBLIC_BETA_FEATURES)的每一个 key 都写成true写入labssetting,然后重新初始化 settings service:
async enableAllLabsFeatures() { const labsValue = Object.fromEntries( labsService.WRITABLE_KEYS_ALLOWLIST.map((key) => [key, true]), ); const labsSetting = DataGenerator.forKnex.createSetting({ key: 'labs', group: 'labs', type: 'object', value: JSON.stringify(labsValue), }); // ... update or add the labs setting, then settingsService.init() }注意:单独的 Vitest 工程并不会开启 flag——真正触发全开行为的是测试调用了fixtureManager.init()或testUtils.setup()。
这套机制保证了受 flag 保护的代码路径能在 Ghost Core 的数据库类测试中被充分覆盖,但它也带来一个陷阱:新增一个 flag 就可能改变 API snapshots,即便该 flag 在生产环境默认关闭。因此,在旧路径仍然重要时,需要补上显式的 flag-off 覆盖。而GA_FEATURES中的 flag 在所有运行环境(包括测试)都默认开启,直到它们被删除或被配置覆盖。
更新 snapshots
新增、提升或移除 flag 后,从ghost/core/目录更新受影响的 snapshots:
pnpm test:single test/e2e-api/admin/config.test.js -u pnpm test:single test/e2e-api/admin/settings.test.js -u(这两个测试文件在仓库中真实存在:config.test.js、settings.test.js。)然后逐条审查 snapshot 差异,确认它们只反映了预期的 Labs keys 与 values。
提升与移除一个 Flag(GA 收尾)
当一个功能准备全面可用(general availability)时:
- 把 key 从
PRIVATE_FEATURES或PUBLIC_BETA_FEATURES移到GA_FEATURES; - 移除它在 Admin 中的开关;
- 以 GA 值验证功能并更新 API snapshots;
- 紧接着删除 flag、被禁用的代码路径,以及那些只为演练废弃路径而存在的测试。
在删除"禁用路径"之前,必须确认:每个受支持的部署环境都能运行启用态行为,并且该 flag 没有在掩盖某个永久的配置、兼容性、权限或可用性条件。
这正是文首所述边界准则的闭环:GA 过渡期的GA_FEATURES是短期开关,最终归宿一定是彻底删除,让"该不该可用"回归到真实的底层条件判断,而不是永远挂着一个恒为true的开关。
小结
从新增、读取、灰度到清理,Ghost 的 Labs flag 体系是一套纪律分明的"临时开关"实践:注册表三阶段划分控制谁可见、谁可写;config.labs > remote > GA > DB的取值链保证配置始终拥有最终解释权;远程清单的稀疏与百分比分桶让灰度可增量推进;各测试套件的默认差异则要求开发者显式覆盖两种状态。把握住"flag 临时性"这一核心前提,你就能在整个 Ghost monorepo 中安全地驾驭从第一行 beta 代码到 GA 清理的完整生命周期。
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考