前置知识: Java、大数据开发

Scala 最小集:为 Spark 而学

12 min中级

划出读懂与写好 Spark 作业所需的 Scala 最小子集:val 与不可变偏好、case class、高阶函数、模式匹配、Option 与 trait,附 Scala 集合与 Java Stream 的对照表、隐式转换的一句话认知,以及与 Java 版对照的词频统计动手实验。

前置知识

  • 会 Java:到 构建工具 的程度,见过 Maven 或 Gradle 管理依赖、知道 lambda 与 Stream 的基本用法——本文大量用 Java 做对照;
  • 读过 大数据全景:知道 Spark 在链路里的位置,对「算子」「作业」两个词有一句话印象。

没读过也可以,本文会带上需要的部分:只要写过 Java 的集合与 Stream,对照就能成立。

学习目标

读完本文你将能够:

  1. 说清本文的学习定位:读懂 Spark 文档示例与报错栈、能用 Scala 写 Spark 作业,而不是成为 Scala 工程师;
  2. 解释 Scala 与 Java 在 JVM 上的互操作关系,以及为什么 Spark 的源码与文档默认用 Scala;
  3. 读写本篇划出的六件套:val、case class、高阶函数、match、Option、trait,并认出 implicit 出现的场景;
  4. 用对照表把 Scala 集合操作映射到你会的 Java Stream 上;
  5. 独立写出 Scala 版词频统计,并向同学讲出它与 Java 版的三点体感差异。

预计 40 到 55 分钟,含 2 个动手实验与 4 道练习。

1. 问题引入:打开 Spark 文档,扑面而来的是 Scala

假设你已经决定学 Spark(Spark 核心 RDD 会正式开始),打开官方文档的词频统计示例,第一眼看到的是这个:

scala
val textFile = spark.read.textFile("README.md")
val counts = textFile
  .flatMap(line => line.split(" "))
  .groupBy(word => word)
  .count()

如果只会 Java,你的反应可能是两种:一种是用 Spark 的 Java API 绕过去(可行,Spark 有完整的 Java 接口);另一种是把这段看懂——但很快你会发现绕不过去的地方越堆越多:文档示例默认 Scala、报错栈是 Scala 调用链、社区博客与比赛历年方案大多贴 Scala 代码、Spark 源码本身就是 Scala 写的。

所以本文的立场先亮出来:目标不是成为 Scala 工程师,而是「读懂 Spark 的一切文字材料 + 能用 Scala 写 Spark 作业」。这个目标决定了学法:只学一个最小子集,且每一样都从 Spark 的视角切入。

2. 语言坐标系:JVM 上的函数式与面向对象混合体

Scala 定位一句话:跑在 JVM 上、把函数式编程和面向对象揉在一起的语言,2003 年由 Martin Odersky 设计。三个坐标点:

  • JVM 语言:Scala 编译成字节码,跑在 JVM 上,与 Java 同一个运行时(对 JVM、GC、性能调优的理解全部平移);
  • 面向对象:万物皆对象,没有 Java 的「基本类型不是对象」的豁口;
  • 函数式:函数是一等公民(可以存变量、当参数传),默认数据结构不可变,鼓励「声明要做什么」而不是「描述怎么做」。

类型推断是日常体验差异最大的一点:Java 里 Map<String, List<Integer>> m = new HashMap<>();,Scala 里 val m = Map("a" -> List(1, 2))——类型写一遍由编译器推出来,val 声明后不可重新赋值(不可变绑定),var 才可变。Scala 代码风格强烈偏好 val 加不可变集合,Spark 算子链式调用的写法正是这个偏好的体现。

版本事实:Spark 4.x 的 Scala 基线是 2.13 系(以 Spark 官方文档为准),本文所有语法以 2.13 为准。

3. 与 Java 的互操作:同一个 JVM,类库直接互调

