Haskell・Servant+Persistent/Esqueletoで作る実用WebAPI (5) 例外処理

# はじめに

Persistent/Esqueletoを使用したRDBへのアクセスでは、エラーが発生した場合に例外が投げられます。また、APIハンドラの実装時にも例外を投げたくなるシーンもあります。そのため、APIハンドラの動作として、例外を「投げる」「受ける」といった機能が使える必要があります。

Haskellに限らず、例外処理はプログラミング言語やフレームワークによって、それぞれ実装のアプローチが異なる話題になります。本記事では、APIハンドラでの例外処理について記載します。最初に「発生する例外」についての概要について記述し、続いてその例外の捕捉について述べます。

# APIハンドラで発生する例外

## Persistent/Esqueletoにより発生する例外

Persistent/Esqueletoでは、RDBとのアクセスの際にエラーが発生した時に例外が投げられます。主な例外発生パターンは下記の通りです

- insert/update/delete等で、RDB側のエラーが起きた場合
例えば「Uniqueキー制約のあるカラムに、既存の値をinsert/updateしようとした」「外部キー制約のあるカラムに、存在しないキーを入れようとした」などの場合、RDBはPersistentのライブラリからのアクセスに対して、エラーを返します。Persistentは、こういったエラーをRDBから受け取った時は、それをそのままエラーメッセージに含めて例外を投げます。アプリレイヤ（＝Haskellでの実装）側で、こういったエラーをある程度防ぐことは可能ですが、本質的にゼロにはできないため、例外のキャッチは必須です。

- select等で取得したデータの処理に不備があった場合
代表的な例として、「PersonType型の値が入るカラムに、その型とは違う文字列が入っている」というような場合、RDBから受け取ったレコードをHaskellの型に変換する際にエラーが発生します。このような場合にもPersistentは例外を投げます。これは主に開発中に発生するような不備ですが、対応しておくとデバッグに有用です。

## Haskell処理系で発生する例外

Haskellで実装すると、ランタイムエラーを一切起こさない（あるいは起きにくくする）ということは可能ですが、逆に、一部の関数は入力値によっては例外を投げます

- fromJustの引数にNothingがあった場合
- headの引数に空リストがあった場合

最初の例の「fromJust」について、「そんなものを使うなよ」という意見もあるかと思いますが、例えば、あるキーの集合「A,B,C」を使ってfromListWithからMapを作成し、これらのいずれかのキーでlookupする場合は、「必ずJustが返ってくる」わけですから、fromJustを使いたくなります。基本的にはうまくいくのですが、実装が複雑になってミスをするとfromJustにNothingを食わしてしまう、ということが起こります。

基本的にはこのようなエラーは「実装ミス（あるいは未実装）」ですので、例外が投げられた場合は「要対処」のエラーです。つまり、デバッグ等に有用なパターンです。「実装ミスを防ぐのではなく、型システムをうまく活用すればいいのでは？」という意見もあるかとは思いますが、話がさらに長くなりますので、ここでは割愛します。

## APIハンドラ内での実装により投げる例外

### 背景
APIハンドラ内の大部分は、doブロックを使って手続き的に実装することが多く、それを見る限りは、一般的なプログラミング言語の実装に近い部分があります。逆に、他のプログラミング言語と大きく違うところとしては、一部の制御構造がありません。goto相当がないのは当然として、ループやブロックから抜けるbreakやreturn相当がありません。Haskellにもreturn関数はありますが、これはdoブロックや関数の戻り値を指定するだけであり、doブロックや関数を「抜ける」ものではありません。

APIハンドラでは、引数やDBの戻り値を使って、多くのエラーチェックを行うのですが、途中でブロックや関数から抜け出す手段がないと、エラーチェックが増えるたびにネストが深くなってしまう、という問題があります。ネストを深くしないために、「エラーに該当したらブロック／関数を抜ける」という手法は、この手の問題へのアプローチの1つでしょう。Haskellの場合、唯一の「ブロック抜け」の手段として「例外を投げる」があります。一般的なプログラミング言語と同様、ある関数やブロック内で例外を投げると、例外を捕捉しているところまで一気に抜け出ることができます。プログラム中で例外を捕捉するところがなければ、実行時エラーとなってプログラム（あるいはスレッド）が終了します。

