例外安全と例外中立
- 強い例外安全性
- 例外が発生した場合、全てのデータは元に戻る
- 基本例外安全性
- 例外が発生しても、システムがクラッシュしない、リソースがリークしない、オブジェクトが無効な状態にならない
- 例外中立性
- 呼び出し先が例外を発生させたときに それを握りつぶさない
-
例外安全については強い例外安全性を求められるケースはミドルウェアとかなのでスルー
基本例外安全性は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を引回すし、呼び出し元が意図せず無視してしまう事もある
-
onComplete
やfailed.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な例外を握り潰さないように気をつけよう!
まとめ
予期しないエラーいうものは起るもの
殆どの場合は無理にエラーを回復せずにエラーを通知し、正しく停止するべきだと思います。