HarmonyOS 7 Zod 4:远端功能开关模式迁移与坏版本隔离
功能开关最危险的时候,通常不是服务端完全不可用,而是它返回了一份“JSON 能解析、业务却不能用”的配置。比如 checkoutV2 本来应该是布尔值,却被后台写成字符串;灰度比例从 0.25 变成 25;依赖的 riskGuard 被关掉,结算入口却仍然开启。应用如果只做 JSON.parse(),这些问题都要等页面跑到相应分支才暴露。
FeatureSnapshotLab 把远端配置看成发布输入,而不是运行时随手消费的数据。Node 构建脚本使用 Zod 4 描述版本化模式,先把旧结构迁移到 v3,再做跨字段检查;Hvigor 构建阶段只接纳通过门禁的标准化快照。设备侧读取 rawfile/feature_snapshot.json,与 Preferences 中的已激活版本对账,不能验证的候选永远不覆盖上一版。
本次演示任务为 CFG-1842-319,时间 18:42,候选版本 2026.10.06-r19,模式版本 3。25 项规则完成 17 项,进度 68%;迁移 4 个字段,发现 3 个坏值,候选状态 QUARANTINED。设备继续使用 2026.10.05-r18,不会因为新文件存在就抢先切换。

一、先从产物反推:安装包里只允许出现一种配置
这类工具最容易写成一个“检查命令”:CI 打印三条红字,但后面的打包任务仍然执行,原始 JSON 还是进了 HAP。看起来有门禁,实际只有提醒。
更稳妥的规则是让打包输入只有一个来源:resources/rawfile/feature_snapshot.json 必须由验证任务生成,仓库不直接提交它;生成失败时文件不存在,后续任务没有可打包的候选。原始响应、迁移日志与错误报告放在构建工作区,不进入应用包。
Hvigor 以任务为基本工作单元,任务之间形成有向无环图。hvigorfile.ts 可以注册任务、插件和生命周期 hook。本文不绑定某个内部任务名,而是要求“验证配置”成为资源处理或打包任务的显式前置依赖。不同 DevEco Studio/Hvigor 版本的扩展 API 以当前工具链声明为准,不能把旧博客中的任务名称原样抄进新工程。
Demo 的输入目录是 config/incoming/feature_2026.10.06-r19.json,输出目录是 entry/src/main/resources/rawfile/feature_snapshot.json,报告是 build/reports/feature-gate/CFG-1842-319.json。只有报告状态为 APPROVED 时才写正式输出;QUARANTINED 只写报告和隔离副本。
二、模式迁移和业务校验必须分成两步
第一段代码解决旧配置结构继续存在的问题。v2 用整数百分比,v3 改为 0 到 1 的小数;v2 的 enabledFeatures 数组也迁移成 flags 对象。迁移只改变表示方式,不偷偷替业务补一个“合理值”。
import * as z from 'zod';
const FlagSchema = z.object({
enabled: z.boolean(),
rollout: z.number().min(0).max(1),
requires: z.array(z.string()).default([])
});
const SnapshotV3 = z.object({
schemaVersion: z.literal(3),
version: z.string().regex(/^\d{4}\.\d{2}\.\d{2}-r\d+$/),
flags: z.record(z.string(), FlagSchema),
expiresAt: z.iso.datetime()
}).check((ctx) => {
const flags = ctx.value.flags;
for (const [name, flag] of Object.entries(flags)) {
for (const dep of flag.requires) {
if (!flags[dep]?.enabled && flag.enabled) {
ctx.issues.push({ code: 'custom',
path: ['flags', name, 'requires'],
message: `enabled flag requires ${dep}`,
input: flag.requires });
}
}
}
});
function migrateToV3(raw: unknown): unknown {
const source = raw as Record<string, unknown>;
if (source.schemaVersion !== 2) return raw;
return migrateV2FieldsWithoutGuessing(source);
}
Zod 4 的 safeParse() 会把成功数据与错误分支分开,不需要靠异常控制普通坏输入;.check() 适合表达跨字段约束。这里故意没有使用 .catch() 给坏值兜底,因为配置门禁的目标不是“无论如何都产出”,而是让错误停在发布前。
migrateV2FieldsWithoutGuessing() 只处理确定的结构变化:整数百分比除以 100,数组项映射成显式对象,旧字段名重命名。遇到 rollout: "25" 不主动转数字,遇到未知依赖也不自动创建开关。自动猜测会让报告变绿,却把后台错误永久写进标准快照。
需要特别注意 Zod 4 的版本边界。官方迁移说明指出,默认值与可选字段的行为存在变化;如果项目从 Zod 3 升级,不能只看 TypeScript 编译通过,还要对“缺字段、显式 undefined、默认值”做快照回归。本文使用的是 Zod 4 公共 API,不依赖内部类型。
三、失败时不要覆盖输出文件
第二段代码是构建脚本的核心。它读取候选、执行迁移、safeParse、整理可复核错误;通过时先写 .next,再替换正式快照;失败时删除本轮 .next,上一份正式快照不动。
import fs from 'node:fs';
import path from 'node:path';
export function buildFeatureSnapshot(input: string, output: string,
reportPath: string): void {
const taskId = 'CFG-1842-319';
const raw = JSON.parse(fs.readFileSync(input, 'utf8')) as unknown;
const migrated = migrateToV3(raw);
const result = SnapshotV3.safeParse(migrated);
const nextPath = `${output}.next`;
if (!result.success) {
const issues = result.error.issues.map((issue) => ({
path: issue.path.join('.'), code: issue.code,
message: issue.message
}));
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify({ taskId,
state: 'QUARANTINED', progress: 68,
version: '2026.10.06-r19', issues }, null, 2));
if (fs.existsSync(nextPath)) fs.unlinkSync(nextPath);
throw new Error(`FEATURE_SNAPSHOT_REJECTED:${issues.length}`);
}
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(nextPath, JSON.stringify(result.data, null, 2));
fs.renameSync(nextPath, output);
}
这里的原子性范围是构建工作区内的同文件系统重命名,不应扩大解释成所有平台、所有文件系统都绝对原子。真正重要的是失败分支没有写正式输出。CI 还要把脚本非零退出码传给 Hvigor,不能在外层捕获后继续打包。
错误报告只保存路径、错误码和脱敏信息。远端配置如果包含实验人群、内部 URL 或商业参数,不应把完整原文上传到公开构建日志。隔离副本要设置访问范围和保留期限,修复完成后按任务号清理。
项目结构中,tools/validate-flags.ts 负责模式迁移与验证,config/incoming 只存 CI 临时输入,resources/rawfile/feature_snapshot.json 是唯一进包产物,pages/FeatureGatePage.ets 负责调试展示,model/FeatureActivation.ets 保存设备侧激活结果。下面的 DevEco Studio 风格图是按演示数据生成的说明画面,不冒充真实构建截图。

