チェインソーマン最終回とアニメ化と第2部が発表ありましたね。なおアニメは「お住まいの地域ではご覧になれません」ので悲しいです。
本記事では、例外に関しての設計指針を記述したいと思います。
例外の歴史
多くのプログラミング言語でサポートされている「例外」は、構造化例外処理(SEH-Structured Error Handling)などと呼ばれるプログラミング言語の機構です。
以下のような特徴を持っていると思います。
- 関数などのスコープを超えて大域脱出の処理が可能
- 実行時のコンテキスト(スタックトレース)を持てる
- catch句のようなハンドラを持って処理する
- キャッチ・非キャッチ例外のような種類があるものもある
例外のない言語
それ以前での例外処理はどうだったか?というと、大まかに分けて2種類です
- エラーコードで区別する
- グローバルエラーオブジェクトを持つ
1は、言語仕様によってさらに以下のような分岐があります
- 関数ごとの戻り値でエラーコードを返す
- 成功・失敗コードを持つ結果オブジェクトを返す
- 複数の戻り値が返せる言語で、最後の戻り値をエラーとして使う(Go言語など)
戻り値が複数持てない言語は変更可能な引数を渡すパターンもあります(C#のOut引数、C++のポインタ変数で渡すなど)
2は、LastErrorなどという大域オブジェクトを定義してエラー発生時に更新するといった仕様(今となっては信じられない方もいるかもしれませんが)もあります。
VBA、Win32 APIなどはそういったものです。今も「LastError」などで検索するとこの仕組みの製品もあるようです。
言語によって変わってきますが、設計思想や別の言語の便利なものを輸入するといったことができるので、説明していきたいと思います。
非キャッチ例外とハンドル
言語によっては、キャッチ例外(検査例外)、非キャッチ例外(非検査例外)などの種類があります。
以下の記事がよくまとまった読み物として面白いと思います。
- 例外をめぐる議論 - IBM デベロッパーワーク
キャッチ例外の仕様は言語によってまちまちで、C++ではthrow句で記述するが、強制力はないという昔の仕様で、最近だと例外を投げる投げないを示すnoexcept(bool値)を足すという仕様になっています。
Javaのキャッチ例外は、メソッドのシグネチャに影響を与えたり、ハンドル(catch処理またはスローの定義)を強制力が強いものです。C#ではすべて非キャッチ例外にされています。
Go言語では、例外がなく、慣例的に複数持てる戻り値の最後にerrを返す慣例があります。
少なくとも強い制約を持つキャッチ例外は、現在はほとんど使われなくなっています。JavaのORMapperのHibernateもVer3.Xで非キャッチ例外へ変わり、Springも非キャッチ例外、C#も言語仕様で非キャッチ例外としています(キャッチ例外を使える余地は残している)
問題の起こる可能性はたくさんあり、すべてを定義したり、ハンドルを前提とするのは効率が悪いことと、キャッチ処理を使って個別対処する場合はたいていのアプリケーションでケースが比較的少ないことが理由かと思います。
例外のハンドル
例外の処理は、各関数で個別に同様のコードを記載するのではなく、例外ハンドラを作って起き、グローバルスコープで用意したりレイヤーやドメイン別に用意したりする慣例が多いです。
ソフトウェアシステムでの例外の設計
利用者の視点の例外の種類
ソフトウェアシステム(APIサーバーや、バックエンド、バッチ、Webサイト、ディスクトップアプリ、モバイルアプリ)での例外は、以下のように大別できると思います。
例外の種類 | 説明 | 例 |
---|---|---|
システム例外 | ユーザで対応不可能な例外 | メモリ不足、DB接続障害、IOエラー、周辺システムとの接続エラーなど |
アプリケーション例外(回復不可) | ユーザーに通知されるべきビジネスルール違反に関する例外 | 商品の在庫切れ、残高不足、記事などの楽観ロックの同時編集エラー |
アプリケーションリカバリ例外(回復可) | ユーザーに通知され、再操作などでリカバリ可能な例外 | 所定のファイルを開くときに書式エラー(再度選択させる)、処理のタイムアウト(再操作)、 |
プログラム違反例外 | プログラム開発者へ通知されるエラー。 | 配列長違反、Nullポインタ、前提条件違反(IllegalStateやIllegalOperationなど) |
言語仕様で分けていたり、フレームワークのガイドラインで語られていたりします。
- C#では SystemExceptionとApplicationException
- CTCガイダンス
システム例外
さて、C++などでオブジェクトをnewするたびにメモリ違反例外を確認するでしょうか? 開発者はファイルIOのエラーを任意に起こせるでしょうか? RDBを使うプログラムで何らかのDB異常が起きたときに、利用者はどうすればいいでしょうか?
答えは「開発者は、実装時にチェックしない(ハンドルで、モックでのテストで想定通り動作するかは確認する)」「利用者は、どうしょうもない(のでサポートに問い合わせる)」です。
システム例外は提供者と管理者・開発者が対応する必要があるのです。
起動時と設定内容のチェックを厳重に行って予防する場合と、稼働中にヘルスチェックの機能を持たせる場合もあります。
システム例外の特徴と対処
- 管理側が問題を特定するために、ログを出すことが重要
- スタックトレースと原因となるコンテキストのキー情報も記録する
- メッセージはほとんどの場合国際化等も不要です。特定するIDなどを記録します。
- 利用者にとっては詳細は重要ではなくシステムで問題が起こり、管理者側にて対応が必要であることを示せばよい
- 起動時にDB接続や外部IF接続などの最低限のチェックし、条件を見たなさい場合は起動しないという対応をする場合も
ある - ヘルスチェックやグレースフルシャットダウンなどの実装を行って監視と安全な再起動を可能にする場合もある
- 監視基盤等での監視を行うため、JSON構造などのログを出す場合もあります。
アプリケーション例外(回復不可)
たとえば、ECサイトで注文する際、同時に注文が行われ在庫が尽きた場合や、利用残高が足りなくて決済できないといった場合は、利用者に「わかりやすく、意味のあるエラーのメッセージ」を出す必要があります。
アプリケーション例外の特徴と対処
- 利用者へのメッセージは、わかりやすさや国際化等が必要になる
- バックエンドやAPIサーバーなどで分かれている場合は、例外を、UI側ではこの例外から「わかりやすいメッセージ」への変換を行う
- バックエンドに、アプリケーションのためのわかりやすいメッセージを埋め込まない(UIとアプリに依存しアプリ開発側で変えられるべきであるため)
- グローバルハンドラやドメインのハンドラを使う
- 例外とわかりやすいメッセージを出すための変換のため、内部コードや共通の構造、メッセージの補完情報を含める設計を行う
共通の構造を持ったエラーフォーマット
ソフトウェアシステムでは、アプリケーション例外は、メッセージに変換して利用者に表示する必要になります。
デスクトップアプリケーションでは、専用のクラスとUIウィジットを用意したり、REST APIサーバーであれば、Problem JSONなどの仕様に沿った共通の構造を持たせるのが良い設計になります。
個人的には、HTTPステータスコードやgRPCステータスコードでは、アプリケーションに必要なメッセージに対応できるような変換は不可能だと考えています。
以下の例のように、内部用のコード(文字列でサービスやドメインごとに体系を持たせる)と、任意の補完情報が入れられるようなKev-Valueの組み合わせの配列を持てるようにするとよいでしょう。
{
"status" : 412,
"message" : "No enough balance in account.",
"error_code" : "EC-TRADE-003",
"details" :
[
{"key":"accountName", "value": "Yamada Taro"},
{"key":"itemId", "value": "123456"},
{"key":"itemName", "value": "Vやねん!タイガース特集号"},
{"key":"itemPrice", "value": "334"},
]
}
また、以下に、Problem JSONの記事と例を載せておきます。
{
"type": "https://example.com/probs/cant-view-account-details",
"title": "Not authorized to view account details",
"status": 401,
"detail": "Due to privacy concerns you are not allowed to view account details of others. Only users with the role administrator are allowed to do this.",
"instance": "/account/123456/details"
}
アプリケーションリカバリ例外(回復可能)
たとえば、所定のフォーマットのファイルを開く、SMSに送信された認証コードを時間以内に入れる、などといった処理は失敗したとしても、ファイルを選びなおす、SMSに再送信して入力しなおすなど、再度操作することで回復可能になります。
こういった場合も上記のアプリケーション例外の扱いと同様ではありますが、再操作を促したり、同じ捜査画面でハンドルするなどといった処理が必要になります。
アプリケーションリカバリ例外(回復可能)の特徴と対処
多くはアプリケーション例外と同様です。
- グローバルハンドラなどの共通処理ではなく、同じ画面でのハンドル等が必要になる場合がある
- 再度実行させる場合は、メッセージを表示するだけでなく、コンポーネント内でリンクを出すなどの場合もある
- キャッチ例外を使う場合がある
そこまでのケースがないので特別にハンドルすることで対応できると思います。
例としては、上記に記載したような場合以外に、サービスが断線した際に、再接続させるなど、タイムアウトや接続の断線でも見かけます。
GoogleのGmailでは、DoSを避けるため再接続の時間を少しずつ指数級数的に増やすというような対応もしていたりします。
プログラム違反例外
すべてのメソッドで、引数がすべてNullかどうかチェックして、NullだったNullPointerExceptionを投げる開発者はたぶんいないと思います。一方、API作成していて、呼ぶ順番前提があるような場合は、IllegalStateやIllegalOperationを使ってガイドする場合はよくあります。
開発の助けになる使用方法違反やマナー違反を扱うものです。アサートがある言語ではそちらを使う方が良いです。無い言語でもある程度は片付けされているので利用しましょう。
対象はプログラマなので、システム例外同様の扱いですが、スタックトレースが出て開発者が直せればよいので、もっとぞんざいに扱っても構わないと思います。
例外のハンドル
ハンドラが定義できるような言語やフレームワークであればそれを使えばよいです。
ない言語も工夫次第で同様のことができます。
一例として、Go言語でのREST APIサーバーを実装したときに作ったコードを載せておきます。
サービス層の処理を行うクラスは、サービスエラー(内部コードを持ったエラー)を返すようにしておき、そのサービスエラーの内部コードをHTTPステータスコードへ変換するハンドラを定義して変換します。
サービスのAPIを呼び、エラーが起きたら、とにかく api.SetErrorStatus
というハンドラを呼び出すだけになります。
func create(c *gin.Context) {
board, serr := getBoardByCreateRequest(c)
if serr != nil {
api.SetErrorStatus(c, serr)
return
}
// create board
tx := orm.GetDB().Begin()
srvc := service.NewBoardService(tx)
serr = srvc.CreateBoard(board)
if serr != nil {
api.Rollback(tx)
api.SetErrorStatus(c, serr)
return
}
serr = api.Commit(tx)
if serr != nil {
api.SetErrorStatus(c, serr)
return
}
res := convertBoardResponse(board)
c.IndentedJSON(http.StatusOK, res)
}
// Definition of ErrorCode
const (
ErrorCodeUnexpected ErrorCode = "UnexpectedError"
ErrorCodeBadRequest ErrorCode = "BadRequest"
ErrorCodeInvalidArguments ErrorCode = "InvalidArguments"
ErrorCodeInvalidlStatus ErrorCode = "InvalidStatusError"
ErrorCodeDB ErrorCode = "DBError"
ErrorCodeNotFound ErrorCode = "NotFound"
ErrorCodeAlreadyExist ErrorCode = "AlreadyExist"
ErrorCodeOptimisticLockFailure ErrorCode = "OptimisticLockFailure"
ErrorCodePreconditionInvalid ErrorCode = "PreconditionInvalid"
ErrorCodeUnauthenticated ErrorCode = "Unauthenticated"
)
// SetErrorStatus sets http status and error response corresponding service.SvcError
func SetErrorStatus(c *gin.Context, err error) {
serr, ok := err.(*service.SvcError)
if !ok {
serr = service.NewSvcErrorf(service.ErrorCodeUnexpected, err,
"Unexpected error occurred Error:%s", err.Error()).(*service.SvcError)
}
// logging to stdout
if serr.Cause == nil {
fmt.Printf("Service error occurred. Code:%s Message:%s", serr.Code, serr.Message)
} else {
fmt.Printf("Service error occurred. Code:%s Message:%s Cause:%+v", serr.Code, serr.Message, serr.Cause)
}
var status int
switch serr.Code {
case service.ErrorCodeUnexpected:
status = http.StatusInternalServerError
case service.ErrorCodeBadRequest:
status = http.StatusBadRequest
case service.ErrorCodeInvalidArguments:
status = http.StatusNotAcceptable
case service.ErrorCodeInvalidlStatus:
status = http.StatusInternalServerError
case service.ErrorCodeDB:
status = http.StatusInternalServerError
case service.ErrorCodeNotFound:
status = http.StatusNotFound
case service.ErrorCodeAlreadyExist:
status = http.StatusConflict
case service.ErrorCodeOptimisticLockFailure:
status = http.StatusPreconditionFailed
case service.ErrorCodePreconditionInvalid:
status = http.StatusPreconditionFailed
case service.ErrorCodeUnauthenticated:
status = http.StatusUnauthorized
}
errorResponse := &ErrorResponse{
Code: string(serr.Code),
Message: serr.Message,
Details: serr.Details,
}
c.IndentedJSON(status, errorResponse)
}
ほかのプログラミング言語であって、便利なコンセプトや設計は、移行できるか補完するライブラリを探すことをお勧めします。自作は最後の手段です。
おわりに
nannany_tisさんの日付、乗っ取りました。めんごめんご、反省してま~す
(もともとは、16日?位にNodeでSQLiteのORMに関して書こうとしていたらElectronとNode.jsのGapにハマっていて、別記事書いたら代理投稿されていたので19日に代理投稿しました)