Scala 与 Java 的关系不是「两种语言抢一个岗位」,而是同一运行时上的邻居:

  • Scala 编译产物就是标准字节码,Java 类库在 Scala 里直接 import 就用(反过来 Java 也能调 Scala 编译出的类,略繁琐);
  • 你在 Scala 代码里调用的 Hadoop 文件系统 API、Kafka 客户端,都是 Java 写的类库;
  • Spark 核心由 Scala 编写,因此文档、类型签名、报错栈天然以 Scala 为第一语言——这就是第 1 节问题的根源,也是「学最小子集」这个策略成立的原因:你不是在学一门通用语言,而是在学「读 Spark」的解码器。

4. 最有用的子集:六件套

按在 Spark 代码里出现的频率排序,逐个过。

4.1 val 与不可变偏好

scala
val name = "orders"          // 不可变绑定:之后再给 name 赋值直接编译错误
var count = 0                // 可变:能改,但风格上被劝退
count += 1

Spark 里 RDD、DataFrame 一旦创建就不可变,所有「转换」都返回新对象——这正是第 6 节高阶函数链式写法的基础。读 Spark 代码时把每个 val 当成一条流水线的命名。

4.2 case class:数据载体的最短写法

scala
// 一行声明:编译器自动生成构造器、getter、equals、hashCode、toString
case class Order(orderId: String, userId: Long, amount: Double)

val o = Order("o1", 10086, 59.9)   // 不用 new:伴生的 apply 方法被自动调用

它是 Spark 代码出现频率最高的结构:DataFrame 转 Dataset、JSON 反序列化、模式匹配解构,全靠 case class 承载数据。用 Java 的眼光看,它等于「POJO + Lombok + 构造模板」三合一压成一行。

4.3 高阶函数:map、filter 与它们的亲戚

函数当参数传是 Scala 的日常:

scala
val nums = List(1, 2, 3, 4)
val doubled = nums.map(n => n * 2)      // List(2, 4, 6, 8)
val evens   = nums.filter(_ % 2 == 0)   // List(2, 4),下划线是"它"的占位符
val total   = nums.fold(0)(_ + _)       // 折叠:从 0 开始逐项相加

_ 占位符(读作「这里的参数」)和链式调用会贯穿所有 Spark 代码。你 Stream 里练的 map、filter、reduce 在这里原样适用,只是不再需要 .stream() 与 .collect() 的仪式。

4.4 match:升级版 switch

scala
val level = amount match {
  case a if a >= 1000 => "大额"
  case a if a >= 100  => "常规"
  case _              => "小额"      // 下划线是默认分支
}

它不只是取值,还能按「结构」匹配——对 case class 解构、对 Option 分支,是处理数据类型分发的利器。Spark 源码与 DSL 里大量出现,读到时知道它是 switch 的超集即可。

4.5 Option:null 的替身

scala
val maybe: Option[Double] = findOrder("o1")   // 查到了是 Some(order),查不到是 None
maybe match {
  case Some(order) => println(s"金额 ${order.amount}")
  case None        => println("查无此单")
}

Option 把「可能没有值」变成类型系统里看得见的状态,编译器强迫你处理 None 分支——Java 里 NullPointerException 一半的来源(忘判 null)在 Scala 里被类型检查挡住。读 Spark API 签名时看到返回 Option[T],就默念「可能拿不到」。

4.6 trait 与混入

scala
trait Loggable {                     // trait ≈ 接口 + 默认实现
  def log(msg: String): Unit = println(s"[INFO] $msg")
}
class OrderService extends BaseService with Loggable with Serializable

with 后面可以混入多个 trait,等于「接口的多继承加实现复用」。Spark 的类定义里常见一长串 with,读的时候当「这个类同时具备这些能力」理解。

5. 隐式转换与 implicit:一句话认知

读 Spark 代码必然撞上这两行:

scala
import spark.implicits._     // 让 case class 能被自动转换成 Dataset

