
对象模型是 Livelymerge 这类合并框架的地基。任何需要把多份版本的数据合并成一份结果的功能都要先回答几个基本问题数据在内存里以什么结构存在哪些内容可以被合并判断两个对象是不是同一个对象的依据是什么出现不一致时冲突该怎么表达。这些问题没有在设计之初定义清楚后面的合并算法、冲突提示、撤销恢复都会变得不可控。接下来围绕 Livelymerge 的对象模型展开先讲它解决什么问题再给出一套可落地的节点建模方式接着实现一个最小三方合并流程最后整理常见坑和生产化建议。适合需要做数据合并、配置合并、协同编辑或版本迁移的开发者阅读。1. 合并类工具为什么必须先定义对象模型1.1 合并本质上是在对象图上做转换合并操作看起来是对文本或文件进行操作但 Livelymerge 这类工具的真正处理对象其实是内存中的对象图。所谓对象图就是由对象、数组、键值对和原始值组成的树形或图形结构。举个例子一个用户配置文件的三个版本在磁盘上是三份文本内容可能是{ user: { name: zhang, theme: light } }但在合并引擎内部这份文本会被解析成节点树。user是一个对象节点name是一个字段节点zhang是一个原始值节点。合并算法不是在字符级别比较light和dark而是在节点级别比较两个叶子节点的值。对象模型就是这棵节点树的约束规则它规定节点有哪些类型、如何寻址、如何比较以及冲突时如何在结构上表达。1.2 没有对象模型时会出现哪些问题如果直接把两份文本做字符串差异合并过程会遇到一连串问题问题具体表现无法识别对象身份同名属性被误判为同一对象即使语义上已经不同无法处理结构变化插入、删除、移动字段时文本 diff 容易给出不可用结果冲突表达不清晰提示行级冲突但不知道冲突发生在哪个业务字段无法感知语义不知道数组元素是列表项还是配置项合并策略不能不同难以扩展规则想对日期、金额、敏感字段做特殊合并缺少挂载点这些问题的根源都是缺少统一的内部表示。没有对象模型合并逻辑就只能靠字符串行号凑合这也是很多合并工具在结构复杂数据时行为难看的原因。1.3 Livelymerge 对象模型的定位Livelymerge 把“如何描述一份可合并数据”这件事抽象成了独立的对象模型。在实际使用中可以把它理解成三层数据层外部传入的 JSON、XML 或程序对象。模型层解析后的统一节点结构所有合并逻辑都在这一层运行。策略层由模型节点上的元信息和自定义规则决定每个差异如何处理。这样设计的好处是数据接入方式和合并策略解耦。换一种数据源不需要重写合并算法调整冲突策略也不需要改动节点解析逻辑。这个分层方式在很多合并框架中都能看到也是一开始先理解对象模型而不是直接看合并 API 的原因。2. 对象模型的核心组成节点类型、身份和元信息2.1 节点类型的统一设计一个能覆盖大多数业务数据的对象模型至少需要四种节点。在 Livelymerge 的设计思路中这四种节点是同一棵树的组成部分节点类型含义示例object对象节点包含一组命名字段{ name: zhang }array数组节点包含一组有序元素[java, mysql]primitive原始值节点不能再展开zhang、18、truereference引用节点指向其他位置的节点跨文件引用、文档关联为什么数组要单独作为一类节点因为在合并中数组和对象的语义差别很大。对象字段通常按名字定位数组元素则依赖顺序或业务主键。如果混为一谈合并算法无法判断一个新元素到底是追加还是插入。2.2 身份确定两个节点是不是同一个节点合并的前提是比较两个版本里“同一个位置”的节点。身份确定方式通常有两类路径身份用从根节点到当前节点的路径定位例如/user/theme。键身份在集合类数据中用业务主键定位例如数组元素里的id字段。路径身份适合字段稳定的对象结构键身份适合数组、列表、Map 这类经常增删的数据。Livelymerge 的模型里可以同时支持两者对象节点默认按路径数组节点可以通过idKey配置指定按哪个键匹配。这里有一个容易误解的点路径身份不等于值身份。/user/name在两份版本里都指向同一个节点是因为它们的父路径和字段名相同而不是因为值相同。对象模型要先区分“身份相同但值不同”和“身份也不同”两种情况前者才是值得合并的差异。2.3 元信息合并规则可以挂载的位置除了业务数据本身对象模型还需要留出元信息字段。常见元信息包括元信息字段作用source数据来自本地还是远端用于记录来源version节点版本号做冲突检测和幂等mergeStrategy该节点的合并策略如 local、remote、manualnullable是否允许为空处理删除语义headers文件级或节点级附加信息如修改时间元信息不应该污染业务数据。常见做法是在节点结构上用单独的meta字段承载而不是把_source这类键直接写进业务 JSON。否则合并结果会带上大量内部字段对外输出时需要清洗。3. 用 JSON 描述一个最小可合并对象模型3.1 一个待合并的配置文件先定义一个实际例子。假设有一份系统配置文件base 版本是这样的{ service: order, version: 1.0.0, tags: [java, mysql], settings: { notify: true, theme: light } }本地版本做了两处修改theme改成darktags里追加了redis。远端版本也做了两处修改notify改成falseversion改成1.1.0。两份改动没有触碰同一个字段逻辑上应该能够自动合并。但如果工具没有对象模型这类操作在文本层面也容易乱。模型层的目标就是把这种“字段级差异”显式表达出来。3.2 模型层节点示例按照前面定义的节点类型settings和theme在模型层可以表示成{ type: object, path: /, properties: { settings: { type: object, path: /settings, properties: { notify: { type: primitive, path: /settings/notify, value: true, meta: { source: base } }, theme: { type: primitive, path: /settings/theme, value: light, meta: { source: base } } } } } }这个表达看起来比原始 JSON 冗长但它带来的好处是每个节点都可以独立承载身份、值和元信息。合并算法遍历节点时不需要再回头解析原始字符串。数组节点稍微不同它需要管理元素列表。带业务主键的数组可以这样表达{ type: array, path: /tags, idKey: null, items: [ { type: primitive, path: /tags[0], value: java }, { type: primitive, path: /tags[1], value: mysql } ] }如果数组元素是对象且带有id字段把idKey设置为id合并时就能按主键匹配而不是按顺序匹配。3.3 普通数据到模型节点的转换过程从外部 JSON 转换到模型节点本质是一次递归解析。伪代码如下parse(value, path): if value is array: 创建 array 节点逐个解析元素 else if value is object: 创建 object 节点逐个解析属性 else: 创建 primitive 节点保存值 统一附加 path 和 meta这一步本身不复杂容易出错的是path 拼接要统一规范推荐/a/b数组使用/a[0]。循环引用要处理JSON 结构本身不会循环但程序对象可能。null要作为合法值还是删除标记建模前必须定清楚。转换完成后base、local、remote 三份数据就都变成了同一套对象模型下的三棵树合并算法才能比较。4. 合并语义三方合并、冲突检测与解决策略4.1 为什么要引入 base 版本只有 local 和 remote 两份数据做 diff 是不充分的。假设theme在 local 里是dark在 remote 里还是light并不知道这个light是 base 的原始值还是 remote 的修改值。如果没有 base合并器只能猜测要么直接冲突要么默认覆盖。两种都不够可靠。三方合并的做法是同时比较 base、local、remote 三份模型树。对每个节点场景local 相对 baseremote 相对 base合并结果两边都没改不变不变取 base 值只有一边改改不变取改过的值两边都改成相同结果改改但结果相同取该值不算冲突两边改成不同结果改改且结果不同产生冲突一边删除一边修改删除修改冲突或按规则保留一方这张表就是合并算法的主要分支逻辑。对象模型的价值在这里体现得很明显判断“改没改”不是靠字符串比较而是靠节点 diff。4.2 冲突检测的粒度冲突检测应该在节点粒度和路径粒度上做双重判断。对叶子节点直接比较值对对象节点先比较字段集合变化再递归比较子节点。数组节点的处理更复杂常见策略有两种顺序匹配按索引对应适合元素顺序有业务含义的数组。主键匹配按idKey字段对应适合列表、集合类数据。主键匹配能避免一个经典问题local 在数组头部插入了一个元素remote 修改了第二个元素。如果按索引匹配合并后可能把 remote 的修改套到错误元素上。按主键匹配后local 的插入和 remote 的修改会落在正确位置。4.3 解决策略与优先级冲突发生后模型层要把冲突记录下来而不是直接抛异常。一个典型的冲突记录包含{ path: /settings/theme, baseValue: light, localValue: dark, remoteValue: blue, status: conflict, resolvedValue: null }解决策略通常包括local、remote、manual、custom四种。local和remote用于明确以哪一方为准manual让外部用户挑选或输入新值custom允许开发者注册自定义函数例如“取较新时间戳的那个值”“对金额字段取和”等。策略可以按路径配置也可以在节点meta里指定。合理做法是全局设置默认策略敏感字段或高频冲突字段单独配置避免所有冲突都堆到人工处理。5. 在代码里实现对象模型和最小合并流程5.1 节点类型定义下面的 TypeScript 定义只用于解释模型结构实际项目需要根据语言和依赖调整type NodeType object | array | primitive | reference; interface MergeNode { type: NodeType; path: string; value?: unknown; properties?: Recordstring, MergeNode; items?: MergeNode[]; meta?: Recordstring, unknown; } interface Change { path: string; baseNode: MergeNode | null; localNode: MergeNode | null; remoteNode: MergeNode | null; }MergeNode可以同时表达四种节点。object使用propertiesarray使用itemsprimitive使用value。meta专门放策略和来源信息不参与业务值比较。5.2 差异计算与合并入口先定义 diff 函数比较两棵树并找出变化路径function diff(base: MergeNode, target: MergeNode): Change[] { const changes: Change[] []; collectDiff(base, target, changes); return changes; } function collectDiff( base: MergeNode | null, target: MergeNode | null, changes: Change[] ) { // 递归比较子节点记录属性新增、删除、修改 }合并入口接收 base、local、remote 三棵树先分别算出 localDiff 和 remoteDiff再对每一条 change 按分支表决定结果function threeWayMerge( base: MergeNode, local: MergeNode, remote: MergeNode ): MergeResult { const localChanges diff(base, local); const remoteChanges diff(base, remote); const merged cloneNode(base); const conflicts: ConflictItem[] []; for (const change of localChanges) { applyChange(merged, change, local); } for (const change of remoteChanges) { if (isChangedAtSamePath(localChanges, change.path)) { // 需要判断两边是否改成相同值 } else { applyChange(merged, change, remote); } } return { mergedNode: merged, conflicts }; }这个实现只描述了主体骨架真实项目还要处理对象节点属性的增删合并而不是简单覆盖整棵子树。数组元素的主键匹配。删除和修改同时发生时的策略决策。合并结果的可回滚记录。5.3 注册自定义合并规则对象模型要允许按路径挂自定义策略。常见做法是维护一张策略表const strategyRegistry: Recordstring, MergeStrategy { /settings/theme: { type: manual }, /counter: { type: custom, handler: (base, local, remote) local remote, }, };合并器处理每个冲突节点前先查策略表再决定是自动解决、标记人工处理还是使用meta.mergeStrategy。自定义 handler 要尽量做成纯函数输入 base、local、remote 三个值输出最终值同时不要修改入参方便测试和回滚。6. 如何验证合并结果6.1 最小验证用例写完合并逻辑后最有效的验证方式不是看一个例子而是建一组覆盖分支表的用例。用例编号场景输入预期T01只有 local 修改theme 在 local 为 dark合并结果 theme darkT02只有 remote 修改notify 在 remote 为 false合并结果 notify falseT03两边修改不同字段local 改 themeremote 改 notify两个字段都生效T04两边改成相同值theme 两边都是 dark无冲突theme darkT05两边改成不同值local 为 darkremote 为 blue产生冲突保留冲突记录T06一边删除一边修改remote 删除 tagslocal 修改 tags策略为 remote 时删除否则冲突这类用例推荐写成数据驱动测试准备好 base.json、local.json、remote.json然后跑断言。6.2 验证方法和输出一个实用的验证步骤是把三份输入解析成模型节点调用合并逻辑接着输出两个结果合并后的 JSON 和冲突列表。node test/run-merge.js --base test/fixtures/base.json \ --local test/fixtures/local.json \ --remote test/fixtures/remote.json正常情况下应看到合并 JSON 包含所有非冲突修改冲突列表为空。T05 场景则应看到冲突列表包含/settings/theme且合并结果里该字段没有被强行覆盖。生产项目还要验证冲突解决之后的再合并人工解决冲突后结果应该能继续作为下一次合并的 base 使用。6.3 边界情况测试清单对象模型最容易出问题的地方集中在边界情况至少准备这些用例空对象和空数组的合并。字段值为null与字段被删除的区别。数组元素移动是否会被误判为删除加新增。深层路径超过十层的合并性能。完全相同的三份输入。含有循环引用的程序对象。每一条都对应一类真实生产问题。空数组处理不当时远端清空操作很容易被 local 的新增操作覆盖。7. 常见问题与排查路径7.1 合并结果丢失了本地修改这是最危险的问题。现象是本地明明改了字段合并后结果却还是旧值。常见原因有两个身份匹配失败或者合并时直接以整个对象节点为单位覆盖。排查顺序检查该字段的 path 在 base、local、remote 三棵树中是否一致。检查 localDiff 中是否包含该字段如果不包含说明本地修改没有进入 diff。检查父节点是否是对象整体覆盖逻辑比如 remote 修改了父对象合并时按“对象级 remote 优先”覆盖了整棵子树。检查数组元素是否按主键找到正确元素匹配不到时会落到新增或删除分支。7.2 冲突没有被检测出来现象是两方改了同一个字段但模型没有报冲突。优先检查两个东西值比较是否做了类型归一化比如1和1是否被当成相等diff 的粒度是否在叶子节点如果在对象节点就停止那么只比较了引用而没比较值。另一个隐蔽原因是 base 版本更新错误。三方合并要求三棵树必须来自同一个 base。如果 local 和 remote 是基于不同 base 发展出来的冲突检测结果没有意义。7.3 数组合并后元素错乱或误删数组是对象模型里最容易出问题的部分。现象可能是 local 新增的元素在合并后消失或者 remote 修改的内容套到了错误的元素上。排查顺序检查项说明idKey 是否配置没有 idKey 时按索引匹配插入元素会导致后续元素错位主键值是否稳定元素 id 在拖动、复制过程中被改写会导致匹配失效移动语义元素移动会被识别为删除加新增需要合并器支持 move 检测空数组处理空数组和 null 不要混为一谈删除数组应显式标记7.4 排查顺序清单遇到合并结果不符合预期时按下面的顺序排查最有效率先确认输入数据本身正确base、local、remote 没有文件错位。查看解析后的模型树确认 path 和节点类型。检查 diff 结果定位是“没检测到变化”还是“检测到但合并策略错误”。检查冲突记录确认冲突是否被策略表自动消化。最后检查输出层确认模型节点转 JSON 时没有丢失 meta 或字段。8. 生产环境中的实践建议8.1 对象建模的几条规范不要在业务字段里混入meta信息。用独立字段承载来源、版本、策略。path 命名统一/a/b和/a/b/不要混用数组索引统一用/a[0]。定义null语义。是“空值”还是“删除标记”必须有一种并且只有一种表达。数组尽量指定idKey不要依赖顺序匹配。合并结果要能转回普通数据且转换结果不能出现内部节点。8.2 学习环境与生产环境的差异在学习环境验证时跑通一个字段的合并就够了。生产环境至少要补齐这些能力能力说明配置外置化合并策略、idKey、默认策略放配置文件不改代码日志每次合并记录输入路径、冲突数、解决策略监控冲突率、合并耗时、最常见冲突路径统计回滚合并结果和基础输入可回溯人工解决后还能反悔权限某些字段不允许自动合并或改写需要按路径隔离幂等同一组输入重复合并得到相同结果8.3 可以扩展的方向对象模型稳定后扩展点会变得很清晰支持更多数据源从 JSON 扩展到 XML、SQL 结果集或自定义对象。增加 diff 可视化把合并结果渲染成字段级对比视图。引入更细粒度的操作日志用操作序列而不是整份快照来描述变更。把冲突解决从“结果选择”升级为“表达式合并”例如列表合并、映射合并。对新手来说比较值得做的练习是不使用任何第三方 diff 库实现一个只支持对象和原始值两个类型的三方合并再逐步加入数组。这个练习能让人真正理解对象模型对合并行为的影响。对象模型不是合并功能的外围概念而是决定合并行为正确性的核心结构。在接入 Livelymerge 或自己实现合并工具时先花时间把节点类型、身份规则、null 语义、数组匹配方式和冲突策略定义清楚后面的算法和排错都会简单很多。