このような背景があるため、「APIハンドラ内で例外を投げる」「設計した場所で例外を補足する」という機能は、実用WebAPIとしては必須の要素となります。

### エラーチェックの実装例

まずは、シンプルに例外を投げるケースを書いてみます（例外を捕捉する部分は省略しています）。

```haskell
getPerson :: PersonId -> MyAppHandler Person
getPerson pid = runSql $ do
  maybe_person <- get pid -- 戻り値はMaybe Person型
  case maybe_person of
    Just p  -> do
      -- 該当IDのデータがあった（ここに正常系を実装）
    Nothing -> throwM err404 -- 該当IDのデータがないので、404エラーを返して終了
```

これが基本的な実装なのですが、上記の「ここに正常系を実装」というところに次のエラーチェックを書いていくと、ネストがどんどん深くなってしまいます。

```haskell
getPerson :: PersonId -> MyAppHandler Person
getPerson pid = runSql $ do
  maybe_person <- get pid
  person <- case maybe_person of
    Just p  -> return p -- 一行上の「person」には、Justの中身が入る
    Nothing -> throwM err404 -- 404エラーを返して終了

  -- 該当IDのデータがあった（ここに正常系を実装）
```

このように実装すると、エラーチェックの後の実装を同じネストで続けることができます。あるいは、Maybe型に対する汎用のエラーチェック関数を定義して使う、という手もあります。このエラーチェック版のfromJustを「fromJustWithError」という名前で作成したとすると、上記の実装は

```haskell
getPerson :: PersonId -> MyAppHandler Person
getPerson pid = runSql $ do
  maybe_person <- get pid
  person <- fromJustWithError (err404, "No such person ID") maybe_person

  -- 該当IDのデータがあった（ここに正常系を実装）
```

という感じになります（fromJustWithErrorの実装については、後述します）。エラーチェックがエラー時の処理まで含めて1行で書けるので、とてもすっきりします。

APIハンドラから例外を投げる時にthrowErrorではなくthrowMを使っています。Servantをそのまま使う場合はthrowErrorを使うのですが、後述する「safe-exceptionsパッケージ」を使う場合には、APIハンドラではthrowErrorのかわりにthrowMを使う必要があります。

# APIハンドラでの例外を捕捉する

## 使用するパッケージ

Haskellでの例外処理は、一般的なプログラミング言語で提供される「文」とは違い、「関数」として提供されます。そのためか、使用するモジュールや使い方もいろんなバリエーションがあります。

まず、モジュールについてですが、本記事シリーズでは「モナド変換子も使え、スレッドセーフであるsafe-exceptionsパッケージ（importするのはControl.Exception.Safe）」を使います。利便性を考えると、他の実装でもこれがメインでいいのではないかと思います。

## 1種類の例外を捕捉する

例外を補足する関数は「catch」「hadle」「catches」等になります。

```haskell
catch :: (MonadCatch m, Exception e) => m a -> (e -> m a) -> m a
handle :: (MonadCatch m, Exception e) => (e -> m a) -> m a -> m a 
```

catchとhandleは引数の順番が違うだけです。handleを例にとると`handle 例外処理　通常処理`となります。例外処理は例外のハンドラを引数にとります。そのため、handleの引数に例外処理のみを引数に与えると、通常処理を引数に受け取るエラーハンドラ関数が実装できます。下記は「ServantErr例外」を捕捉するエラーハンドラ関数の実装例です。

```haskell
errorHandler :: MyAppHandler a -> MyAppHandler a
errorHandler = handle $ \(e::ServantErr) -> do
  runSql $ logError' $ T.pack $ show e
  throwError e
```

このエラーハンドラを使ってAPIハンドラを実装すると、下記のようになります。 APIハンドラの入口のところで「errorHandler」を入れて、終わりまで一つのブロックとすると、APIハンドラ全体が例外捕捉の対象になります。

