第10章 实战:文件、JSON 与并发

把前面学的串成一条工程链路:读写文件、解析 JSON、统计报表,最后用 Future 做并发与综合项目。

实战第14-16周3 讲6 段示例代码

本章学习目标

  • 能用 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
SCALA
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 读写并统计班级平均分
SCALA
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 解析并生成班级报表
SCALA
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
▶ 不依赖第三方库的正则版本
SCALA
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 汇合一组任务
SCALA
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
▶ 成绩分析与词频统计并发执行
SCALA
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 结束时消失。