前回作成した簡単な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"
}

学習遍歴

  1. JDBCでH2 Databaseに接続
    • 簡易Rest API作成
    • JDBCでH2へ接続
  2. Spring Bootでのエラーレスポンの制御 ←今ココ

目的の整理

うまいことログ周りを整理しようと思ったが、ここでログ周りで知りたいことが2個あった。

  1. デバッグ?運用上の監視のためアプリケーション自体のログはもうちょっと加工して吐いてほしい
  2. APIで返すレスポンの中身をキレイ(StackTraceは返したくない。ここを直せみたいなエラー文字列を返したい)にしたい

とりあえず最初のモチベーションはAPIのエラーレスポンスであったため、コレを掘り下げる。アプリケーションログはまた今度やる。またコードは前回作成した簡単なAPIを元に作成する。

目次

  1. クライアントからリクエストを受け付けてエラーを返すまでの流れ
  2. APIで返すレスポンの中身をキレイに
  3. レスポンスheader,bodyの制御

1. クライアントからリクエストを受け付けてエラーを返すまでの流れ

マジで全体像を把握するにはTERASOLUNAがわかりやすかったのでそれを参考に。(MVCでは無いためクライアントに返しているViewやjspは一旦無視) エラーってつまるところ誰かがExceptionをthrowして、それを誰かがcatchする構成になっている。throwするのは処理を担当するサービス層とかRepository層とかなので、APIの大本であるControllerでどの単位でcatchするか決めれる。方法は下記2通り。

  1. try-catchで囲い呼び出しているサービス単位で捕捉する
  2. このContorollerクラス(FindUserName)単位で各Exception事に専用の処理を書いておき、springに呼んでもらう

そんな細かくエラーハンドリングする気持ちででなかったため、2番目のContorollerクラス単位でのエラーをハンドリングする方法を見ていく。terasolunaorgでのユースケース単位でControllerクラスがハンドリングする場合の基本フローと一致すると今のところ理解。

(参考) 1.try-catchで囲いサービス単位で捕捉

呼び出すServiceや処理単位でtry-catchで囲みExeptionは都度処理するパターン。特にSpirngの機能を使わず、従来のJavaって感じ。処理毎にthrowされたExeptionを解釈しいろいろ考慮した上でエラーを返せるが、基本throwされたExeption事にエラーハンドリングできればいいのでちょっと細かすぎる印象。どうせEception事に同じエラー返すので書く量が多くなるだけかも。

イメージ

try-catch

コード

@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事に処理

イメージ

execption-annotation

流れは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": "想定外の山田エラー"
}