系列:Java 后端实战精通营 · 作者:鱼宵
配套课程仓库:https://gitee.com/j67mk2/stream-journey (第 9 课)
先看一个每天都在发生的场景
产品扔给你一个 Excel 导出的 orders.csv,说"按分类出个销售报表,下班前要"。你打开一看,头都大了:第一行是表头不算数据;中间混着几行金额是 abc 的;有一行金额干脆是空的;还有几笔是退款和取消的。
如果你把所有逻辑塞进一个 main 方法里------读文件、切字符串、过滤、分组、排序、拼表格,五六百行搅在一起------那下一次需求变成"按客户分组"时,你得从头读到尾才敢改。更要命的是,脏数据混进去了报表数字就是错的,你连"到底滤掉了几行"都说不清。
这篇文章把前 8 课学的东西(读文件、filter、groupingBy、sorted、格式化)串成一条真实的报表管道:读、洗、聚、排、打,五个阶段五个方法,main 只负责串。代码全在 stream-journey 第 9 课工程里,clone 下来跑一遍,现象当场就出来。
学完本文你能回答 :给你一个 CSV 文件,怎么用 Stream 从原始数据一路打印出统计报表?数据文件放 src/main/resources 下运行时怎么读不报错?管道在设计模式里对应哪个模式?
一句话原理:报表就是一条"自来水管道"
先摆结论:数据管道就是把"读进来的原始数据"当水流过一串加工站,每一站只做一件事,最后流出成品报表。
类比:自来水厂。江水进来(取水口)→ 沉淀 → 过滤 → 消毒 → 加压 → 你家水龙头。每个车间只管自己一段,不串着干。报表管道也一样:load 取水、clean 沉淀过滤、aggregate 消毒、sort 加压、format 出水龙头。
为什么要拆?因为所有逻辑堆在 main 里几百行,改一个需求要通读全文件;拆成五段,想换"读数据库"只改 load(),想加"按地区分组"只动 aggregate()------每段可替换,这就是工程化。
顺带把本课要用的几个词一句话讲完:
- record(数据类) :Java 16+ 的简写,自动生成构造器、equals、toString。本课的
Order(一笔订单)和CategoryStat(一个分类的统计行)都是 record。 - DoubleSummaryStatistics(一把统计器) :
summarizingDouble一次吐出 个数/总和/平均/最高/最低,不用写五行收集器。 - resources 目录 :Maven 工程里
src/main/resources/下的文件,编译时被原封不动复制 到target/classes/,运行时按相对路径读,不依赖你机器上的绝对路径。
类比:resources 像快递单上的内置文件------不管快递箱(项目目录)被搬到哪座城市,这个文件都跟着箱子走,不用你填新地址。
五个阶段各管一件事:
| 阶段 | 方法 | 干什么 | 类比 |
|---|---|---|---|
| 1 读 | load() |
从 resources 读 CSV 全部行 | 取水口 |
| 2 洗 | clean() |
解析 + 过滤脏数据,打印清洗日志 | 沉淀 + 过滤 |
| 3 聚 | aggregate() |
按分类分组,一次凑齐五项统计 | 消毒 |
| 4 排 | sort() |
按总金额降序 | 加压 |
| 5 打 | format() |
拼成对齐的报表字符串 | 水龙头 |
设计模式视角:这就是责任链模式(Chain of Responsibility)------多个处理器排成链,数据依次穿过,每个处理器决定"加工它"还是"扔掉它"。本课 clean() 内部更是链中链:表头、空行、段数、金额、状态,任何一道不过就被踢出去。
错误写法 vs 正确写法 :读文件写死绝对路径(换台机器就找不到)vs 用 getResource("/orders.csv") 从 classpath 读;不洗数据直接分组(脏数字混进报表)vs 清洗完打印"跳过几行"的日志;一个 main 全揽(改需求动全身)vs 五段方法各管一段(main 只编排)。
完整代码:五段管道,main 只负责串
本课三个文件:Order.java、CategoryStat.java 是两个 record(数据结构),Main.java 是管道本体。先看两个小文件的核心片段,完整可运行主类在后面。
数据结构片段(完整版都在课程工程里):
java
// Order.java:一笔订单的"内部单据",从 CSV 解析出来后,后面所有阶段只认它
record Order(String orderId, String customer, String category, double amount, String status) {
}
// CategoryStat.java:一个分类的统计行
// 第二个构造器是"从 JDK 的统计结果顺手造一个本表行"
record CategoryStat(String category, long count, double total, double avg, double max, double min) {
CategoryStat(String category, DoubleSummaryStatistics s) {
this(category, s.getCount(), s.getSum(), s.getAverage(), s.getMax(), s.getMin());
}
}
主类完整可运行(JDK 21+,直接复制就能跑;记得先把 orders.csv 放到 src/main/resources/ 下):
java
package com.stream.lesson09;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.DoubleSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Stream 实战精通营 · 第 9 课:实战:报表数据管道
* 一条完整的报表管道,五个阶段、五个方法,各管一段:
* load() 阶段 1:从 src/main/resources 读 orders.csv 全部行(Files.lines)
* clean() 阶段 2:清洗------解析 + 过滤(坏数据/空金额/非 PAID),打印清洗日志
* aggregate() 阶段 3:按分类分组聚合(groupingBy + summarizingDouble)
* sort() 阶段 4:按总金额降序(sorted)
* format() 阶段 5:拼成一张对齐的报表字符串(String.format)
*/
public class Main {
public static void main(String[] args) throws Exception {
// 管道编排:五个阶段依次喂数据,main 只负责串起来
List<String> rawLines = load();
List<Order> validOrders = clean(rawLines);
List<CategoryStat> stats = aggregate(validOrders);
List<CategoryStat> sorted = sort(stats);
String report = format(sorted, rawLines.size(), validOrders.size());
System.out.println();
System.out.println(report);
}
/** 阶段 1:读数据文件。 */
static List<String> load() throws URISyntaxException, java.io.IOException {
var url = Main.class.getResource("/orders.csv"); // 从 classpath 根目录取资源
if (url == null) {
throw new IllegalStateException("找不到 orders.csv,请先 mvn compile 把 resources 复制到 target/classes");
}
Path path = Path.of(url.toURI());
System.out.println("========== 阶段 1:读取数据 ==========");
System.out.println("数据文件:" + path);
// Files.lines 按 UTF-8 逐行读成流;try-with-resources 保证流用完关文件
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
List<String> all = lines.toList(); // 先收成列表,后面 clean 要逐行带序号处理
System.out.println("读到原始行:" + all.size() + " 行");
return all;
}
}
/** 阶段 2:清洗。为什么用 for 而不是纯流:要边洗边计数、边打印日志。 */
static List<Order> clean(List<String> rawLines) {
System.out.println();
System.out.println("========== 阶段 2:清洗过滤 ==========");
List<Order> valid = new ArrayList<>();
int skipHeader = 0, skipBlank = 0, skipBad = 0, skipNonPositive = 0, skipStatus = 0;
for (int i = 0; i < rawLines.size(); i++) {
String line = rawLines.get(i);
if (i == 0 && line.startsWith("orderId")) { // 第一行是表头
skipHeader++;
continue;
}
if (line.isBlank()) { // 空行直接跳过
skipBlank++;
continue;
}
String[] parts = line.split(","); // 按逗号切五段
if (parts.length != 5) { // 段数不对就是坏数据
skipBad++;
continue;
}
double amount;
try {
amount = Double.parseDouble(parts[3].trim()); // 金额必须是合法数字
} catch (NumberFormatException e) {
skipBad++;
continue;
}
if (amount <= 0) { // 金额 <= 0 不参与统计
skipNonPositive++;
continue;
}
String status = parts[4].trim();
if (!"PAID".equals(status)) { // 只统计已支付,退款/取消剔除
skipStatus++;
continue;
}
valid.add(new Order(parts[0].trim(), parts[1].trim(), parts[2].trim(), amount, status));
}
System.out.println("跳过表头:" + skipHeader + " 行");
System.out.println("跳过空行:" + skipBlank + " 行");
System.out.println("坏数据(段数不对/金额非法):" + skipBad + " 行");
System.out.println("金额 <= 0 剔除:" + skipNonPositive + " 笔");
System.out.println("非 PAID(退款/取消)剔除:" + skipStatus + " 笔");
System.out.println("有效订单:" + valid.size() + " 笔");
return valid;
}
/** 阶段 3:分组聚合。groupingBy 分摞,summarizingDouble 一次凑齐五项统计。 */
static List<CategoryStat> aggregate(List<Order> orders) {
System.out.println();
System.out.println("========== 阶段 3:分组聚合 ==========");
Map<String, DoubleSummaryStatistics> byCategory = orders.stream()
.collect(Collectors.groupingBy(
Order::category,
Collectors.summarizingDouble(Order::amount)));
List<CategoryStat> stats = byCategory.entrySet().stream()
.map(e -> new CategoryStat(e.getKey(), e.getValue())) // 顺手映射成报表行
.toList();
System.out.println("分出分类:" + stats.size() + " 个 -> "
+ stats.stream().map(CategoryStat::category).toList());
return stats;
}
/** 阶段 4:排序。按总金额从高到低,报表一眼看出哪个分类是大头。 */
static List<CategoryStat> sort(List<CategoryStat> stats) {
System.out.println();
System.out.println("========== 阶段 4:排序(总金额降序) ==========");
List<CategoryStat> sorted = stats.stream()
.sorted(Comparator.comparingDouble(CategoryStat::total).reversed())
.toList();
System.out.println("排序后顺序:"
+ sorted.stream().map(CategoryStat::category).toList());
return sorted;
}
/** 阶段 5:格式化。%-8s 左对齐占 8 字符,%12.2f 占 12 位保留两位小数。 */
static String format(List<CategoryStat> stats, int rawCount, int validCount) {
System.out.println();
System.out.println("========== 阶段 5:格式化报表 ==========");
StringBuilder sb = new StringBuilder();
sb.append("================ 订单分类销售报表 ================\n");
sb.append("数据来源:src/main/resources/orders.csv\n");
sb.append("原始行数:").append(rawCount)
.append(" 行,有效订单:").append(validCount).append(" 笔\n");
sb.append("--------------------------------------------------\n");
sb.append(String.format("%-8s %6s %12s %10s %10s %10s%n",
"分类", "订单数", "总金额", "平均", "最高", "最低"));
for (CategoryStat s : stats) {
sb.append(String.format("%-8s %6d %12.2f %10.2f %10.2f %10.2f%n",
s.category(), s.count(), s.total(), s.avg(), s.max(), s.min()));
}
sb.append("--------------------------------------------------\n");
double grandTotal = stats.stream().mapToDouble(CategoryStat::total).sum();
long grandCount = stats.stream().mapToLong(CategoryStat::count).sum();
sb.append(String.format("合计:%d 笔,总金额 %.2f 元%n", grandCount, grandTotal));
return sb.toString();
}
}
这段代码讲什么:
load():getResource("/orders.csv")从 classpath 拿到资源真实路径;Files.lines(path, UTF_8)按行读成Stream<String>,中文文件必须显式指 UTF-8 否则乱码;try (...)自动关文件,不写会泄漏文件句柄。不先跑mvn compile会找不到文件 ------因为 CSV 在 resources 下,要编译后才复制到target/classes/。clean():一行订单依次过五道闸------表头、空行、段数、金额合法性、状态,任何一道不过就被踢掉、计数器加一。为什么这里用 for 不用流?因为要边洗边计数打日志,循环最直白。aggregate():groupingBy(Order::category, summarizingDouble(Order::amount))------按分类分摞,每摞一次凑齐个数/总和/平均/最高/最低,一行顶五行。sort():comparingDouble(CategoryStat::total).reversed()就是"总金额反着排",大额分类排前面。format():数字列一律%12.2f定宽,文字列%-8s左对齐------不定宽,长短不一报表就歪。
完整演示 + 实测输出
运行命令(Linux/macOS 的 Bash,Windows 用 WSL 或 Git Bash):
bash
cd stream-journey/lesson-09
mvn -q clean install -DskipTests
java -cp target/classes com.stream.lesson09.Main
实测输出(本机 JDK 21.0.12.1 运行结果):
text
========== 阶段 1:读取数据 ==========
数据文件:/home/user/Doubao/chats/38445300231993090/stream-journey/lesson-09/target/classes/orders.csv
读到原始行:22 行
========== 阶段 2:清洗过滤 ==========
跳过表头:1 行
跳过空行:0 行
坏数据(段数不对/金额非法):3 行
金额 <= 0 剔除:1 笔
非 PAID(退款/取消)剔除:2 笔
有效订单:15 笔
========== 阶段 3:分组聚合 ==========
分出分类:4 个 -> [食品, 图书, 服装, 电子产品]
========== 阶段 4:排序(总金额降序) ==========
排序后顺序:[电子产品, 服装, 图书, 食品]
========== 阶段 5:格式化报表 ==========
================ 订单分类销售报表 ================
数据来源:src/main/resources/orders.csv
原始行数:22 行,有效订单:15 笔
--------------------------------------------------
分类 订单数 总金额 平均 最高 最低
电子产品 4 8326.00 2081.50 4299.00 129.00
服装 3 779.90 259.97 320.00 199.90
图书 4 320.90 80.23 120.00 45.80
食品 4 180.60 45.15 78.00 15.90
--------------------------------------------------
合计:15 笔,总金额 9607.40 元
输出怎么读:
- 阶段 1 那行路径是我机器上的
target/classes/orders.csv------你跑的时候会显示你自己机器的路径,关键是它在target/classes下面,证明资源被 Maven 复制过去了,跟项目绝对路径脱钩。 - 阶段 2 的清洗日志是全课灵魂:原始 22 行 → 表头 1 + 坏数据 3(abc 金额、空金额、缺段)+ 空金额 1 + 非 PAID 2 = 剔除 7 行 → 有效 15 笔,账对得上。真实报表的数字能不能信,全看这行交代。
- 阶段 4 按总额降序:电子产品(8326)排第一,食品(180.6)垫底------一眼看出营收大头是电子产品。
- 报表最后"合计:15 笔,总金额 9607.40 元"是四行分类的加总,拿计算器验算就知道管道没算错。
挑战题(答案都在课程源码里)
- ⭐ 往 CSV 里加两笔订单 :打开
src/main/resources/orders.csv,末尾加两行1022,张三,图书,30.00,PAID和1023,李四,食品,12.50,PAID,重新编译运行------阶段 2 的"有效订单"从 15 变成几?最后的"合计"金额变成多少?(提示:新加的两笔都合法,会一路过五道闸) - ⭐⭐ 放宽金额闸 :把 clean() 里
if (amount <= 0)改成if (amount < 0)------原来那笔 0.00 元的订单(1018)会被踢出去吗?再看报表"电子产品"那行:订单数从 4 变成几?平均金额从 2081.50 变成多少?想清楚为什么总额没变、平均却变了。 - ⭐⭐ 换个排序键 :把 sort() 里
Comparator.comparingDouble(CategoryStat::total)改成Comparator.comparingDouble(CategoryStat::count)------报表行的顺序怎么变?电子产品、图书、食品都是 4 笔订单,并列时谁排前面?(提示:sorted 是稳定排序,并列时保持分组时的原顺序)
生产环境进阶
三个加分项,面试聊深了能用:
- 管道进阶写法:Function 链 。本课 main 是"方法 A 返回值喂给方法 B"。更工程化的写法是把每段抽象成
Function<List<String>, List<Order>>这样的函数,然后clean.andThen(aggregate).andThen(sort)链式拼,再加"日志/计时"包装器统一套上去------这就是责任链模式的函数式表达。 - 打成 jar 后资源读法 :
getResource().toURI()只在"资源是磁盘真实文件"时成立;打成 jar 后资源在压缩包内没有 URI,那时要换成getResourceAsStream("/orders.csv")包BufferedReader逐行读。本课为了用Files.lines这个关键 API,走了 target/classes 布局------这个取舍面试可以主动讲。 - 流式 clean 的取舍 :想把 clean 也写成纯流,可以用
Collectors.partitioningBy把"合法/非法"分开,但多种跳过原因各自计数就别扭了------这就是"带观察的过滤用循环、纯变换用流"的实战经验,不是所有地方都该硬上流。
面试回答模板
问:给你一个订单 CSV 文件,让你做一份分类销售报表,你怎么设计?
- 一句话结论:我会把它拆成五段管道:读文件、清洗脏数据、按分类分组聚合、按总额降序、格式化输出,每段一个方法。
- 展开 1~2 句:读用
Files.lines配 UTF-8;清洗要过滤空行/金额非法/非 PAID 并打印清洗日志;聚合用groupingBy + summarizingDouble一行凑齐总数/平均/最高最低。 - 收一句场景:这种拆法在真实报表、ETL 任务里最常见,想换数据源只改 load,想加维度只动 aggregate(指向本课 §3 五阶段、§4 实测输出)。
问:Files.lines 怎么按行读文件?用完不关流会怎样?
- 一句话结论:
Files.lines(path, UTF_8)把文件按行读成Stream<String>,必须配 try-with-resources 用完关流。 - 展开 1~2 句:它底层开着文件句柄,不关会泄漏,跑多了系统报"打开文件太多";中文文件还要显式指 UTF-8,不然用平台默认编码读会乱码。
- 收一句场景:报表管道、日志解析这类逐行读文件的场景,都是这个固定写法(指向本课 §3 load() 的完整代码)。
总结表:坑 → 原因 → 解法
| 坑 | 原因 | 解法 |
|---|---|---|
| 直接运行报"找不到 orders.csv" | CSV 在 resources 下,要编译后才复制到 target/classes | 先 mvn -q clean install -DskipTests 再运行 |
| 中文 CSV 读出来乱码 | Files.lines 用平台默认编码 | 显式传 StandardCharsets.UTF_8 |
| 文件句柄泄漏 | Files.lines 底层开着文件流,不关 | try-with-resources 自动关 |
| 脏数据让 parseDouble 崩 | CSV 里混着 abc、空金额 | clean 逐道闸过滤,异常计数打日志 |
| 报表数字列歪歪扭扭 | %s 不定宽 | 数字列一律 %12.2f 定宽,文字列 %-8s |
关于这个系列
完整代码在 stream-journey 课程第 9 课 (src/main/java/com/stream/lesson09/Main.java,另含 Order.java、CategoryStat.java 两个 record 和 src/main/resources/orders.csv 数据文件):
bash
git clone https://gitee.com/j67mk2/stream-journey.git
cd stream-journey/lesson-09
mvn -q clean install -DskipTests
java -cp target/classes com.stream.lesson09.Main
本文属于 Java 后端实战精通营 系列(Stream 实战 10 课):L1 流的世界观 → L2 流的创建与管道骨架 → L3 中间操作全家桶 → L4 终端操作与短路 → L5 Collector 分组聚合 → L6 并行流与性能 → L7 惰性求值与无限流 → L8 Optional×Stream → L9 实战:报表数据管道(本课)→ L10 企业级实践与面试冲刺。
下一篇:企业级实践与面试冲刺------这条报表管道怎么升级成企业级?可配置、可观测、兜底、自检这些工程化要素怎么加?十课收官,面试话术一次性给齐。
你工作中接过最脏的 CSV 是什么样的?评论区晒晒。觉得有用就收藏一下,追更这个系列。