implicit 是 Scala 的一个机制:编译器在需要某种类型转换或参数时,自动寻找标记为 implicit 的转换器插进来。你暂时只需要一个认知——这行 import 把「case class 到 Dataset 的编码器」塞进了作用域,没有它 spark.createDataset(List(Order(...))) 会报「找不到 Encoder」的错。会认、知道为什么报错,不要求会写自己的 implicit。

6. Scala 集合与 Java Stream 对照表

把你会的 Stream 操作映射过去,最小集就贯通了:

你会写的 Java对应的 Scala备注
list.stream().map(x -> x * 2)list.map(x => x * 2)无需 stream() 仪式
.filter(x -> x > 0).filter(_ > 0)占位符更短
.collect(Collectors.toList())不需要返回本身就是集合
Collectors.groupingBy(w -> w).groupBy(w => w)返回 Map
Collectors.summingInt(w -> 1) 配 groupingBy.groupMapReduce(w => w)(_ => 1)(_ + _)Scala 一步到位
map.computeIfAbsent(k, ...) 手写累加.mapValues(_.size) 等组合见第 8 节实验
Optional<T>Option[T]概念同源,Scala 侧更常用
interface 默认方法traitScala 可多混入

一个语义差异值得记住:Java Stream 是「一次性游标」(消费完即废),Scala 集合是「可重复使用的不可变结构」(每次操作返回新集合,原集合不变)。所以 Scala 侧看不到「stream 已被消费」这类异常。

7. trait 与混入在 Spark 里的样子

第 4.6 节的语法在 Spark 代码里最常出现的位置是类定义头:

scala
class SalesJob extends SparkJobBase
  with KafkaSource
  with Checkpointable   // 读的时候当能力清单看:这个作业同时具备 Kafka 读取、
                        // checkpoint 管理等能力,实现细节在各 trait 里

不需要写 trait,但需要练出「从 with 列表读出类的能力清单」的眼力,这决定了你能不能快速看懂比赛模板代码的结构。

8. 动手实验:同一个词频统计,两种语言

实验一(15 分钟):同一份词频统计,先写 Java 版(你应该已很熟练),再写 Scala 版,体感差异自己数。

Java 版(Stream 风格):

java
Map<String, Long> counts = Arrays.stream(text.split("\\s+"))
    .map(String::toLowerCase)
    .collect(Collectors.groupingBy(w -> w, Collectors.counting()));

Scala 版:

scala
val counts: Map[String, Int] = text
  .toLowerCase
  .split("\\s+")
  .groupBy(identity)        // 按单词分组:Map[单词, 单词数组]
  .view                     // 惰性视图:避免中间集合复制(原因见下)
  .mapValues(_.length)      // 每组数一下元素个数,得到词频
  .toMap

三点体感差异,写实验报告用:

  1. 没有 stream 与 collect 的仪式:集合本身就是可操作的,链路从头到尾是数据本身;
  2. 类型几乎不用写:Map[String, Int] 由编译器推出,注意力留在变换逻辑上;
  3. 占位符与组合更密:_.length、identity 这类小写法把样板代码压没了。

一个 2.13 的现实细节:mapValues 在 Scala 2.13 标记为弃用,推荐写法是 .view.mapValues(...) 或一步到位的 groupMapReduce(identity)(_ => 1)(_ + _)——故意保留这个「弃用警告」给你:编译器的弃用提示与修复建议,正是你以后读 Spark 报错栈要练的同一种能力,遇到就顺藤摸瓜点进去看一眼。

实验二(10 分钟):把 Scala 版词频统计的每一行改写成你认为等价的 Java Stream 写法,逐行对照第 6 节的表,验证对照表没有骗你;再故意删掉 import spark.implicits._ 类比实验——把任意一行 val 改成 var 并重新赋值,读一遍编译器报错,确认你理解 val 的契约。

9. 学习策略:用 Spark 作业反过来学 Scala

