我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Groovy List<Map>多属性分组:复合键与避坑指南

Groovy List<Map>多属性分组:复合键与避坑指南 1. 先说清楚这个需求到底卡在哪儿groovy对listMap按多个属性进行分组这个标题我第一眼看到的时候心里就有数了——会搜这句话的人大概率不是刚学 Groovy 语法的新手而是已经在业务代码里写了一版groupBy然后发现结果不对、或者键取不出来、或者下游序列化炸了才回头找标准答案的。单属性分组谁都会写list.groupBy { it.region }一行就完事真正让人挠头的是多个属性这四个字背后的三个问题复合键怎么表达、分组结果怎么取、边界数据怎么不翻车。这个场景在数据整理类的工作里出现频率极高。报表按区域 品类汇总、订单按渠道 支付方式对账、日志按服务名 状态码聚类、库存按仓库 SKU归并全都是ListMap按多字段分组的变体。Groovy 因为动态类型和闭包语法写起来比 Java 短得多但短不等于稳恰恰是因为太灵活好几种写法看起来都能跑跑出来的结果却差别很大。这篇内容适合三类人看写过 Groovy 脚本但没深究过groupBy返回值的正在用 Groovy 做数据处理、需要把结果交给前端或导出的以及从 Java StreamCollectors.groupingBy迁过来、写法习惯还没切换的。我会把复合键的原理、四种主流写法的取舍、空值和可变对象两个大坑、以及百万行量级的性能取舍一次讲透代码都可以直接拿去改字段名就用。先把结论摆前面用groupBy { [it.a, it.b] }返回 List 当复合键是最通用、最不容易出错的做法但前提是你得知道 List 当 key 时hashCode/equals是怎么算的以及为什么不能图省事用字符串拼接。后面的内容基本都在解释这句话为什么成立。2. 复合键的本质Map 到底拿什么在找值2.1 闭包返回值直接决定分组粒度groupBy的方法签名在 Groovy 里有好几个重载最常用的是接收一个Closure的版本它的行为可以一句话概括遍历集合对每个元素执行闭包把闭包返回值当作 Map 的 key把元素塞进这个 key 对应的 List 里。返回类型是MapK, ListT底层实现是LinkedHashMap所以分组结果的顺序等于各分组首次出现的顺序这一点在报表场景里非常关键省去了额外的排序步骤。理解了这个机制按多个属性分组就不是一个特殊功能而是让闭包返回一个能同时表达多个属性的对象。这个对象可以是 List、可以是 Map、可以是拼接出来的 String甚至可以是一个自定义的Record类。选哪种取决于你后续怎么用这个 key。很多人第一次写会写成这样// 错误示范这样只按最后一个表达式分组 def grouped rows.groupBy { it.region; it.category }分号在 Groovy 闭包里只是语句分隔符闭包的返回值是最后一个表达式的值所以这里实际等价于groupBy { it.category }region被完全丢弃了。这个错误极隐蔽因为不报错、结果条数也对只是分组粒度变粗了往往要等业务方反馈华东和华南的数据混在一起了才被发现。2.2 List 作为 key 时 hashCode 是怎么算的这是整篇文章最需要吃透的一段。Java 的List.hashCode()有明确契约对于[a, b, c]其哈希值等于31*(31*(31*1 a.hashCode()) b.hashCode()) c.hashCode()也就是逐个元素累积计算equals则是逐个元素比较。Groovy 里[it.region, it.category]生成的是ArrayList完全遵守这个契约。由此推出三条实用结论第一元素顺序参与计算。[华东, 数码]和[数码, 华东]是两个完全不同的 key哈希值不同、equals 也不同。所以同一份数据如果在不同代码位置用了不同的字段顺序就会分成两组而且不会报错。我的做法是抽一个常量闭包全项目共用final Closure KEY_OF { Map row - [row.region, row.category] } def g1 rows.groupBy(KEY_OF) def g2 anotherRows.groupBy(KEY_OF)第二元素本身必须正确实现 hashCode。String、Integer、Long、BigDecimal注意1.0和1.00在 BigDecimal 里 equals 为 false、枚举都没问题。但Date是可变对象hashCode基于内部的毫秒时间戳一旦 key 建好之后这个 Date 被修改整个 key 的哈希值就变了Map 里再也找不到它——这就是经典的可变键失联问题后面第 5 章会专门展开。第三null 元素是合法的。[null, 数码]能正常作为 keyArrayList.hashCode会跳过 null 元素的哈希计算。这意味着你可以放心地让字段为空不需要提前做非空判断但也意味着空和没有这个字段会归到一起是否符合业务语义需要你自己确认。2.3 为什么不建议用字符串拼接当复合键我见过太多代码写成groupBy { it.region - it.category }理由是key 是字符串打印和序列化都方便。这个写法能跑但有三个致命缺陷我在真实项目里全都踩过。缺陷一分隔符冲突。区域叫华东-南京品类叫数码拼出来是华东-南京-数码另一个区域叫华东品类叫南京-数码拼出来一模一样两条本该分开的数据被强行合并。你的分隔符只要可能在业务数据里出现就一定会出现。用\u0001这种不可见字符能缓解但没人能保证外部导入的 CSV 里没有控制字符。缺陷二null 被转成字符串。null - 数码结果是null-数码和一个真实的字符串null完全无法区分。业务里真有人把区域字段填成字符串null从 Excel 导出来的脏数据这时候你的分组结果就会莫名其妙多出几条。缺陷三类型信息丢失。1和1拼出来都是1一个 Integer 类型的门店编号和一个 String 类型的门店编号会被合并。Groovy 是弱类型语言从 JSON 或 Excel 读进来的 Map同一个字段在不同行可能是不同类型这个坑非常常见。如果你的下游确实需要一个字符串 key比如要写入缓存、当作 Redis 的 field、或者拼进 SQL 的 group by正确的做法是先用 List 分组最后一步再转字符串而不是一开始就用字符串当 keydef byPair rows.groupBy { [it.region, it.category] } def strKeyed byPair.collectEntries { k, v - [k.collect { it null ? \\N : it.toString() }.join(\\u0001), v] }用一个业务数据里绝对不会出现的占位符表示 null用控制字符当分隔符这样才叫可控。3. 四种复合键写法横向对比与选型3.1 List 键、Map 键、嵌套 Map、字符串键除了 List还有另外三条路可以走各有适用面。我按实际使用频率排了个序做成表格方便对照写法复合键类型取值方式可读性保序主要风险推荐场景groupBy { [it.a, it.b] }ArrayListkey[0]、key[1]中是下标与字段顺序靠人记通用首选改动最少groupBy { [a: it.a, b: it.b] }LinkedHashMapkey.a、key[b]高是key 体积略大key 字段多、要自解释groupBy({ it.a }, { it.b })嵌套Mapm[k1][k2]中是展开成平铺结构麻烦多级报表、层级展示groupBy { it.a | it.b }String直接当字符串高是分隔符冲突、null 混淆、类型丢失需要序列化 key 的出口层Map 当 key 这个写法值得单独说两句。很多人不知道 Groovy 里 Map 是可以直接做 HashMap 的 key 的LinkedHashMap.equals比较的是键值对集合与插入顺序无关hashCode是各 entry 哈希的求和同样与顺序无关。所以[a:1, b:2]和[b:2, a:1]是同一个 key这比 List 更安全——List 顺序错了就分成两组Map 顺序错了依然合并。代价是每次分组要多创建一个 Map 对象内存开销约为 List 的 2 到 3 倍。我个人的取舍标准是字段数 ≤ 4 用 List字段数多或者 key 需要跨函数传递用 Map。因为 key 一旦跨越函数边界key[0]这种下标访问就变成了隐式契约接手的人必须去翻上一步的代码才知道 0 是哪个字段而key.region是自解释的。3.2 多闭包版本Groovy 2.4 之后才有的嵌套分组groupBy从 Groovy 2.4 开始支持传入多个闭包效果是逐层嵌套分组def nested rows.groupBy({ it.region }, { it.category }) // 结果是 Mapregion, Mapcategory, ListMap nested[华东][数码] // 直接二层定位这个写法在区域 → 品类 → 明细的三级钻取报表里非常顺手一层一层往下取就行。但它有两个限制要知道一是版本依赖。多闭包重载在 2.4 之前不存在如果你维护的是老项目有些企业系统还锁在 Groovy 2.1 或 2.2这行代码会静默地只按第一个闭包分组第二个闭包被当成可变参数忽略了结果就是分组粒度不对但完全不报错。我建议在代码里加一行防御性注释或者干脆用 List 键 手工拆解跨版本更稳。二是展平成本。嵌套 Map 要变回平铺的ListMap需要双层collectMany写起来比 List 键麻烦def flat nested.collectMany { region, subMap - subMap.collect { category, items - [region: region, category: category, items: items] } }如果下游是前端表格或者 JSON 接口平铺结构几乎总是更好的选择那还不如一开始就用 List 键。3.3 选型的三个判断问题不要纠结哪种写法更高级按这三个问题走三十秒就能定下来。问题一key 会不会被序列化出去如果分组结果要转 JSON 给前端ArrayList会变成 JSON 数组[华东,数码]前端得按下标取很别扭。这种情况在出口层做一次collect把 key 拆成具名字段不要直接把带 List key 的 Map 丢给序列化器。Groovy 的JsonOutput.toJson对 List key 的处理是把 key 转成字符串[\华东\,\数码\]前端解析又是一轮折磨。问题二分组之后还要不要按 key 排序要排序就得让 key 可比较。List 之间可以比较字典序但 null 元素会让比较抛异常Map 之间完全没有自然顺序排序必须自己写比较器。所以如果排序是刚需List 键或者List 键 规范化 null是最省事的。问题三分组数量级多大如果分组维度是用户 ID 日期可能产生几十万个 key那 key 对象本身的内存开销就不能忽视了。这时候字符串键反而更省一个 String 比一个 ArrayList Object[] 小但必须接受它丢失类型信息的代价。百万级以上的数据我更倾向于用long编码或者干脆不分组、直接流式聚合。4. 完整实操从原始数据到可用结果4.1 造一份带脏数据的样例直接上代码。为了把各种边界情况都覆盖到我故意在数据里塞了 null、重复组合、字符串数字混用的情况def rows [ [region: 华东, category: 数码, channel: 线上, amount: 1200.0, qty: 3, day: 2024-03-01], [region: 华东, category: 数码, channel: 线下, amount: 800.0, qty: 2, day: 2024-03-02], [region: 华东, category: 家电, channel: 线上, amount: 2400.0, qty: 1, day: 2024-03-03], [region: 华南, category: 数码, channel: 线上, amount: 500.0, qty: 5, day: 2024-03-04], [region: 华南, category: 数码, channel: 线上, amount: 700.0, qty: 2, day: 2024-03-05], [region: 华南, category: 家电, channel: 线下, amount: 3000.0, qty: 1, day: 2024-03-06], [region: null, category: 数码, channel: 线上, amount: 150.0, qty: 1, day: 2024-03-07], [region: 华北, category: 家电, channel: 线上, amount: null, qty: 2, day: 2024-03-08], ]注意最后两行一行 region 是 null一行 amount 是 null。真实数据里这两种情况出现的概率加起来超过 30%如果你写的分组代码在这两行上跑不通上线之后一定会在半夜被叫起来。4.2 基础分组两个字段一起当 keydef grouped rows.groupBy { [it.region, it.category] } grouped.each { key, items - println ${key[0]} / ${key[1]} - ${items.size()} 条, 金额 ${items.sum { it.amount ?: 0 }} }输出应该是华东 / 数码 - 2 条, 金额 2000.0 华东 / 家电 - 1 条, 金额 2400.0 华南 / 数码 - 2 条, 金额 1200.0 华南 / 家电 - 1 条, 金额 3000.0 null / 数码 - 1 条, 金额 150.0 华北 / 家电 - 1 条, 金额 0这里有两个细节要专门提醒。items.sum { it.amount ?: 0 }里的?:不能省因为sum闭包返回 null 时会抛 NPE而items.sum()在集合为空时返回null而不是 0这是 Groovysum()一个反直觉的设计空集合求和的正确写法是items.sum(0)或者items.sum(0G)传入初始值。金额字段我习惯用BigDecimal从数据库取出来如果是Double求和会累积浮点误差几十万行累加能差出几分钱财务对账时这一分钱能扯半天。4.3 分组结果的三种下游形态分组本身很少是终点绝大多数时候中间还要加工。我整理了三种最常见的下游形态代码可以直接抄。形态一每组计数 求和输出平铺表格。这是最简单的聚合collect配合sum就够了def summary grouped.collect { key, items - [ region : key[0], category: key[1], orders : items.size(), amount : items.sum(0G) { (it.amount ?: 0) as BigDecimal }, qty : items.sum(0) { (it.qty ?: 0) as Integer } ] }.sort { a, b - (a.region ?: ) (b.region ?: ) ?: a.category b.category }注意sum(0G)的写法初始值必须是BigDecimal类型否则第一次累加会走BigDecimal Double的隐式转换路径遇到 null 还是可能出问题。as BigDecimal的显式转换是双保险因为从 Excel 读进来的数字有可能是Double也有可能是Integer不统一类型后面求平均会算错。形态二每组取最新的一条或最大的一条。去重场景里极高频用max配合比较闭包def latestPerGroup rows.groupBy { [it.region, it.category] } .collectEntries { key, items - [key, items.max { it.day }] }max传闭包时比较的是闭包返回值day是 ISO 格式的yyyy-MM-dd字符串字典序恰好等于时间序所以能直接用。如果是MM/dd/yyyy这种格式就废了必须转成LocalDate或者时间戳再比。这个坑我踩过一次二月份的数据被排到了十二月份后面原因是02/01/2024 12/01/2023按字符比较成立。形态三分组后组内再排序、再截断。比如每个品类取金额 Top 3def top3 grouped.collectEntries { key, items - [key, items.sort { -((it.amount ?: 0) as BigDecimal) }.take(3)] }sort会修改原 ListGroovy 的sort()是原地排序如果你还要保留原始分组结果用toSorted或者先items.clone()。这个可变性问题是 Groovy 集合 API 里最容易踩的一个文档里写了但很容易忘。4.4 把结果还原成ListMap很多人的下游是JsonOutput.toJson或者写 CSV需要干净的ListMapkey 里不能残留 List 或者 null。这一步我封装成了一个通用的摊平函数参数化字段名ListMap flattenGrouped(Map grouped, ListString keyNames) { grouped.collectMany { key, items - def base [:] keyNames.eachWithIndex { name, i - base[name] key[i] } items.collect { item - base item } } } def flat flattenGrouped(grouped, [region, category]) // 每条记录都会带上 region 和 category 两个字段如果 key 本身就是 Map第 3.1 节的第二种写法这个函数可以简化成items.collect { key it }一行搞定。这也是我越来越偏爱 Map 键的原因之一——出口层的代码少一半可读性还更好代价只是分组时多一点点对象开销。5. 边界场景那些不报错但结果错的坑5.1 空值和 null key 的三种处理策略null 作为 key 是合法的但业务上未知区域和区域为空往往是两回事得区分对待。我总结了三种策略按需选策略一原样保留出口层再处理。分组逻辑完全不动null等到生成报表时统一替换为未知之类的展示文案。好处是数据加工层保持纯净坏处是下游每个消费方都要记得处理 null容易漏。策略二分组前规范化用哨兵值替换。在 key 提取闭包里做兜底def keyOf { Map r - [r.region ?: \\N, r.category ?: \\N] }\\N是数据仓库里约定俗成的 null 表示法选它是因为业务数据里几乎不可能出现反斜杠加 N 的字符串。用-或者未知都有被真实数据撞上的风险。策略三过滤掉不完整的数据。如果 region 为空的行本来就是脏数据不该进报表那就先findAll再分组比事后处理干净def clean rows.findAll { it.region ! null it.category ! null } def grouped clean.groupBy { [it.region, it.category] }提示不要在 key 提取闭包里用it.region.trim()这种链式调用一旦 region 是 null 直接 NPE整个分组崩掉。要么用安全导航it.region?.trim()要么在分组前统一清洗。还有一类容易被忽视的情况空字符串和 null 混在一起。从 Excel 或者 CSV 读进来的数据空单元格可能是null也可能是还可能是一个空格 。这三种在 Map 里是三个不同的 key分组结果会莫名其妙多出好几组看起来一样的数据。我的习惯是在数据入口处统一 normalize 一遍trim()之后空串一律转 null把清洗和分组彻底分开。5.2 GString 当 key 的经典失联这是 Groovy 专属的一个坑Java 用户不会遇到所以特别值得说。Groovy 里的双引号字符串如果是插值生成的类型是GStringImpl而不是java.lang.String。虽然GStringImpl的hashCode()被重写为toString().hashCode()和 String 一致但它的equals()只认 GString不认 String。直接后果是def prefix R def key1 ${prefix}01 // GStringImpl def key2 R01 // String println(key1 key2) // trueGroovy 的 做了类型转换 println(key1.equals(key2)) // falseequals 没做转换 def map [(key1): ok] println(map[key2]) // null取不到返回 true 会让人误以为两者等价但 HashMap 查找走的是hashCodeequalsequals返回 false 就查不到。如果你的分组 key 是从某处配置读来的 GString 拼出来的后面又用普通字符串去get就会拿到 null而且完全不报错。解决办法只有一个GString 在进入 Map 之前必须.toString()。如果你确实要用字符串拼接当 key前面说过不太推荐但如果非要用至少包一层def keyOf { Map r - ${r.region}-${r.category}.toString() }List 键天然规避了这个问题因为 List 里放的是什么类型equals就按什么类型比不会出现看起来相等但 equals 为 false的情况——除非你往 List 里塞了 GString。5.3 可变对象当 keyDate 是重灾区Date对象做 key 之后被修改Map 就再也找不到这个 key 了。原理很简单Date.hashCode()基于内部的fastTime字段key 建好之后调用setTime哈希值变了原来存放的桶位置就找不到了get会去新的桶里找自然找不到。def d new Date() def map [([d, x]): v] d.time d.time 86400000L // 改了一天 println(map[([d, x])]) // null这个场景在按日期分组里极容易出现尤其是有人为了省事直接复用同一个Date实例做循环变量。安全的写法是把时间转成不可变的值// 方案一转时间戳long不可变 def keyOf { Map r - [r.day.time, r.category] } // 方案二转字符串需要保证格式统一 def keyOf { Map r - [r.day.format(yyyy-MM-dd), r.category] } // 方案三转 LocalDateJava 8 的不可变时间类 def keyOf { Map r - [r.day.toLocalDate(), r.category] }我推荐方案三LocalDate和LocalDateTime都是不可变且正确实现了hashCode/equals的放进 List 当 key 完全安全。唯一的注意点是如果用的是java.sql.TimestamptoLocalDate()的行为和java.util.Date一致但Timestamp.equals(Date)会返回 false混用两种类型做 key 会分成两组务必在数据入口统一类型。另外顺带提一个隐性的可变键自定义的 POJO 或者Expando。如果拿一个实体对象当 key 的一部分而这个对象的某个字段后面被赋值了同样会失联。规则很简单当 key 用的对象要么不可变要么建完 key 之后永不修改。做不到就用它的业务主键ID、code替身。6. 数据量上去了怎么办性能与内存的取舍6.1 十万行以内groupBy不用想太多groupBy的复杂度是 O(n)每个元素执行一次闭包、算一次哈希、往 List 里 add 一次十万行数据在普通笔记本上大概几十毫秒完全没有优化的必要。这个量级下你应该优先保证代码可读性别为了省几毫秒把逻辑写复杂后面维护的人会骂人。内存方面临时对象的量级大概是每组一个 ArrayList 每行一个 List 键 每行一个 Object[] 数组。十万行、一千个分组的话键对象大约占几 MBGC 轻松处理。6.2 百万行以上的三个优化方向真到了百万行级别groupBy的瓶颈不在计算而在内存因为它是全量驻留——所有原始 Map 和分组结果同时在堆里。这时候有三个方向可以走。方向一把 key 压缩成基本类型。如果分组字段是有限的枚举值用一个int编码复合键能省掉绝大部分对象开销def regionCode [华东: 1, 华南: 2, 华北: 3, null: 9] def catCode [数码: 1, 家电: 2] def grouped rows.groupBy { r - (regionCode[r.region] ?: 9) * 100 (catCode[r.category] ?: 9) } // 反解: int k - [k.intdiv(100), k % 100]一个 Integer 的 key 比一个 ArrayList 通常省 40 字节左右百万行就是几十 MB 的差别。代价是需要维护映射表且映射表必须覆盖所有可能值否则不同的未知值会撞到同一个编码上。方向二分段处理边读边合并。如果数据源是文件或者数据库游标不要一次性 load 到内存按 5000 行一批读每批groupBy之后合并到总结果里。合并的逻辑是key相同就addAlldef total new LinkedHashMap() reader.eachBatch(5000) { ListMap batch - batch.groupBy { [it.region, it.category] }.each { key, items - total.computeIfAbsent(key) { [] }.addAll(items) } }内存占用从全量降到全量结果 一批原始数据峰值能砍掉一半以上。方向三只要聚合值不要明细。如果下游只是要每组的金额合计那根本不需要保留明细 List直接在遍历时累加def acc new LinkedHashMap() rows.each { r - def k [r.region, r.category] def slot acc.computeIfAbsent(k) { [cnt: 0, amt: 0G] } slot.cnt 1 slot.amt (r.amount ?: 0) as BigDecimal }这是最省内存的写法因为每个分组只存两个数字明细行用完即弃。代价是灵活性差加了新的统计维度就要改代码。6.3 一个容易被忽视的性能细节groupBy的 key 闭包会被调用 n 次如果闭包里做了字符串拼接、日期格式化、类型转换这些重操作开销会线性放大。我见过有人在 key 闭包里调format()格式化日期百万行数据下格式化占了总耗时的 70%。解决办法是在分组前预处理一遍数据把格式化结果写进 Map 的一个临时字段分组时直接取字段。rows.each { r - r._dayKey r.day.format(yyyy-MM-dd) } def grouped rows.groupBy { [it._dayKey, it.category] }临时字段可以用下划线开头标注内部使用避免和业务字段混淆。如果用的是不可变的 Map比如Collections.unmodifiableMap那就得先转成可变的副本再处理。7. 问题速查表与个人避坑清单7.1 常见症状对照表症状大概率原因快速定位方法分组数量比预期少闭包用了分号分隔、只返回最后一个表达式打印闭包返回值类型和值分组数量比预期多null 与空串混用、类型不一致1 vs 1grouped.keySet().collect { it.collect{ x - x?.getClass()?.simpleName } }.unique()用等值 key 取不到值GString 与 String 混用、Date 被修改打印key.getClass()和key.hashCode()分组顺序乱了中途经过HashMap、groupBy结果被重建检查是否有new HashMap(...)或.sort()之外的重排求和结果差几分钱Double 累加浮点误差改用 BigDecimal检查初始值类型空集合求和报 NPEsum()对空 List 返回 null改成sum(0)或sum(0G)排序时抛 NPEkey 里含 null比较抛异常比较前用?: 兜底或自定义 nullsLast老项目里多闭包分组失效Groovy 版本低于 2.4GroovySystem.version打印确认这张表里我最有感触的是第一行。分号那个坑我在三个不同项目里见过每次都是新人写的而且每次都要花一两个小时才定位到——因为groupBy不报错结果看起来也像那么回事。7.2 我个人的五条硬规矩规矩一key 提取闭包一律抽成常量。不管是final Closure KEY_OF还是static方法全项目只允许有一个地方定义什么算同一个组。曾经有个模块三个文件里分别写了[region, category]、[category, region]、[region, category, channel]报表数据怎么也对不上最后发现是第三个多带了个维度。规矩二分组前先做一次数据体检。至少打三行日志总行数、关键字段为 null 的行数、key 的 distinct 数量。这三个数字能提前暴露 80% 的问题成本几乎为零。规矩三不要在分组结果上直接做序列化。出口层一定先flattenGrouped或者collect成干净的ListMap。List key 直接转 JSON 的格式是[\华东\,\数码\]这种字符串前端解析成本高而且一旦 key 里有特殊字符JSON 里会出现转义地狱。规矩四涉及钱一律BigDecimal。Double的二进制表示无法精确表示 0.1累加误差在一万行以内可能看不出来上到十万行就会出现分位差异。sum(0G)的G后缀是 Groovy 的 BigDecimal 字面量标记别写成sum(0.0)。规矩五给分组逻辑写断言。Groovy 的assert在脚本里很好用跑完分组加两行验证比事后 debug 便宜一百倍assert grouped.values().sum(0) { it.size() } rows.size() // 总数守恒 assert grouped.every { k, v - v.every { it.region k[0] } } // 组内一致第二条断言特别有用它能一次性验证闭包返回的 key 和组内元素真的对得上如果你不小心写成了groupBy { it.region }但后面按key[0]取值这条断言会立刻炸出来。7.3 一段可以拿去当模板的完整代码最后给一份我平时用的模板把前面说到的兜底、排序、平铺都串起来了改字段名就能用import groovy.transform.Field Field final ListString KEY_FIELDS [region, category] def groupAndSummarize(ListMap rows) { // 1. 分组null 统一转哨兵值避免空串和 null 混淆 def normalized rows.collect { r - r.collectEntries { k, v - [k, (v instanceof String v.trim().isEmpty()) ? null : v] } } def grouped normalized.groupBy { r - KEY_FIELDS.collect { f - r[f] ?: \\N } } // 2. 校验总数守恒 assert grouped.values().sum(0) { it.size() } rows.size() // 3. 聚合 排序null 参与排序时兜底为空串 grouped.collect { key, items - def base [:] KEY_FIELDS.eachWithIndex { f, i - base[f] key[i] \\N ? null : key[i] } base [ orders: items.size(), amount: items.sum(0G) { (it.amount ?: 0) as BigDecimal }, qty : items.sum(0) { (it.qty ?: 0) as Integer } ] }.sort { a, b - KEY_FIELDS.collect { f - (a[f] ?: ) (b[f] ?: ) } .find { it ! 0 } ?: 0 } }sort那段的多字段比较写法值得解释一下collect出每一维的比较结果find { it ! 0 }取第一个非零值就实现了按第一维排相同再按第二维排的字典序。比写一串?:连接更清晰维度增加到五六个也不会变成天书。模板里那个assert我建议保留虽然生产环境可能关掉断言但在开发和测试阶段它能挡住绝大多数低级错误。真正上线前把它换成日志告警也行关键是总数守恒这个检查一定要有——分组丢数据的 bug 是最难排查的一类因为它不报错只是结果悄悄少了。最后分享一个我在实际使用中的体会groupBy这个 API 的坑几乎全部集中在key 是什么这一件事上把复合键的类型、顺序、null 语义三件事定死剩下的聚合和排序都是体力活。所以每次动手之前先花两分钟想清楚 key 长什么样比写完再 debug 划算得多。
返回列表