一家商户可以对应多个商品

目录导入把商户编号映射到商品编号,同一商户输入 B、A、B。若记录代表三条导入事件,两个 B 都应保留;若记录代表商户当前拥有的商品集合,重复 B 可以合并。Map<String, String> 无法同时保留多个值,Map<String, List> 能表达第一种需求,但空分组、计数与删除最后一个值需要额外约定。

Guava 的 Multimap 将一个键对应多个值作为公开集合契约。它并没有替所有实现选择相同的重复规则:ArrayListMultimap 的每键值集合是 List,HashMultimap 的每键值集合是 Set。选型首先由业务是否允许重复决定,其次才是 API 调用是否简短。

本篇使用 Guava 33.5.0-jre、Java 8 API,在 Zulu 8.0.472 和 Corretto 21.0.11 上验证。完整测试与 运行说明可复跑各返回视图的修改。前篇 Immutable 集合讨论冻结结果,这里检查可变多值结构如何维持各入口的一致性。

size 计数的是键值对

向 ArrayListMultimap 插入 shop→B、shop→A、shop→B,size 为 3,keySet.size 为 1,get(shop) 为 B、A、B。向 HashMultimap 插入相同输入,size 为 2,因为重复的键值对被合并。同一个值若属于不同的键,仍分别计数,去重范围是每个键对应的 Set。

ArrayListMultimap 文档保证同一键下按添加顺序保存值;HashMultimap 文档不保证键或值的迭代顺序。因此,本实验对 List 比较完整顺序,对 HashMultimap 只检查大小和包含关系。某次运行打印出 A、B,不是可以写进导出格式的顺序保证。

size 的区别会影响指标。页面上“商户数”应来自不同键数量,“关联数”应来自键值对数量;如果使用允许重复的实现,“不同商品数”还需要按定义去重。把这三个指标都记作 map.size,会在迁移到 Multimap 后改变统计含义。

两个可变实现允许 null 键和值,本实验分别插入 null→null 并检查 containsEntry。这个事实不能外推到 ImmutableMultimap,也不等于业务应接受 null 商户编号。类库容器允许的值域与导入入口允许的值域不同,业务校验仍应在创建关联前完成。

需求 ArrayListMultimap HashMultimap
保留同键重复值 保留 去重
同键顺序 添加顺序 不承诺
size 所有条目数,含重复 不同键值对数
null 允许键和值 null 允许键和值 null
空分组长期保存 不保存 不保存

缺失键的 get 返回可写视图

普通 Map.get 在键缺失时返回 null。Multimap.get 返回空集合,但读取这一集合并不会立即创建一个持久的空分组。实验取得 shop 的 values 后,values.isEmpty 为 true,containsKey(shop) 为 false,asMap().get(shop) 为 null。这三个结果同时成立。

向 values 添加 A 后,Multimap 中出现 shop→A,containsKey 变成 true。values 是连接到原 Multimap 的视图,不是对缺失结果创建的普通临时列表。随后通过 asMap().get(shop) 添加 B,原先保存的 values 也读到 A、B。修改能够从两个入口传播到同一状态。

Multimap 接口文档用视图描述这些集合。get 适合调用方将“尚无值”作为空集合处理的情形;asMap().get 则保留 Map 风格的缺失 null。把两种访问方式混用而没有检查缺失分支,仍可能产生 NullPointerException。

查询接口若直接返回 get(key),就把修改权限交给了调用方。方法名是 queryProducts 并不会使 List 自动只读。若需求是实时只读视图,可明确包装;若需求是查询时的结构快照,可复制成 ImmutableList。第 09 篇的元素可变边界仍然存在,容器快照没有冻结商品对象内部状态。

可迁移的判断是按返回值能否回写来审查权限。集合返回类型只说明可以调用哪些方法,不说明调用后的影响范围。DAO 返回集合、配置管理器返回 Map、缓存返回对象时,都应检查别名关系,不能仅凭方法名或变量名推断所有权。

删除最后一个值后,键也消失

values.remove(A) 后只剩 B,size 为 1。再调用 values.clear,shop 不再属于 keySet,containsKey(shop) 为 false。Multimap 的结构不会保留“存在但没有任何值”的键。这个约束使其可被理解为键值对集合,而不是任意 Map<K, Collection> 的完整替代物。

例如普通 Map 中可以存在 shop→空 ArrayList,用来表达“商户已经完成同步,目前没有商品”。把这个 Map 的所有条目逐一 putAll 到 Multimap,空组不会被保存,无法再区分“已同步无商品”与“从未同步”。需要保留这种状态时,应另外保存商户同步状态,或继续使用允许空分组的业务模型。

测试还保留了最初取得的 values 视图。clear 后重新通过 Multimap.put(shop, C),旧 values 再次读取时得到 C。根视图仍然代表 shop 当前的值集合,不是某一代内部列表的永久引用。这个行为可以通过 AbstractMapBasedMultimap 固定实现解释。

get 在键不存在时创建底层集合并返回 WrappedCollection 或 WrappedList,包装中保存键和 delegate。首次添加时 addToMap 把非空集合挂到 map;删除变空时 removeIfEmpty 删除键;读取空 delegate 前 refreshIfEmpty 会检查该键是否已有新的底层集合,并更新引用。totalSize 随条目增删维护,因此从视图写入不能绕过全局计数。

这里描述的是根 get 视图。subList、迭代器等派生对象对底层替换有更细的有效性条件,源码中也存在 ConcurrentModificationException 检查。不能把“根视图能够重新连接”扩大成“任意旧迭代器在修改后都能继续使用”。修改期间继续遍历,应遵守各迭代器契约,而非依赖这个刷新分支。

