欢迎光临

Scala 错误处理全景指南:从 Option 到 Either、Try 和 Cats Validated 的实战解析

在 Scala 生态中,错误处理与 Java 的 try-catch 模型有本质区别。Scala 提供了一套基于代数数据类型(ADT)的错误处理体系,让错误成为值(errors as values)而非控制流异常。本文将系统性地拆解 Option、Either、Try 以及 Cats 的 Validated/Nec,从基础用法到生产级实战,帮助你在不同场景下做出正确的选择。

Scala 错误处理编程

一、为什么 Scala 不推荐用异常处理常规错误

Java 的 checked exception 机制看似安全,实则在实践中被广泛诟病。Joshua Bloch 在《Effective Java》中就指出,过度使用受检异常会降低 API 的可用性。Scala 选择了另一条路:用类型系统来表达可能失败的计算。

异常的问题在于:

  • 不可组合:异常打破了类型签名上的契约,调用方无法从函数签名得知可能抛出什么异常
  • 控制流跳跃:异常导致执行流从抛出点直接跳到 catch 块,跳过中间的所有逻辑和资源释放
  • 性能开销:构建异常对象需要填充堆栈跟踪,在高频路径上开销显著
  • 并发不友好:在 Future 和 Actor 模型中,异常跨线程传播的行为难以追踪

Scala 的做法是把”可能不存在”和”可能失败”编码到返回类型中。这带来一个巨大的好处:你看到函数签名就知道它会不会出错、会出什么错。

二、Option:处理”可能不存在”的值

Option 是最基础的错误处理工具,表达”有值或无值”的二元状态。它替代了 Java 中的 null,从根本上消除了 NullPointerException。

2.1 Option 的基本结构

Option 是一个密封特质(sealed trait),只有两个实例:


1
2
3
sealed trait Option[+A]
case class Some[A](value: A) extends Option[A]
case object None extends Option[Nothing]

关键设计点在于

1
None

的类型是

1
Option[Nothing]

,而

1
Nothing

是所有类型的子类型。由于 Option 是协变的(+A),

1
None

可以作为任意

1
Option[A]

出现,无需类型转换。

2.2 常用操作实战


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
case class User(id: Long, name: String, email: Option[String])

val users = Map(
  1L -> User(1, "Alice", Some("alice@example.com")),
  2L -> User(2, "Bob", None)
)

// map: 对内部值做变换
val emailDomain: Option[String] =
  users.get(1L).flatMap(_.email).map(_.split("@")(1))
// Some("example.com")

// getOrElse: 提供默认值
val bobEmail: String = users.get(2L).flatMap(_.email).getOrElse("unknown")
// "unknown"

// orElse: 链式回退
val primary: Option[String] = users.get(2L).flatMap(_.email)
val fallback: Option[String] = primary.orElse(Some("default@company.com"))

// fold: 同时处理两种情况
val greeting: String = users.get(3L).fold("Guest")(_.name)
// "Guest"

2.3 Option 的 for 推导式

for 推导式是 Option 最强大的组合工具。当多个 Option 参与计算时,任何一个为 None 都会短路:


1
2
3
4
5
6
7
8
9
def getUserProfile(id: Long): Option[UserProfile] = for {
  user      <- userRepo.findById(id)
  dept      <- deptRepo.findById(user.deptId)
  manager   <- userRepo.findById(dept.managerId)
  if manager.role == "ADMIN"
} yield UserProfile(user, dept, manager)

// 任何一个步骤返回 None,整体就是 None
// 无需嵌套 if-null 判断

这种写法的底层实现是 flatMap 链。Scala 编译器会将上面的 for 推导式展开为:


1
2
3
4
5
userRepo.findById(id)
  .flatMap(user => deptRepo.findById(user.deptId)
    .flatMap(dept => userRepo.findById(dept.managerId)
      .filter(_.role == "ADMIN")
      .map(manager => UserProfile(user, dept, manager))))

三、Either:携带错误信息的二元类型

Option 的局限在于 None 不携带任何信息——你不知道为什么会失败。Either 解决了这个问题:它有两个分支,各自可以携带任意类型的值。

3.1 Either 的结构


1
2
3
sealed trait Either[+E, +A]
case class Left[+E, +A](value: E) extends Either[E, A]
case class Right[+E, +A](value: A) extends Either[E, A]

按惯例,Left 表示失败(携带错误信息),Right 表示成功。在 Scala 2.12 之前,Either 是不带偏的(unbiased),你需要显式调用

1
.right

1
.left

