首页 / 资讯中心 / 文章详情

Groovy List<Map>多字段分组:复合Key、静默故障与工具封装

Groovy List<Map>多字段分组:复合Key、静默故障与工具封装 ★ FEATURED ARTICLE
Groovy 里对ListMap按多个属性分组看起来就是把groupBy的闭包从{ it.warehouse }改成{ [it.warehouse, it.channel] }这么简单三行代码就能跑出结果。但我经手过的几个报表类脚本里真正花时间的从来不是写这三行而是分组结果和预期对不上号的那几个小时明明同一个仓库同一个渠道硬是被拆成了两组或者分组之后想按 key 取某一组返回的永远是 null。这篇就把ListMap多字段分组这件事从头拆一遍——复合 key 是怎么成立的、几种写法各自适合什么场景、以及那些不报错但结果错的静默故障怎么定位适合已经会写 Groovy 但还没被它坑过的同学。1. 先把需求摆清楚List多字段分组到底难在哪1.1 一个出库明细看板的真实诉求假设手上有一批出库明细结构就是最普通的ListMap每条记录长这样单号、仓库、渠道、商品编码、数量、金额。业务方要的不是明细而是按仓库 渠道汇总的一张表每种组合一行后面跟总数量、总金额、单量。这种需求在报表、对账、看板类脚本里出现的频率极高本质上就是 SQL 里group by warehouse, channel的等价物。问题在于 Groovy 的groupBy在设计上只接收一个 key 闭包它天生是为单维度分组准备的。你没法像 SQL 那样在后面继续挂字段所以多字段分组的第一件事就是想办法把多个属性压缩成一个可以比较的 key。这个压缩动作决定了后面所有的坑。1.2 groupBy 只给了一个参数位置第二个维度往哪塞常见的压缩方式就那么几种我在不同项目里都见过闭包返回一个 List里面按顺序放各个属性值也就是[it.warehouse, it.channel]闭包返回拼接后的字符串it.warehouse | it.channel先按第一个属性groupBy再对每个分组的结果按第二个属性groupBy形成嵌套 Map定义一个专用的 key 类把属性塞进去靠类的equals/hashCode完成比较。这四种写法在简单的 demo 数据上结果完全一样但把数据换成线上真实数据、把运行环境换成沙箱脚本差异立刻就出来了。列表 key 最省事但怕类型不一致字符串 key 最直观但怕分隔符冲突和 GString嵌套分组结构清晰但下游遍历麻烦key 对象最稳但要多写一个类。选哪一种取决于这个分组结果是给人看的还是给程序继续处理的。1.3 三行代码能跑通但跑对是另一回事我印象最深的一次是一段跑了大半年的对账脚本突然开始出现重复行。同一张报表里同一个仓库同一个渠道出现了两遍一遍数量正常一遍数量少了一截。查了半天数据源没问题查了半天数据库没问题。最后发现是上游改了一处序列化逻辑有一批记录的qty字段从Integer变成了Long而分组 key 里带上了这个数量的序号字段——Integer的 5 和Long的 5作为 List 的元素比较时不相等于是同一组数据被劈成了两半。这类问题的共同特征是代码不报错结果只是稍微不对。它不像空指针那样一眼能看到堆栈也不像类型转换异常那样直接中断它就是安静地多出几行、少算几个数等你发现的时候已经发到业务方手里了。所以第 4 节我会专门花篇幅讲这类静默故障的排查顺序那个比记住 API 重要得多。2. 复合 key 的成立条件从 groupBy 源码行为反推2.1 groupBy 内部那几行循环做了什么想搞明白多字段分组的边界最省事的办法是知道groupBy到底干了什么。抛开各种重载它的核心逻辑极其朴素准备一个LinkedHashMap遍历集合里的每个元素用闭包算出 key去 Map 里找这个 key 对应的列表找不到就新建一个空列表塞进去然后把当前元素追加到列表末尾。遍历结束返回这个 Map。这个朴素实现带来三个直接影响值得单独记住返回类型是LinkedHashMap也就是说分组结果天然保持了每组第一个元素在原列表里出现的顺序。这对报表很重要很多时候你不需要额外排序行序就是数据进来的顺序。每个 value 是新建的ArrayList不是原列表的切片视图。所以你往某个组里add或remove不会影响源列表的结构但组里的元素和源列表里的元素是同一批对象引用改元素内部的字段源数据跟着变。key 的比较完全交给hashCode()和equals()不走 Groovy 的语义。这一点是后面所有坑的根源第 2.2 和 4.1 节会反复用到。另外两个边界行为也顺手记一下源集合是空列表时返回的是空 Map 而不是 null下游不用做非空判断源集合里有 null 元素时闭包里一取属性就 NPE得提前findAll过滤。2.2 List 能当 key靠的是内容级 equals/hashCode为什么[it.warehouse, it.channel]这种写法能正常工作因为 Groovy 里的列表字面量拿到的是ArrayList而ArrayList重写了equals和hashCode两个列表长度相同、对应位置元素相等就判定相等hashCode是各元素hashCode按固定公式累加出来的。这正好符合 Map 对 key 的要求。所以[ WH1, APP ]这两个内容一样的列表无论创建多少次、是不是同一个对象都能落到同一个 key 上。这也是列表 key 写法能用一行代码解决多字段分组的根本原因。但这里有个非常容易误判的地方ArrayList.equals内部比较元素时用的是元素的equals方法不是Groovy 的运算符语义。这两者在处理数字类型时行为完全不同。你可以自己在 Groovy 控制台里跑一下println(5 5L) // trueGroovy 的 对 Number 会做数值比较 println([5] [5L]) // false走的是 ArrayList.equals - Integer.equals(Long)单值比较说相等装进列表就说不相等。而 Map 找 key 用的恰恰是后者。这就是为什么数量字段参与分组这种设计在数据源类型不稳定的时候特别危险——它在肉眼和数据源里看都是同一个值只有equals认为不是。2.3 key 一旦可变分组结果就会失联还有一个更隐蔽的问题如果作为 key 组成部分的对象在分组之后被修改了那么它在 Map 里的哈希位置就对不上了。举个例子假设分组 key 里放的是一个 Map 或一个自定义对象分组完成后你又去改了这个对象的某个字段hashCode随之改变之后再拿这个 key 去grouped[key]取值就会返回 null——因为 Map 是拿着新哈希值去另一个桶里找的。我在一个配置合并的脚本里踩过这个key 用的是配置对象本身分组完成后顺手给对象补了个默认值结果后面按 key 回查全部失败。修复方式也简单分组之前把 key 需要的字段拍平成不可变的值——字符串、数字、日期字符串这类不要直接把可变对象引用塞进 key。这条经验适用于所有语言里的哈希结构不只是 Groovy。3. 四种多字段分组写法我为什么默认选 List key3.1 写法一闭包返回 List这是最直接的一种也是我在绝大多数场景下的默认选择def rows [ [orderNo: A001, warehouse: WH1, channel: APP, qty: 3, amount: 39.0], [orderNo: A002, warehouse: WH1, channel: APP, qty: 2, amount: 26.0], [orderNo: A003, warehouse: WH1, channel: WEB, qty: 5, amount: 65.0], [orderNo: A004, warehouse: WH2, channel: APP, qty: 1, amount: 13.0] ] def grouped rows.groupBy { [it.warehouse, it.channel] } grouped.each { key, list - println ${key.join(/)} - ${list.size()} 条 } // WH1/APP - 2 条 // WH1/WEB - 1 条 // WH2/APP - 1 条好处是 key 保持了结构下游要拆字段直接按下标取或者key[0]、key[1]顺序稳定字段顺序就是你在闭包里写的顺序不引入任何字符串格式约定也就没有分隔符冲突的问题。缺点是 key 打印出来不好看得自己join一下以及刚才说的类型敏感问题。字段多的时候我一般会给 key 加个名字避免下游key[3]这种魔法下标def grouped rows.groupBy { [warehouse: it.warehouse, channel: it.channel] }用 Map 当 key 也是合法的LinkedHashMap的equals/hashCode同样是内容语义。代价是每次计算 key 都要新建一个 Map字段多、数据量大的时候开销比 List 明显一些而且拼错了字段名不会报错只会静默多出一组。所以我更倾向于小数据量、可读性优先时用 Map key大数据量、走批处理时用 List key。3.2 写法二拼字符串做 keydef grouped rows.groupBy { it.warehouse | it.channel }优点是 key 直接可读、可直接当报表行标题、可排序、可做日志。很多需要把分组结果直接输出成文本或者写进缓存的地方我都会选这一种。但有两个硬性前提。第一分隔符必须选一个业务数据里绝对不可能出现的字符|、\u0001、::都行但别用-或者_因为仓库编码和渠道编码里很可能就带横杠。第二务必用拼接或者显式toString()不要图省事写${it.warehouse}|${it.channel}。字符串插值拿到的是GString而不是String拿它当 Map 的 key 会出现看起来一模一样但取不到值的情况具体原因放在 4.2 节。3.3 写法三嵌套 groupBydef nested rows.groupBy { it.warehouse } .collectEntries { wh, list - [(wh): list.groupBy { it.channel }] } // {WH1{APP[...], WEB[...]}, WH2{APP[...]}}结构上最贴近多维的概念适合下游本来就是两级遍历的场景比如按仓库渲染一个折叠面板展开后是渠道明细。这时嵌套结构反而最省事不用在循环里反复判断仓库变了没有。代价也很明确想按仓库渠道整体遍历一遍得写双重循环想对最内层统一做排序或者聚合得先拍平想整体按组合排序更麻烦。所以我只在数据结构天然是树形的时候用它其他情况一律拍平。顺带提一个变体如果你需要的是三层及以上就别嵌套了直接在闭包里返回[it.a, it.b, it.c]三层嵌套 Map 的遍历成本已经超过收益了。3.4 写法四专用 key 对象import groovy.transform.EqualsAndHashCode import groovy.transform.Immutable Immutable class GroupKey { String warehouse String channel } def grouped rows.groupBy { new GroupKey(warehouse: it.warehouse, channel: it.channel) }Immutable会自动生成equals/hashCode同时把字段变成 final从根上杜绝了分组后 key 被改的问题。字段多到五六个、而且这个分组逻辑在多个脚本里复用的时候我会用这种方式好处是字段名有编译器检查、IDE 能跳转、改字段不会漏改下游。代价是每条数据都要 new 一个对象数据量十万级以上时这项开销不能忽略另外Immutable对构造参数的校验比较严格如果某个字段可能为 null得确认生成的构造器能接受 null必要时换成EqualsAndHashCode配普通字段。3.5 对比表格与选型结论写法key 形态可读性robust 程度主要风险适用场景List key[a, b]中中元素类型不一致导致分组失效通用默认批处理主力Map key[a: x, b: y]中中字段名拼错静默多组字段多、需自解释字符串 keya|b高中高分隔符冲突、GString 混入结果直接展示或做缓存键嵌套 groupByMapMap高高下游遍历成本高树形展示、两级折叠key 对象new Key()高最高每条数据一次对象分配逻辑复用、字段多、有测试我给自己定的默认策略是先看下游怎么用。如果分组结果只是内部中间态接着就要聚合用 List key最短路径如果要输出成报表行、要进缓存、要做日志用字符串 key但分隔符和toString()两个点必须落实如果这个分组逻辑要在三处以上复用直接上 key 对象多写一个类的成本远低于三处各修一遍 bug。嵌套分组只在数据结构真是树的时候用别为了看起来有条理去嵌套。4. 分不出组、多出组、取不到组三类静默故障的定位过程4.1 故障一数字类型不一致导致同一维度被拆开现象很典型报表行数比预期多但每行数据都合理总数加起来又是对的。这种数据没丢只是散了的情况八成是 key 的相等性判定出了问题。我的排查顺序是这样的。第一步把分组结果的 key 全打印出来按toString()排一遍序肉眼找看起来一样的两行grouped.keySet().sort { it.toString() }.each { k - println ${k} | ${k.collect { it?.getClass()?.simpleName }} }第二步看类型。上面这行会直接打出每个 key 元素的运行时类型。如果出现[5, WH1]和[5, WH1]两行但类型分别是[Integer, String]和[Long, String]答案就出来了。第三步验证假设。可以直接写println([5] [5L]) // false println([5].hashCode() [5L].hashCode()) // true哈希碰撞但 equals 不等哈希值一样、equals不一样意味着它们落在 Map 的同一个桶里但被判定为两个不同的 key。这就是为什么这类问题用toString()排序能看出来因为打印出来一样但用哈希或者长度去查完全查不出来。修复方式我一般选分组前统一类型而不是分组闭包里逐个转换因为前者一次到位后者容易漏字段def normalized rows.collect { r - r [qty: (r.qty ?: 0) as Long, amount: (r.amount ?: 0) as BigDecimal] }as BigDecimal这里还有个附加好处BigDecimal的equals是严格的1.0和1.00不相等但如果你在源头就用as BigDecimal统一了 scale 之外的表示后面就不用担心精度尾巴。反过来说如果金额字段确实可能带不同 scale那就不要在 key 里放金额——数量、金额这类值本身就不该当分组维度真需要按金额分档先分档再分组。4.2 故障二GString 混进来key 查不到这个故障的表现是分组本身没问题遍历也没问题但按 key 回查某一组时永远返回 null。def g rows.groupBy { ${it.warehouse}-${it.channel} } println(g[WH1-APP]) // null即使分组里确实有这个组原因在于双引号插值产生的是GString类型它的equals只认同为GString的对象跟内容相同的String不认。而g[WH1-APP]里传的是String字面量两者hashCode相同GStringImpl的哈希是按最终字符串算的、equals不同于是判定为不同的 key。这个问题最阴的地方在于它的触发条件很随机如果分组结果你只是each遍历打印永远发现不了只有当你需要按 key 定位某个分组或者把 key 传给别的组件做查找时才会暴露。而且在大数据量下偶发的分组异常会让人怀疑是并发问题。修复方式很简单两条任选用拼接返回的是String或者显式toString()。rows.groupBy { it.warehouse - it.channel } rows.groupBy { ${it.warehouse}-${it.channel}.toString() }顺带提一下同样的坑在Map字面量里也会出现[${a}: 1]这种写法key 同样是GString。所以我在所有需要把插值结果当 key 的地方都统一.toString()养成习惯之后就不用每次想类型了。4.3 故障三日期精度把一天切成了几十组按日期分组是另一个高频事故点。如果直接拿Date对象当 key 的一部分Date.equals比的是毫秒时间戳同一自然日里不同时刻的两条记录会被判成两组一天的数据能给你切出上千组。// 错误示范精度到毫秒几乎每组都只有一条 rows.groupBy { [it.createdAt, it.channel] } // 正确做法先格式化到需要的粒度 rows.groupBy { [it.createdAt.format(yyyy-MM-dd), it.channel] }java.util.Date的format在 Groovy 里可以直接用也可以用fmt加SimpleDateFormat。如果用的是LocalDateTime这类新 API就用toLocalDate()再toString()或者truncatedTo(ChronoUnit.DAYS)。关键是日期进 key 之前一定要先确定粒度并显式转换让同一组的定义变成代码里明确写出的一件事而不是依赖对象默认的相等性。排查这类问题的方法也很直接把 key 的第一段取出来统计一下组数def dayCount grouped.keySet().collect { it[0] }.unique().size()如果这个数字远大于业务上应有的天数基本就是精度问题不需要再看别的。4.4 一套可复用的排查顺序踩过几次之后我固化了一个顺序遇到分组结果不对就按这个走通常两三轮就能定位看组数和预期的维度组合数比。组数偏多基本是 key 相等性判定失效组数偏少是 key 取值本身有问题比如字段取错、取到了 null。打 key 的类型用 4.1 里那行collect { it?.getClass()?.simpleName }一眼能看出数字类型、GString、日期对象的问题。看 null 组如果结果里出现[null, APP]这样的组说明某条记录缺字段Map.get返回了 null 而不是抛异常很容易被忽略。验证单条挑两条应该同组却分开的记录手动算 key用和equals各比一次差异立刻显形。回归源头确认数据是哪里来的、经过了几次序列化。多数类型漂移都是序列化/反序列化边界引入的。5. 分组只是中间态聚合、排序与二次加工5.1 collectEntries 把分组变成报表行分组出来的MapList, ListMap几乎不会直接交给下游通常要拍平成每个组合一行的报表结构。collectEntries是干这个的标准工具def report rows.groupBy { [it.warehouse, it.channel] } .collectEntries { key, list - [(key.join(/)): [ orderCount: list.size(), totalQty : list.sum { it.qty } ?: 0, totalAmt : list.sum { it.amount } ?: 0 ]] }这里几个细节值得说。collectEntries的闭包必须返回一个 Map 或者键值对形式的 List返回别的会被静默忽略——我见过有人直接返回聚合后的 Map 本体结果外层 key 丢了所有数据挤在一个 key 下面。list.sum { it.qty }对空集合返回 null所以我会加?: 0兜底或者在sum之前过滤。如果需要在每组里再算派生指标比如客单价、数量占比我一般不在collectEntries里硬算而是先把基础聚合做出来再走一遍collectEntries补字段。两步写看起来啰嗦但调起来方便尤其占比这类需要全量总数的指标在第一次遍历时根本算不出来。5.2 组内排序和组间排序的两种语义Groovy 的sort(Closure)默认是原地修改的这点和很多人从函数式语言带过来的直觉不同。所以list.sort { it.amount }会改掉原来那个组内的顺序而由于组内的ArrayList是groupBy新建的改它不会影响源列表的结构——但如果你后面还要用这个组做别的计算就已经被改过了。想要一份排序后的副本而不动原列表用两参数版本def byAmountDesc list.sort(false) { -it.amount }组间排序则是对分组结果这个 Map 排序Map.sort返回的是新的LinkedHashMap不会改原 Mapdef sortedGroups grouped.sort { a, b - a.key[0] b.key[0] ?: a.key[1] b.key[1] }这里返回 0 时用 Elvis 挂到第二个字段上多个字段依次兜底写起来比写一串 if 舒服。注意a.key[0]如果是可能为 null 的字段对 null 是安全的null 被认为更小但如果字段类型混杂还是会出意料之外的顺序稳妥点是先统一转成字符串再比。5.3 sum、countBy 与空集合的返回值陷阱聚合里最容易踩的是空集合的返回值。sum()对空列表返回 null直接参与加法会得到 null 而不是数字sum(0)则返回初始值。如果聚合结果要写进报表我统一用sum(0)或sum(0L)的形式把类型也一并固定下来。countBy是groupBy的轻量版直接返回key 到数量的 Map只想知道每种组合有多少条的时候比groupBy再取size()省事def pairCount rows.countBy { [it.warehouse, it.channel] } // [[WH1, APP]: 2, [WH1, WEB]: 1, [WH2, APP]: 1]countBy的 key 判定规则和groupBy完全一样所以第 4 节讲的所有坑它一个不落。我在排查分组问题的时候会先跑一遍countBy因为它输出更短一眼能看出组数和分布。5.4 组内去重和取 TopN实际数据经常有重复行比如同一单号因为 join 放大出现了两条。组内去重直接用unique加闭包list.unique { it.orderNo }注意unique(Closure)也是原地修改而且它保留的是第一次出现的元素不保证顺序稳定在 Groovy 的实现里通常稳定但别依赖。需要严格稳定的时候我自己写inject维护一个LinkedHashSet。取每组金额前两名这类需求排序加take就行def top2 list.sort(false) { -it.amount }.take(2)take对不足长度的列表是安全的不会抛越界。要取除前两名之外用drop(2)两者配合能覆盖绝大多数 TopN 场景。6. 边界数据与运行环境那些让脚本在生产上翻车的细节6.1 null 元素、null 属性、空 List三种 null 要分清楚。列表里有 null 元素闭包一取属性就 NPE分组前必须findAll { it ! null }或者findAll()Groovy 的findAll()无参版本会自动过滤掉 Groovy truth 为假的值包括 null。Map 里缺某个字段it.warehouse返回 null不会抛异常这一条会成为一个独立的 null key 组。这个行为有时候是对的缺字段单独成组便于发现问题有时候是灾难一堆脏数据合成一组把报表拉歪取决于你的业务。源列表本身是 nullnull.groupBy会 NPE入口处加个?: []兜底最省事。还有个 Groovy truth 的坑要提一句空列表在布尔上下文里是 false。所以if (grouped[key]) { ... }这种写法在组存在但列表为空的时候会走进 else 分支。分组结果本身不会是空列表每组至少一个元素但如果你在collectEntries里做了二次过滤就可能产生空组这时候判断组是否存在要用grouped.containsKey(key)而不是直接取布尔值。6.2 沙箱/流水线环境里的序列化与闭包捕获在受限的脚本沙箱里跑 Groovy 时groupBy有一个额外的约束闭包捕获的外部变量会被一起带进执行上下文如果捕获的是不可序列化的对象某些场景下会直接报序列化失败。我在一个流水线脚本里遇到过闭包引用了外层的配置对象分组逻辑本身完全正确但执行到那一步就中断。规避方式很朴素闭包里只引用方法参数和基本类型/字符串需要用的配置值提前解构成局部变量传进去不要把整个大对象关在闭包里。另外沙箱里部分DefaultGroovyMethods的方法可能不在白名单内遇到方法找不到的报错先确认是不是被拦截了别急着怀疑自己的写法。这类环境问题排查成本高我的做法是在沙箱环境里先跑一个最小验证脚本把groupBy、collectEntries、sum这几个要用到的方法各跑一遍确认可用之后再写业务逻辑。6.3 数据量上去之后的内存与耗时groupBy是单次遍历时间复杂度 O(n)瓶颈不在算法而在内存分组结果会额外持有 n 个元素的引用元素本身不复制加上每个组的ArrayList开销。十万级数据没感觉百万级就要留意了。几个实测有效的手段key 尽量用字符串或小的 List别用 Map 或大对象key 的哈希计算发生在每次插入不需要保留明细的时候别groupBy之后再collectEntries { size() }直接countBy省掉所有中间列表需要分组内只保留少量字段时先collect裁剪字段再分组别把整条记录带进去组特别多几十万组每组一两条的场景groupBy的 Map 本身就很占内存这时候用inject手动累积同样的结构也没用得考虑分批处理或者换用流式方案。7. 收进工具方法一份可以直接抄走的分组封装7.1 封装思路与代码同一套分组逻辑在几个脚本里重复三遍之后我把它抽成了一个小工具目标很明确统一 key 生成方式、统一处理 null、统一把结果拍平成可直接用的报表行。class GroupUtil { /** * 按多个字段分组key 统一转成 String避免类型和 GString 问题 * param rows 源数据允许有 null 元素 * param fields 参与分组的字段名按顺序 * param sep key 分隔符必须确认业务数据中不出现 */ static MapString, ListMap groupByFields(ListMap rows, ListString fields, String sep \u0001) { if (!rows) return [:] def clean rows.findAll { it ! null } if (!clean) return [:] return clean.groupBy { Map row - fields.collect { String f - def v row[f] v null ? \u0000NULL : v.toString() }.join(sep) } } /** * 分组 数值字段求和直接产出报表行 */ static MapString, Map sumByFields(ListMap rows, ListString fields, MapString, String sumFields) { groupByFields(rows, fields).collectEntries { String key, ListMap list - def agg [count: list.size()] sumFields.each { outName, srcField - agg[outName] list.sum(0) { Map r - (r[srcField] ?: 0) as BigDecimal } } [(key): agg] } } }这里有几个刻意的设计决定。key 一律转成字符串牺牲了一点类型信息换来的是彻底避开Integer/Long、GString、日期精度这三类问题——因为toString()之后比较的是字符串内容只要格式化规则一致就一定相等。null 值用占位符代替因为join遇到 null 会拼出null字样如果业务数据里真有一个字段值就叫 null就会撞车用控制字符占位更安全。分隔符默认用\u0001业务数据里基本不可能出现调用方想换成可读的|也行但得自己保证不冲突。7.2 自测用例工具方法不写用例等于没写尤其是这种看起来简单、错起来没声的逻辑。我习惯带上这么几组def util GroupUtil // 1. 基础分组 assert util.groupByFields([ [w: WH1, c: APP, q: 1], [w: WH1, c: APP, q: 2], [w: WH1, c: WEB, q: 3] ], [w, c]).size() 2 // 2. 数字类型不一致不应拆组 assert util.groupByFields([ [w: WH1, q: 5], [w: WH1, q: 5L] ], [w, q]).size() 1 // 3. null 元素被过滤 assert util.groupByFields([null, [w: WH1]], [w]).size() 1 // 4. 缺字段单独成组 assert util.groupByFields([[w: WH1], [w: WH2]], [w, c]).size() 2 // 5. 空输入 assert util.groupByFields([], [w]) [:] // 6. 求和 def s util.sumByFields([[w: WH1, q: 1], [w: WH1, q: 2]], [w], [total: q]) assert s.values().first().total 3第 2 条和第 4 条是最有价值的它们把最容易出问题的两个边界固化成了断言。以后谁改了 key 的生成方式跑一遍就知道有没有破坏原有语义。我个人的经验是分组逻辑的单元测试不需要覆盖业务计算只要覆盖 key 的生成规则就够了因为 90% 的线上问题都出在 key 上而不是出在聚合公式上。7.3 我给自己定的几条使用约定用久了之后我在这件事上形成了几个不太会破的习惯。第一分组字段永远从白名单里选不在闭包里做计算。需要按月份分组就提前在数据里补一个month字段而不是在闭包里it.date.format(yyyy-MM)。后者每次都要重新格式化数据量大时耗时明显更重要的是它让分组维度藏在了代码深处别人接手时不容易发现。把维度显式化成字段代码自解释也方便加断言。第二分组结果一旦要跨方法传递立刻拍平成MapString, Map。带 List key 的 Map 在方法之间传来传去接收方永远要猜key[0]是什么改一次字段顺序就全线崩。拍平成字符串 key 加扁平 value是最省心的接口形态。第三别在分组闭包里做业务判断。见过有人在闭包里写it.type X ? it.a : it.b这种逻辑分组维度随数据变化排查时完全无从下手。要区分类型就把类型作为维度之一加进 key 里让它显式地分成不同的组而不是在 key 上做隐式变形。这三条说白了都是一件事让分组维度的定义在代码里是显式的、唯一的、可断言的。groupBy本身只有五行实现它的复杂度全在你喂给它的那个闭包上把闭包管住这个 API 就一直是好用的。
阅读完成 · 觉得有帮助?
咨询建站