同一对商品节点为什么需要两条边

商品发布前需要检查依赖:SKU 依赖价格,也依赖库存。若只关心“有没有依赖”,一个有向边足够。如果还要记录检查成本,可以给这条关系附加数值。如果两条规则分别来自两个供应商,并且可以独立启停和审计,那么同一对节点之间就需要两个独立的边对象。

这三种需求分别接近 Guava 的 Graph、ValueGraph 与 Network。选择接口的关键是边有没有独立身份,而不是哪种接口方法更多。本章固定 Guava 33.5.0-jre,使用同一组商品依赖输入,在 Java 8 与 Java 21 验证边、视图、顺序与快照。官方图模型说明也把独立边对象作为选择 Network 的重要依据。GraphsExplained

实验节点是 sku、price、stock、orphan。最后一个没有依赖关系,专门用来检查“没有边的节点”会不会在模型转换时丢失。边输入则包括 sku 到 price 的两个来源,以及 sku 到 stock 的一个来源。这个小数据集同时包含方向、重复端点和孤立节点,足以区分三种表示。

Graph 保存关系,ValueGraph 保存关系的值

Graph 的边由端点确定。向有向图第一次添加 sku→price,putEdge 返回 true;再次添加相同关系返回 false,图中不会多出一条边。随后添加 sku→stock,边数为二。这个返回值表达结构是否改变,不是两次业务规则是否相等。

ValueGraph 仍然以端点确定边,只是边上可以有一个值。实验先写入 sku→price 的值 10,再写入 20,第二次返回旧值 10,当前值变为 20。添加 sku→stock 后,边数仍为二。若把两个供应商规则依次写在同一对端点上,后写值会替换先写值;这不符合独立审计两条规则的需求。

值本身也可以是一个规则集合,但这样多条规则的增删、身份和合并协议都由业务维护。不能因为 ValueGraph 的值可以任意复杂,就认为它自动提供多重边。评审模型时,应先问删除一条规则以后同端点的另一条是否仍存在,再检查数据结构能否直接表达这件事。

固定源码的接口说明明确区分这三种边表示,构建器再决定方向、自环等属性。查看 Graph 和 ValueGraph 时,重点应放在端点关系与值替换,而非照着方法名选择。Graph、ValueGraph

Network 让边具有独立身份

Network 实验开启 allowsParallelEdges,把 source-a、source-b 分别作为 sku→price 的边对象,再添加 source-c 连接 sku→stock。结果是四个节点、三条边,其中 sku 与 price 之间有两条边。这两个边对象可以分别用于查询和后续删除,端点相同不会令它们合并。

边对象的身份依赖 Java 的相等契约。实验试图把已经使用的 source-a 改为连接 stock→price,抛出 IllegalArgumentException;不能让同一个边对象同时表示两组端点。若实际边类型的 equals 只比较供应商名,而同一供应商还有多条独立规则,仍可能错误合并身份。这与第 03 篇的键身份问题相同。

Network 的 asGraph 只观察节点之间是否相连。本例的 Network 有三条边,asGraph 观察到两条端点关系。这个视角变化会丢失边的重复数量与独立身份,适合只判断可达性等需求,不适合继续统计规则数量。对图模型的转换,应该像对数据投影一样检查信息损失。Network

表示 sku→price重复输入 本例最终边数 独立规则身份
Graph 同一关系 2 无
ValueGraph 替换关系值 2 无,除非业务自己编码
Network允许多重边 两个不同边对象 3 有
Network.asGraph 折叠为端点关系 2 该视角不保留

方向、自环与迭代顺序必须单独声明

依赖关系通常有方向。sku→price 不自动产生 price→sku,实验对此作独立断言。若业务需要双向关联,可以选择无向图;不能仅凭两个节点都能出现在 adjacentNodes 中,就推断反向依赖存在。依赖检查应使用 successors 或明确方向的查询。

本例有向依赖图禁止自环,添加 sku→sku 被拒绝。另一张明确允许自环的无向图只含这条自环,degree(sku) 为二,因为该边的两个端点都落在同一节点。把度数简单理解成不同邻居数量,会在自环场景出错。业务若不允许商品依赖自身,应在构建器表达约束,也保留相应失败测试。

节点顺序与图算法的处理顺序也不同。本例为 Graph 配置 ElementOrder.insertion,nodes 的遍历顺序与四个节点的插入顺序一致;测试没有据此声称 successors 或 edges 也采用同样顺序。排序规则应明确作用于哪类元素,特别是把遍历结果写入报告、快照或稳定测试输出时。

源码中 nodeOrder 与 incidentEdgeOrder 是不同的接口属性;选择一个并不等于配置另一个。若依赖调度要求稳定顺序,应显式整理候选节点,或选择有确定顺序协议的算法,而不是把当前哈希迭代输出当成优先级。打印日志用于观察,不应反过来成为没有文档支持的排序契约。

不可修改的集合也可能是实时视图

第二个实验先构造 sku→price,获取 successors(sku),然后向原图添加 sku→stock。此前保存的 successors 集合立即包含 stock,但直接对这个集合 add 会抛 UnsupportedOperationException。这个结果同时说明两件事:调用者不能通过集合修改图,集合仍然观察着图的变化。

在修改之前创建 ImmutableGraph.copyOf,快照不包含后来加入的 stock。随后再添加 price→sku,当前图有环,旧快照仍没有环。不可变副本与只读视图承担不同的时间语义,不能只看返回类型是 Set 就混用。Graph 的实时视图契约

