Spring Bootでのエラーレスポンの制御
前回作成した簡単なAPI失敗時に大量なスタックトレースを呼び出し元に返したため、Spring Bootでのログ、レスポンスの扱いを整理してみる。
ユーザ名登録API失敗時に呼び出し元に返されるレスポンスが下記である。APIのログとしては過剰すぎる。。。trace
を消したいし、path
もいらないとかとかとか。前回作成した簡単なAPIにエラーレスポンスの改修を入れてみる。コードはそっちを参考に。
{
"timestamp": "2024-03-30T01:21:42.512+00:00",
"status": 500,
"error": "Internal Server Error",
"trace": "com.example.springjdbc.repository.UserNameRepositoryJdbcImpl$1: DataAccessException発生\n長々とStackTraceが書かれる...",
"message": "DataAccessException発生",
"path": "/user_name"
}
学習遍歴
- JDBCでH2 Databaseに接続
- 簡易Rest API作成
- JDBCでH2へ接続
- Spring Bootでのエラーレスポンの制御 ←
今ココ
目的の整理
うまいことログ周りを整理しようと思ったが、ここでログ周りで知りたいことが2個あった。
- デバッグ?運用上の監視のためアプリケーション自体のログはもうちょっと加工して吐いてほしい
- APIで返すレスポンの中身をキレイ(StackTraceは返したくない。ここを直せみたいなエラー文字列を返したい)にしたい
とりあえず最初のモチベーションはAPIのエラーレスポンスであったため、コレを掘り下げる。アプリケーションログはまた今度やる。またコードは前回作成した簡単なAPIを元に作成する。
目次
- クライアントからリクエストを受け付けてエラーを返すまでの流れ
- APIで返すレスポンの中身をキレイに
- レスポンスheader,bodyの制御
1. クライアントからリクエストを受け付けてエラーを返すまでの流れ
マジで全体像を把握するにはTERASOLUNAがわかりやすかったのでそれを参考に。(MVCでは無いためクライアントに返しているViewやjspは一旦無視) エラーってつまるところ誰かがExceptionをthrowして、それを誰かがcatchする構成になっている。throwするのは処理を担当するサービス層とかRepository層とかなので、APIの大本であるControllerでどの単位でcatchするか決めれる。方法は下記2通り。
- try-catchで囲い呼び出しているサービス単位で捕捉する
- このContorollerクラス(
FindUserName
)単位で各Exception事に専用の処理を書いておき、springに呼んでもらう
そんな細かくエラーハンドリングする気持ちででなかったため、2番目のContorollerクラス単位でのエラーをハンドリングする方法を見ていく。terasolunaorgでのユースケース単位でControllerクラスがハンドリングする場合の基本フロー
と一致すると今のところ理解。
(参考) 1.try-catchで囲いサービス単位で捕捉
呼び出すServiceや処理単位でtry-catchで囲みExeptionは都度処理するパターン。特にSpirngの機能を使わず、従来のJavaって感じ。処理毎にthrowされたExeptionを解釈しいろいろ考慮した上でエラーを返せるが、基本throwされたExeption事にエラーハンドリングできればいいのでちょっと細かすぎる印象。どうせEception事に同じエラー返すので書く量が多くなるだけかも。
イメージ
コード
@RestController
@RequiredArgsConstructor
public class FindUserName {
private final FindUserNameService service;
@RequestMapping(value = "/user_name/", method = RequestMethod.GET)
public Map invoke(){
// findAllだけで捕捉
try {
List<UserName> userNameList = service.findAll();
}catch (Exception e){
System.out.println("findAllでErrorをcatch!");
}
// findAllとfilter単位で捕捉
try {
List<UserName> userNameList = service.findAll();
service.filter(userNameList)
}catch (Exception e){
System.out.println("findAll・filterでErrorをcatch!");
}
Map<String, List<UserName>> result = new HashMap<>();
result.put("user_name", userNameList);
return result;
}
}
2. Contorollerクラス単位で各Exception事に処理
イメージ
流れはTERASOLUNAのNoteに書いてある通りである。(Spring Boot で Boot した後に作る Web アプリケーション基盤も良かった)大事なのはthrowされた例外を誰に渡すか決めているHandlerExceptionResolver
がいることである。こいつはControllerに定義されている例外ハンドラーを探しに行く、なければ後続のResponseStatusExceptionResolver
その後にDefaultHandlerExceptionResolver
へ処理を渡していく。Contorllerに例外ハンドラーを設定してやればHandlerExceptionResolver
が拾ってくれる。
2. APIで返すレスポンの中身をキレイに
@ExceptionHandler
アノテーションを付与したメソッドを作成すれば、HandlerExceptionResolver
が読んでくれる。登録APIにてわざと発生させているDataAccessException
用のハンドラーを設定。レスポンスはとりあえず正常系と同じMapで返すようにした。また、応答されるstatus codeは@ResponseStatus
アノテーションにてINTERNAL_SERVER_ERROR(status code 500)
を返すよう指定する。この指定が無い場合200 ok
を返してしまう。
UserNameRepositoryJdbcImpl.java
APIが必ず失敗するようにUserNameRepositoryJdbcImpl
を変更して確認した。確かjdbc失敗時にthrowするのはDataAccessExceptionであるため、同じ動きなはず。
@Override
public int insertUserName(UserName userName) throws DataAccessException {
int result = jdbcTemplate.update(
"INSERT INTO USER_NAME (FIRST_NAME, LAST_NAME) VALUES(?, ?)"
,userName.FirstName()
,userName.LastName());
if (result > 0) throw new DataAccessException("DataAccessException発生") {};
return result;
}
RegisterUserName.java
package com.example.springjdbc.api;
import com.example.springjdbc.domain.UserName;
import com.example.springjdbc.service.RegisterUserNameService;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataAccessException;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequiredArgsConstructor
public class RegisterUserName {
private final RegisterUserNameService service;
@RequestMapping(value = "/user_name", method = RequestMethod.POST)
public Map invoke(@RequestBody RegisterUserNameRequest request) {
service.insert(UserName.of(request.FirstName(),request.LastName()));
Map<String, String> status = new HashMap<>();
status.put("Status","200");
return status;
}
@ExceptionHandler(DataAccessException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map dataAccessExceptionHandler(DataAccessException e) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("Status","500");
errorResponse.put("Message", e.getMessage());
return errorResponse;
}
}
レスポンス
設定したstatusとmessageが返ってきた!
% curl -v -X POST localhost:8080/user_name -H "Content-Type: application/json" -d @- <<EOF | jq .
{
"FirstName":"山田",
"LastName":"太郎"
}
EOF
Note: Unnecessary use of -X or --request, POST is already inferred.
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> POST /user_name HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 48
>
} [48 bytes data]
< HTTP/1.1 500
< Content-Type: application/json
< Transfer-Encoding: chunked
< Connection: close
<
{ [60 bytes data]
100 102 0 54 100 48 390 347 --:--:-- --:--:-- --:--:-- 733
* Closing connection
{
"Status": "500",
"Message": "DataAccessException発生"
}
@ResponseStatus
アノテーション無い場合、Headerのstatusは200で返してくる。
% curl -v -X POST localhost:8080/user_name -H "Content-Type: application/json" -d @- <<EOF | jq .
{
"FirstName":"山田",
"LastName":"太郎"
}
EOF
Note: Unnecessary use of -X or --request, POST is already inferred.
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> POST /user_name HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 48
>
} [48 bytes data]
< HTTP/1.1 200
< Content-Type: application/json
< Transfer-Encoding: chunked
<
{ [60 bytes data]
100 102 0 54 100 48 6372 5664 --:--:-- --:--:-- --:--:-- 12750
* Connection #0 to host localhost left intact
{
"Status": "500",
"Message": "DataAccessException発生"
}
3. レスポンスheader,bodyの制御
レスポンスってheaderとbodyの構成であるため、それぞれ制御するためにResponseEntity
を使う。ここでもTERASOLUNA様々であるが詳細はそちらに。ResponseEntity
にstatus,header,bodyを渡せばよさそう。想定外のエラーを拾うunexpectedErrorExceptionHandler
を作成し、Controllerにてすでに定義したDataAccessException
以外の適当なeception(今回はなんとなく山田がリクエストされたらRuntimeExceptionを投げた)をthrowさせて動作確認した。
RegisterUserName.java
package com.example.springjdbc.api;
import com.example.springjdbc.domain.UserName;
import com.example.springjdbc.service.RegisterUserNameService;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@RestController
@RequiredArgsConstructor
public class RegisterUserName {
private final RegisterUserNameService service;
@RequestMapping(value = "/user_name", method = RequestMethod.POST)
public Map invoke(@RequestBody RegisterUserNameRequest request) {
if (request.FirstName().equals("山田")) throw new RuntimeException("想定外の山田エラー");
service.insert(UserName.of(request.FirstName(),request.LastName()));
Map<String, String> status = new HashMap<>();
status.put("Status","200");
return status;
}
@ExceptionHandler(DataAccessException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map dataAccessExceptionHandler(DataAccessException e) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("Status","500");
errorResponse.put("Message", e.getMessage());
return errorResponse;
}
@ExceptionHandler()
public ResponseEntity<Map> unexpectedErrorExceptionHandler(Exception e) {
// status
HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
// header
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add("Content-Type","application/json; charset=UTF-8");
HttpHeaders httpHeaders = new HttpHeaders(headers);
// body
Map<String, String> body = new HashMap<>();
body.put("message",e.getMessage());
return ResponseEntity
.status(httpStatus)
.headers(httpHeaders)
.body(body);
}
}
レスポンス
hederに指定した情報が乗ってるし、bodyの内容も良さげ。
% curl -v -X POST localhost:8080/user_name -H "Content-Type: application/json" -d @- <<EOF | jq .
{
"FirstName":"山田",
"LastName":"太郎"
}
EOF
Note: Unnecessary use of -X or --request, POST is already inferred.
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> POST /user_name HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.4.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 48
>
} [48 bytes data]
< HTTP/1.1 500
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Connection: close
<
{ [47 bytes data]
100 89 0 41 100 48 304 356 --:--:-- --:--:-- --:--:-- 664
* Closing connection
{
"message": "想定外の山田エラー"
}