四、68% 不代表“差不多能发”,而是明确不能激活
构建页面很容易把进度做成心理暗示。25 项过了 17 项,看起来已经大半完成,但其中任何一条可能是硬门槛。Demo 将规则分为三类:ERROR 阻断产物,REVIEW 要求人工确认,INFO 只记录变化。进度只表示检查执行比例,不表示风险剩余比例。
候选 2026.10.06-r19 的 3 个坏值分别是:checkoutV2.rollout 使用字符串;couponStack.enabled 缺失;checkoutV2 开启但依赖的 riskGuard 关闭。前两个是结构错误,第三个是业务不变量错误。即便迁移脚本成功移动了 4 个字段,也不能把候选状态改成通过。
03 图展示构建门禁的当前状态:任务 CFG-1842-319、模式 v3、候选版本、17/25 与 68%、迁移字段 4、坏值 3、状态 QUARANTINED。红色标注圈出“3 个坏值”,提醒进度与放行是两套信号。

五、设备侧只激活经过构建批准的版本
第三段代码解决安装包内快照与设备历史状态的交接。应用从 rawfile 读取标准快照,解析出版本后,先与 Preferences 中的激活版本比较,再更新版本标记。示例假定构建门禁已经保证结构有效,设备侧仍对 JSON 和必要字段做最小防御检查。
import { preferences } from '@kit.ArkData';
import { util } from '@kit.ArkTS';
async function activateBundledSnapshot(context: Context): Promise<string> {
const bytes = await context.resourceManager
.getRawFileContent('feature_snapshot.json');
const text = new util.TextDecoder().decodeToString(bytes);
const candidate = JSON.parse(text) as Record<string, Object>;
const version = String(candidate['version'] ?? '');
const schemaVersion = Number(candidate['schemaVersion'] ?? 0);
if (version.length === 0 || schemaVersion !== 3) {
throw new Error('BUNDLED_SNAPSHOT_INVALID');
}
const store = await preferences.getPreferences(context,
'feature_activation');
const current = await store.get('activeVersion',
'2026.10.05-r18') as string;
if (current === version) return current;
await store.put('pendingVersion', version);
await store.flush();
await store.put('activeVersion', version);
await store.delete('pendingVersion');
await store.flush();
return version;
}
Preferences 的 put() 修改需要通过 flush() 或 flushSync() 同步到持久化文件。示例使用 pendingVersion 留下两阶段痕迹,应用崩溃后可判断激活是否完成。但 Preferences 不是关系型事务;如果配置内容本身还需要运行时下载和多表更新,应改用具有事务能力的存储,而不是把两个 flush() 宣称为原子事务。
为什么演示设备仍显示 2026.10.05-r18?因为候选 r19 在构建期已经隔离,正式 feature_snapshot.json 没有被覆盖。设备不会看到坏候选,更不会执行激活代码。调试页可以读取随测试包附带的脱敏报告,但生产包不应携带隔离详情。
六、把坏版本留在证据链里,而不是留在运行时
04 图从结果反推整个流程:18:42:11 读取 r19,18:42:11 完成 v2→v3 的 4 字段迁移,18:42:12 规则进度到 68%,随后记录 SCHEMA_ISSUES=3 和 QUARANTINED;正式输出保持 r18,设备激活版本也是 r18。红圈标在 OUTPUT_UNCHANGED,因为这才是门禁真正产生的结果。