来使用 map/flatMap。从 Scala 2.12 开始,Either 变为 Right-偏向(Right-biased),可以直接像 Option 一样使用 for 推导式。

3.2 实战:表单校验链


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
sealed trait ValidationError
case class InvalidEmail(email: String)    extends ValidationError
case class PasswordTooShort(min: Int)      extends ValidationError
case class UsernameTaken(username: String) extends ValidationError

def validateEmail(email: String): Either[ValidationError, String] =
  if (email.contains("@") && email.contains("."))
    Right(email.toLowerCase.trim)
  else
    Left(InvalidEmail(email))

def validatePassword(pw: String): Either[ValidationError, String] =
  if (pw.length >= 8) Right(pw)
  else Left(PasswordTooShort(8))

def validateUsername(name: String): Either[ValidationError, String] =
  if (takenUsernames.contains(name))
    Left(UsernameTaken(name))
  else
    Right(name)

// 链式校验——任一失败即短路
def register(email: String, password: String, username: String)
  : Either[ValidationError, Account] = for {
  validEmail  <- validateEmail(email)
  validPass   <- validatePassword(password)
  validName   <- validateUsername(username)
} yield Account(validEmail, validPass, validName)

// 结果: Left(InvalidEmail("bad-email")) 在第一个校验就短路

3.3 错误类型的选择策略

错误类型的设计直接决定了 API 的可用性。以下是一个推荐的分层策略:

层级 错误类型 适用场景
域错误 sealed trait + case class 业务逻辑错误,如余额不足、权限不够
基础设施错误 Exception 子类 网络超时、数据库连接失败
验证错误 集合类型(List/Nec) 表单校验,需要收集所有错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 域错误用 sealed trait,确保穷尽匹配
sealed trait PaymentError
case class InsufficientFunds(needed: BigDecimal, available: BigDecimal) extends PaymentError
case class CardExpired(expiredAt: java.time.LocalDate) extends PaymentError
case class NetworkTimeout(timeoutMs: Long) extends PaymentError

def processPayment(amount: BigDecimal, card: Card): Either[PaymentError, Receipt] =
  ???

// 调用方可以穷尽匹配所有错误情况
result match {
  case Right(receipt)        => println(s"Payment: ${receipt.id}")
  case Left(InsufficientFunds(n, a)) => println(s"Need $n, have $a")
  case Left(CardExpired(at))      => println(s"Card expired: $at")
  case Left(NetworkTimeout(ms))   => println(s"Timeout after ${ms}ms")
}

四、Try:桥接异常世界与值世界

当你必须调用可能抛出异常的第三方库(比如 Java 互操作)时,Try 是你的安全网。它把任意代码块包装成值。

4.1 Try 的基本用法


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import scala.util.{Try, Success, Failure}

// 解析 JSON 可能抛出异常
def parseJson(raw: String): Try[JsonNode] = Try {
  objectMapper.readTree(raw)
}

parseJson("""{"name":"Alice"}""") match {
  case Success(node) => println(s"Parsed: ${node.get("name")}")
  case Failure(ex)   => println(s"Parse failed: ${ex.getMessage}")
}

// 链式操作与恢复
val result: Try[Int] = parseJson(input)
  .map(_.get("count").asInt)
  .recover {
    case _: JsonParseException => 0
    case _: NullPointerException => 0
  }

4.2 Try vs Either 的选择

Try 和 Either[Throwable, A] 在功能上等价,但语义不同:

  • Try 用于”我不知道会不会抛异常”的场景——主要是与 Java 互操作或调用不可信的代码
  • Either 用于”我知道有哪些可能的错误”的场景——你定义了错误类型,它是 API 契约的一部分
  • 不要在纯 Scala 代码中用 Try 替代 Either——那等于把”已知的错误”退化回”未知的异常”

4.3 Try 与 Future 的配合

Future 内部使用 Try 来表示异步结果。Future 的 onComplete 回调接收的就是 Try:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Success, Failure}

val futureResult: Future[String] = Future {
  httpClient.get("https://api.example.com/data")
}

futureResult.onComplete {
  case Success(data) => println(s"Got: $data")
  case Failure(ex)   => println(s"Failed: ${ex.getMessage}")
}

// futureToEither 工具函数
def futureToEither[A](f: Future[A]): Future[Either[Throwable, A]] =
  f.map(Right(_)).recover { case e => Left(e) }

五、Cats Validated:错误累积的利器