```haskell
getPersonList :: Maybe PersonType -> MyAppHandler [ApiPerson]
getPersonList ptype = errorHandler $ runSql $ do
  plist <- select $ from $ \p -> do
    where_ (if M.isNothing ptype then val True else p ^. PersonType ==. val (fromJust ptype))
    return p
````

例外処理側に戻りますが、例外処理ハンドラでは例外の種類（上記の例では「ServantErr」）を指定すると、その例外のみを捕捉します。全ての例外を捕捉できる「SomeException」という例外もあるのですが、例外の種類毎に処理を書き分けることを考えると、SomeExceptionは使うべきではありません。

## 複数種類の例外を捕捉する

catchやhandleは1つの例外のみを扱う関数のため、複数種類の例外を捕捉し、その種類毎に処理をかき分けるためには、catchesを使います。

```haskell
catches :: (MonadCatch m, MonadThrow m) => m a -> [Handler m a] -> m a
```

catchesはcatchやhandleと違い、例外処理は「Handler」として設定します。これは単純にHandlerをつければいいだけなのですが、ServantのHandlerとかぶるので、importするモジュールをプレフィックスとしてつける必要があります。catchesと引数の順番が違う「handles」というような関数があればよりシンプルなのですが、定義されていないので、catchesと引数の順番を入れ替えるflipを使ってerrorHandlerを下記のように実装します。APIハンドラでの使用方法は前節と同じです（エラーハンドラの型は同じですしね）。

```haskell
import Control.Exception.Safe as Ex (Handler(Handler), catches, throwM)

errorHandler :: MyAppHandler a -> MyAppHandler a
errorHandler = flip catches [Ex.Handler (\(e::ServantErr) -> do
                                            runSql $ logError' $ T.pack $ show e
                                            throwError e)
                           , Ex.Handler (\(e::PersistException) -> do
                                         runSql $ logError' $ T.pack $ show e
                                         throwError err500 {errBody = LBS.pack $ show e})
                           , Ex.Handler (\(e::MySQLError) -> do
                                         runSql $ logError' $ T.pack $ show e
                                         throwError err400 {errBody = LBS.pack $ show e})
                           , Ex.Handler (\(e::ErrorCall) -> do
                                         runSql $ logError' $ T.pack $ show e
                                         throwError err400 {errBody = LBS.pack $ show e})]
```

各例外の種類については概要は以下の通りです。各例外でどのAPIエラーを返すかについては、あくまで「例」として考えてください。もちろん、各例外の種類毎に、さらにエラーを細部化した処理も実装できます。

- ServantErr : 404エラー等の例外です。基本的にはAPIハンドラ内から投げるものです。例外処理としては、投げられた例外をそのまま投げなおしています。
- PersistException : RDBの処理でHaskell側のエラーが発生した時のエラーです。基本的にRDBのデータの不備ですので、500エラーを投げています。
- MySQLError : RDBの処理で、RDB側のエラーが発生した時のエラーです（MySQL使用時限定）。基本的にAPIアクセスする側の問題なので400エラーを投げています
- ErrorCall : RDBではないところで発生するHaskell処理系でのエラーです。プログラム側の不備のため、500エラーを投げています

これまで実装してきた経験的に、MySQLを使った場合は以上4つの例外で十分なのですが、他のRDBを使用した場合などには、発生する例外に対応した例外の種類が必要になります。

## 例外の種類を特定する

ServantErrあたりはわかりやすくていいのですが、MySQLアクセス時のエラーでどの例外が投げられるのかについては、ドキュメント等がほとんどなく（そもそもHaskellでMySQLを使った実装記事が少ない）、特に「MySQLErrorが投げられる」というのを知るのが大変でした。というのも、「SomeExceptionで例外を捕捉してprintすれば、例外の種類が見れるだろう」と思ってやってみても、show eとして得られるのは、エラーメッセージのbodyだけであって、「それがどの種類の例外か」という情報が出てこないためです。

例外の種類を表示するためには、Data.Typableの「typeOf」を使います。
例として、外部キー制約を違反するプログラムを実行してみます。

```haskell
errorHandler :: IO () -> IO () -- ここではmainでのテストなのでIOとして定義
errorHandler = handle $ \ (SomeException e) -> do -- SomeExceptionで受ける
  print $ show e -- showバージョン
  print $ "Caught exception of type " ++ show (typeOf e) -- typeOfバージョン


