CSV 怎么做销售报表?Stream 第 9 课:读洗聚排打五段管道一次跑通

系列: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 元"是四行分类的加总,拿计算器验算就知道管道没算错。

挑战题(答案都在课程源码里)

  1. ⭐ 往 CSV 里加两笔订单 :打开 src/main/resources/orders.csv,末尾加两行 1022,张三,图书,30.00,PAID 和 1023,李四,食品,12.50,PAID,重新编译运行------阶段 2 的"有效订单"从 15 变成几?最后的"合计"金额变成多少?(提示:新加的两笔都合法,会一路过五道闸)
  2. ⭐⭐ 放宽金额闸 :把 clean() 里 if (amount <= 0) 改成 if (amount < 0)------原来那笔 0.00 元的订单(1018)会被踢出去吗?再看报表"电子产品"那行:订单数从 4 变成几?平均金额从 2081.50 变成多少?想清楚为什么总额没变、平均却变了。
  3. ⭐⭐ 换个排序键 :把 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 是什么样的?评论区晒晒。觉得有用就收藏一下,追更这个系列。


相关推荐
jaysee-sjc2 小时前
【苍穹外卖】Day12:工作台数据看板与 Excel 报表导出
java·开发语言·数据库·spring boot·spring·微信·excel
w_zero_one2 小时前
算法--桶思想
java·开发语言·算法
MetaLite2 小时前
Spring 同一个类里调用 @CacheEvict,为什么缓存没清掉?
java·spring·缓存
sunburn-2 小时前
数据库聚合查询完整教程:从基础函数到实战统计
java·数据库·mysql·数据库开发
wangchunyu1142 小时前
JAVA 类加载机制与双亲委派模型
java·开发语言
小蒜学长3 小时前
基于 Spring Boot 的英语单词背诵系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端
lambo mercy3 小时前
持久化01——线程级持久化
java·开发语言·jvm
CoderYanger3 小时前
Java EE 进阶:5. Spring IoC&DI(第一弹):Spring IoC
java·开发语言·spring boot·spring·面试·职场和发展·java-ee
zyseo83 小时前
谷歌SEO 内容更新焕新流程:让老页面重新起量
java·前端·javascript