前面所有的工具(Option、Either、Try)都有一个共同特征:短路。一旦遇到错误,立即停止后续计算。这在表单校验场景下是反模式——用户提交注册表单时,你应该一次性告诉他邮箱格式错误、密码太短、用户名已占用,而不是逐条纠正。

Cats 的 Validated 正是为”收集所有错误”而生的。

5.1 Validated 的基本结构


1
2
3
4
5
6
7
import cats.data.Validated
import cats.data.Validated.{Valid, Invalid}
import cats.data.NonEmptyChain

// Validated 有两个类型参数:错误类型和成功类型
// E 通常是错误集合,如 NonEmptyChain[ValidationError]
type ValidationResult[A] = Validated[NonEmptyChain[ValidationError], A]

NonEmptyChain(简称 Nec)是 Cats 提供的不可空链表,保证至少有一个元素——这正好符合”至少有一个错误”的语义。

5.2 实战:多字段表单校验


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import cats.data.{Validated, NonEmptyChain}
import cats.implicits._
import cats.data.Validated.{Valid, Invalid}

sealed trait FormError
case class EmailInvalid(email: String)     extends FormError
case class PasswordShort(len: Int, min: Int) extends FormError
case class UsernameTaken(name: String)     extends FormError

def checkEmail(email: String): ValidationResult[String] =
  if (email.contains("@") && email.contains("."))
    email.toLowerCase.trim.validNec
  else
    EmailInvalid(email).invalidNec

def checkPassword(pw: String): ValidationResult[String] =
  if (pw.length >= 8)
    pw.validNec
  else
    PasswordShort(pw.length, 8).invalidNec

def checkUsername(name: String): ValidationResult[String] =
  if (takenUsernames.contains(name))
    UsernameTaken(name).invalidNec
  else
    name.validNec

// 关键:用 |@| (product) 或 mapN 组合,不会短路!
case class RegistrationForm(email: String, password: String, username: String)

val result: ValidationResult[RegistrationForm] =
  (checkEmail("bad-email"), checkPassword("123"), checkUsername("alice"))
    .mapN(RegistrationForm(_, _, _))

// 结果:Invalid(Chain(EmailInvalid("bad-email"), PasswordShort(3, 8)))
// 两个错误都收集到了!

5.3 Validated vs Either 决策矩阵

维度 Either Validated
错误处理 短路(fail-fast) 累积(fail-then-continue)
flatMap 支持 原生支持 不支持(设计如此)
for 推导式 支持 不支持
组合方式 flatMap / for mapN / product / |@|
典型场景 业务流程编排 表单校验、数据管道校验
错误数量 最多一个 可以多个

核心区别在于:Either 是 Monad(有 flatMap),Validated 是 Applicative(只有 ap)。Monad 的 flatMap 允许后续步骤依赖前一步的结果,这意味着遇到错误时无法继续——因为后续步骤的输入不存在。Applicative 的 ap 则不需要这种依赖,所有步骤可以独立执行,因此可以收集所有错误。

六、实战整合:构建分层错误处理架构

在真实项目中,你通常需要组合使用这些工具。以下是一个典型的分层架构:

6.1 架构分层


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 第一层:领域模型用 sealed trait 定义错误
sealed trait DomainError
case class NotFound(entity: String, id: String)       extends DomainError
case class ValidationFailed(errors: List[String])      extends DomainError
case class Conflict(message: String)                   extends DomainError
case class InfrastructureError(cause: Throwable)       extends DomainError

// 第二层:Repository 层用 Try 包装可能抛异常的 IO 操作
class UserRepository(db: Database) {
  def findById(id: String): Try[Option[User]] = Try {
    db.query("SELECT * FROM users WHERE id = ?", id)
      .headOption
      .map(row => User(row("id"), row("name")))
  }
}

// 第三层:Service 层把 Try 转为 Either[DomainError, A]
class UserService(repo: UserRepository) {
  def getUser(id: String): Either[DomainError, User] =
    repo.findById(id) match {
      case Success(Some(user)) => Right(user)
      case Success(None)       => Left(NotFound("User", id))
      case Failure(ex)         => Left(InfrastructureError(ex))
    }
}

// 第四层:Controller 层把 DomainError 映射为 HTTP 状态码
def routeGetUser(id: String): HttpResponse =
  userService.getUser(id) match {
    case Right(user)   => HttpResponse(200, user.toJson)
    case Left(NotFound("User", id))    => HttpResponse(404, s"User $id not found")
    case Left(ValidationFailed(errs))  => HttpResponse(400, errs.mkString(", "))
    case Left(Conflict(msg))           => HttpResponse(409, msg)
    case Left(InfrastructureError(_))  => HttpResponse(500, "Internal error")
  }

