第10章 实战:文件、JSON 与并发
把前面学的串成一条工程链路:读写文件、解析 JSON、统计报表,最后用 Future 做并发与综合项目。
本章学习目标
- 能用 Source / Using / java.nio 读写文本与 CSV
- 能解析 JSON 并做分组统计、排序、输出报表
- 理解 Future / Await 的基本用法与坑
- 独立完成一个“数据处理小工具”并交付
本章导览
核心内容
10.1
文件读写:Source / Using / java.nio
用 Source、Using 与 java.nio 安全地读写文本文件。
能用 Source 逐行读取文本并用 Using 保证关闭能用 Files 读写 UTF-8 文件并处理简单的 CSV能定位编码、路径和资源未关闭这三类常见问题
知识点
读文件先选 API
- Source.fromFile(file, "UTF-8") 返回可关闭的 Source,getLines 得到逐行迭代器。
- 小文件可以 getLines().toList 一次读完;大文件要边读边处理,别整份塞进内存。
- 读完必须关闭,Scala 2.13 推荐让 Using 负责这件事。
Using 把关闭放进 finally
- Using.resource(src) { ... } 无论代码块是否抛异常,都会调用 close。
- Using(...) 返回 Try,用 Success / Failure 或 getOrElse 处理结果。
- 只有 Closeable 一类资源才适合 Using,普通计算用 try/finally 更直白。
java.nio 适合一次性读写
- Files.write(path, lines.asJava) 和 Files.readAllLines(path, UTF_8) 代码最短。
- Files.lines 返回惰性流,必须放进 try-with-resources 或 Using,否则句柄泄漏。
- 路径用 Paths.get,不要手拼反斜杠,Windows 和 Linux 的写法不一样。
CSV 先用 split 够用
- 按逗号 split(",", -1) 时,-1 会保留末尾空字段,避免列数莫名变少。
- 字段本身含逗号或引号时需要真正的 CSV 解析器,split 解决不了。
- Windows 文件可能有 BOM 和 CRLF,读入后要 trim,并处理看不见的 BOM。
代码示例
▶ Source 配 Using 读取 CSV
import scala.io.Source
import scala.util.Using
import java.nio.charset.StandardCharsets
import java.nio.file.Files
object SourceDemo {
def main(args: Array[String]): Unit = {
val path = Files.createTempFile("scores", ".csv")
Files.write(path, "张三,90\n李四,85\n王五,78\n".getBytes(StandardCharsets.UTF_8))
Using.resource(Source.fromFile(path.toFile, "UTF-8")) { src =>
src.getLines().foreach(line => println(s"读到:$line"))
}
Using(Source.fromFile(path.toFile, "UTF-8")) { src =>
src.getLines().toList
} match {
case scala.util.Success(lines) => println(s"共 ${lines.size} 行")
case scala.util.Failure(e) => println(s"读取失败:${e.getMessage}")
}
Files.deleteIfExists(path)
}
}👀 运行结果
读到:张三,90 读到:李四,85 读到:王五,78 共 3 行
▶ java.nio 读写并统计班级平均分
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Paths}
import scala.jdk.CollectionConverters._
object NioReport {
def main(args: Array[String]): Unit = {
val path = Paths.get("report-tmp.csv")
val lines = List("一班,张三,90", "一班,李四,80", "二班,王五,70")
Files.write(path, lines.asJava, StandardCharsets.UTF_8)
val readBack = Files.readAllLines(path, StandardCharsets.UTF_8).asScala.toList
val scores = readBack.flatMap { line =>
line.split(",", -1).toList match {
case cls :: name :: score :: Nil =>
score.toIntOption.map(n => (cls, name, n))
case _ => None
}
}
scores.groupBy(_._1).toList.sortBy(_._1).foreach { case (cls, rows) =>
val avg = rows.map(_._3).sum.toDouble / rows.size
println(f"$cls:${rows.map(_._2).mkString("、")},平均 $avg%.1f")
}
Files.deleteIfExists(path)
}
}👀 运行结果
一班:张三、李四,平均 85.0 二班:王五,平均 70.0
常见错误
⚠️ 踩坑与解决
Source 读完不关闭,运行久了报 Too many open files
→ Source 持有文件句柄,什么时候被 GC 回收并不确定,不能靠垃圾回收关闭。;用 Using.resource 包住 Source,或在 finally 里显式调用 close。Windows 上中文读出来是乱码
→ 写入和读取使用了不同的字符集,系统默认编码可能是 GBK。;两端都显式指定 UTF-8,例如 Source.fromFile(file, "UTF-8") 与 StandardCharsets.UTF_8。Paths.get("data.csv") 报文件不存在
→ 相对路径以程序的工作目录为准,sbt run 时通常是项目根目录,不是源码所在目录。;改用绝对路径,或把文件放进 src/main/resources 并用 getResourceAsStream 读取。
💡 路径、编码、关闭三件事一次写对:UTF-8 显式写,资源交给 Using,路径不要手拼。
10.2
JSON 解析与分组统计报表
把 JSON 解析成 Scala 数据,再分组统计并输出报表。
能用 uPickle 的 ujson 读取 JSON 数组并取字段能用 groupBy / sortBy / f 插值生成统计报表不引第三方库时,能用正则解析固定格式的 JSON
知识点
sbt 依赖与 ujson 最小用法
- 在 build.sbt 加 libraryDependencies += "com.lihaoyi" %% "upickle" % "3.1.0",然后 sbt reload。
- ujson.read(text) 得到 ujson.Value,数组取 .arr,对象取 .obj,字段用 o("name") 访问。
- 字符串用 .str,数字用 .num(返回 Double),取字段前最好先确认结构。
- 生产项目更推荐 upickle.default.macroRW 或 readwriter 映射到 case class,而不是到处手取字段。
先转普通集合再统计
- 把 .arr 映射成 (班级, 姓名, 分数) 三元组,后面的分组排序就不再依赖 JSON 库。
- groupBy(_._1) 分组,sortBy 固定输出顺序,报表结果才稳定。
- 算平均分要先转 Double,整数相除会把小数截断。
不引库也能解析固定格式
- 字段固定、没有嵌套时,用正则 findAllMatchIn 抽三元组最省依赖。
- 正则处理不了嵌套对象、转义和字段顺序变化,只适合课程里的受控数据。
- 解析失败要显式返回 Either 或跳过并记录,不能悄悄丢数据。
代码示例
▶ ujson 解析并生成班级报表
import ujson._
object JsonReport {
def main(args: Array[String]): Unit = {
val raw =
"""[
| {"name":"张三","score":90,"class":"一班"},
| {"name":"李四","score":76,"class":"一班"},
| {"name":"王五","score":88,"class":"二班"},
| {"name":"赵六","score":59,"class":"二班"}
|]""".stripMargin
val rows = read(raw).arr.toList.map { o =>
(o("class").str, o("name").str, o("score").num.toInt)
}
rows.groupBy(_._1).toList.sortBy(_._1).foreach { case (cls, xs) =>
val avg = xs.map(_._3).sum.toDouble / xs.size
println(f"$cls 人数=${xs.size} 平均分=$avg%.1f")
}
rows.sortBy(r => -r._3).take(1).foreach { case (_, name, score) =>
println(s"最高分:$name $score")
}
}
}👀 运行结果
一班 人数=2 平均分=83.0 二班 人数=2 平均分=73.5 最高分:张三 90
▶ 不依赖第三方库的正则版本
object JsonRegexReport {
val Row = """\{"name":"([^"]+)","score":(\d+),"class":"([^"]+)"\}""".r
def main(args: Array[String]): Unit = {
val raw =
"""[
| {"name":"张三","score":90,"class":"一班"},
| {"name":"李四","score":76,"class":"一班"},
| {"name":"王五","score":88,"class":"二班"},
| {"name":"赵六","score":59,"class":"二班"}
|]""".stripMargin
val rows = Row.findAllMatchIn(raw).map { m =>
(m.group(3), m.group(1), m.group(2).toInt)
}.toList
println(s"解析到 ${rows.size} 条")
rows.sortBy(r => (-r._3, r._2)).take(2).foreach { case (cls, name, score) =>
println(s"$cls $name $score")
}
}
}👀 运行结果
解析到 4 条 一班 张三 90 二班 王五 88
常见错误
⚠️ 踩坑与解决
编译报 object ujson is not a member of package
→ build.sbt 少了依赖、没有 reload,或 % 与 %% 写错导致 Scala 版本后缀不对。;加上 upickle 依赖后执行 sbt reload;检查依赖是否写在当前子项目里。JSON 少一个字段,o("score").num 抛 NoSuchElementException
→ ujson 取值不会自动补默认值,key 缺失就直接失败。;先用 o.obj.get("score") 得到 Option,或定义带默认值的 case class 做反序列化。
💡 先把 JSON 的“形状”定下来再写解析;报表先 groupBy 再排序,不要边遍历边打印。
10.3
Future 并发与综合项目:成绩分析与词频统计
用 Future 并发跑成绩分析和词频统计,并正确汇合结果。
能创建 Future、选择 ExecutionContext,并用 Await 取回结果能用 Future.sequence 和 zip 汇合多个并发任务独立完成成绩分析与词频统计两个小功能
知识点
Future 要有执行上下文
- import scala.concurrent.ExecutionContext.Implicits.global 给 Future 提供线程池。
- Future { ... } 的代码块在别的线程执行,主线程不会自动等它。
- 不 Await 程序可能先结束;但 Await 只放在程序边界,别在每个步骤都阻塞。
组合多个 Future
- Future.sequence 把 List[Future[A]] 变成 Future[List[A]],结果顺序与输入一致。
- zip 把两个 Future 的结果配成元组,适合“成绩分析 + 词频统计”两条独立流水线。
- map / flatMap 可以继续链式处理,一个任务依赖另一个任务时才需要 flatMap。
并发里别碰共享可变状态
- 多个 Future 同时改同一个 var 或 mutable.Map 会出现竞态和丢失更新。
- 让每个任务返回局部结果,最后在单线程里合并;必须共享时用 AtomicInteger 或并发容器。
- Future 里的异常会变成失败状态,Await 时以原异常重新抛出,别忘了处理。
综合项目的拆分方式
- 成绩分析:按班级分组、算平均、找最高分。
- 词频统计:统一小写、按非字母切词、去掉空串、再按次数排序。
- 两块互不依赖就放进两个 Future,最后用 zip 汇合后统一输出。
代码示例
▶ Future.sequence 汇合一组任务
import scala.concurrent.{Await, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
object FutureBasic {
def square(n: Int): Future[Int] = Future {
Thread.sleep(100)
n * n
}
def main(args: Array[String]): Unit = {
val fs: List[Future[Int]] = (1 to 4).toList.map(square)
val all: List[Int] = Await.result(Future.sequence(fs), 3.seconds)
println(all)
println(s"总和=${all.sum}")
val one = Future { 21 * 2 }
println(s"单个结果=${Await.result(one, 1.second)}")
}
}👀 运行结果
List(1, 4, 9, 16) 总和=30 单个结果=42
▶ 成绩分析与词频统计并发执行
import scala.concurrent.{Await, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
object MiniProject {
final case class Row(cls: String, name: String, score: Int)
def scoreReport(rows: List[Row]): String = {
val lines = rows.groupBy(_.cls).toList.sortBy(_._1).map { case (cls, xs) =>
val avg = xs.map(_.score).sum.toDouble / xs.size
f"$cls 平均=$avg%.1f 最高=${xs.maxBy(_.score).name}"
}
lines.mkString("\n")
}
def wordFreq(text: String): List[(String, Int)] =
text.toLowerCase
.split("[^a-z]+")
.filter(_.nonEmpty)
.groupBy(word => word)
.view.mapValues(_.length)
.toList
.sortBy { case (w, c) => (-c, w) }
def main(args: Array[String]): Unit = {
val rows = List(
Row("一班", "张三", 90), Row("一班", "李四", 80),
Row("二班", "王五", 70), Row("二班", "赵六", 95)
)
val text = "Scala is fun, fun scala is fast. Spark uses Scala!"
val reportF = Future(scoreReport(rows))
val freqF = Future(wordFreq(text))
val (report, freq) = Await.result(reportF.zip(freqF), 3.seconds)
println("== 成绩分析 ==")
println(report)
println("== 词频 Top3 ==")
freq.take(3).foreach { case (w, c) => println(s"$w: $c") }
}
}👀 运行结果
== 成绩分析 == 一班 平均=85.0 最高=张三 二班 平均=82.5 最高=赵六 == 词频 Top3 == scala: 3 fun: 2 is: 2
常见错误
⚠️ 踩坑与解决
Future 里 println 了结果,主程序退出时却没看到
→ 任务跑在异步线程,main 结束后 JVM 可能直接退出,没有同步点就等不到输出。;在边界用 Await.result 汇合,或组合成 Future.sequence / zip 后统一等待。多个 Future 同时 count += 1,结果总是偏小
→ var 的读-改-写不是原子操作,线程交错时后写的会覆盖先写的。;让每个 Future 返回局部计数再合并,或改用 AtomicInteger / 并发容器。报 Cannot find an implicit ExecutionContext
→ 没有导入 global 执行上下文,也没有显式把 ExecutionContext 传给 Future 组合方法。;在入口 import scala.concurrent.ExecutionContext.Implicits.global;生产环境自建线程池并负责关闭。
💡 先找互不依赖的任务,再并发;并发之后一定要有汇合点,不然结果会在 main 结束时消失。