main :: IO ()
main = do
  errorHandler $ runSql $ do
    -- exception sample
    let pid :: PersonId = toSqlKey 10 -- 存在しないPersonIdを作成
    insert BlogPost {blogPostTitle = "titletest", blogPostAuthorId = pid} -- ここでエラー発生
```

実行結果は以下の通りとなります。

```
"ConnectionError {errFunction = \"query\", errNumber = 1452, errMessage = \"Cannot add or update a child row: a foreign key constraint fails (`esqueleto0`.`blog_post`, CONSTRAINT `blog_post_author_id_fkey` FOREIGN KEY (`author_id`) REFERENCES `person` (`id`))\"}"
"Caught exception of type MySQLError"
```

1行目は「show e」をprintしたもので、MySQLで発生したエラー情報が入っています。2行目が「show (typeOf e)」をprintしたもので、「MySQLError」という種類が表示されています。

# ユーティリティ
先の説明で書いた、「あるべきデータがない場合にAPIエラーとして終了する」として使える処理をユーティリティとして定義しておくと便利です。

```haskell
-- 引数のMaybe型からJustの中身を返す。Nothingの時には指定したServantエラーを投げる
fromJustWithError :: (ServantErr, LBS.ByteString) -> Maybe a -> SqlPersistM' a
fromJustWithError (err,ebody) Nothing = throwM err {errBody = ebody}
fromJustWithError _ (Just a) = return a

-- 引数のリスト型から先頭の値を返す。空リストの時には指定したServantエラーを投げる
headWithError :: (ServantErr, LBS.ByteString) -> [a] -> SqlPersistM' a
headWithError (err,ebody) [] = throwM err {errBody = ebody}
headWithError _ a = return $ head a

-- 引数のEither型からRightの中身を返す。Leftの時には指定したServantエラーを投げる
rightWithError :: (ServantErr,LBS.ByteString) -> Either l r -> SqlPersistM' r
rightWithError (err,ebody) (Left _) = throwM err {errBody = ebody}
rightWithError _ (Right r) = return r
```

「SqlPersistM'型」については、後の記事で説明します。ここでは3種類の関数を定義していますが、使い方はどれも同じです。Servantエラーの指定では、エラーの種類（404エラー、500エラーなど）とメッセージボディを指定します。

```haskell
  person <- fromJustWithError (err404, "No such person ID") maybe_person
```

# トランザクションとロールバック

runSql配下のブロック中の処理で例外が発生すると、そのブロック中で実行されたSQLはロールバックされます。詳細については後の記事で書きます。

# まとめ
Javaでは、一部の関数でtry-catchの使用が強制されるため、try-catchを使うシーンは多いのですが、tryブロックはどの範囲をカバーすべきなのか？例外を発生する関数だけを包むべきなのか、それ以外も包んでしまってもいいのか？というところはよくわからなかったりします。

Haskellでは、「ブロックから抜ける手段が『例外を投げる』だけ」という事情がありますので、基本的には「APIハンドラ全体を包む」という形でいいかと思います。エラーハンドラ側で複数種類の対応もできますので、共通のエラーハンドラを定義して、それを全APIハンドラの冒頭で1行書けば、それで例外処理は全て完了してくれます。今回も説明が長かったですが、実装としては「共通のエラーハンドラ'errorHandler'を実装」し、「APIハンドラの冒頭で'errorHandler'を挟む」だけで終わりです。

前記事で紹介した下記リポジトリに、エラーハンドラも実装済みです。
[https://github.com/cyclone-t/servant-esqueleto-sample](https://github.com/cyclone-t/servant-esqueleto-sample)

# 参考にしたサイト
- [Haskellの例外、今はコレ！ Control.Monad.Catch](https://qiita.com/aiya000/items/6700753df2dfb9ae942e)
safe-exceptionの使い方解説です

- [続・Haskellの最近の例外ハンドリング] (http://syocy.hatenablog.com/entry/2016/08/28/175500)
safe-exceptionの説明です

- [Is there a way to get the type of an exception in Haskell?](https://stackoverflow.com/questions/9799734/is-there-a-way-to-get-the-type-of-an-exception-in-haskell)
StackOverflowで出た「例外の種類を取得する方法」についてのコメントです