把学习顺序摆正,是本文最重要的一节建议:

  • 不要从语言教程正面啃 Scala。完整 Scala 语言包括类型系统、隐式解析、高阶类型这些深水区,按教程顺序学完要几周,而你的目标只用到十之二三;正面啃的另一个风险是把「学语言」变成目标本身,迟迟写不出第一个 Spark 作业;
  • 正确路径是回字形的:先读完本文的最小集(半小时量级),立刻去写 Spark 核心 RDD 的第一个作业;写的过程中每遇到一个看不懂的语法,回头查、当场补;两三个作业下来,你的「工作 Scala 词汇量」自然滚到够用;
  • 报错栈是教材。Scala 的类型报错信息密度大,初读吓人;但 Spark 报错栈往往是「第几行的哪个算子收到了不符合的类型」。把读报错当成每周固定练习,一个月后你会发现文档和源码的可读性同步上涨——因为障碍从来主要不是语言,是没练过读它的错误信息。

10. 常见困惑

问:比赛用 Java API 写 Spark 行不行?

行,Spark 的 Java API 功能完整,评审也不会因语言扣分。但三个现实因素让 Scala 占优:官方文档与大多数模板代码是 Scala;报错栈与类型签名按 Scala 形状给出,Java API 遇到边缘问题反而查不到资料;Spark SQL 的 Dataset 体验在 Scala 侧最顺滑。Java 功底扎实的同学可以「作业用 Java 写通、读例用 Scala 练」双轨并行。

问:Scala 2 与 Scala 3 是什么关系,我该学哪个?

Scala 3 是 2021 年后的新一代版本,语法更简洁,但 Spark 4.x 的基线仍是 2.13 系(以官方文档为准)。你的目标是读 Spark,按 Spark 用的版本学即可——这再次呼应本文的定位:语言跟着生态走,不追求「最新版」。

问:_ 这个下划线到底有几种意思?

很多种:lambda 占位符(_ + 1)、match 默认分支(case _)、import 通配(implicits._)、忽略变量名。初学阶段不用背清单,每遇到一种当场记一句含义,两周就能全部认全。它高频出现只是因为「省略」是 Scala 的核心审美。

问:Option 和 null 能混用吗,Java 库返回 null 怎么办?

可以混但别主动混。调 Java 库拿到可能为 null 的返回值时,用 Option(...) 工厂包一层,后续就回到 Option 的轨道——边界处转换一次,内部全程 Option,这是社区共识姿势。

11. 自检清单

  1. 能一句话说出本文的定位边界,并解释为什么「不从语言教程正面啃」;
  2. 能画出 Scala 与 Java 在 JVM 上的关系,并解释 Spark 文档默认 Scala 的原因;
  3. 能不看资料写出 case class 声明并说出编译器自动生成了什么;
  4. 能把一段五行的 Java Stream 代码逐句翻译成 Scala,并说出两处以上体感差异;
  5. 看到 import spark.implicits._ 与「找不到 Encoder」的报错,能说出两者之间的因果关系;
  6. 能读出 class A extends B with C with D 的能力清单结构。

官方文档

本章总结

为 Spark 学 Scala 的定位是解码器而不是新语言:读懂文档示例、报错栈与社区代码,能写 Spark 作业即可。Scala 是 JVM 上的函数式加面向对象混合体,与 Java 类库直接互调,Spark 源码与文档因此以它为第一语言。最小子集六件套:val 不可变绑定、case class 数据载体、高阶函数与占位符、match 模式匹配、Option 替代 null、trait 能力清单,外加 implicit 的「认得出」级别认知;Scala 集合与 Java Stream 一一对应,只是免去了 stream 与 collect 的仪式。学习路径是回字形:最小集打底,用 Spark 作业反过来滚词汇量,把报错栈当教材。

下一步

带着这把钥匙进入 Spark 核心 RDD,把本文的语法立刻用起来;Spark SQL 与 DataFrame 里 case class 将成为表结构本身——那时你会重新体会第 4.2 节的价值。