诊断时不要只搜异常字符串。需要把 taskId、候选版本、模式版本、输入摘要、工具版本、规则集版本、输出摘要和最终状态关联起来。这样后台修复后重新生成 r20,才能证明它使用了哪份输入、通过了哪组规则,而不是仅凭“这次构建绿了”。
同一个候选不应无限重试。CI 可以按内容摘要去重:摘要相同且上一次是 QUARANTINED,直接复用报告并阻断;只有输入或规则集变化才重新验证。这能避免错误配置触发每小时构建,制造一串内容完全相同的失败记录。
七、测试重点放在迁移边界和输出不变性
第一组使用完整 v3 配置,检查标准化输出稳定,同样输入必须生成同样字段顺序与摘要。第二组使用合法 v2 配置,验证 4 个字段迁移后语义不变。第三组加入字符串百分比、缺失布尔值和关闭依赖,确认得到 3 个结构化错误,正式输出哈希保持不变。
第四组覆盖 Zod 4 默认值行为:字段缺失、显式 undefined、空对象分别断言,不让升级库版本悄悄改变快照。第五组让写 .next 成功、重命名前中断,下一次任务必须先识别并清理本任务临时文件,不能把陈旧 .next 当新候选。
第六组验证设备激活中断:只写入 pendingVersion 后进程退出,重启时应该回到上一稳定版本,并记录恢复原因。第七组检查 release 包,确认只包含批准快照,不包含 config/incoming、隔离副本和完整错误报告。
工具升级也要进入证据链。Zod 4 小版本、Node 版本、Hvigor 版本或规则集变化,都可能改变输出。升级后先对历史有效配置和历史坏配置跑回归语料,再允许新工具生成发布快照。构建工具不是“开发环境细节”,它参与决定安装包内容,本身就是供应链的一部分。
八、最终目标不是让 JSON 更漂亮,而是让错误停得更早
远端功能开关常被当成一种灵活能力,但灵活不等于可以跳过发布纪律。Zod 负责把输入变成可解释的成功或失败,迁移函数负责保留版本语义,Hvigor 负责把门禁放进任务依赖,设备侧只接手已经批准的快照。
CFG-1842-319 在 68% 发现 3 个坏值后停下,r19 被隔离,r18 保持激活。表面看是一次构建失败,实际避免的是一份坏配置在设备上变成随机页面行为。好的门禁不一定让发布更快,但它能让失败发生在最容易解释、最容易回滚的位置。
参考资料:
更多推荐


所有评论(0)