· engineering· 约 25 分钟精读
端侧离线登记册的数据模型演进与向前兼容工程:JSON 落盘解码边界、增量字段策略与失败语义取舍
没有服务端可以登入的迁移窗口,是离线产品的固有约束。本文以密旋科技 TagWorks 与 SlingGuard 的端侧登记册为样本,拆开一个 JSON 文件从落盘到被新版本读回的整条路径:可整体替换的快照与原子写、必填键与新增键的解码边界、属性默认值为什么不参与回填、枚举 rawValue 作为冻结存储格式、派生字段如何让一类变更不成为迁移,以及“保留原文件”与“静默返回”两种失败语义的代价。
一、没有服务端可以登入的迁移窗口
一台手持电气安全检测工具在工地上装机时是第一个版本。一年之后,它长出了新的记录类型(检测、维修、复检、放行),长出了检验仪器的溯源字段(型号、序列号、校准日期),也长出了起重侧的“隔离—放行”关联记录。每一次这样的功能追加,都是一次对“数据长什么样”的改写——改写的数据此刻正躺在每一位检测工程师的手机里。
在服务端一侧,这种改写通常是一次迁移脚本:由工程人员在有备份、有回滚、有暂存环境的前提下跑一遍。离线产品没有这些。没有可以登入的服务器,没有能替你跑脚本的运维,没有可以先行试错的副本。唯一存在的迁移引擎,是每台设备上安装的那一份应用副本;唯一存在的迁移窗口,是这份副本在升级后的首次启动——窗口之后,新写回的文件已经覆盖了旧文件,旧的数据形状也随之消失。
所以离线产品的纪律与在线产品不同:它不能指望“迁移会把它修好”,它必须让每个已发布的版本都能读回上一个版本留下的文件。本文以密旋科技 TagWorks(便携式电气安全,对标 AS/NZS 3760)与 SlingGuard(起重吊索具,对标 LOLER 1998 与 OSHA 29 CFR 1910.184)的端侧登记册为样本,把这件事拆到可执行的层面:文件怎么落盘、解码的边界在哪里、为什么有些变更根本不算迁移、为什么有些变更一旦裸奔就不可挽回、以及失败路径为什么和成功路径同样重要。
二、落盘形态:一个可整体替换的 JSON 快照
两个登记册的落盘方式相同:一份 JSON 文件,放在应用文档目录下的自有文件夹里(TagWorks 为 register.json),内容是一个 Snapshot 结构体,装着两张表——资产表与记录表。写入走标准编码器与原子写:先把整个快照编码成字节,再调用带原子选项的写盘接口。原子写的含义是,写盘过程中发生崩溃也不会留下半份文件:操作系统先写临时文件、再改名替换,文件要么是旧的、要么是新的,不会是一半。
保存不是每次改动都立刻触发,而是抑制了短时间内的连续写入:改动落地后延迟约 0.4 秒再写一次,需要立即确定时由显式的落盘调用负责。证据照片不进 JSON,它们以独立图片文件存在别处,记录里只留文件名,报告生成时再按名取用。
| 存储对象 | 载体 | 写入方式 | 关联方式 |
|---|---|---|---|
| 资产与检测记录 | 文档目录下的 register.json | 整体编码 + 原子写 | 记录以资产 UUID 指向资产 |
| 证据照片 | 文档目录下的独立图片文件 | 逐文件写盘 | 记录以文件名引用 |
| 检查员身份 | 本地键值存储 + 位图文件 | 键值与文件分别写 | 生成报告时逐份取用 |
这份文件本身没有版本字段。它的“模式”不是数据库里的一张表定义,而是参与编码的那组类型。也就是说,向前兼容不是靠文件里写了一个数字,而是靠每一个类型在解码时怎么对待缺失的键。这一点决定了后面所有的取舍。
三、解码边界:属性默认值不会替你回填
序列化框架在大多数情况下会替类型合成一个解码器:逐个按键名向容器索取值,再填回到同名属性。这条路径里有两个容易被忽略的后果。
第一,对于非可选属性,合成的解码器会按“必须存在”的方式取值,键不存在就抛错。类型声明处写的属性默认值,不会在解码这一步被使用——默认值只在直接构造对象时生效,不在从字节反解时生效。因此“我给这个字段写了默认值,旧文件肯定没问题”这个判断是错的,除非该字段是可选的,或者你亲手写了那个解码器。
第二,对于可选属性,解码器改用“存在则取、缺失则空”的调用,缺失映射为无值而不是失败。
可用的规则因此很窄:在一个离线文件格式里,任何后来才追加的字段,都必须做成可选,或者用“存在则取、缺失回落到默认值”的方式读取,而且这个回落值必须等于旧版本在同样情况下会使用的值。TagWorks 的记录类型正是这样做的:资产的标识、时间、结论这些承重字段按“必须存在”读取,而后来追加的记录类型、关联记录编号、放行授权人、检验仪器型号与序列号、仪器校准日期、作业程序编号、目视检验结论,全部按“存在则取、缺失回落”读取——其中目视检验结论的缺失被明确定义为“历史记录、当时未记录目视检验”,而不是“未检验”,这个区分直接决定它能不能被判定为合格。SlingGuard 的快照则演示了另一种追加:整个“隔离放行”数组是后来引入的独立实体,用“存在则取、缺失取空数组”读取,旧文件里没有这张表,新代码读到的就是一个空表。
| 键的类别 | 解码调用 | 缺失时的行为 | 适合承载 |
|---|---|---|---|
| 承重键 | 必须存在 | 抛错,整份登记册判为不可读 | 资产身份、时间、结论 |
| 后加的可选键 | 存在则取 | 回落到默认值或无值 | 溯源、类型、关联字段 |
| 后加的独立集合 | 存在则取空 | 得到空集合 | 新引入的实体表 |
这里有一个具体的陷阱。资产类型并没有手写解码器,它的字段全部带着声明处的默认值。这让人误以为“资产加字段是安全的”。事实上,如果给资产追加一个非可选字段而不改写读取逻辑,那么每一个已存在的文件都会在解码时抛错——整份登记册立刻变成“不可读”。也就是说,加入一个非可选字段,等价于一次破坏性变更;安全的做法是把它做成可选并赋予“无值即旧世代”的含义,或者补写显式解码器。这条边界不写在任何文档里,只能靠纪律守住。
四、枚举的 rawValue 是冻结的存储格式
登记册里大量语义是靠枚举承载的:作业环境的四类、设备类别(接地、双重绝缘、功能性接地)、检测结论、记录类型、目视检验结论。这些枚举都以字符串原始值参与编码。于是原始值本身就是存储格式:改一个分支的名字、改它的原始值、或者调整顺序,都会改变写到盘上的字节。旧文件里记着 classI,新版本的分支如果改叫别的写法,读回时该键就取不到——而且由于这条记录位于记录数组之中,失效的不是某一行,而是整份登记册。
关键在于,枚举上的字符串不是一处,而是三处:显示面、存储面、导出面。显示面可以随版本自由改写;存储面是冻结的;导出面(写进报表的那套标记,例如“未发现缺陷”)一旦被下游解析程序依赖,也同样冻结。三者分开维护,只有显示面是自由的。
| 语义面 | 承载位置 | 可否随版本变更 | 例 |
|---|---|---|---|
| 显示面 | 枚举的显示名 | 可自由改写 | 办公 / 温和环境 |
| 存储面 | 枚举字符串原始值 | 变更即破坏旧文件 | classI |
| 导出面 | 报表导出标记 | 变更即影响下游解析 | NO_DEFECT_OBSERVED |
因此,一次看起来“只是收拾一下命名”的重构,在离线产品里是一次文件格式的破坏性变更,必须被升级为版本事件,而不是随手提交的改写。
五、派生优于存储:让一类变更不成为迁移
有些值不落盘——它们在需要时算出来。资产的下次到期时间不是存储字段,而是“上次检测时间 + 该资产适用的检测间隔”算出来的;间隔本身来自一张按环境与风险等级分档的规则表(依据 AS/NZS 3760 的再检测间隔)。资产的整体状态(逾期、临近、正常、不合格、证据不全、未检测)同样是读取时判定的结果。
这带来一个直接的好处:当规则变化时——例如某类施工场景下的高风险设备间隔由 6 个月收紧到 3 个月——没有任何一条历史记录需要被改写,每台设备在下次读取时自动重算到期日。变更是一次代码变更,不是一次数据迁移。反过来,如果当初把到期日当字段存下来,规则一变就必须逐条重写资产表;在离线机队里,这就意味着每台设备都得在升级后跑一次迁移,而一台始终不升级的设备会永远保留那个过期的日期。
| 字段 | 存储还是派生 | 变更代价 |
|---|---|---|
| 上次检测时间 | 存储 | 只能追加新记录 |
| 间隔规则表 | 代码 | 换表即生效,零数据改写 |
| 下次到期时间 | 派生 | 自动跟随规则 |
| 整体状态 | 派生 | 读取时重算 |
由此得到的取向是:只存不可再生的原始事实——测到了什么、什么时候、由谁测的;凡产品能算出来的,都算出来而不要存。派生值是把“未来的变更”变成“非事件”的最省力手段。
六、失败语义:保留原文件,还是静默返回
解码失败时会发生什么,是这条链路上最容易被低估的一环,也是两个登记册取向分岔的地方。
TagWorks 的做法是失败即封闭:解码失败时,它会记录一个持久化问题状态(不可读或不可解码),并置起一个“需要恢复”的标志;在这个标志被清掉之前,保存接口直接拒绝写盘。同类提示会明确告诉用户:磁盘上的登记册文件没有被替换,可以重试读取或恢复一份已知良好的本地备份。也就是说,新版本拒绝覆盖一份它读不懂的文件,数据仍然躺在原处,留给更旧的版本或一次恢复去处理。
SlingGuard 的读取路径更简省:用容错取值读取文件,任何失败都直接返回,不去改变内存中的数组——在冷启动时,它们本就是空的。问题在于那之后:防抖保存会把此刻内存里的空快照写回盘上,而这份空快照与一份“本来就空”的登记册在文件层面无法区分。解码失败是静默的。在顺利路径上这没有任何代价,但在病态路径上(文件由更新的版本写出,或者文件损坏),它可能把一份“读不懂但仍然完整”的文件变成一份“空的”文件。
| 行为 | 失败即封闭 | 静默返回 | 决定的东西 |
|---|---|---|---|
| 解码失败 | 置持久化问题 + 需要恢复 | 静默返回,不改内存 | 问题能否被发现 |
| 覆盖写 | 需要恢复期间拒绝保存 | 防抖保存可能写入空快照 | 数据能否幸存 |
| 用户可见 | 明确提示文件未被替换 | 无提示 | 责任能否被交代 |
在离线产品里,解码失败不是一个可以吞掉的错误,它恰恰是应用唯一一次挡在用户法定记录与丢失之间的时刻。稳妥的默认是三条:把读不懂的文件原地保留、在旧版本下继续充当当前登记册、把问题暴露给使用者。只有当写盘路径在该状态下被明确阻断时,“静默回落为空”才是可以接受的。
七、版本号与备份闸门:把破坏性变更挡在门外
与登记册文件不同,备份归档显式携带了版本号:当前格式版本为 2,读取时用“存在则取、缺失按 1 处理”的方式取出,于是早于该字段的最早归档被当作第 1 代。它还用到一处更细的信号:通过“键是否存在”来判断归档里是否包含检查员身份——早于该功能的归档不会被本地身份覆盖。而恢复入口的规则是单向的:一旦归档的版本高于当前应用的格式版本,就整份拒收,并提示“该备份由更新的版本生成,请先升级再恢复”。
这条“同代或更旧可读、更新一律拒收、不做部分读取”的规则,是把破坏性变更挡在门外的那道闸。它和备份放在一起看才完整:既然旧版本读不懂新版本写的字段,回滚在字面上是不可能的;离线设备真正拥有的回滚点,是升级之前留下的那一份自包含归档。
| 变更类型 | 是否需版本号 | 处置方式 |
|---|---|---|
| 新增可选字段 | 不需要 | 存在则取,缺失回落 |
| 新增独立集合或实体 | 不需要(建议留存在性判据) | 存在则取,缺失取空 |
| 枚举原始值改名 | 需要 | 版本闸门 + 显式迁移或拒收 |
| 字段改必填或语义改变 | 需要 | 必须显式迁移,不能靠默认值 |
版本号与“键是否存在”都是信号,而后者更细:它不需要为一个纯属修饰的改动升版本,就能精确区分“引入该键的那一代”。真正需要升级版本号的,是读者必须按写入方世代改变行为的场合。
八、恢复路径:先写盘,再露头
恢复不是把文件读进内存就算完。它的顺序是:先把整份快照写到盘上,成功之后才把内存中的登记册整体换成它——写盘失败时,当前登记册原封不动。一次完整恢复还会在动作之前先落一份“恢复前安全点”;而如果归档缺失了它引用的证据照片、或者照片名不安全,那么什么都不会被恢复。
原子替换与恢复前安全点合起来,给了离线产品它唯一会拥有的回滚能力。这也把上文的结论收拢成一句:既然应用无法回滚一次模式变更(旧版本读不懂新字段),真正的回滚就是“留住上一份文件”——要么是升级前的归档,要么是失败即封闭带来的“拒绝覆盖”。迁移与备份在这里是同一件事的两面:在新副本身被验证之前,绝不销毁唯一的那一份。
九、迁移保证不了什么
把能力边界说清楚,比把好处说满更重要。
格式兼容不等于语义正确。一份能解码的文件,不等于一份数值含义未变的文件。如果某个字段的含义从“间隔月数”变成“到期日期”,读取方会毫无怨言地解出一个数字,然后把它当成另外一种东西来用。这种语义平移只有显式迁移能做,缺省回落做不到。
枚举原始值改名留下的空缺无法被桥接。旧文件里的旧标记既不是别名也不是模糊匹配,它就是不存在;新代码拿到的是“缺省回落”,而不是“旧值”。把回落值写成什么,完全由纪律决定。
派生值会静默地改变结论。调整间隔规则表会在下一次读取时改变“是否逾期”的呈现,而没有任何一条记录被编辑、也没有留下痕迹。这是设计使然——规则才是事实来源——但也意味着“数据没变”并不等于“结论没变”,两者必须在文档里被区分。
静默失败叠加自动保存,是唯一一种能把可读文件变成空文件的组合。防住它的不是格式,而是失败语义:读不懂时拒绝写。
十、可复用的纪律清单
把上面的取舍压缩成一张可执行的表:
| 纪律 | 具体做法 | 防住的问题 |
|---|---|---|
| 只增不改 | 优先追加字段,避免改名、改必填、改语义 | 旧文件整份失效 |
| 新增即可选 | 新增字段做成可选或用存在则取 | 依赖声明默认值的错觉 |
| 原始值冻结 | 显示、存储、导出三套字符串分开维护 | 命名重构变成破坏性变更 |
| 优先派生 | 能算出来的不落盘 | 规则变更变成数据迁移 |
| 失败必留痕 | 读不懂时保留原文件并阻断写盘 | 空快照覆盖好文件 |
| 版本守单向 | 读同代或更旧,拒收更新 | 部分读取带来的错值 |
| 发布前备份 | 升级前留一份自包含归档 | 失去唯一的回滚点 |
结语
离线优先的承诺,是个人记录永不离开设备。这句话还有容易被忘记的后半句:既然记录不离开设备,那么设备就是唯一的保管者,唯一可能弄丢它的人就是产品自己。在线产品把一次模式变更藏在别人替你跑的迁移脚本后面;离线产品必须让这次变更对文件而言不曾发生。解码边界、派生字段、失败语义与版本闸门存在的理由,不是让代码显得高明,而是让检测工程师的记录,在下一次更新之后依然读得出来。
参考文献
[1] IETF RFC 8259, The JavaScript Object Notation (JSON) Data Interchange Format. https://www.rfc-editor.org/rfc/rfc8259 [2] Apple Developer Documentation, Encoding and Decoding Custom Types. https://developer.apple.com/documentation/foundation/encoding-and-decoding-custom-types [3] Apple Developer Documentation, Data.WritingOptions.atomic. https://developer.apple.com/documentation/foundation/data/writingoptions/atomic [4] Swift Evolution SE-0166, Swift Archival & Serialization. https://github.com/swiftlang/swift-evolution/blob/main/proposals/0166-swift-archival-serialization.md [5] IETF RFC 3339, Date and Time on the Internet: Timestamps. https://www.rfc-editor.org/rfc/rfc3339 [6] Standards Australia/Standards New Zealand, AS/NZS 3760 In-service safety inspection and testing of electrical equipment. https://www.standards.org.au/ [7] Health and Safety Executive (UK), Lifting Operations and Lifting Equipment Regulations 1998 (LOLER). https://www.hse.gov.uk/work-equipment-machinery/loler.htm [8] U.S. Occupational Safety and Health Administration, 29 CFR 1910.184 Slings. https://www.ecfr.gov/current/title-29/section-1910.184