LoginSignup
16
9

More than 3 years have passed since last update.

Scalaのエラーハンドリング

Last updated at Posted at 2019-10-08
1 / 27

例外安全と例外中立

  • 強い例外安全性
    • 例外が発生した場合、全てのデータは元に戻る
  • 基本例外安全性
    • 例外が発生しても、システムがクラッシュしない、リソースがリークしない、オブジェクトが無効な状態にならない
  • 例外中立性
    • 呼び出し先が例外を発生させたときに それを握りつぶさない

  • 例外安全については強い例外安全性を求められるケースはミドルウェアとかなのでスルー
    基本例外安全性はC/C++だと重要な問題だが
    ScalaだとLoanパターンを使えば十分なので触れない

  • 例外中立性を破るとシステムが異常な状態に陥っている事に気づけない、異常な動作を続ける事になる


Scalaにエラーハンドリングの手段は多種多様。

  • 例外
  • scala.util.Try
  • Either
  • (ちょっと違うが非同期計算の失敗の情報を持っているFuture)

何を使うべき?


例外

  • ScalaはJavaライブラリ資産を引き継いでるのでIOエラーなどはだいたい例外が投げられる。
  • 例外の親クラスはThrowable型でサブクラスにException型、Error型があり、 アプリケーションエラーはException、致命的なエラーはErrorを投げる
  • JavaにおいてError型はアプリケーションコードでcatchするべきではないがぶっちゃけ守られていない(?)。
  • ScalaではNonFatalな例外はキャッチするべきではない

try {
  fun()//例外発生!
} catch {
  case e => log.err(e) //warning: This catches all Throwables. If this is really intended, use ` case e : Throwable` to clear this warning.
}

ここでuse case e : Throwableって書いてあるからThrowableでcatchしよう
としてはいけない

この警告は本当にThrowableで受けるのかどうかを聞いているのであって、Throwableで受けよという意味ではない。


try {
 fun()//例外発生!
} catch {
 case e: NumberFormatException => 0
}

意図しない例外の型で回復してしまうのは例外の握り潰しと同じなので
このように意図した例外のみ対処・回復すべき


  • 上がってきた例外のスタックトレースは捨てないようにする

例えばエラーをそのアプリ固有の例外に変換させようとする時に登ってきた例外を捨ててしまう事がある

try {
 fun()//例外発生!
} catch {
 case NonFatal(e) => throw new MyAppException()
}

このようなコードはスタックトレースを捨てる事になるので避けた方が良い


try {
 fun()//例外発生!
} catch {
 case NonFatal(e) => throw new MyAppException(e)
}

投げる例外のコントラクタにキャッチした例外を渡そう


例外をどう使うか

  • Scalaにおいて例外は型安全、composableではないと悪者にされがちだが基本的にこれを使うべき
  • NonFatalでcatchできないエラーをcatchしてはいけない
  • 回復余地があるエラーはEitherなどを使った方が良い
    • どこまでが回復余地なのかは作りたいアプリによるが。。
    • Rustと違ってScalaはleft値を処理していない事をコンパイラが検知してくれない
    • Eitherは例外中立を破りがち
  • なんでも回復できてしまうのはよくない、 回復を諦め呼び出し元に処理を任せよう

scala.util.Try

  • 成功と例外を保持するクラス
  • 一見すると例外の代わりにこれを使えばいいように思える
  • しかし思ったより使うシーンは少ない
    • Left型がThrowable固定
    • 意図せず(何も考えずに)握り潰しができてしまう

def hogeTry: Try[Unit] = Try { throw new Exception() }
hoge() //例外が起きた事は誰も知らない

実際のアプリケーションではこのように事を書きがち


Tryをハンドリングしない事は

try {
  hoge()
} catch {
  NonFatal(e) => //何もしない
}

と書く事と同じだが容易に書けてしまう


Tryが有用なケース

コールバックで例外と成功値を処理させたい場合、
例えばFutureにあるtransform

def transform[S](f: (Try[T])  Try[S])(implicit executor: ExecutionContext): Future[S]

このようにTryを受け取る事で

Future(1 / 0)
  .map(_ + 1)
  .recover { case e: ArithmeticException => 0 }

となるところを

Future(1 / 0).transform { 
  case Success(x) => Success(x + 1)
  case Failure(e: ArithmeticException) => Success(0) 
}

と書ける
関数に例外可能性がある値を処理させたい以外に使うべき所は少ないと思う。。
特にpublicメソッドでTryを返すのは避けるべきだと思うがどうなんですかね・・


Either

成功値またはエラー値を持つ型
注意しないとLeft値の時にエラーを無視してしまいがちなのはTryと同じ
例外に比べてるとどんなエラーが静的に分かり、型安全

ビジネスロジックでエラーを扱う時に使うと良い

sealed trait LoginError
object LoginError {
  case object NotFound extends LoginError
  case object Locked extends LoginError
}

def findUser(email: String, password: String): Either[LoginError, User] = userDao.find(email, password) match {
  case Some(u) if u.locked => Left(Locked)
  case Some(u) => Right(u) 
  case None => Left(NotFound)
}

