【Day 24】アプリケーションまわりの整備【じゃんけんアドカレ】

[じゃんけんアドベントカレンダー](https://qiita.com/advent-calendar/2020/janken) の 24 日目です。

- 初回 ... [【Day 1】とりあえず 1 クラスに全部書く【じゃんけんアドカレ】](https://qiita.com/os1ma/items/630e2d7fb5d744bbdf8a)
- 前回 ... [【Day 23】ドメインモデルをブラッシュアップ【じゃんけんアドカレ】](https://qiita.com/os1ma/items/2e4f7c901591ca681b73)

---

今回は最終調整として、今更ですが、開発環境や Spring の設定などのアプリケーションまわりの整備をしていこうと思います。

実施するのは以下の 3 つです。

- 開発環境の Java のバージョン指定
- Spring のエラーハンドリング
- Spring のログ設定

## 開発環境の Java のバージョン指定

まずはローカルでの Java のバージョンの管理を考えていきます。

開発は手元の PC で行うことが多いと思います。
その場合、複数のプロジェクトを同時進行していたり、しばらく日が空いてから開発を再開しようとすると、**言語のバージョン違いでエラーが発生する**ことがあります。

この問題を解決するツールを導入します。

### どんなツールを使うか

言語の複数バージョンを管理するツールとしては

- nodenv、rbenv などを直接使う
- anyenv 経由で nodenv、rbenv などを使う
- Docker を使う

あたりが定番だと思いますが、個人的には、**asdf をオススメします**。

asdf は anyenv に似ていますが、各種言語の複数バージョンの管理に加えて、**terraform や kubectl といった CLI ツールも複数バージョン管理できます**。

### asdf の導入

asdf の導入方法や使い方については [公式ドキュメント](https://asdf-vm.com/#/) を参照ください。

プロジェクトの Java のバージョンの指定としては、プロジェクトホームに .tool-versions というファイルを作成して、以下のように書いておくだけです。

```
java corretto-11.0.9.12.1
```

### Docker より asdf がいいか

最近は Docker で環境構築することも多いですが、**Java の場合は環境への依存が非常に小さいので、Docker を使うメリットはあまりありません**。
DB などの依存先だけを Docker で扱い、Java 自体は PC にインストールしてしまうことをオススメします。

**Python や Ruby など、ある程度環境への依存が強い言語であれば、asdf や○○env ではなく Docker を使うメリットが大きくなりうるとお思います**。

anyenv、asdf、Docker の比較については、個人ブログの記事「[anyenv vs asdf vs Docker で asdf を使う理由](https://www.kanzennirikaisita.com/posts/anyenv-asdf-docker)」にもまとめています。興味がある方は参照ください。

## Spring のエラーハンドリング

次に、Spring のエラーハンドリングを実装していきます。

エラーハンドリングの実装で目指すのは、以下の 2 つです。

- アプリケーション上で投げている例外の内容を踏まえてレスポンスのステータスを決定する
- 使っているフレームワークなどを推測できないようにする

### エラーハンドリングの実装

それでは実装していきます。

なお、Spring でのエラーハンドリングの方法はいくつもあるので、この記事で紹介する方法よりも良い実装方法があるかもしれません。参考にされる場合はご注意ください。

まず、エラー発生時のレスポンスボディを表す ErrorResponseBody 型を作ります。

```java
@AllArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public class ErrorResponseBody {
    private int status;
    private String message;
}
```

今回は簡易的に HTTP ステータスとメッセージだけを返しています。
他にもエラーコードなどを返す場合もあります。
API でエラーとしてどのような内容をどう返すべきかは、書籍『[Web API: The Good Parts](https://www.amazon.co.jp/dp/4873116864)』などで解説されています。

エラーのレスポンスが上記のものになるよう、カスタムの ErrorController を実装します。

```java
@RestController
@Slf4j
public class CustomErrorController implements ErrorController {

    private static final String ERROR_PATH = "/error";

    private static final List<Class<? extends Throwable>> BAD_REQUEST_EXCEPTIONS = List.of(
            MethodArgumentNotValidException.class,
            BindException.class,
            MissingPathVariableException.class,
            HttpMessageConversionException.class);

    @Autowired
    private ErrorAttributes errorAttributes;

    @Override
    public String getErrorPath() {
        return ERROR_PATH;
    }

    @RequestMapping(ERROR_PATH)
    public ResponseEntity<ErrorResponseBody> error(WebRequest request) {
        val error = errorAttributes.getError(request);

        if (error == null) {
            // エラーが存在しない場合は 404

            val status = HttpStatus.NOT_FOUND;
            val body = new ErrorResponseBody(status.value(), "Not Found");
            return ResponseEntity.status(status).body(body);

        } else if (BAD_REQUEST_EXCEPTIONS.contains(error.getClass())) {
            // Spring がリクエストを処理する際のエラーの場合

            val status = HttpStatus.BAD_REQUEST;
            val body = new ErrorResponseBody(status.value(), "Invalid request format");
            return ResponseEntity.status(status).body(body);

        } else if (error instanceof ApplicationException) {
            // アプリケーション上で明示的に投げたエラーの場合

            val applicationException = (ApplicationException) error;
            val status = applicationException.getStatus();

            if (status.is5xxServerError()) {
                log.error(error.getMessage(), error);
            } else {
                log.warn(error.getMessage(), error);
            }

            val body = new ErrorResponseBody(status.value(), applicationException.getMessage());
            return ResponseEntity.status(status).body(body);

        } else {
            // 想定外のエラーの場合

            log.error(error.getMessage(), error);

            val status = HttpStatus.INTERNAL_SERVER_ERROR;
            val body = new ErrorResponseBody(status.value(), "Unexpected error occurred");
            return ResponseEntity.status(status).body(body);
        }
    }

}
```

なお、私はこういったコードは infrastructure 以下に spring などのパッケージを切って配置します。
**infrastructure はフレームワークやデータベースなどの技術詳細に関する層**だと考えているためです。

### エラーを投げる側の実装

アプリケーション上でエラーを投げる側では、application.exception 配下に作成した以下の例外を使います。

```java
@Getter
public class ApplicationException extends RuntimeException {

    private HttpStatus status;

    public ApplicationException(HttpStatus status,
                                String message) {
        super(message);
        this.status = status;
    }

    public ApplicationException(HttpStatus status,
                                String message,
                                Throwable cause) {
        super(message, cause);
        this.status = status;
    }

}
```

例えば JankenApplicationService の中で以下のように投げるようにしました。

```java
:
public class JankenApplicationService {
    :
    public Optional<Player> play(String player1Id, Hand player1Hand,
                                 String player2Id, Hand player2Hand) {

        // プレイヤーの存在チェック
        playerRepository.findPlayerById(player1Id)
                .orElseThrow(() -> new ApplicationException(HttpStatus.BAD_REQUEST, PLAYER_NOT_EXIST_MESSAGE));
        playerRepository.findPlayerById(player2Id)
                .orElseThrow(() -> new ApplicationException(HttpStatus.BAD_REQUEST, PLAYER_NOT_EXIST_MESSAGE));
        :
```

この実装の場合、ApplicationException に HttpStatus を与えるため、**アプリケーション層が HTTP という実装の詳細を知っている**ことになってしまっています。
**本来的には HTTP という要素はプレゼンテーション層やインフラストラクチャ層に隠蔽すべき**です。

しかし、HttpStatus を隠蔽したところで、結局どこかで HttpStatus と対応するエラーのステータスを定義する必要が出てきてしまいます。
そのような**自作のエラーステータスと HttpStatus のマッピングのメリットがあまり大きくないことや、HttpStatus がエラーのステータスとして使いやすいことから、このコードではアプリケーション層で HttpStatus を扱うことを許容しています**。

ここまで設定してきた  Spring のエラーハンドリングや次に設定するログなどについては、以下のスライドが参考になります。

- [Spring Boot で Boot した後に作る Web アプリケーション基盤/spring-boot-application-infrastructure](https://speakerdeck.com/sinsengumi/spring-boot-application-infrastructure)

## Spring のログ設定

続いて、ログの設定として以下の 2 つを実施します。

- アクセスログとして、HTTP メソッド・URL・クエリパラメータ・レスポンスのステータスを出力する
- UUID を使い、リクエストからレスポンスまでのログをトレース可能にする

### 実装

アクセスログの出力と UUID の払い出しを実行する AccessLogFilter を以下のように作成しました。

```java
@Component
@Slf4j
class AccessLogFilter implements Filter {

    private static final String REQUEST_ID_KEY = "REQUEST_ID";

    private static final String LOG_FORMAT_FOR_ACCESS_REQUEST = "[Request] method={}, url={}";
    private static final String LOG_FORMAT_FOR_ACCESS_RESPONSE = "[Response] method={}, url={}, status={}";

    /**
     * コントローラの処理の前後でアクセスログを出力します。
     */
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        val uuid = UUID.randomUUID().toString();
        MDC.put(REQUEST_ID_KEY, uuid);

        val attributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        val httpServletRequest = attributes.getRequest();
        val method = httpServletRequest.getMethod();
        val urlWithQueryString = urlWithQueryString(httpServletRequest);

        log.info(LOG_FORMAT_FOR_ACCESS_REQUEST, method, urlWithQueryString);

        try {
            chain.doFilter(request, response);

        } finally {
            val status = Optional.of(attributes)
                    .map(ServletRequestAttributes::getResponse)
                    .map(HttpServletResponse::getStatus)
                    .orElse(null);

            log.info(LOG_FORMAT_FOR_ACCESS_RESPONSE, method, urlWithQueryString, status);

            MDC.remove(REQUEST_ID_KEY);
        }
    }

    /**
     * URL をクエリストリングとともに返します。
     * <p>
     * クエリストリングが空の場合、URL のみを返します。
     */
    private String urlWithQueryString(HttpServletRequest request) {
        val uri = request.getRequestURI();
        val queryString = request.getQueryString();
        return queryString == null ? uri : uri + "?" + queryString;
    }

}
```

また、src/main/resources 以下に logback-spring.xml を作成しました。

```xml
<?xml version="1.0"?>
<!-- 参考: https://github.com/spring-projects/spring-boot/blob/master/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml -->
<!-- 参考: https://qiita.com/tag1216/items/b898b8fb58920bf335b2#%E3%83%87%E3%83%95%E3%82%A9%E3%83%AB%E3%83%88%E8%A8%AD%E5%AE%9A%E3%82%92%E5%A4%89%E6%9B%B4%E3%81%99%E3%82%8B -->
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="CONSOLE_LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%15.15t] [%X{REQUEST_ID:- }] %-40.40logger{39} : %m%n%wEx"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
```

これで設定完了です。

この状態でアクセスすると、以下のようなログが出力されます。

```
2020-12-24 16:27:07.584  INFO 79979 --- [nio-8080-exec-2] [542f6fa7-c163-4353-8f6b-34360756319c] c.e.j.i.spring.logging.AccessLogFilter   : [Request] method=GET, url=/api/health
2020-12-24 16:27:07.662 DEBUG 79979 --- [nio-8080-exec-2] [542f6fa7-c163-4353-8f6b-34360756319c] org.jooq.tools.LoggerListener            : Executing query          : select 1 as `one` from dual
2020-12-24 16:27:07.677 DEBUG 79979 --- [nio-8080-exec-2] [542f6fa7-c163-4353-8f6b-34360756319c] org.jooq.tools.LoggerListener            : Fetched result           : +----+
2020-12-24 16:27:07.678 DEBUG 79979 --- [nio-8080-exec-2] [542f6fa7-c163-4353-8f6b-34360756319c] org.jooq.tools.LoggerListener            :                          : | one|
2020-12-24 16:27:07.680 DEBUG 79979 --- [nio-8080-exec-2] [542f6fa7-c163-4353-8f6b-34360756319c] org.jooq.tools.LoggerListener            :                          : +----+
2020-12-24 16:27:07.680 DEBUG 79979 --- [nio-8080-exec-2] [542f6fa7-c163-4353-8f6b-34360756319c] org.jooq.tools.LoggerListener            :                          : |   1|
2020-12-24 16:27:07.680 DEBUG 79979 --- [nio-8080-exec-2] [542f6fa7-c163-4353-8f6b-34360756319c] org.jooq.tools.LoggerListener            :                          : +----+
2020-12-24 16:27:07.680 DEBUG 79979 --- [nio-8080-exec-2] [542f6fa7-c163-4353-8f6b-34360756319c] org.jooq.tools.LoggerListener            : Fetched row(s)           : 1
2020-12-24 16:27:07.692  INFO 79979 --- [nio-8080-exec-2] [542f6fa7-c163-4353-8f6b-34360756319c] c.e.j.i.spring.logging.AccessLogFilter   : [Response] method=GET, url=/api/health, status=200
```

実際には認証情報から取得したユーザ ID も出力すると便利だと思います。
また、セキュリティ要件から IP アドレスを出力するといった場合もあります。

こういったログ設定には、APM や分散トレーシングなどを使う場合もあります。

参考

- [defaults.xml](https://github.com/spring-projects/spring-boot/blob/master/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml)
- [Spring Bootのログ設定を変更する](https://qiita.com/tag1216/items/b898b8fb58920bf335b2)
- [【SpringBoot2】UUIDを自動採番してX-Request-Idとしてlogbackのログに埋め込む方法【MDC】](https://blogenist.jp/2019/04/30/8203/)
- [APIでリクエストからレスポンスまでのログを特定したい？ Tracerを使ってみよう](https://dev.classmethod.jp/articles/tracer-with-spring/)

## その他

以上で、アプリケーションまわりの整備を完了とします。
デモ用のアプリケーションであればこのくらいで十分かもしれませんが、実際には

- API レベルの自動テスト
- アプリケーションの脆弱性診断
- 性能テスト

などを実施することが考えられます。

## 次回のテーマ

今回でじゃんけんアプリケーションは一旦完成としようとします。
認証機能すらないので実用的ではないですが、かなり色々な要素を盛り込めたのではないかと思います。

次回は最終回なので、このアドベントカレンダー全体のふりかえりをしようと思います。

それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
[じゃんけんアドベントカレンダー](https://qiita.com/advent-calendar/2020/janken) に興味を持ってくださった方は、是非購読お願いします。

## 現時点のコード

コードは GitHub の [この時点のコミット](https://github.com/os1ma/JankenEnterpriseEdition/tree/e354792e6d2a502b851236dd94251ccb393440fc) を参照ください。

ディレクトリ構成は以下のようになっています。

```shell
app/src/main/java/
└── com
    └── example
        └── janken
            ├── JankenCLIApplication.java
            ├── JankenWebApplication.java
            ├── application
            │   ├── exception
            │   │   └── ApplicationException.java
            │   ├── query
            │   │   ├── health
            │   │   │   └── HealthQueryService.java
            │   │   └── player
            │   │       ├── PlayerListQueryModel.java
            │   │       ├── PlayerListQueryModelPlayer.java
            │   │       └── PlayerQueryService.java
            │   ├── scenario
            │   │   ├── PlayJankenInputPort.java
            │   │   ├── PlayJankenOutputPort.java
            │   │   └── PlayJankenScenario.java
            │   └── service
            │       ├── health
            │       │   └── HealthApplicationService.java
            │       ├── janken
            │       │   └── JankenApplicationService.java
            │       └── player
            │           └── PlayerApplicationService.java
            ├── domain
            │   ├── model
            │   │   ├── janken
            │   │   │   ├── Hand.java
            │   │   │   ├── HandSelection.java
            │   │   │   ├── HandSelections.java
            │   │   │   ├── Hands.java
            │   │   │   ├── Janken.java
            │   │   │   ├── JankenDetail.java
            │   │   │   ├── JankenExecutor.java
            │   │   │   ├── JankenRepository.java
            │   │   │   └── Result.java
            │   │   └── player
            │   │       ├── Player.java
            │   │       └── PlayerRepository.java
            │   └── transaction
            │       ├── Transaction.java
            │       └── TransactionManager.java
            ├── infrastructure
            │   ├── csvdao
            │   │   ├── CsvDaoUtils.java
            │   │   ├── JankenCsvDao.java
            │   │   ├── JankenDetailCsvDao.java
            │   │   └── PlayerCsvDao.java
            │   ├── dao
            │   │   ├── JankenDao.java
            │   │   ├── JankenDetailDao.java
            │   │   └── PlayerDao.java
            │   ├── jdbctransaction
            │   │   ├── InsertMapper.java
            │   │   ├── JDBCTransaction.java
            │   │   ├── JDBCTransactionManager.java
            │   │   ├── RowMapper.java
            │   │   ├── SimpleJDBCWrapper.java
            │   │   └── SingleRowMapper.java
            │   ├── mysqldao
            │   │   ├── JankenDetailMySQLDao.java
            │   │   ├── JankenMySQLDao.java
            │   │   └── PlayerMySQLDao.java
            │   ├── mysqlquery
            │   │   ├── health
            │   │   │   └── HealthMySQLQueryService.java
            │   │   └── player
            │   │       └── PlayerMySQLQueryService.java
            │   ├── mysqlrepository
            │   │   ├── JankenMySQLRepository.java
            │   │   └── PlayerMySQLRepository.java
            │   └── spring
            │       ├── error
            │       │   ├── CustomErrorController.java
            │       │   └── ErrorResponseBody.java
            │       └── logging
            │           └── AccessLogFilter.java
            ├── presentation
            │   ├── api
            │   │   ├── health
            │   │   │   ├── HealthAPIController.java
            │   │   │   └── HealthResponseBody.java
            │   │   ├── janken
            │   │   │   ├── JankenAPIController.java
            │   │   │   ├── JankenPostRequestBody.java
            │   │   │   └── JankenPostResponseBody.java
            │   │   └── player
            │   │       ├── PlayerAPIController.java
            │   │       └── PlayerListResponseBody.java
            │   └── cli
            │       ├── janken
            │       │   ├── PlayJankenStandardInputController.java
            │       │   └── PlayJankenStandardOutputPresenter.java
            │       └── view
            │           └── StandardOutputView.java
            └── registry
                └── ServiceLocator.java

39 directories, 60 files
```