OpenFeature:把应用评估 API 与特性平台解耦
一个结算服务准备更换特性开关平台。旧代码里散落着 vendorClient.boolVariation(...)、vendorClient.jsonVariation(...)、供应商专有的用户对象和事件上报方法;几十个业务模块还各自决定断网时返回什么。迁移团队先替换 SDK 包,编译错误很快修完,灰度后却出现更难解释的分歧:相同用户在新旧节点命中不同变体,缺失开关有时进入旧流程、有时放行新流程,事件平台里的曝光量又高于真实请求量。
问题不是“API 名字没有统一”,而是应用代码同时依赖了供应商的评估接口、上下文模型、错误语义、生命周期和观测方式。OpenFeature 在应用与特性管理系统之间定义一层厂商中立契约:业务只调用类型化评估 API,Provider 把这份契约翻译成某个平台、本地文件或自研服务的能力。它不创建环境、规则或审批流,也不是一个托管控制台;控制面仍负责存放 flag、variant、targeting 和变更记录,Provider 才负责把这些数据带入运行时并完成评估。
这层抽象真正有价值的地方,是把“调用方必须稳定的语义”和“平台可以替换的实现”分开。OpenFeature 统一布尔、字符串、数值、对象四类评估入口,统一 default、evaluation context、resolution details、hooks、provider events 和 tracking;Provider 仍可以选择本地评估、远程评估、流式同步、轮询缓存或文件读取。解耦不是抹平所有产品差异,而是让差异集中到适配层和架构决策中,不再散落进每一个业务分支。
先在空目录里看见 default
官方 Node.js 服务端 SDK通过 npm 发布,采用 Apache-2.0 许可。当前 @openfeature/server-sdk 1.22.0 的包清单要求 Node.js 20 或更高版本;官网概览页仍显示 Node.js 18,因此安装前应以实际包清单的 engines 为准。实验只需一个空目录和 npm,不需要云账号、控制面或密钥:
mkdir openfeature-lab
cd openfeature-lab
npm init -y
npm install @openfeature/server-sdk@1.22.0固定版本让实验结果可复现;团队仓库还应提交 lockfile,并在升级时分别检查 SDK、Provider 与 Node.js 运行时。可用 npm view @openfeature/server-sdk version license engines peerDependencies --json 复核当前发布信息。若使用 Yarn,官方 SDK 文档要求显式加入与 SDK 匹配的 @openfeature/core peer dependency;不要手工猜版本。库作者若封装公共 feature 模块,应把 @openfeature/server-sdk 声明为 peer dependency,避免应用中出现两份 SDK 单例,造成类型和全局 Provider 状态分裂。
新建 noop.mjs:
import { OpenFeature } from '@openfeature/server-sdk';
const client = OpenFeature.getClient('checkout-service', '1.0.0');
const details = await client.getBooleanDetails('checkout-v2', false, {
targetingKey: 'subject-demo-001',
});
console.log(details);
await OpenFeature.close();执行 node noop.mjs,没有注册 Provider 时,SDK 使用 no-op provider。结果的核心特征是 value: false,也就是调用方传入的 default;当前 Node.js SDK 还会以 PROVIDER_NOT_READY 表明没有可用 Provider,而不是制造一次正常命中。这里的 false 不是 OpenFeature 替你选择的默认策略,而是业务主动声明:“评估系统异常时,结算仍走旧路径。”对删除保护、风控拦截或紧急停机开关,安全方向可能恰好相反。default 必须经过故障评审,不能批量填同一个布尔值。
普通 getBooleanValue 只返回值,适合稳定热路径;getBooleanDetails 还返回 flagKey、value,并尽可能返回 variant、reason、errorCode、errorMessage 和 flagMetadata。OpenFeature 要求评估调用在 Provider 异常、类型错误或网络失败时仍返回 default,不把异常抛进业务请求;配置 Provider 和初始化这类管理操作则可以失败。因此,业务分支用 value 保持可预测,诊断与度量用 details 判断“真实命中”还是“降级得到同一个值”。
用内存 Provider 跑通类型化评估
Node.js SDK 自带 TypedInMemoryProvider,适合单测、契约实验和本地开发。它不是共享控制面,没有持久化、审批、分发或多实例一致性,不能因为它“能返回 flag”就把它当作生产平台。
新建 positive.mjs:
import {
OpenFeature,
TypedInMemoryProvider,
} from '@openfeature/server-sdk';
const flags = {
'checkout-v2': {
variants: { on: true, off: false },
defaultVariant: 'on',
disabled: false,
},
'payment-mode': {
variants: { stable: 'legacy', canary: 'parallel-verify' },
defaultVariant: 'stable',
disabled: false,
},
'retry-budget': {
variants: { conservative: 1, normal: 3 },
defaultVariant: 'conservative',
disabled: false,
},
'checkout-layout': {
variants: {
compact: { columns: 1, showCoupon: false },
rich: { columns: 2, showCoupon: true },
},
defaultVariant: 'compact',
disabled: false,
},
};
await OpenFeature.setProviderAndWait(new TypedInMemoryProvider(flags));
const client = OpenFeature.getClient('checkout-service', '1.0.0');
console.log(await client.getBooleanDetails('checkout-v2', false));
console.log(await client.getStringValue('payment-mode', 'legacy'));
console.log(await client.getNumberValue('retry-budget', 0));
console.log(await client.getObjectValue(
'checkout-layout',
{ columns: 1, showCoupon: false },
));
await OpenFeature.close();执行 node positive.mjs,第一项应包含 value: true、variant: 'on' 和成功原因;其余三项分别得到 legacy、1 与对象值。配置字段的含义并不复杂:variants 是变体名到类型化值的映射,defaultVariant 是这个内存 Provider 在无其他规则时选择的变体,disabled 表示 Provider 是否把 flag 视作禁用。它与调用 API 时传入的 default 是两层概念:前者是 Provider 成功评估后的选择,后者是异常执行时由应用兜底的值。
typed evaluation 不是 TypeScript 的装饰。如果调用 getBooleanDetails('payment-mode', false) 请求布尔值,而 Provider 实际给出字符串,SDK 必须把类型不匹配视作异常并返回 false,details 应出现 TYPE_MISMATCH。这能阻止平台管理员把字符串 flag 改成对象后,业务在运行时悄悄把值当真值使用。
两个反向实验:缺失与 Provider 崩溃
先在上面的 Provider 中请求不存在的 key:
const missing = await client.getBooleanDetails('checkout-v3', false);
console.log(missing);预期 value 仍为 false,同时 reason 指向错误,errorCode 为 FLAG_NOT_FOUND。监控必须按 errorCode + provider + flagKey 聚合并限流,不能对每次缺失都打印堆栈;一个高 QPS 请求路径会在几秒内淹没日志系统。修复动作是确认 key、环境和 domain 绑定,或先创建 flag 再发布引用它的代码。不要把 default 返回值误判成“开关确实关闭”。
再写一个最小故障 Provider,验证 resolver 抛错不会穿透业务:
import { OpenFeature } from '@openfeature/server-sdk';
class BrokenProvider {
runsOn = 'server';
metadata = { name: 'broken-provider' };
async resolveBooleanEvaluation() {
throw new Error('simulated provider outage');
}
async resolveStringEvaluation() {
throw new Error('simulated provider outage');
}
async resolveNumberEvaluation() {
throw new Error('simulated provider outage');
}
async resolveObjectEvaluation() {
throw new Error('simulated provider outage');
}
}
await OpenFeature.setProviderAndWait(new BrokenProvider());
const client = OpenFeature.getClient();
console.log(await client.getBooleanDetails('checkout-v2', false));
await OpenFeature.close();预期进程正常结束,结果仍是 value: false,reason 为 ERROR,普通 Error 会映射为 GENERAL。Provider 若要让调用方区分 FLAG_NOT_FOUND、TYPE_MISMATCH、INVALID_CONTEXT 或其他标准故障,必须返回对应 errorCode,或抛出携带该错误码的 OpenFeature 错误。若业务请求直接收到 simulated provider outage,说明调用绕过了 OpenFeature API,或故障发生在 Provider 注册/初始化而非评估阶段。前者要收口调用入口,后者要由启动屏障处理,不能混为一种失败。
上下文不是随手拼出的用户画像
规则要按租户、地域、应用版本或稳定主体分桶时,Provider 读取 evaluation context。服务端动态上下文按以下顺序合并,同名字段由右侧覆盖:
global < transaction < client < invocation < before hook这套优先级来自 OpenFeature evaluation context 规范。global 适合 serviceName、region 这类进程级属性;transaction 适合当前请求主体;client 适合某个业务模块;invocation 适合一次评估的局部事实;before hook 可以补充或规范化最终字段。不要把当前用户写到 global 或复用可变对象,否则并发请求会互相污染。
import {
AsyncLocalStorageTransactionContextPropagator,
OpenFeature,
} from '@openfeature/server-sdk';
OpenFeature.setContext({
serviceName: 'checkout-service',
region: 'cn-test-1',
});
OpenFeature.setTransactionContextPropagator(
new AsyncLocalStorageTransactionContextPropagator(),
);
app.use((req, _res, next) => {
const subject = String(req.auth.subjectId);
OpenFeature.setTransactionContext({
targetingKey: subject,
tenantTier: req.auth.tenantTier,
}, next);
});targetingKey 是评估主体的稳定标识。百分比分桶通常对它和 flag key 做确定性散列;若每次请求生成随机 UUID,同一用户会反复换组,发布和实验都失真。它也不必是真实邮箱或数据库主键。更稳妥的做法是使用不可逆、跨请求稳定、限定用途的伪名标识,并让日志只记录散列后的短标签。
上下文字段可能同时进入 Provider 请求、曝光事件、日志和 trace。只传规则真正需要的最小集合:例如规则只看 tenantTier,就不要附带邮箱、IP、完整角色列表和设备指纹。禁止把 access token、Cookie、支付信息或自由文本放入 context。客户端 SDK 的 context 与规则可能被终端用户观察或篡改,所以任何授权、计费资格和数据访问决策都必须在可信服务端再次校验;feature flag 控制体验,不承担安全授权。
Hooks、events 与 tracking 各自回答不同问题
Hooks 规范把一次评估分成 before、after、error、finally 四个阶段。before 可补充上下文;成功后走 after;失败走 error;finally 无论结果如何都执行。JavaScript 的阶段方法名就是 finally,finallyAfter 用于 finally 是保留字的语言。进入评估前按 API、client、invocation、Provider 执行,离开时按 Provider、invocation、client、API 逆序退栈;同一层也遵循先注册后进入、后注册先退出,因此适合创建并关闭 span、统计延迟和对错误码计数。
class EvaluationMetricsHook {
before(ctx) {
ctx.hookData.set('startedAt', performance.now());
}
finally(ctx, details) {
const startedAt = ctx.hookData.get('startedAt');
const elapsed = performance.now() - startedAt;
metrics.observe('feature_evaluation_duration_ms', elapsed, {
provider: ctx.providerMetadata?.name ?? 'unknown',
flag: ctx.flagKey,
reason: details.reason ?? 'UNKNOWN',
error: details.errorCode ?? 'NONE',
});
}
}
OpenFeature.addHooks(new EvaluationMetricsHook());不要在 hook 里同步请求数据库或分析平台。Hook 位于业务热路径,额外网络调用会把观测系统变成业务依赖;高基数的 targetingKey、用户邮箱和完整 error message 也不应成为指标标签。记录 flag key、provider、reason、error code 和受控 variant 通常足够,原始主体留在受访问控制的事件管道并设置保留期限。
Provider events 表示运行时状态变化,例如 ready、error、stale 或配置改变,具体支持项由 Provider 决定。它们适合更新 readiness、触发告警或记录配置版本,不等于一次 flag 曝光。处理器即使在 Provider 已进入对应状态后注册也必须立即运行;Node.js Client 还暴露 providerStatus,可读取 NOT_READY、READY、STALE、ERROR 或 FATAL。多 domain 应优先在绑定到目标 domain 的 client 上注册处理器;全局 API 处理器会接收所有 Provider 的事件,不能用一个布尔变量代表整个进程的健康状态。
import { OpenFeature, ProviderEvents } from '@openfeature/server-sdk';
const statusClient = OpenFeature.getClient('checkout-service');
const refreshHealth = (event) => {
health.featureProvider = {
status: statusClient.providerStatus,
provider: event.providerName,
};
};
statusClient.addHandler(ProviderEvents.Ready, refreshHealth);
statusClient.addHandler(ProviderEvents.Stale, refreshHealth);
statusClient.addHandler(ProviderEvents.Error, refreshHealth);Tracking 则描述“评估之后发生了什么业务行为”。例如用户看到了新结算页,再触发 checkout-completed。client.track(name, context, details) 会把合并后的上下文和数值/属性交给支持 tracking 的 Provider;不是所有 Provider 都实现它,未实现时 SDK 必须 no-op。规范把 track 定义为返回 void,当前 Node.js SDK 也不会给调用方返回送达回执,因此“调用成功”不能当成事件已写入分析平台。曝光、点击、转换必须使用同一主体语义和实验标识,否则数据平台无法正确归因。
client.track(
'checkout-completed',
{ targetingKey: subjectId, tenantTier: 'trial' },
{ value: 128.5, currency: 'CNY' },
);评估可能每请求多次发生,而业务转换通常更少。若每次 evaluation 都外发完整事件,成本会随 QPS 线性增长。团队要决定哪些 flag 需要曝光、是否在请求内去重、采样比例、缓冲上限、批量大小、flush 超时和失败丢弃策略;事件字段必须脱敏并有保留期限。tracking 不应阻塞结算响应,也不能在事件失败时重试到压垮业务进程。是否存在内存队列、何时发送以及 close 是否 flush 都是具体 Provider 的契约,不能从 OpenFeature track API 本身推断。
Provider 生命周期决定启动和退出是否可信
setProviderAndWait(provider) 会等待 Provider 初始化,适合把“必须拿到有效规则”设为启动条件;setProvider(provider) 立即返回并在后台完成初始化,失败通过日志和 events 暴露。评估发生在 NOT_READY 或 FATAL 状态时,SDK 必须短路 resolver、运行错误 hook,并分别以 PROVIDER_NOT_READY 或 PROVIDER_FATAL 返回调用方 default。ERROR 与 STALE 不属于这两个强制短路状态:Provider 若仍持有可用缓存,可以继续解析并在 reason、metadata 和 events 中暴露降级证据;具体行为必须由 Provider 文档和故障实验确认。更换 Provider 时,SDK 在旧 Provider 不再被任何 domain 使用后调用其 shutdown;全局注册变化会影响已经创建的 client,不能把 client 当成旧 Provider 的快照。
await OpenFeature.setProviderAndWait(buildProviderFromEnvironment());
const client = OpenFeature.getClient('checkout-service', buildVersion);
async function shutdown(signal) {
server.close();
await Promise.race([
OpenFeature.close(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('provider close timeout')), 5000),
),
]);
process.exitCode = 0;
}
process.once('SIGTERM', shutdown);
process.once('SIGINT', shutdown);OpenFeature.close() 用于进程退出,调用所有已注册 Provider 的清理逻辑,例如停止轮询、关闭流连接和 flush 事件。先停止接收新流量,再等待在途请求,最后关闭 OpenFeature;反过来会让尚未结束的请求突然落到 default。退出超时要有上限并记录丢弃事件数,不能无限等待分析端点。
凭证属于 Provider 配置,不属于 evaluation context。server-side SDK key、远程评估 token 或客户端证书从 secret manager 注入,只授予目标项目与环境的读取/评估权限;管理 flag、修改规则和审批发布使用另一组短期管理凭证。泄漏后立即撤销并轮换,检查日志、错误对象和诊断端点是否曾打印。浏览器或移动端只能使用供应商明确标记为可公开的 client-side 标识,绝不能打包 server key 或 admin token。
接进项目时收口业务语义
不要让每个模块自由拼 flag key、default 和 context。用一个薄的领域网关把故障方向、类型和用途固化,同时保留 details 供诊断:
import type { Client, EvaluationContext } from '@openfeature/server-sdk';
export class CheckoutFeatures {
constructor(private readonly client: Client) {}
async useCheckoutV2(context: EvaluationContext): Promise<boolean> {
const details = await this.client.getBooleanDetails(
'checkout-v2',
false,
context,
);
auditEvaluation(details);
return details.value;
}
async paymentMode(context: EvaluationContext): Promise<string> {
return this.client.getStringValue('payment-mode', 'legacy', context);
}
}网关测试至少固定四类契约:真实变体返回正确类型;缺失 key 返回评审过的 default;Provider 错误不抛进业务;上下文只含允许字段。业务测试不应依赖真实 SaaS 租户,可以注入 TypedInMemoryProvider;Provider 适配器本身再做独立集成测试,检查凭证、环境、规则同步和 details 映射。
domain 可以把不同 client 绑定到不同 Provider,例如结算由主平台评估、基础设施 kill switch 来自本地 Provider。若 domain 没有专属 Provider,会回落到默认 Provider。domain 名是架构边界,不宜按用户或请求动态创建,否则 Provider 数量、连接和事件缓冲不可控。
从供应商 SDK 迁移而不制造双重真相
迁移先盘点业务依赖的语义,而不是只搜索方法名:flag 类型、default、上下文字段、稳定分桶键、变体、reason、错误码、初始化行为、缓存、事件和退出 flush 都要列出。然后分四步推进:
先引入领域网关,让旧 Provider 包装现有供应商 SDK,业务调用统一改为 OpenFeature Client。用相同上下文离线或影子比较新旧 Provider 的 resolution details,差异按 flag、variant、reason 分类,不能只比较最终布尔值。选择低风险 domain 或一组 flag 切到新 Provider,保留明确回切入口;不要在每次请求中同时调用两个远程平台。
全量稳定后撤销旧 SDK key、停止旧事件出口、删除旧依赖和兼容字段,再清理平台 flag。
Node SDK 的 Multi-Provider 可按策略组合多个 Provider。FirstSuccessfulStrategy 可忽略前序错误后尝试后备源,ComparisonStrategy 可比较结果。但“自动后备”会引入一致性问题:主平台最新规则尚未同步到备平台时,故障切换可能返回旧规则;比较模式会把评估和事件成本放大。它适合有版本约束和明确冲突策略的迁移窗口,不应成为永久堆叠供应商的借口。
OpenFeature 降低的是应用 API 与供应商 SDK 的锁定,不会消除控制面锁定。复杂 targeting DSL、segment 模型、实验统计、审批、审计、数据驻留、离线缓存格式和客户端分桶算法仍可能是专有能力。选型时分别计算三类迁移成本:业务调用面、规则与数据面、组织治理面。若只迁走 API,却无法导出规则、环境和审计证据,vendor lock-in 仍然很高。
OFREP(OpenFeature Remote Evaluation Protocol)进一步标准化 Provider 与特性管理系统之间的评估 API。它是协议,不是 Provider,也不是控制面标准。服务端动态上下文模式通常每次评估都把 context 发给 OFREP API;客户端静态上下文模式则用一个共同 context 批量取得所有 flag,再基于缓存结果本地评估。两种模式的延迟、隐私和缓存故障完全不同,不能只写“支持 OFREP”就视为等价。OFREP 也不自动提供流式更新、审批、规则可移植性或高可用;采用前仍要核对具体系统和语言 Provider 的实现状态、认证、限流、超时及错误码映射。
长期治理围绕 flag 债务而不是 SDK 数量
每个 flag 都应有 owner、用途类型、创建原因、default 故障方向、目标环境、敏感上下文字段、事件策略、到期条件和删除任务。release flag 在全量稳定后应把胜出分支固化进代码;experiment flag 在结论形成后停止曝光并归档指标定义;ops flag 和 kill switch 可以长期存在,但要定期演练 default、权限和恢复路径。
升级时同时检查 OpenFeature SDK、Provider 和底层供应商 SDK 的兼容矩阵。先在测试中重放关键 context 与 details,再验证初始化、配置更新、断网、凭证错误和 close。观察 evaluation_total、evaluation_error_total{error_code}、default_total、Provider 状态、配置年龄、事件队列深度和 flush 失败;阈值由流量基线与 SLO 决定,不写成脱离负载的固定数字。
本地实验清理很简单:删除 openfeature-lab 目录即可。共享环境退出还要删除测试 flag、segment 和事件数据,撤销临时凭证,关闭影子比较与额外事件出口。回滚 Provider 时恢复上一版依赖锁文件和 Provider 配置,确认旧 Provider 已 ready 后再接收流量;不要只回滚业务镜像却保留新平台的上下文映射和事件 hook。
当业务代码只认识 OpenFeature 类型化 API,Provider 失败能稳定返回经过评审的 default,details 能区分真实命中与降级,上下文和事件有最小数据边界,Provider 又能完整初始化与关闭时,解耦才真正成立。此时更换控制面仍是一项工程迁移,但它不再要求重写每一条业务判断,也不会把供应商故障直接扩散成不可预测的业务异常。