def login(email: String, pass: String): HttpStatus = 
  findUser(data.email, data.pass).map { u =>
    startSession(u.id)
    HttpStatus.Ok(u.toJson)
  }.left.map {
    case NotFound => HttpStatus.NotFound
    case Locked => HttpStatus.BadRequest("locked!")
  }.merge


Eitherで複数のエラー処理を纏めたいる時Left型が違う場合はコンパイルが通らない


def userForm(request: HttpRequest): Either[FormError, User] = ???
def findUser(email: String, password: String): Either[LoginError, User] = ???

//Left型が異なるのでコンパイルが通らない
def login(request: HttpRequest): HttpResult = (for { 
  data <- userForm(request)
  u    <- findUser(data.email, data.pass)
  _    =  startSession(u.id)
} yield HttpStatus.Ok(u.json)).left.map {
  ...
}.merge

この場合、leftMapをしてLeftの型を合わせる

def login(request: HttpRequest): HttpStatus = (for { 
  data <- userForm(request).leftMap(_.toStatus)
  u    <- findUser(data.email, data.pass).leftMap(_.toStatus)
  _    =  startSession(u.id)
} yield HttpStatus.Ok(u.json)).merge

Eitherは例外と異なりStackTraceを持たないが不便な場合もある

case object IOError
def startSession(id: UserId): Either[IOError, Unit] = ??? 

def login(request: HttpRequest): HttpStatus = (for { 
  data <- userForm(request).leftMap(_.toStatus)
  u    <- findUser(data.email, data.pass).leftMap(_.toStatus)
  _    <- startSession(u.id)
} yield HttpStatus.Ok(u.json)).merge

startSessionがIOErrorを返す時、単なるobjectなのでどこから投げられたかわからない
この場合はIOErrorを継承したclassにするといい

case class IOError extends Throwable {
  def toStatus: = {
    log.error(e)
    HttpStatus.InternalError
  }
}

def login(request: HttpRequest): HttpStatus = (for { 
  data <- userForm(request).leftMap(_.toStatus)
  u    <- findUser(data.email, data.pass).leftMap(_.toStatus)
  _    <- startSession(u.id).leftMap(_.toStatus)
} yield HttpStatus.Ok(u.json)).merge

エラーハンドリング使い分け

   型安全性 composability 例外安全 例外中立 StackTrace
例外 なし 
テストでカバー
低い 実装依存 強い あり
Try なし 
Left型がThrowable固定
高い 実装依存 弱い あり 
Either あり 
Left型を静的にチェックできる
高い 実装依存 弱い なし
Thorableを継承すればあり

Future

  • Scalaの非同期計算用の型 エラーのハンドリング専用の型ではないが、非同期のエラーハンドリングはFutureを使う事になる. Left型がThorable固定なのでTryと事情と似ている
def postUser = {
  userForm(request).fold(_.toStatus, user =>
    Future(doo.insert(user))
    Http.Ok()
}

doo.insert(user)が失敗した場合、Futureが失敗状態になるが誰もハンドリングしていない
これは例外中立を破る事になる

def postUser = {
  userForm(request).fold(_.toStatus, user =>
    Await.ready(Future(doo.insert(user)), Duration.Inf)
    Http.Ok()
}

Awaitを使うと失敗した場合例外を投げてくれる


Futureのエラーハンドリング方法

  • Awaitを使う
    • 一番単純、ただしスレッドをブロックする
  • Futureを返す
    • 結果をFutureにして返す。 スレッドをブロックしないが、Futureを引回すし、呼び出し元が意図せず無視してしまう事もある
  • onCompletefailed.foreachを使ってエラーハンドリング
    • 例外をlogに吐いたりはできるが呼び出し元にエラー返してないので例外中立を破る

一長一短


Fatalな例外時の処理

  • Futureがcatchする例外はNonFatalなものだけ(OutOfMemoryとかはcatchしない)
  • Fatalな例外が起きたら何が起るのかというとExecutionContextのreportFailureが呼ばれる
  • しかしデフォルトでは標準エラー出力にstacktraceが出力されるだけ
    • 辛い

回避策

  • ExecutionContext.fromExecutorServiceのreporterをエラー処理を定義したExecutionContextを使用
  • akkaを使用している場合、actorSystem.dispatchers.lookupでExecutionContextを生成する
    • 個人的にオススメ
    • fatalな例外が発生した場合akkaのlog出力を使ってくれるakka-slf4jとか使えばslf4jの設定で例外を吐いてくれる
    • jvm-exit-on-fatal-errorをonにしておけばjvmをshutdownしてくれる
    • jvmをshutdownしたくない場合でもActorSystemのregisterOnTerminationやwhenTerminatedをwatchすれば終了時の処理を書ける

Fatalな例外を握り潰さないように気をつけよう!


まとめ

予期しないエラーいうものは起るもの
殆どの場合は無理にエラーを回復せずにエラーを通知し、正しく停止するべきだと思います。


参考資料

エラー処理を書いてはいけない
C++の設計と進化


16
9
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
9