可迁移模式是先找结构不变量,再检查每个写入口如何维持它。Multimap 的“不保存空组”和“size 等于全部条目数”需要 get、asMap、values、entries、keySet 多条路径共同维护。手写 Map of collections 若只封装 put,却把内部 List 暴露出去,就很难保证全局计数与空组清理一致。

asMap 不是普通的全功能 Map

asMap 返回 Map<K, Collection> 视图。已有键的集合允许添加、删除,从这个集合写入会更新 Multimap。但是 asMap.put 不受支持;本实验对 asMap().put(other, list) 断言 UnsupportedOperationException。返回类型包含 put 方法不代表该操作属于这个视图支持的子集。

删除操作又不同。asMap().remove(shop) 删除整组关联,并返回被删除值的集合。之后重新加入 shop→C,先前返回的 removed 仍保持 A、B,说明它是删除结果,不会继续作为 shop 的活视图读取新值。应用若要审计删除内容,可以保留这个返回值;若要观察商户后续变化,应保留或重新取得 get 视图。

其他集合视图也有对应写入传播。values.remove© 删除一个匹配值对应的条目;当这恰是最后一个条目,键也消失。entries 的迭代器 remove 删除当前键值对,keySet.remove 删除该键的整组值。测试将这些动作依次落在只含一个键的结构上,分别断言结果为空,避免仅检查某个局部列表而漏掉全局状态。

这不意味着所有写操作都可通过所有视图执行。例如 values 不包含插入时所需的键,无法凭空确定新增值应属于哪个分组。审查一个视图时,应分别列出查询、添加、删除和替换行为,而不是笼统写成“可修改”或“不可修改”。这些细分契约比实现类是否以 Map 结尾更有用。

与 Map of collections 的选择

Java 8 的 computeIfAbsent 可以方便地构建 Map<K, List>,一次性导入后立即遍历输出时往往已经够用。Map.computeIfAbsent 文档也给出了多值映射的用法。若工程不需要共享视图、总条目数和一致的删除语义,引入额外抽象未必减少代码。

当多个调用入口都要修改分组,Multimap 可以把空组清理和条目计数集中在容器实现中。但它同时确定了“不保留空组”的模型,接口使用者必须接受。数据交换时把 Multimap 转成普通 Map 也要说明是视图还是复制,否则接收方继续写入会影响原数据。

并发需求另行处理。ArrayListMultimap 与 HashMultimap 不是据此自动线程安全;测试中的写入均为单线程,不提供并发更新保证。即使使用同步包装,遍历与复合业务动作仍需依据包装文档安排同步。业务若要求“读取某商户全部商品并原子替换”,应定义一个操作边界,不能从单个集合方法推出整个流程原子。

删除一项与删除整组的区别

允许重复后,“删除商品”必须明确作用范围。ArrayListMultimap 的 remove(key, value) 删除一个键值对出现;removeAll(key) 删除这个键的全部关联。如果导入事件里同一商品出现三次,删除一次不会自动去掉另外两次。HashMultimap 同键下没有重复值,因此两种实现对一次 remove 的可见结果可能不同,迁移时需要保留这个断言。

替换某键的所有值还涉及输入集合是否与现有视图共享。直接把查询得到的集合当作独立工作副本,在原集合清空后再遍历它,会读不到原值。这类问题不依赖 Multimap 特有实现:任何“读视图、清空源、再消费视图”的程序都可能破坏自己的输入。需要在修改之前保留旧值时,应在明确位置创建副本,并把复制时刻写入流程。

全局 values 视图还可能包含属于不同商户的同名商品。调用 values.remove 只能表达删除某个匹配出现,不能携带商户限制;需要指定商户时,使用 remove(key, value) 或该键下的 get 视图。若逻辑要批量删除所有匹配关联,应使用支持删除的迭代器逐项处理,并在测试里覆盖跨键重复,避免在只有一个商户的数据上误判为正确。

这些选择应反映在应用接口的名字和参数中,例如删除一次导入事件、删除某商户全部商品、删除跨商户同编号关联,是三种业务动作。把它们统一封装成只有一个 value 参数的 removeProduct,会隐藏必要条件。类库可以保持结构一致,无法替含糊的应用接口确定删除意图。

审计输出还应明确记录删除前的键和值,而不是稍后再读取活视图。活视图反映当前状态,后续重新插入可能改变审计内容;删除结果或显式复制更适合保存当次操作的输入。

实验与改动题

Chapter10Test 四个方法在 JDK 8、21 各通过 4 项,失败、错误、跳过为 0。它们覆盖 list/set 重复语义、size、null、缺失键、get 与 asMap 回写、最后值删除、旧根视图重新读取、新插入被 asMap 拒绝、删除结果独立以及 values/entries/keySet 删除传播。原始日志与 Surefire XML 位于 evidence/10/。

反例题:Map 中存有两家商户,其中一家商品列表为空,逐项 putAll 到 ArrayListMultimap 后 keySet.size 是否仍是 2?不一定。空分组不产生条目,因此这一家不会出现在 Multimap 的键集合。若空组用于表达完成状态,这种转换丢失业务信息。

改动练习:把“删除所有商户中的某商品”实现为迭代 entries 删除匹配条目。测试至少应有两个键、重复商品及一个不匹配商品,核验总条目数、最后值被删后的键消失,以及未匹配值仍保留。不能把 values.remove 一次调用误认为删除所有匹配项。

需求信号 可迁移判断 具体选择
重复代表不同事件 明确关系的重复语义 ListMultimap
同一关联只出现一次 明确关系的重复语义 SetMultimap
查询返回集合 审查回写权限 视图、只读包装或快照
保留已同步空组 先确认结构不变量 显式状态或普通 Map
多入口共同修改 检查各入口维护同一状态 Multimap 契约与传播测试