ログに何十行も赤い文字が出ている。Caused byが大事だとは聞いたけれど、一番下の例外名を検索した後、どのコードを直すのか分からない。
この記事は、Javaの不具合調査を初めて担当する人向けです。ゴールは例外名を覚えることではなく、失敗した値と、その値を作った処理を調べ始められることです。
注文CSVの例に加え、Springで実際に発生させた400エラーを比べます。自作コードの行が出ない場合にどこへ戻るか、行が出た場合に何を直して再確認するかまで追います。
原因例外を読んだら、自分たちのコードへ戻る。
一番下の例外名は「何が失敗したか」の手がかりです。それだけで、悪い入力を作った場所や直し方までは決まりません。
まず、調べる一件を選ぶ
同じ時刻に複数のリクエストが動く環境では、近くに出たエラーが同じ失敗とは限りません。操作時刻、処理名、リクエストIDなどで、対象の一件をそろえます。
次のログは説明用に短くした例です。注文CSVの金額を数値へ変換する処理で失敗しています。
java.lang.IllegalStateException: 注文CSVの取込に失敗しました
at ImportService.importRow(ImportService.java:18)
Caused by: java.lang.NumberFormatException: For input string: "1,200"
at java.base/java.lang.Integer.parseInt(Integer.java:...)
at ImportService.parseAmount(ImportService.java:24)
at ImportService.importRow(ImportService.java:16)
... 1 moreInteger.parseIntの内部行を直す話ではありません。まずImportService.parseAmountが何を渡したかを見ます。この例なら、桁区切りのカンマを含む文字列が数値変換へ渡っています。
上から文脈、下から手がかり、自作コードで合流する
| 読む場所 | この例から分かること | 次に見る場所 |
|---|---|---|
| 先頭の例外 | 注文CSV取込という処理が失敗した | 対象の取込操作と入力 |
| Caused by | 数値変換で失敗した | 変換へ渡した文字列 |
| 原因に近い自作コード | parseAmountから変換を呼んだ | 金額を取り出した処理と入力仕様 |
Caused byは、例外に保持された原因を示します。外側の例外と原因例外は別の情報です。JavaのThrowable公式仕様でも、この連鎖とスタック出力が定義されています。
自作コードの行を開いたら、ログを出した版と手元のコードの版が一致するかも確認します。別バージョンの18行目を見ても、違う処理を調べてしまいます。
カンマを消せば直る、と決める前に入力の約束を確認する
このログから言えるのは、「1,200という文字列を、今回の変換処理が受け付けなかった」までです。どう直すかは入力仕様で変わります。
| 入力の約束 | 直す方向 |
|---|---|
| 桁区切りを許す | 許す書式を確定し、その書式を扱う変換を用意する |
| 桁区切りを許さない | 入力の問題として、該当行と項目を伝えられるようにする |
| 本来は数量欄を読んでいる | 列の対応やCSV解析の誤りを直す |
「例外を消す」だけなら文字を除去して通せるかもしれません。しかし列の対応がずれているのに値を加工すると、間違った金額を登録する可能性があります。値・取り出した場所・入力仕様の三つを照合してから変更します。
Springの400で自作コードの行が見つからないとき
400の例外ログに、ControllerやServiceの行が必ず出るわけではありません。例えば、次の引数を持つControllerへquantity=threeを送ると、メソッドを呼ぶ前の数値変換で失敗します。
@GetMapping("/quantity/int")
public Map<String, Integer> integer(@RequestParam("quantity") int quantity) {
return Map.of("quantity", quantity);
}次はJava 17.0.8・Spring Framework 6.2.19で、MockMvcを使って採取した例外の抜粋です。getResolvedException()で取得した例外を検証用に出力しました。標準設定でこの全文がサーバーログへ出るという意味ではありません。[掲載時に中間フレームを省略]は記事の注記で、Javaが出力する... moreとは別です。
org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Method parameter 'quantity': Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "three"
at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.convertIfNecessary(AbstractNamedValueMethodArgumentResolver.java:301)
[掲載時に中間フレームを省略]
Caused by: java.lang.NumberFormatException: For input string: "three"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:668)
at java.base/java.lang.Integer.valueOf(Integer.java:999)確認結果はHTTP 400、Controllerメソッドの呼び出し0回でした。原因例外のNumberFormatExceptionにも、このアプリのController・Service・Converterのフレームはありません。テストを起動した行はログに含まれますが、業務処理の発生行とは区別します。
ここで開くのは、ログにないServiceの行ではなく、対象URLの@RequestParam("quantity") int quantityという定義と、送信されたquantity=threeです。この例のAPIは整数を受け取る約束なので、検証用の送信値を2へ直して再送するとHTTP 200、本文は{"quantity":2}、Controller呼び出し1回になりました。実画面で同じ誤入力が起こるなら、入力欄や送信処理も確認します。400を消すために、根拠なく引数をStringへ変える必要はありません。
Spring MVCでは、文字列以外の型で受けるリクエスト引数に型変換が適用され、標準の例外処理は型不一致を400へ対応付けます。Spring公式の型変換の説明、例外とHTTPステータスの対応
自作Converterの行が出たら、入力の約束と照合して直す
対照として、整数を保持する独自のQuantity型と、文字列から変換するConverter<String, Quantity>を登録した構成を調べます。Quantityはpublic record Quantity(int value) {}です。この検証の入力仕様は「整数の前後空白を許す」と決めていますが、修正前のConverterは空白付きの値をそのまま渡していました。
package info.syachiku.stacktrace.source;
import org.springframework.core.convert.converter.Converter;
public class BrokenQuantityConverter implements Converter<String, Quantity> {
@Override
public Quantity convert(String source) {
return new Quantity(Integer.parseInt(source));
}
}以下は実行したQuantityControllerの独自型を受けるメソッドの抜粋で、@RestControllerを付けたクラス内に置く断片です。Converterと同じパッケージにQuantityを置きます。importはjava.util.Mapと、org.springframework.web.bind.annotation配下のGetMapping・RequestParam・RestControllerです。callsは呼び出し回数を調べる検証用のフィールドで、クラス内にprivate final java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();と定義しています。
@GetMapping("/quantity/custom")
public Map<String, Integer> custom(@RequestParam("quantity") Quantity quantity) {
calls.incrementAndGet();
return Map.of("quantity", quantity.value());
}Converterは作るだけでは使われません。この検証では、JUnitのテストクラス内に次の補助メソッドを置き、MockMvcへ渡すConversionServiceに登録しています。importはorg.springframework.test.web.servlet.MockMvc、org.springframework.core.convert.converter.Converter、org.springframework.format.support.DefaultFormattingConversionServiceと、static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetupです。テストが別パッケージならinfo.syachiku.stacktrace.source.QuantityControllerもimportします。
private MockMvc mvc(QuantityController controller, Converter<?, ?> converter) {
var conversion = new DefaultFormattingConversionService();
if (converter != null) conversion.addConverter(converter);
return standaloneSetup(controller).setConversionService(conversion).build();
}同じテストクラスの、throws Exceptionを付けた@Testメソッド内で実行した部分が次の断片です。追加のimportはinfo.syachiku.stacktrace.source.BrokenQuantityConverter、org.junit.jupiter.api.Test、static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get、static org.springframework.test.web.servlet.result.MockMvcResultMatchers.statusです。空白を含む値をそのままparamへ渡し、400を確認してからresult.getResolvedException()で例外を取得しました。
var controller = new QuantityController();
var mvc = mvc(controller, new BrokenQuantityConverter());
var result = mvc.perform(get("/quantity/custom").param("quantity", " 2 "))
.andExpect(status().isBadRequest()).andReturn();@RequestParam("quantity") Quantity quantityで受ける/quantity/customへ、同じく空白を含む" 2 "を送った結果です。HTTP 400でControllerメソッドは未実行でも、今回は原因ブロックに自作Converterの行が残っています。
org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Method parameter 'quantity': Failed to convert value of type 'java.lang.String' to required type 'info.syachiku.stacktrace.source.Quantity'; Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam info.syachiku.stacktrace.source.Quantity] for value [ 2 ]
at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.convertIfNecessary(AbstractNamedValueMethodArgumentResolver.java:301)
[掲載時に中間フレームを省略]
Caused by: org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam info.syachiku.stacktrace.source.Quantity] for value [ 2 ]
at org.springframework.core.convert.support.ConversionUtils.invokeConverter(ConversionUtils.java:47)
at org.springframework.core.convert.support.GenericConversionService.convert(GenericConversionService.java:182)
at org.springframework.beans.TypeConverterDelegate.convertIfNecessary(TypeConverterDelegate.java:131)
[掲載時に中間フレームを省略]
Caused by: java.lang.NumberFormatException: For input string: " 2 "
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:654)
at java.base/java.lang.Integer.parseInt(Integer.java:786)
at info.syachiku.stacktrace.source.BrokenQuantityConverter.convert(BrokenQuantityConverter.java:8)
at info.syachiku.stacktrace.source.BrokenQuantityConverter.convert(BrokenQuantityConverter.java:5)
at org.springframework.core.convert.support.GenericConversionService$ConverterAdapter.convert(GenericConversionService.java:364)最下段のNumberFormatExceptionから上側のフレームを読むと、最初の自作コードはBrokenQuantityConverter.java:8です。実際に実行した8行目はreturn new Quantity(Integer.parseInt(source));でした。5行目の同名メソッドは、この検証ではジェネリクス用に生成された橋渡しのフレームです。Controllerへ渡す前の変換なので、Controllerの業務処理の行を探す必要はありません。
今回は前後空白を許す約束があるため、変換直前の一行を次へ変更します。空白を許さない仕様なら、この加工を採用する判断にはなりません。
return new Quantity(Integer.parseInt(source.strip()));同じURL・同じ入力で、登録するConverterだけを修正版へ替えて再確認しました。エラーが消えただけでなく、変換後の値と、誤った文字列を引き続き拒否することも確認しています。
| 確認した入力 | 修正後の結果 | 確認する意味 |
|---|---|---|
" 2 " | 200、quantityは2 | 失敗した入力が、仕様どおり扱われる |
"2" | 200、quantityは2 | 従来の正常入力を壊していない |
"three" | 400、Controller呼び出し0回 | 数値以外を勝手な値へ置き換えていない |
二つの400は同じNumberFormatExceptionを含みますが、開く場所は違います。標準変換で自作行がないなら送信値と引数定義へ、自作Converterの行があるならその変換処理と入力仕様へ戻ります。例外名だけで修正内容を決めず、変更後は同じ入力で確かめてください。
この対照はSpring MVCの型変換とHTTP応答を検証したものです。JSON、FormのValidation、独自例外ハンドラまで一律に同じ連鎖になるわけではありません。
... moreは別のエラー件数ではない
... 1 moreのような表記は、外側の例外と共通する末尾の呼び出し経路を省略したものです。「あと一つエラーがある」という意味ではありません。必要なら外側のスタックとつなげて読みます。
Suppressedがある場合は、causeとは別の補足例外です。例えばtry-with-resourcesでは、本体の処理に加えてcloseも失敗した場合、本体の例外にcloseの失敗が抑制例外として残ります。原因チェーンの続きとして機械的に扱わず、後片付けの失敗も含む別の情報として読みます。Oracleの解説
相談するときは、例外名だけを送らない
次の四行があれば、相談相手は調査を続けやすくなります。
現象:検証環境で注文CSVの取込が失敗した。
確認済み:金額変換へ "1,200" が渡り、NumberFormatExceptionになった。
未確認:桁区切りを許す仕様か、列の対応が正しいか。
次の確認:CSVの項目定義と、parseAmountの呼び出し元を照合する。「原因はNumberFormatExceptionです」だけでは、まだコードを変える判断はできません。「何が渡った」「何はまだ分からない」を足します。共有用ログには認証情報や実データを貼らず、必要な場合は権限管理された原本の場所を案内します。
例外を包む側になったら、causeを残す
呼び出し元へ処理の文脈を付けたいときは、元の例外を第二引数に渡します。
try {
return Integer.parseInt(amount);
} catch (NumberFormatException e) {
throw new IllegalStateException("注文金額の変換に失敗しました", e);
}メッセージだけを新しい例外へコピーすると、元の型や発生箇所を追いにくくなります。この例はJava標準APIだけで動く断片です。業務例外へ変換する場合も、その例外クラスがcauseを受け取れる形にします。
次に長いログを見たら、先頭の失敗と原因例外を選び、自作コードの行があるか確かめてください。行があれば渡った値と入力仕様を照合し、なければ対象の入力や引数定義へ戻ります。修正後は失敗した入力を再送し、正常入力と不正入力の扱いも確認しましょう。
必須パラメータ不足、JSON変換、入力チェックなど400の原因がまだ絞れない場合は、Spring Bootで400になりControllerに値が入らないときの確認順へ進んでください。
アプリが自分で記録した例外ログに発生行やCaused byが残っていないなら、ログへThrowableを渡す方法と、例外を一度だけ記録する判断も確認できます。

