アプリケーションと三種の異常
はじめに
アプリケーションに異常はつきものです。
今回は、いわゆる「異常系」をコーディングするときに使える一つの考え方を紹介します。 アプリケーションに起こる「異常」を3種類に大別し、それぞれの特性から対応を考えていきます。
Note
これは、ただ単に私が普段使っている考え方で、原典となる書物等は存在しません。ご承知おきください。
本記事は「ポエム特集」カテゴリの記事です。
本記事のコードサンプルにはScalaとJavaを用いていますが、とても短い単純なサンプルしか含まれませんので、これらの言語に慣れていない方でも問題なく読めると思います。
目次
失敗って何だろう
「失敗」と聞いて、まず何を思い浮かべますか? 料理中に塩と砂糖を間違えたこととか、過去に失言してしまったこととか、人それぞれ色々あると思いますが、基本的には次のような文脈になっているはずです。
- ある要因のために良い結果が得られないこと
もちろん、先ほどの例もこの文脈持っています。
- 塩と砂糖を間違えたためにまずい料理ができてしまった
- 失言をしたために相手を怒らせてしまった
アプリケーションにおける「失敗」も同じで、ある要因のために良い結果が得られないことを指しています。 ここで言うアプリケーションは「クライアント(利用者)の要求に応えて処理を行う」もの(「記事投稿」ボタン押下→記事を投稿する、ツイート情報取得APIリクエスト→ツイート情報の返却、etc.)です。 アプリケーションに限って、より具体的に「失敗」を説明するなら次のような文脈になるでしょう。
- ある要因のためにクライアントが期待した処理を完了できなかった
例えば、「『記事投稿ボタン』を押下したところ記事が投稿されずに『データベース接続エラー』と表示された」なら、それは失敗です。 他にも、「ツイート情報取得APIリクエストを送信したところツイートが存在しなかったため『404 Not Found』ステータスが返却された」なら、それは失敗です*1。どちらもクライアントの期待した処理を完了していません。
このアプリケーションの「失敗」はより上位の利用者(クライアントアプリケーション)にとって異常になりえます。そして異常は、その利用者の動作を「失敗」させます。その「失敗」もまた、より上位の利用者にとって異常になりえます。
この異常と失敗の連鎖は、最終利用者(エンドユーザー)まで伝達するか、途中で失敗が回避されることで終わります。このモデルを理解するために、利用者(クライアント)側から見た異常について詳しく考えてみます。
異常について
3種類の異常
利用者(クライアント)側から見た異常は、大雑把にわけると次の三種類に分別されます。
- 要求する異常(Desired error)
- 予期する異常(Expected error)
- 予期しない異常(Unexpected error)
このうち、「要求する異常」は「予期する異常」の部分集合です。ベン図っぽく描くと以下のようになります。
異常の表現方法
コード上では、ほとんどの異常が例外(Exception)や代数的なデータ型によって表現されます。
例外とは、JavaやC#などでおなじみの「処理の途中で『これは異常だ』と判断し、Exceptionをスローして直ちに処理を終了するもの」です。
代数的なデータ型は、文字どおり代数的なデータ構造を持つ型を指しています。 歴史のあるBoolean(ブール代数)を始め、今では多くの言語でサポートされているMaybeやEitherなどが例に挙げられます。 「異常を代数的なデータ型で表現する」と書くと堅苦しいですが、実体はもっとフレンドリーで、 例えば「処理が問題なく完了したらtureを返し、それ以外ではfalseを返す関数」などは異常を代数的なデータ型で表現している*2と言えます。
さて、それでは三種類の異常はそれぞれどのような属性によって区別されるものなのか、これから確認していきましょう。
要求する異常
要求する異常(Desired error)は、発生が予期されていて、かつ発生したら後続の処理が有意に分岐する異常を指します。
フレンドリーに言い表すと「俺がなんとかするから、お前は安心してていいぞ!」です(後述)。
「要求する異常」の例
ここからは例をとって考えてみます。いま私たちは「電子書籍のECサイト」を開発していて、現在着手中の機能は「書籍情報の取得」です。この処理のフローは以下の通りです。
- 書籍マスタから書籍情報の取得を試みる
- 該当する書籍がある ⇒ 書籍情報を返却する
- 該当する書籍がない ⇒ Kindleストアの紹介ページから取得した情報を返却する
ではまず、このアプリケーションを実装するための小さなパーツを確認してみましょう。
def getBook(bookName: String): Option[Book] = BookRepository.findByName(bookName)
getBook関数は自アプリケーションの書籍マスタから書籍を取得します。BookRepository.findByNameは該当する書籍があればSome(book)を、なければNoneを返します。
この関数自体の戻り型もOption[Book]です。この関数がNoneを返すことは、《該当する書籍情報がなかった》という異常を表しています。
次はより大きな、より上位のパーツを確認します。
def getBookInfo(bookName: String): Option[Book] = getBook(bookName) orElse KindleStoreService.lookUpForName(bookName) //(注)上記のコードはJava7風に書いた次のコードと等価です // val b = getBook(bookName) // if (b.isDefined) { // return b // } else { // return KindleStoreService.lookUpForName(bookName) // }
getBookInfo関数は少し上で説明した「書籍情報の取得フロー」の実装です。KindleStoreServiceはキンドルストアから書籍情報を取得するサービスです。
この関数は、getBook関数呼び出しの結果、書籍情報があるかないかで後続処理が分岐しています。 そしてこの分岐は、エラー表示や例外処理のための分岐ではなく、アプリケーションの機能を表した分岐(ビジネスロジック)です。
getBookInfo関数はこのアプリケーションの書籍情報取得機能を表現しています。この機能は、getBook関数が《該当する書籍情報がない》という異常を返すことを知っています。そしてその異常が返されたとき、後続のアプリケーション処理は有意に分岐します。 前述の定義と合わせて考えると、getBook関数の《該当する書籍情報がない》という異常は、利用者(getBookInfo)側が「要求する異常」と言えます。
上図は《該当する書籍情報がない》という異常が発生した際の、サイト全体の流れです。失敗と異常の連鎖がgetBookInfoの時点で断ち切られ、処理全体としては成功している*3のがわかります。 言い換えれば、《該当する書籍情報がない》件については、getBookInfoが責任をもって面倒を見てくれています。これをもっとフレンドリーに言い表すとすれば
「《該当する書籍情報がない》は俺(getBookInfo)がなんとかするから、お前(より上位の利用者)は安心してていいぞ!」
という感じです。
この例のように、利用者側から見て《発生が予期されていて、かつ発生したら後続の処理が有意に分岐する異常》を「要求する異常」と呼んでいるわけです。この「要求する異常」は《発生しても処理が失敗しない≒利用者から見て『より上位の利用者』に異常が連鎖しない》*4という特徴を持っています。
「要求する異常」の表現
もう一度getBook関数について考えてみましょう。戻り型はOption[Book]で、《該当する書籍情報がない》という異常はNoneで表されています。 これは先ほど説明した「代数的なデータ型で表現された異常」です。
def getBook(bookName: String): Option[Book] = BookRepository.findByName(bookName)
この関数にとって、《書籍マスタに該当の書籍情報がないため、情報を取得できなかった》ことは明らかな失敗です。そしてこの失敗は、より上位の利用者(getBookInfo)にとっては「要求する異常」にあたります。
ところで、異常を表す一般的な方法には、代数的なデータ型以外にもう一つありました。そう、例外です。ものは試しに、getBook関数を、《該当する書籍情報がない》を表す例外をスローするように変更してみます。
def getBook(bookName: String): Book = BookRepository.findByName(bookName) getOrElse { throw new NoSuchElementException("(書籍情報が)ないです") }
このシグニチャになったgetBook関数を利用して、書籍情報の取得処理を実装することを想像してみてください。 恐らく、getBookの呼び出しをtry-catchでくくって、catch節で分岐後のビジネスロジック(Kindleストアから……)を呼ぶものになるはずです。本来if文があるべき箇所を、try-catchが代役しています。この実装がイケてると思う方は、まず居ないでしょう。
ほとんどの場合、「要求する異常」は代数的なデータ型で表現すべきです。これは前述の例からもわかるとおり、「要求する異常」の《発生したら後続の処理が有意に分岐する》という性質を鑑みると、「例外による実装」よりも「代数的データ型による実装」が適しているためです。
「要求する異常」のまとめ
「要求する異常」は、次のように定義されます。
- 利用者側からみて発生が予期されていて、かつ発生したら後続の処理が有意に分岐する異常
「要求する異常」には、以下のような性質があります。
- 発生しても、直ちに処理が中断し失敗することはない
- 利用者から見て『より上位の利用者』に異常が連鎖しない
- 代数的なデータ型による表現が適している場合が多い
この性質をもっとフレンドリーに表現すると、次のような感じです。
- 俺がなんとかするから、お前(より上位の利用者)は安心してていいぞ!
ある一連の処理のなかで、「要求する異常」のみが発生し、その他の異常が一切発生していないとすれば、その一連の処理は全体として成功します。
予期する異常
予期する異常(Excepted error)は、発生が予期されていて、発生したら後続の処理を中断する異常を指します。
フレンドリーに言い表すと「俺にはわからんけど、お前ならわかるんじゃね?」です(後述)。
「予期する異常」の例
先ほどの電子書籍ECサイトをふたたび例に取ります。今回は「書籍情報の更新」を考えてみましょう。処理フローは以下のとおりです。
- 書籍IDに該当する書籍情報を更新する(DBのレコードを更新)
- 更新した内容を表示する
見ての通り、この処理に有意なビジネスロジックとしての分岐はありません。ただし、異常がないわけではありません。 この処理は《既存の書籍情報を更新する》ものですから、《該当する書籍情報がない》という異常があって然りです。
気づいた方も多いと思いますが、この《該当する書籍情報がない》という異常は、先ほど「要求する異常」の例として上げたものと同じです。 しかし、《利用者側がその異常をどう捉えているか》が「要求する異常」の時とは異なります。 今回の異常は、利用者側から要求されてはいません。利用者はこの異常を「発生しうること」として想定していますが、先ほどのようにビジネスロジックが有意に分岐することはありません。
ところで、《1. 書籍IDに該当する書籍情報を更新する》で《該当する書籍情報がない》とき、《2. 更新した内容を表示する》ことはできません。前提となる処理が失敗しているためです。 言い換えると、《該当する書籍情報がない》が発生した場合、後続の処理は中断されます。
ここまでで示した要素と、先に上げた定義とを合わせてみると、この異常は「予期する異常」だと言えます。
異常と失敗の連鎖は、終端まで続きます。処理が中断されるので当然ですね。
ここで着目していただきたいのは、アプリケーションはこの異常を《想定していないわけではない》ということです。 処理は中断され、アプリケーションロジックとは直接関係のないエラー表示ロジックに移りますが、そこで異常の内容を利用者(本例ではユーザー)に伝えることで、利用者はこの異常を回避するための行動をとるかもしれません。それを助けるために、今回の例(ECサイト)であれば、説明的なエラー文章を用意したり、Q&Aへのリンクを貼ったりといった対策を講じておくこともできます。
「予期する異常」は、この例ように《発生が想定されていて、かつ自分(自アプリケーション)で対応できないが、利用者は対応できる可能性がある》という特徴を持っています。 これをもっとフレンドリーに言えば
「俺にはわからんけど、お前(利用者)ならわかるんじゃね?」
という感じです。
「予期する異常」の表現
ここで、HTTPステータスコードについて考えてみましょう。《該当する書籍情報がない》という異常をユーザーに伝えるとき、どんなステータスコードを選ぶべきでしょうか。 アプリケーション自体が持つ文脈にもよりますが、ほとんどの場合は「404(Not Found)」だと思います。
4xx系のコードはクライアントエラー(利用者側のエラー)を表しています。 4xx系のエラーが返されたら、ほとんどの場合、クライアント(利用者)は自分のリクエスト内容を再度確認するなど、具体的な改善行動をとることができます。 これは「予期する異常」が持っている《利用者が対応できる可能性もある》という特徴とほぼ同じです。 そのため、ほとんどの場合、「予期する異常」はHTTPのインターフェイスでは4xx系のステータスコードで返却されます。
次に、コード上での表現方法についてです。 例外が適切か、代数的なデータ型が適切かを考えたくなりますが、「予期する異常」については《例外と代数的データ型どちらがより適切か》を明言することができません。
一見すると、「予期する異常」は後続の処理を中断してしまうものですから、例外のスロー*5で表現するのが適切に思えます。 その場合には、《その例外をそのままアプリケーション層の例外として扱ってよいのか》という疑問と向き合わなければなりません。
例えば、KVSアクセス用のライブラリを使っていて、もし指定されたキーが存在しなければNoSuchKeyException
という独自例外を返すとします。
この例外は、レイヤ化アーキテクチャなどで言う「インフラ層」の知識です。一方で、この例外を「予期する例外」として扱った際に、対応するエラーを表示/返却するのは「プレゼンテーション層」の責務です。
勘の良い方は、この時点で《インフラ層とプレゼンテーション層が密結合になってしまった》と気づかれるでしょう。
これを避けるためには、レイヤの境界で例外をマッピングする必要があります。これはまるでキャッチアンドリリースですし、コードベースが複雑になってしまいます。
//java try { doSomethingInfrastructure(); } catch (NoSuchKeyException e) { throw new SomeApplicationLayerException(e); }
もしレイヤ間のマッピングをするつもりがあるなら、例外を使わずに代数的なデータ型で表現しておくべきでしょう。
//scala Either val result: Either[NoSuchKeyException, Unit] = doSomethingInfrastructure() result.left.map(SomeApplicationLayerException) //scala Future val future: Future[Unit] = doSomethingInfrastructure() future.transform(identity, { case e: NoSuchKeyException => SomeApplicationLayerException(e) })
私たちがよくやるのは、例外をスローする手法です。 まず、異常系についてはアプリケーションが一枚岩になることを容認します。その上で、複数レイヤをまたいで参照されるExceptionは「コア層」と呼ばれる特別なレイヤに格納します。 そしてコア層はどのレイヤからも直接参照可能とします。
このように、「予期する異常」をどのように表現すべきかは、アプリケーションの規模や複雑さ、もっと言えば開発の哲学に依存します。こういった事情から、どちらを使うべきか明言できないのです。
「予期する異常」のまとめ
「予期する異常」は、次のように定義されます。
- 利用者側からみて発生が予期されていて、発生したら後続の処理を中断する異常
「予期する異常」には、以下のような性質があります。
- 発生すると、直ちに後続の処理を中断する
- 失敗が上位に伝達(連鎖)する
- 末端の利用者(ユーザーなど)は、この異常を受けて解決行動を取りうる
- どうのようなコード上の表現が適切かはケースバイケース
この性質をもっとフレンドリーに表現すると、次のような感じです。
- 俺にはわからんけど、お前(利用者)ならわかるんじゃね?
ある一連の処理のなかで、「予期する異常」が発生した場合、その一連の処理は全体として失敗します。
予期しない異常
予期しない異常(Unexpected error)は、名前どおり発生が予期されていない異常を指します。
フレンドリーに言い換えると「俺たちにはどうすることもできない……」です(後述)。
「予期しない異常」の例
まず最初に言葉の意味を明確にしておきます。「予期しない異常」は、「存在を知らなかった異常」のみを指すものではありません。 存在は知っていたが対応を考慮しなかった異常や、そもそも対応が不可能なので無視せざるを得ない異常もここに含まれます。
先ほど「予期する異常」で例に取った「書籍情報の更新」をもう一度例にとって考えてみます。この処理のフローは次のとおりでした。
- 書籍IDに該当する書籍情報を更新する(DBのレコードを更新)
- 更新した内容を表示する
前節では、《1. 書籍IDに該当する書籍情報を更新する》で《該当する書籍情報がない》という異常が発生するケースを考えました。そして、その異常は「予期する異常」に相当することがわかりました。
今回は、《1. 書籍IDに該当する書籍情報を更新する》で《DBとの通信に失敗した》という異常が発生するケースについて考えます。
上図を見て分かるとおり、この《DBとの通信に失敗した》は「予期しない異常」です。
利用者に「アプリケーションサーバーとDBの接続エラーが発生したよ」と伝えても、利用者には為す術がありません。そしてこれはアプリケーションサーバ(Webアプリ)にとっても同じです。彼にはDBとの接続障害を解決する能力がないのです。
これをフレンドリーな言葉で言い換えれば、次のようになります。
「俺たち(自アプリケーションと利用者)にはどうすることもできない……」
DB接続障害を解決する能力を持っているのは、ネットワーク管理者です。それ以外の誰に(あるいは何に)この異常を伝えても、文字通り「どうすることもできない」です。 逆説的ですが、《どうすることもできない、対応することができないなら、そもそも想定しない(すべきでない)》ので、この特徴を持つ異常は「予期しない異常」と言えるでしょう。
「予期しない異常」の表現
まず、HTTPステータスコードについて考えてみます。 「予期しない異常」は、ほとんどの場合「500(Internal Server Error)」に相当します。まれにそれ以外の5xx系に相当するかもしれません。
5xx系のコードは《利用者は悪くない(≒利用者には為す術がない)》という意味合いを持っています。 これは、「予期しない異常」の持っている意味合いとよく合致しています。
次に、コード上での表現について考えます。端的に言って、「予期しない異常」は例外のスロー5で表現されるべきです。 各種の「予期しない異常」に1対1で対応するコードを書くことはまずあり得ません。Webアプリならたぶん《ただいまメンテナンス中につき、しばらくお待ち下さい》とか《エラーが発生しました。お手数ですが時間を置いてお試し下さい》とかの包括的な障害用ページを用意して、 「予期しない異常」発生時にはとりあえずそれを表示する、くらいの対応になるはずです。そしてたぶん、「予期しない異常」が発生した旨のメールを運営チームのメーリングリストに送信するでしょう。
代数的なデータ型を用意したところで、その値をもとに分岐が発生しないのであれば、それ無意味です。「予期する異常」の場合には、少なくともエラー返却・表示時に型とロジックが1対1で対応する可能性がありました。
もう一つの大きな理由として、《必要のないコードは書かない方がよい》があります(You Ain't Gonna Need It)。「予期しない異常」についてのあれこれ想定してコードを書くのは、得策ではないでしょう。
「予期しない異常」のまとめ
「予期しない異常」は、次のように定義されます。
- 利用者側からみて発生が予期されていない異常
- 「単純に見落としてた」「あえて無視した」「無視せざるを得なかった」など、様々な理由があり得ます。
「予期しない異常」には、以下のような性質があります。
- 発生すると、直ちに後続の処理を中断する
- 失敗が上位に伝達(連鎖)する
- 末端の利用者(ユーザーなど)は、この異常を受けても為す術がない
- 自アプリケーションは、この異常を受けても為す術がない
- 例外(Exception)によって表現されるべき
この性質をもっとフレンドリーに表現すると、次のような感じです。
- 俺たち(自アプリケーションと利用者)にはどうすることもできない……
ある一連の処理のなかで、「予期しない異常」が発生した場合、その一連の処理は全体として失敗します。
まとめ
今回は、アプリケーションを開発している時に意識すべき《3種の異常》について説明しました。
それぞれをまとめて比較すると、次の表の通りです。
異常の種類 | 想定されている | 処理が中断する | HTTP Status | 表現方法 | フレンドリーな表現 |
---|---|---|---|---|---|
要求する異常 | はい | いいえ | 後続の処理による | 代数的データ型が合う | 俺がなんとかするから、お前は安心してていいぞ! |
予期する異常 | はい | はい | 4xx系 | コンテキストによりけり | 俺にはわからんけど、お前ならわかるんじゃね? |
予期しない異常 | いいえ | はい | 5xx系 | 例外 | 俺たちにはどうすることもできない…… |
異常系をプログラミングしている時に、《自分がいま取り扱っているのはどんな種類の異常だっけ?》と考えるようにすれば、ぐっとコードが書きやすくなるのではないかと思います。
私たちプログラマーと「異常」とは切っても切れない関係なので、うまーく付き合って行きたいですね!ではまた!
注釈
1 クライアントがこのAPIを「ツイート存在確認API」の感覚で使っている場合に、ステータスが404でも期待した処理を完了したと言えるのではないか、という疑問があるかもしれません。 今回の例では、API提供側はツイートが存在しないこと後述の「予期する異常」として扱い、クライアントに404を返却します。一方クライアントは「要求する異常」として404を取り扱っています。 このため、「提供されたAPIアプリケーション」ではこの404は失敗ですが、APIのクライアントまで含めたアプリケーションを操作しているクライアントの視点ではこのAPIの結果が404かどうかによらず(他の要因がなければ)成功しています。
2 Booleanは列挙型です。実際のコードでは、より表現力の高い直和型を使うことが圧倒的に多いはずです。また例に上げた関数は、明らかに副作用を持っています。
3 明らかに《キンドルストアから書籍情報を取得できなかった》という異常が存在しますが、説明を簡略化するため、言及しません。
4 《失敗の直接的な原因になり、直ちに処理を中断させるようなものではない》という意味です。続くビジネスロジックのなかで更に判断があり、その結果として処理が失敗することはあり得ます。
5 モナディックに書いているなら、例外をスローするのではなく、失敗の文脈を返り値にします。今回の例のようなアプリケーションを作る場合、そのほとんどでは結合可能な一方向性のモナドを扱っているはずです(e.g. Future.failed(new Exception)
)。