4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-01-02

はじめに

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

参考にしたサイト

4
1
0

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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?