视图的有效性也依赖节点仍存在。固定版本文档对删除节点后既有视图的失效有专门说明。本文未执行删除后视图的完整操作矩阵,因此不把它推广为“永远可以使用的空集合”。长期保存查询结果时,如果真正需要的是某个时刻的数据,应复制集合或复制图,并明确复制发生的时点。

图快照也不是节点对象的深复制。若节点使用可变对象,修改参与 equals 或 hashCode 的字段仍会破坏身份查询;修改展示属性也可能改变快照渲染的内容。更稳妥的做法是以不可变商品编号作为节点,把可变商品属性放在有版本的外部数据中。结构不可变不能代替领域对象不可变。

邻接 Map 需要维护相同协议

JDK 的 Map<String, Set> 足以表达简单有向关系,但需要先定义每个键是一个节点,即使没有后继也保留空集合。实验先遍历全部图节点创建键,再复制 successors;这样 orphan 得以保留,stock 也不会因为只有入边就丢失。

如果只遍历边并调用 computeIfAbsent(source),孤立节点与纯终点节点都可能消失。此时“图节点数”已经改变,后面的拓扑排序或依赖校验可能把本应参与发布的商品遗漏。转换函数的验收应比较节点集合和每个节点的后继集合,不能只比较边条数。

无向邻接 Map 还需要成对维护两个方向,删除节点时需要移除别处指向它的关系。多重边则不能只使用 Set<目标节点>,否则相同端点的多个规则会被合并。Guava 图接口的价值在于集中这些结构契约;业务仍须决定哪些结构合法,以及错误输入如何报告。

以下独立 Java 8 示例检查商品依赖图是否有环。增加反向边之后,第二次检查识别出环。它只回答当前图中是否存在环,不负责解释是哪条业务规则应该被删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import com.google.common.graph.GraphBuilder;
import com.google.common.graph.Graphs;
import com.google.common.graph.MutableGraph;

public final class DependencyGraph {
public static void main(String[] args) {
MutableGraph<String> graph = GraphBuilder.directed().allowsSelfLoops(false).build();
graph.putEdge("sku", "price");
if (Graphs.hasCycle(graph)) { throw new AssertionError(); }
graph.putEdge("price", "sku");
if (!Graphs.hasCycle(graph)) { throw new AssertionError(); }
System.out.println("cycle detected after reverse dependency");
}
}

图约束和业务约束不是一回事

构建器禁止自环,仍不等于禁止所有环。sku→price 与 price→sku 都不是自环,分别添加可以成功,合起来却形成长度为二的环。实验先断言无环,再增加反向关系并断言有环,就是为了区分这两个层次。若商品依赖必须构成有向无环图,需要在更新协议中调用环检查或采用等价约束,不能只配置 allowsSelfLoops(false)。

批量加载时,还应明确错误发生以后保留哪一部分输入。逐条修改共享图,最后才发现有环,会留下已经写入的非法结构。可以先在临时图中完成建模和校验,再发布不可变副本;这样读者只看到通过校验的版本。它是应用层发布协议,ImmutableGraph.copyOf 本身不会替调用者验证业务依赖是否合理。

有多个写入者时,创建不可变副本也不能自动为整个读取过程提供事务隔离。应先通过锁、单写入者或其他明确协议得到一致的构建输入,再生成快照。本文实验只有单线程图更新,没有测量并发修改时的行为;不能把结构复制成功推广成共享可变图可被无同步地安全读写。

删除节点同样涉及业务决策。结构上移除 price 以及相关边,不表示剩余商品的发布条件已经满足;也可能表示配置遗漏了价格依赖。可以先验证每个必需节点存在,再检查依赖关系与环。结构检查回答图是否满足指定形式,领域检查回答这些节点和边是否完整表达了商品规则。

输出错误时最好保留原始规则编号。只有端点关系的 Graph 在发现环后,可能还需要额外索引才能定位哪个配置来源创建了这条边;Network 已经有独立边对象,可以把来源编号放入稳定身份中。选择模型因此也影响可诊断性,而不只是存储数量。需要的诊断信息应在建模阶段保留,不能等发生错误后从已经合并的端点关系里恢复。

实测与选择

完整实验在 Java 8、21 上均通过两项测试,运行说明列出工程入口、依赖和复现命令。原始输出保留了三种边数量、实时视图与快照、邻接 Map 内容;顺序只在明确配置的 nodes 上断言。

手算题:两条不同供应商规则连接相同端点,删除其中一条后仍需认为有依赖,应选哪个基础模型?允许多重边的 Network 可以直接保留两条独立边;Graph 只有一个关系,无法从结构中知道还剩哪个来源。

改动练习:为邻接 Map 写一个删除节点操作,检查入边、出边和孤立节点;再与 MutableGraph 对同一输入执行删除后的结果比较。只验证被删除节点的键消失不够,其他键的后继集合也必须更新。

可迁移做法 适用场景
先确定边身份,再确定容器接口 商品规则、权限关系、构建依赖
转换时分别验收节点、边与时间语义 邻接表导出、不可变快照、图模型降级

如果需求只是小规模无向关联,已有邻接 Map 且协议清楚,未必需要新增库。出现独立边身份、复杂视图或较多结构操作时,统一图抽象更容易减少重复维护。本章没有性能基准,不据此给出图规模或内存优势的承诺。