6.2 批量校验与业务流程的组合


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import cats.data.EitherT
import cats.data.Validated
import cats.implicits._

// 用 Validated 做批量校验
def validateOrder(order: Order): Validated[NonEmptyChain[DomainError], ValidOrder] =
  (
    validateItems(order.items),
    validateAddress(order.address),
    validatePayment(order.payment)
  ).mapN(ValidOrder(_, _, _))

// 用 EitherT 做业务流程编排(在 Future 上叠加 Either)
type FResult[A] = EitherT[Future, DomainError, A]

def placeOrder(order: Order): FResult[OrderId] = for {
  validOrder <- EitherT.fromEither[Future](
    validateOrder(order).toEither.left.map(_.head))  // 取第一个错误
  orderId    <- EitherT(processPayment(validOrder))   // 支付
  _          <- EitherT(saveOrder(orderId, validOrder)) // 持久化
  _          <- EitherT(sendConfirmation(orderId))      // 通知
} yield orderId

EitherT 是 Cats 提供的 Monad Transformer,它让你在 Future 的上下文中嵌套 Either 的错误处理能力。for 推导式中的每一步都可能返回 Left 短路,同时整个计算是异步的。

七、性能考量与最佳实践

7.1 避免在热路径上构建异常

Try 会捕获异常并构建堆栈跟踪。在每秒处理万级请求的热路径上,这会产生显著开销:


1
2
3
4
5
6
7
8
9
10
11
// 差:在高频路径上用 Try 包裹预期内的"异常"
def parsePort(raw: String): Try[Int] = Try(raw.toInt)
// Integer.parseInt 抛 NumberFormatException,构建堆栈开销约 1-5μs

// 好:用 Either 做显式检查
def parsePort(raw: String): Either[String, Int] = {
  val n = raw.toIntOption.getOrElse(-1)
  if (n >= 0 && n <= 65535) Right(n)
  else Left(s"Invalid port: $raw")
}
// 无异常构建,开销在纳秒级

7.2 Option 的 flatMap 链深度

过深的 flatMap 链(超过 5-7 层)会降低可读性。当 Option 链超过 3 层时,考虑以下优化:

  • 提取子方法,给中间步骤命名
  • 使用 for 推导式提升可读性
  • 如果逻辑复杂,考虑用 Either 携带失败原因

7.3 模式匹配的穷尽性检查

使用 sealed trait 定义错误类型时,编译器会强制穷尽匹配。这是类型系统提供的安全网,不要用通配符 _ 绕过:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 差:通配符隐藏了未来新增的错误分支
result match {
  case Right(_)          => handleSuccess()
  case Left(_: NotFound) => handleNotFound()
  case _                 => handleOther()  // 如果新增了 Conflict 分支,这里会默默吞掉
}

// 好:穷尽匹配,新增分支时编译报错
result match {
  case Right(_)            => handleSuccess()
  case Left(_: NotFound)   => handleNotFound()
  case Left(_: Conflict)   => handleConflict()
  case Left(InfrastructureError(_)) => handleInfraError()
  case Left(ValidationFailed(_))    => handleValidationError()
}

八、总结:何时用什么

以下是一张完整的决策表,帮助你在不同场景下选择正确的工具:

场景 推荐工具 理由
值可能不存在(如 Map 查找) Option 语义最简单,无错误信息需求
单个步骤可能失败 Either 携带错误信息,支持 flatMap 链
多步骤业务流程 Either + for 短路语义正确,一步失败即停
调用 Java/不可信代码 Try 桥接异常,自动捕获
表单/批量校验 Cats Validated 收集所有错误,不短路
异步业务流程 EitherT[Future, E, A] 叠加异步与错误处理
需要部分成功 Validated + partition 分离成功和失败项

记住核心原则:错误是值,不是控制流。当你把错误编码到类型系统中,编译器就成了你的错误处理审查员——它会在编译期强制你处理每一个可能的错误分支。这是 Scala 类型系统赋予你的最强大的安全保障之一。

从 Option 开始,在需要错误信息时升级到 Either,在桥接异常时用 Try,在需要累积错误时引入 Validated。这套工具链覆盖了从简单值查找到复杂业务流程编排的全部错误处理场景,是每个 Scala 开发者必须掌握的核心技能。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Scala 错误处理全景指南:从 Option 到 Either、Try 和 Cats Validated 的实战解析
分享到: 更多 (0)