例外をHTTPに変換する
このレッスンでは、業務のことばで投げられた例外を HTTP のステータスコードへ翻訳する設計を身につけます。
業務の失敗と HTTP の失敗は別のことばです
Service 層は業務のことばで失敗を表します。「その利用者は存在しない」「そのメールアドレスはすでに使われている」「残高が足りない」といった具合です。一方 HTTP のクライアントが理解できるのは 404、409、400 といった 3 桁の数字です。この 2 つの世界をつなぐ翻訳の層がどこかに必要になります。
翻訳をどこに置くかで、コードの見通しは大きく変わります。Service の中で ResponseEntity を組み立ててしまうと、Service が HTTP を知ってしまい、バッチ処理やメッセージ受信から同じ Service を呼びたくなったときに困ります。逆に Controller のすべてのメソッドで try と catch を書くと、同じ変換が何十か所にも散らばります。
Service は業務例外を投げ、外側が翻訳します
現実的な落としどころは、Service が業務例外を投げ、Web の入口に近い層が例外の型を見てステータスを決める形です。
Java
public class UserNotFoundException extends RuntimeException {
private final long userId;
public UserNotFoundException(long userId) {
super("user not found. id=" + userId);
this.userId = userId;
}
public long getUserId() {
return userId;
}
}Java
@Service
public class UserService {
public User find(long id) {
return repository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
}Service は 404 という数字を一度も書いていません。「見つからなかった」という業務の事実だけを表明しています。この例外を受け取った側が 404 へ翻訳します。次のレッスンで扱う @RestControllerAdvice が、その翻訳を 1 か所に集める仕組みです。
翻訳表を先に決めます
実務では、翻訳のルールを表として先に決めておくとぶれません。よく使う対応は次の通りです。
| 業務上の失敗 | 例外の型 | ステータス |
|---|---|---|
| 対象が存在しない | NotFoundException | 404 |
| 入力が不正である | ValidationException | 400 |
| 一意制約とぶつかった | DuplicateKeyException | 409 |
| 認証されていない | UnauthorizedException | 401 |
| 認証済みだが権限が足りない | ForbiddenException | 403 |
| 上記のどれでもない | 想定外の例外すべて | 500 |
401 と 403 の違いは頻出です。401 は「あなたが誰なのか分からない」、403 は「あなたが誰かは分かったが、それをする権限がない」です。ログインし直せば解決する見込みがあるのが 401、し直しても解決しないのが 403 と覚えると迷いません。
想定外は必ず 500 に落とします
翻訳表に載っていない例外を、それらしいステータスへ推測で割り当ててはいけません。表に無い例外は「こちらが想定していなかった不具合」であり、その正体は 500 です。4xx を返してしまうと、クライアント側は自分の入力が悪いのだと解釈して直そうとし、実際にはサーバーの不具合なので永遠に直りません。既定を 500 にしておくことは、不具合を不具合として表に出すための設計です。
同時に、500 の応答本文に例外のスタックトレースをそのまま載せないでください。内部のクラス名やライブラリの構成が漏れ、攻撃の手がかりになります。詳細はサーバーのログに残し、クライアントへは短いメッセージと追跡用の識別子だけを返す形が定石です。
演習で書くもの
Spring では例外の型からハンドラを探し、そこに書かれたステータスを応答に乗せます。その「型からステータスを決める」判断そのものを、ここでは対応表を持つ 1 つの関数として自分で書きます。表に無い名前が来たら 500 を返してください。
要件
- 表にある 5 つの例外名について、決められたステータスコードを返すこと
- 表に無い名前と null は 500 を返すこと
- 戻り値は int とし、文字列で返さないこと
入出力例
toStatus("NotFoundException") → 404
toStatus("ValidationException") → 400
toStatus("DuplicateKeyException") → 409
toStatus("UnauthorizedException") → 401
toStatus("ForbiddenException") → 403
toStatus("OutOfMemoryError") → 500
toStatus("notfoundexception") → 500
toStatus("") → 500