はじめに
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としては必須の要素となります。
エラーチェックの実装例
まずは、シンプルに例外を投げるケースを書いてみます(例外を捕捉する部分は省略しています)。
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エラーを返して終了
これが基本的な実装なのですが、上記の「ここに正常系を実装」というところに次のエラーチェックを書いていくと、ネストがどんどん深くなってしまいます。
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」という名前で作成したとすると、上記の実装は
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」等になります。
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例外」を捕捉するエラーハンドラ関数の実装例です。
errorHandler :: MyAppHandler a -> MyAppHandler a
errorHandler = handle $ \(e::ServantErr) -> do
runSql $ logError' $ T.pack $ show e
throwError e
このエラーハンドラを使ってAPIハンドラを実装すると、下記のようになります。 APIハンドラの入口のところで「errorHandler」を入れて、終わりまで一つのブロックとすると、APIハンドラ全体が例外捕捉の対象になります。
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を使います。
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ハンドラでの使用方法は前節と同じです(エラーハンドラの型は同じですしね)。
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」を使います。
例として、外部キー制約を違反するプログラムを実行してみます。
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エラーとして終了する」として使える処理をユーティリティとして定義しておくと便利です。
-- 引数の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エラーなど)とメッセージボディを指定します。
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
参考にしたサイト
-
Haskellの例外、今はコレ! Control.Monad.Catch
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?
StackOverflowで出た「例外の種類を取得する方法」についてのコメントです