Spring 非同期処理とリトライ処理

お仕事でSpringの非同期処理とリトライ処理に触れることがあったので、まとめておきます。

Spring MVCの非同期処理の細かい話については、こちらを参照してください。

今回の環境

  • Java 11
  • Spring Boot 2.5.2

@Asyncを使った非同期処理

@org.springframework.scheduling.annotation.Asyncメソッドに付与することで非同期処理を行うことができます。 Spring Boot 2.1より前のバージョンだと、taskExecutorというBean名でThreadPoolTaskExecutort.javaをBean登録することが普通でしたが、Spring Boot 2.1からはデフォルトでBean登録されるようになりました。参考

実装

1.@Asyncによる非同期処理を有効にする

設定用のクラスを準備して@EnableAsyncを付与します。

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration(proxyBeanMethods = false)
@EnableAsync
public class WebMvcConfig implements WebMvcConfigurer {}

2.非同期処理したいさせたいメソッドに@Asyncを付与する。

非同期処理させたいメソッドに@Asyncを付与してください。AOPを使っているので、DIしてこのメソッドを呼ぶ使い方をしないと非同期処理が行われません。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import com.b1a9idps.springasyncdemo.dto.request.AsyncRequest;
import com.b1a9idps.springasyncdemo.exception.FailedFileUploadException;
import com.b1a9idps.springasyncdemo.service.AsyncService;

@Service
public class AsyncServiceImpl implements AsyncService {

    private static final Logger LOGGER = LoggerFactory.getLogger(AsyncServiceImpl.class);

    @Override
    @Async
    public void save(AsyncRequest request) {
        LOGGER.info("Start Async processing.(number = " + request.getNumber() + ")");

        try {
            Thread.sleep(5000);
            LOGGER.info("Hi!.(number = " + request.getNumber() + ")");
        } catch (InterruptedException e) {
            LOGGER.error("thrown InterruptedException.");
        }

        LOGGER.info("End Async processing.(number = " + request.getNumber() + ")");
}

挙動を見てみる

今回は、こんな感じの設定で試しています。

最小スレッド数2(spring.task.execution.pool.core-size=2)
最大スレッド数3(spring.task.execution.pool.max-size=3)
キュー数4(spring.task.execution.pool.queue-copacity=4)

スクリプトで連続的にリクエストしてみます。

#!/bin/bash

number=1

while true;
do
  curl -X POST -H "Content-Type: application/json" -d '{"number" : "'$number'"}'  http://localhost:8080/async
  echo ''
  number=$(expr $number + 1)
  sleep 1s;
done

スクリプトの実行ログ

{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"too busy..."}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"too busy..."}

キューがいっぱいでタスクの実行を受け入れられないときにRejectedExecutionException.javaが投げられるので、 AsyncControllerExceptionHandler#handleRejectedExecutionException でハンドリングしています。

アプリケーションログ

2021-07-09 15:38:19.033  INFO 79541 --- [nio-8080-exec-1] c.b.s.controller.AsyncController         : Start.(number = 1)
2021-07-09 15:38:19.037  INFO 79541 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 1)
2021-07-09 15:38:19.174  INFO 79541 --- [nio-8080-exec-2] c.b.s.controller.AsyncController         : Start.(number = 2)
2021-07-09 15:38:19.174  INFO 79541 --- [         task-2] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 2)
2021-07-09 15:38:19.300  INFO 79541 --- [nio-8080-exec-4] c.b.s.controller.AsyncController         : Start.(number = 3)
2021-07-09 15:38:19.421  INFO 79541 --- [nio-8080-exec-6] c.b.s.controller.AsyncController         : Start.(number = 4)
2021-07-09 15:38:19.542  INFO 79541 --- [nio-8080-exec-8] c.b.s.controller.AsyncController         : Start.(number = 5)
2021-07-09 15:38:19.667  INFO 79541 --- [io-8080-exec-10] c.b.s.controller.AsyncController         : Start.(number = 6)
2021-07-09 15:38:19.793  INFO 79541 --- [nio-8080-exec-2] c.b.s.controller.AsyncController         : Start.(number = 7)
2021-07-09 15:38:19.793  INFO 79541 --- [         task-3] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 7)
2021-07-09 15:38:19.913  INFO 79541 --- [nio-8080-exec-4] c.b.s.controller.AsyncController         : Start.(number = 8)
2021-07-09 15:38:20.040  INFO 79541 --- [nio-8080-exec-5] c.b.s.controller.AsyncController         : Start.(number = 9)
2021-07-09 15:38:20.163  INFO 79541 --- [nio-8080-exec-6] c.b.s.controller.AsyncController         : Start.(number = 10)
2021-07-09 15:38:20.285  INFO 79541 --- [nio-8080-exec-7] c.b.s.controller.AsyncController         : Start.(number = 11)
2021-07-09 15:38:24.038  INFO 79541 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : Hi!.(number = 1)
2021-07-09 15:38:24.038  INFO 79541 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : End Async processing.(number = 1)
2021-07-09 15:38:24.038  INFO 79541 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 3)
2021-07-09 15:38:24.177  INFO 79541 --- [         task-2] c.b.s.service.impl.AsyncServiceImpl      : Hi!.(number = 2)
2021-07-09 15:38:24.177  INFO 79541 --- [         task-2] c.b.s.service.impl.AsyncServiceImpl      : End Async processing.(number = 2)
2021-07-09 15:38:24.178  INFO 79541 --- [         task-2] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 4)
2021-07-09 15:38:24.794  INFO 79541 --- [         task-3] c.b.s.service.impl.AsyncServiceImpl      : Hi!.(number = 7)
2021-07-09 15:38:24.794  INFO 79541 --- [         task-3] c.b.s.service.impl.AsyncServiceImpl      : End Async processing.(number = 7)
2021-07-09 15:38:24.794  INFO 79541 --- [         task-3] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 5)
2021-07-09 15:38:29.044  INFO 79541 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : Hi!.(number = 3)
2021-07-09 15:38:29.044  INFO 79541 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : End Async processing.(number = 3)
2021-07-09 15:38:29.044  INFO 79541 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 6)
2021-07-09 15:38:29.181  INFO 79541 --- [         task-2] c.b.s.service.impl.AsyncServiceImpl      : Hi!.(number = 4)
2021-07-09 15:38:29.181  INFO 79541 --- [         task-2] c.b.s.service.impl.AsyncServiceImpl      : End Async processing.(number = 4)
2021-07-09 15:38:29.799  INFO 79541 --- [         task-3] c.b.s.service.impl.AsyncServiceImpl      : Hi!.(number = 5)
2021-07-09 15:38:29.800  INFO 79541 --- [         task-3] c.b.s.service.impl.AsyncServiceImpl      : End Async processing.(number = 5)
2021-07-09 15:38:34.046  INFO 79541 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : Hi!.(number = 6)
2021-07-09 15:38:34.046  INFO 79541 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : End Async processing.(number = 6)

1.task-番号がSpring MVCが用意したスレッドで、2.nio-8080-exec-番号がTomcatのスレッドを表しています。最大スレッド3で非同期処理が行われていることがわかります。

Spring Retryを使ったリトライ処理

Springが作っている、 Spring Retry を利用すると簡単にリトライ処理を行うことができます。

実装

1.Spring Retryによるリトライ処理を有効にする

設定用のクラスに@EnableRetryを付与します。

import org.springframework.context.annotation.Configuration;
import org.springframework.retry.annotation.EnableRetry;

@Configuration(proxyBeanMethods = false)
@EnableRetry
public class RetryConfig {}

2.リトライ処理したいさせたいメソッドに@Retryableを付与する。

リトライ処理させたいメソッドに@Retryableを付与してください。今回の例だと、「FailedFileUploadException.javaが投げられたらリトライ処理を行い、リトライ全部失敗したらsaveRecoverメソッドを呼ぶ」という風になっています。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import com.b1a9idps.springasyncdemo.dto.request.AsyncRequest;
import com.b1a9idps.springasyncdemo.exception.FailedFileUploadException;
import com.b1a9idps.springasyncdemo.infrastructure.FileService;
import com.b1a9idps.springasyncdemo.service.AsyncService;

@Service
public class AsyncServiceImpl implements AsyncService {

    private static final Logger LOGGER = LoggerFactory.getLogger(AsyncServiceImpl.class);

    private final FileService fileService;

    public AsyncServiceImpl(FileService fileService) {
        this.fileService = fileService;
    }

    @Override
    @Retryable(value = FailedFileUploadException.class, recover = "saveRecover")
    @Async
    public void save(AsyncRequest request) {
        LOGGER.info("Start Async processing.(number = " + request.getNumber() + ")");

        try {
            Thread.sleep(500);
            fileService.upload();
        } catch (InterruptedException e) {
            LOGGER.error("thrown InterruptedException.");
        }

        LOGGER.info("End Async processing.(number = " + request.getNumber() + ")");
    }

    @Recover
    private void saveRecover(FailedFileUploadException e, AsyncRequest request) {
        LOGGER.error("failed to upload file(number = " + request.getNumber() + ")", e);
    }
}

挙動を見てみる

非同期の例で使ったスクリプトを使って、リクエストしてみます。

スクリプトで連続的にリクエストしてみます。

#!/bin/bash

number=1

while true;
do
  curl -X POST -H "Content-Type: application/json" -d '{"number" : "'$number'"}'  http://localhost:8080/async
  echo ''
  number=$(expr $number + 1)
  sleep 1s;
done

スクリプトの実行ログ

{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"success"}
{"message":"too busy..."}
{"message":"too busy..."}
{"message":"too busy..."}

アプリケーションログ

2021-07-09 18:55:03.083  INFO 82164 --- [nio-8080-exec-1] c.b.s.controller.AsyncController         : Start.(number = 1)
2021-07-09 18:55:03.098  INFO 82164 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 1)
2021-07-09 18:55:03.217  INFO 82164 --- [nio-8080-exec-2] c.b.s.controller.AsyncController         : Start.(number = 2)
2021-07-09 18:55:03.218  INFO 82164 --- [         task-2] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 2)
2021-07-09 18:55:03.338  INFO 82164 --- [nio-8080-exec-4] c.b.s.controller.AsyncController         : Start.(number = 3)
2021-07-09 18:55:03.458  INFO 82164 --- [nio-8080-exec-6] c.b.s.controller.AsyncController         : Start.(number = 4)
2021-07-09 18:55:03.582  INFO 82164 --- [nio-8080-exec-8] c.b.s.controller.AsyncController         : Start.(number = 5)
2021-07-09 18:55:03.603  INFO 82164 --- [         task-1] c.b.s.i.impl.FileServiceImpl             : try upload.
2021-07-09 18:55:03.701  INFO 82164 --- [io-8080-exec-10] c.b.s.controller.AsyncController         : Start.(number = 6)
2021-07-09 18:55:03.722  INFO 82164 --- [         task-2] c.b.s.i.impl.FileServiceImpl             : try upload.
2021-07-09 18:55:03.817  INFO 82164 --- [nio-8080-exec-2] c.b.s.controller.AsyncController         : Start.(number = 7)
2021-07-09 18:55:03.818  INFO 82164 --- [         task-3] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 7)
2021-07-09 18:55:03.936  INFO 82164 --- [nio-8080-exec-4] c.b.s.controller.AsyncController         : Start.(number = 8)
2021-07-09 18:55:04.060  INFO 82164 --- [nio-8080-exec-5] c.b.s.controller.AsyncController         : Start.(number = 9)
2021-07-09 18:55:04.182  INFO 82164 --- [nio-8080-exec-6] c.b.s.controller.AsyncController         : Start.(number = 10)
2021-07-09 18:55:04.319  INFO 82164 --- [         task-3] c.b.s.i.impl.FileServiceImpl             : try upload.
2021-07-09 18:55:04.608  INFO 82164 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 1)
2021-07-09 18:55:04.727  INFO 82164 --- [         task-2] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 2)
2021-07-09 18:55:05.109  INFO 82164 --- [         task-1] c.b.s.i.impl.FileServiceImpl             : try upload.
2021-07-09 18:55:05.228  INFO 82164 --- [         task-2] c.b.s.i.impl.FileServiceImpl             : try upload.
2021-07-09 18:55:05.322  INFO 82164 --- [         task-3] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 7)
2021-07-09 18:55:05.822  INFO 82164 --- [         task-3] c.b.s.i.impl.FileServiceImpl             : try upload.
2021-07-09 18:55:06.109  INFO 82164 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 1)
2021-07-09 18:55:06.231  INFO 82164 --- [         task-2] c.b.s.service.impl.AsyncServiceImpl      : Start Async processing.(number = 2)
2021-07-09 18:55:06.610  INFO 82164 --- [         task-1] c.b.s.i.impl.FileServiceImpl             : try upload.
2021-07-09 18:55:06.615 ERROR 82164 --- [         task-1] c.b.s.service.impl.AsyncServiceImpl      : failed to upload file(number = 1)

com.b1a9idps.springasyncdemo.exception.FailedFileUploadException: file upload failed.
    at com.b1a9idps.springasyncdemo.infrastructure.impl.FileServiceImpl.upload(FileServiceImpl.java:19) ~[main/:na]
    at com.b1a9idps.springasyncdemo.service.impl.AsyncServiceImpl.save(AsyncServiceImpl.java:34) ~[main/:na]
    at com.b1a9idps.springasyncdemo.service.impl.AsyncServiceImpl$$FastClassBySpringCGLIB$$7a0ef216.invoke(<generated>) ~[main/:na]
    at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) ~[spring-core-5.3.8.jar:5.3.8]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:779) ~[spring-aop-5.3.8.jar:5.3.8]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.3.8.jar:5.3.8]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) ~[spring-aop-5.3.8.jar:5.3.8]
    at org.springframework.retry.interceptor.RetryOperationsInterceptor$1.doWithRetry(RetryOperationsInterceptor.java:93) ~[spring-retry-1.3.1.jar:na]
    at org.springframework.retry.support.RetryTemplate.doExecute(RetryTemplate.java:329) ~[spring-retry-1.3.1.jar:na]
    at org.springframework.retry.support.RetryTemplate.execute(RetryTemplate.java:225) ~[spring-retry-1.3.1.jar:na]
    at org.springframework.retry.interceptor.RetryOperationsInterceptor.invoke(RetryOperationsInterceptor.java:116) ~[spring-retry-1.3.1.jar:na]
    at org.springframework.retry.annotation.AnnotationAwareRetryOperationsInterceptor.invoke(AnnotationAwareRetryOperationsInterceptor.java:163) ~[spring-retry-1.3.1.jar:na]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.8.jar:5.3.8]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) ~[spring-aop-5.3.8.jar:5.3.8]
    at org.springframework.aop.interceptor.AsyncExecutionInterceptor.lambda$invoke$0(AsyncExecutionInterceptor.java:115) ~[spring-aop-5.3.8.jar:5.3.8]
    at java.base/java.util.concurrent.FutureTask.run$$$capture(FutureTask.java:264) ~[na:na]
    at java.base/java.util.concurrent.FutureTask.run(FutureTask.java) ~[na:na]
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128) ~[na:na]
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628) ~[na:na]
    at java.base/java.lang.Thread.run(Thread.java:834) ~[na:na]

ログを見ると、リトライ処理が行われていることがわかると思います。

Docker上で起動したPrometheusを使って、Micrometerで収集したメトリクス可視化する

Amazon Managed Service for Prometheusが値下げされるという 記事 を見かけて、「そろそろMicrometerのモニタリングシステムをCloud Watchから乗り換えるか」って気持ちになったのでPrometheusをサクッと試してみました。

Prometheusとは

Prometheusは、SoundCloudが2012年に作ったOSSのシステムモニタリングとアラートのツールです。

機能

  • メトリクス名とkey/valueペアによって識別された時系列データを持つ多次元のデータモデル
  • この次元を利用するための柔軟性のあるクエリ言語のPromQLを提供
  • 依存しない分散型ストレージ。シングルサーバノードが独立している
  • メトリクスの収集方法はプル型
  • 中間ゲートウェイ経由でプッシュ型もサポートしている
  • サービスディスカバリや静的な設定経由で、ターゲットが検出される
  • グラフ化やダッシュボード化のいくつかのモードをサポートしている

コンポーネント

Prometheusのエコシステムは複数のコンポーネントから成り立つ

  • メインのPrometheusサーバは時系列データを取得して保存する
  • 計測するアプリケーションコードのためのクライアントライブラリ
  • 一時的なジョブをサポートするためのプッシュゲートウェイ
  • HAProxy, StatsD, Graphite, などのようなサービスのための特別な目的のエクスポーターズ
  • アラートを扱うためのアラートマネージャ
  • 多くのPrometheusコンポーネントgolangで書かれている

Micrometerとは

以前書いた Micrometerでメトリクスを収集してAmazon Cloud Watchで可視化する にまとめてあるので省略。

アプリケーション作成

メトリクス収集対象のアプリケーションを作ります。今回はSpring Bootで作ります。

まず、build.gradleの依存関係です。PrometheusがActuatorのエンドポイントを叩いてメトリクス情報を収集します。

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-actuator'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
}

次に、アプリケーションの実装です。

@RestController
@RequestMapping("/")
public class IndexController {
  @GetMapping
  public IndexResponse index() {
    return new IndexResponse("Hello World!!");
  }
}

アプリケーションを起動して、GET http://localhost:8080を叩くと次のようなレスポンスが返ってきます。

{
  "message": "Hello World!!"
}

次に、Actuatorの GET /actuator/prometheus エンドポイントを有効にします。Prometheusがこのエンドポイントを叩いてメトリクス情報を収集します。

management:
  endpoints:
    web:
      exposure:
        include: prometheus

GET http://localhost/actuator/prometheusを叩くと、Prometheusのフォーマットでメトリクス情報が返ってきます。

Dockerコンテナで起動

docker-compose.ymlを用意して、先に実装したアプリケーションとPrometheusをDockerコンテナ上で起動するようにします。

version: '3'
services:
  prometheus:
    image: prom/prometheus
    container_name: prometheus
    volumes:
      - ./prometheus:/etc/prometheus
    command: "--config.file=/etc/prometheus/prometheus.yml"
    ports:
      - 9090:9090
    restart: always
  micrometer-prometheus-api:
    image: micrometer-prometheus-api
    container_name: micrometer-prometheus-api
    ports:
      - 18080:8080

次に、Prometheusの設定ファイルを用意します。 ほぼ、Spring Bootのドキュメントにあるサンプル通りです。

global:
  scrape_interval: 10s
  scrape_timeout: 10s
  evaluation_interval: 10s

scrape_configs:
  - job_name: 'micrometer-prometheus'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets:
        - micrometer-prometheus-api:8080

次に、jibのGradleプラグインを使ってアプリケーションのDockerイメージを作成します。8080番ポートで起動するようにしています。

plugins {
  id 'com.google.cloud.tools.jib' version '3.0.0'
}

jib {
  container {
      ports ['8080']
      jvmFlags = ['--enable-preview']
  }
  from {
      image = 'openjdk:15-alpine'
  }
  to {
      image = 'micrometer-prometheus-api'
      tags = ['latest']
  }
}

./gradlew jibDockerBuildでDockerイメージを作成します。

それでは、コンテナを起動してみましょう。

$ docker-compose up
prometheus                   | level=info ts=2021-05-14T04:11:02.765Z caller=main.go:388 msg="No time or size retention was set so using the default time retention" duration=15d
prometheus                   | level=info ts=2021-05-14T04:11:02.765Z caller=main.go:426 msg="Starting Prometheus" version="(version=2.27.0, branch=HEAD, revision=24c9b61221f7006e87cd62b9fe2901d43e19ed53)"
prometheus                   | level=info ts=2021-05-14T04:11:02.765Z caller=main.go:431 build_context="(go=go1.16.4, user=root@f27daa3b3fec, date=20210512-18:04:51)"
prometheus                   | level=info ts=2021-05-14T04:11:02.765Z caller=main.go:432 host_details="(Linux 5.10.25-linuxkit #1 SMP Tue Mar 23 09:27:39 UTC 2021 x86_64 29b273b8d0c7 (none))"
prometheus                   | level=info ts=2021-05-14T04:11:02.765Z caller=main.go:433 fd_limits="(soft=1048576, hard=1048576)"
prometheus                   | level=info ts=2021-05-14T04:11:02.765Z caller=main.go:434 vm_limits="(soft=unlimited, hard=unlimited)"
prometheus                   | level=info ts=2021-05-14T04:11:02.778Z caller=web.go:540 component=web msg="Start listening for connections" address=0.0.0.0:9090
prometheus                   | level=info ts=2021-05-14T04:11:02.780Z caller=main.go:803 msg="Starting TSDB ..."
prometheus                   | level=info ts=2021-05-14T04:11:02.788Z caller=tls_config.go:191 component=web msg="TLS is disabled." http2=false
prometheus                   | level=info ts=2021-05-14T04:11:02.790Z caller=head.go:741 component=tsdb msg="Replaying on-disk memory mappable chunks if any"
prometheus                   | level=info ts=2021-05-14T04:11:02.794Z caller=head.go:755 component=tsdb msg="On-disk memory mappable chunks replay completed" duration=6.319µs
prometheus                   | level=info ts=2021-05-14T04:11:02.794Z caller=head.go:761 component=tsdb msg="Replaying WAL, this may take a while"
prometheus                   | level=info ts=2021-05-14T04:11:02.795Z caller=head.go:813 component=tsdb msg="WAL segment loaded" segment=0 maxSegment=0
prometheus                   | level=info ts=2021-05-14T04:11:02.795Z caller=head.go:818 component=tsdb msg="WAL replay completed" checkpoint_replay_duration=42.188µs wal_replay_duration=1.023386ms total_replay_duration=1.236444ms
prometheus                   | level=info ts=2021-05-14T04:11:02.799Z caller=main.go:828 fs_type=EXT4_SUPER_MAGIC
prometheus                   | level=info ts=2021-05-14T04:11:02.799Z caller=main.go:831 msg="TSDB started"
prometheus                   | level=info ts=2021-05-14T04:11:02.799Z caller=main.go:957 msg="Loading configuration file" filename=/etc/prometheus/prometheus.yml
prometheus                   | level=info ts=2021-05-14T04:11:02.805Z caller=main.go:988 msg="Completed loading of configuration file" filename=/etc/prometheus/prometheus.yml totalDuration=6.051413ms remote_storage=2.506µs web_handler=635ns query_engine=1.369µs scrape=2.644871ms scrape_sd=85.53µs notify=1.254µs notify_sd=2.059µs rules=1.15µs
prometheus                   | level=info ts=2021-05-14T04:11:02.805Z caller=main.go:775 msg="Server is ready to receive web requests."
micrometer-prometheus-api    |
micrometer-prometheus-api    |   .   ____          _            __ _ _
micrometer-prometheus-api    |  /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
micrometer-prometheus-api    | ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
micrometer-prometheus-api    |  \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
micrometer-prometheus-api    |   '  |____| .__|_| |_|_| |_\__, | / / / /
micrometer-prometheus-api    |  =========|_|==============|___/=/_/_/_/
micrometer-prometheus-api    |  :: Spring Boot ::                (v2.4.5)
micrometer-prometheus-api    |
micrometer-prometheus-api    | 2021-05-14 05:25:22.680  INFO 1 --- [           main] c.b.micrometerprometheus.Application     : Starting Application using Java 15-ea on aad30f1b5aec with PID 1 (/app/classes started by root in /)
micrometer-prometheus-api    | 2021-05-14 05:25:22.684  INFO 1 --- [           main] c.b.micrometerprometheus.Application     : No active profile set, falling back to default profiles: default
micrometer-prometheus-api    | 2021-05-14 05:25:23.854  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
micrometer-prometheus-api    | 2021-05-14 05:25:23.866  INFO 1 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
micrometer-prometheus-api    | 2021-05-14 05:25:23.867  INFO 1 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.45]
micrometer-prometheus-api    | 2021-05-14 05:25:23.928  INFO 1 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
micrometer-prometheus-api    | 2021-05-14 05:25:23.929  INFO 1 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1182 ms
micrometer-prometheus-api    | 2021-05-14 05:25:24.458  INFO 1 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
micrometer-prometheus-api    | 2021-05-14 05:25:24.640  INFO 1 --- [           main] o.s.b.a.e.web.EndpointLinksResolver      : Exposing 1 endpoint(s) beneath base path '/actuator'
micrometer-prometheus-api    | 2021-05-14 05:25:24.688  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
micrometer-prometheus-api    | 2021-05-14 05:25:24.705  INFO 1 --- [           main] c.b.micrometerprometheus.Application     : Started Application in 2.464 seconds (JVM running for 2.963)
micrometer-prometheus-api    | 2021-05-14 05:25:26.655  INFO 1 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
micrometer-prometheus-api    | 2021-05-14 05:25:26.655  INFO 1 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
micrometer-prometheus-api    | 2021-05-14 05:25:26.657  INFO 1 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 2 ms

リクエストログをみてみると、10秒おきにエンドポイントが叩かれてるのがわかります。

2021-05-14 06:05:39.806 DEBUG 1 --- [nio-8080-exec-7] o.s.w.f.CommonsRequestLoggingFilter      : Before request [GET /actuator/prometheus]
2021-05-14 06:05:39.816 DEBUG 1 --- [nio-8080-exec-7] o.s.w.f.CommonsRequestLoggingFilter      : Request data : GET /actuator/prometheus]
2021-05-14 06:05:49.784 DEBUG 1 --- [nio-8080-exec-9] o.s.w.f.CommonsRequestLoggingFilter      : Before request [GET /actuator/prometheus]
2021-05-14 06:05:49.789 DEBUG 1 --- [nio-8080-exec-9] o.s.w.f.CommonsRequestLoggingFilter      : Request data : GET /actuator/prometheus]
2021-05-14 06:05:59.785 DEBUG 1 --- [nio-8080-exec-8] o.s.w.f.CommonsRequestLoggingFilter      : Before request [GET /actuator/prometheus]
2021-05-14 06:05:59.792 DEBUG 1 --- [nio-8080-exec-8] o.s.w.f.CommonsRequestLoggingFilter      : Request data : GET /actuator/prometheus]
2021-05-14 06:06:09.788 DEBUG 1 --- [io-8080-exec-10] o.s.w.f.CommonsRequestLoggingFilter      : Before request [GET /actuator/prometheus]
2021-05-14 06:06:09.796 DEBUG 1 --- [io-8080-exec-10] o.s.w.f.CommonsRequestLoggingFilter      : Request data : GET /actuator/prometheus]

PrometheusのUIを確認

ブラウザで、Prometheusにアクセスすると、UIを見ることできます。 「Expression」にPromQLを入力して「Execute」ボタンを押下すると表示されます。

● Table

● Graph

やってみた記事なので、一旦ここまで。

JenkinsからGitHub Actionsへの移行をキメた

社のCI/CDをJenkinsからGitHub Actionsに移行しました。公式ドキュメント読み倒してたくさんのymlを書いたので、tipsでも残して置きます。

環境

  • レポジトリは15くらい
  • デプロイ環境は、Amazon ECSとAWS Elastic Beanstalk
  • アプリケーションは全部Java / Spring Boot(Gradle)

移行の背景

2019年までに作ったアプリケーションのデプロイ・リリース作業はJenkinsで行なっていました。2020年に入ってコンテナ化が進み、AWS CodeBuild・AWS CodeDeployを使ってデプロイするようになったり、一部ではGitHub Actionsを使ってデプロイするようになったりとデプロイ・リリース方法が多様化していきました。 JenkinsはEC2インスタンス立てて、そこにインストールしていましたが長年メンテナンスされてなかったし、ジョブの作り上デプロイ完了待ちが発生していました。

移行理由をまとめると、次の通りです。

  1. デプロイ・リリース方法が多様化されている上にちゃんとドキュメントがなく、全環境の手順把握してる人もいないため、使うツールや手順を統一したい
  2. 数年Jenkinsのメンテナンスされてこなかったし、これからもメンテナンスしたくない
  3. ジョブの作り上、ビルド・デプロイに時間がかかるため改善したい

既存で使われていたのが、AWS CodeBuild・AWS CodeDeployとGitHub Actionsだったのでこの2択でした。前者だと、設定ファイル結構用意しないといけないしデプロイ作業が手間そうだったので、後者に決めました。

移行作業

主に作ったワークフローは次の通りです。

  1. ビルド用ワークフロー(PR作成・更新をトリガ)
  2. テスト環境にデプロイするワークフロー(手動実行)
  3. 本番環境にデプロイするワークフロー(手動実行)
  4. テスト環境に日次デプロイするワークフロー(スケジューリングトリガ)

公式ドキュメントを読めばできることしかやってないので、実際のワークフローの中身については省略します。

利用した公式アクション

工夫点

実行ログの永続化

デプロイログを一定期間残したかったので、独自でバッチを作りました。今はプライベートレポジトリの実行ログ保存期間は最大400日になっていますが、当時は最大90日だった(気がする)のでログを永続化する方法を考えました。

GitHub Actions側の後処理も含めて全ジョブ終了後にログが保存されるため、ワークフローの中にログを保存するジョブを入れることができませんでした。 そこで、1. GitHub APIを叩いてログファイルを取得2. 1で取得したZIPファイルをS3にアップロード を行うバッチを作って、GitHub Actionsのscheduleトリガを使って日次で動かすようにしました。

複数レポジトリにワークフローを置くのは管理コストが高くなるので、それ用にレポジトリを立てるようにしました。これは、ログアップロードバッチのレポジトリのログアップロード用ワークフローです。

name: Upload CI Log

on:
  schedule:
    - cron: '0 1 * * *'

jobs:
  upload:
    name: CI Log uplaad
    runs-on: ubuntu-latest
    strategy:
      matrix:
        repository:
          - repo-a
          - repo-b
          - repo-c
    steps:
      - uses: actions/checkout@v2
      - name: Set up JDK 11
        uses: actions/setup-java@v1
        with:
          java-version: 11
      - name: Cache modules
        uses: actions/cache@v2
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
      - name: Upload
        env:
          AWS_REGION: ap-northeast-1
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          # GitHub APIを叩くための個人アクセストークン
          DOWNLOAD_LOGS_ACCESS_TOKEN: ${{ secrets.DOWNLOAD_LOGS_ACCESS_TOKEN }}
        run: |
          ./gradlew -x test build
          # 引数はOrganization名/Repository名
          java -jar ./build/libs/upload-ci-logs.jar Hoge/${{ matrix.repository }}

EB CLIのインストール

AWS CLIはデフォルトで、 インストールされている のですが、EB CLIはインストールが必要です。 このように書けば、20秒ほどでインストールできます。

steps:
  - name: Set up Python
    uses: actions/setup-python@v2
    with:
      python-version: '3.9'
  - name: Install awsebcli
    run: pip install -U awsebcli

action用プライベートレポジトリ

GitHub Actionsでは、 独自アクション を作成することができます。複雑な処理をしたいときに使うのがよいかと思います。 独自アクションの使い方は次の通りで、action用のプライベートレポジトリを作るには工夫が必要でした。

  • 公開アクションは uses: Organization名/Repository名 で指定
  • 非公開アクションは実行するレポジトリ内に独自アクションを置き、 uses: ./アクション配置パス で指定

全レポジトリでSlackにデプロイ通知する必要がありました。複雑な処理だったので「独自アクションにしたい!」となったのですが各レポジトリに置きたくはなかったので、独自アクション×別レポジトリのチェックアウトでaction用プライベートレポジトリっぽいことを実現しました。

  1. action用プライベートレポジトリの用意
  2. アクションを作る(複数作ることを考えて、独自アクションごとにディレクトリ分けるのがおすすめ)
  3. アクションを実行したいレポジトリで、1で作成したレポジトリをチェックアウト
  4. 3でチェックアウトした独自アクションを実行

これは、アクションを利用するレポジトリのワークフローです。

steps:
  - name: Checkout actions
    uses: actions/checkout@v2
    with:
      # Hoge/actionsレポジトリにnotify-slackという名前のディレクトリを切って独自アクションを準備
      repository: Hoge/actions
      # 外部のプライベートレポジトリをチェックアウトするときは個人トークンが必要
      token: ${{ secrets.TOKEN }}
      # 配置するパス
      path: actions
  - name: Notification
    uses: ./actions/notify-slack
    with:
      message: "Test notificatin"

入力値チェック

手動実行時の入力値は、プルダウンが使えないためワークフロー内で入力値チェックを行うようにした。

これは、パラメータ environment の入力値がtest1でもtest2でもない時に失敗させる例です。

steps:
  - name: Check
    if: ${{ github.event.inputs.environment != 'test1' && github.event.inputs.environment != 'test2'}}
    run: |
      echo "::error ${{ github.event.inputs.environment }} is invalid. environment must be 'test1' or 'test2'."
      exit 1

依存関係やビルドによる成果物をキャッシュする

公式の actions/cache を使えば簡単に実現できます。

jobやstep単位でタイムアウト時間を設定する

jobのタイムアウト時間はデフォルトで360分なので設定しないと長時間実行し続けて無料枠をすぐ消費してしまいます。timeout-minutes: 時間(分) で指定します。 公式ドキュメント

Drive API Client Library for Javaで遊ぶ

Drive API Client Library for Javaで遊んだのでまとめます。

環境
- Java 11
- Spring Boot 2.4.4
- Google Auth Library 0.25.2
- Drive API Client Library for Java v3-rev20210315-1.31.0

GCPコンソール側の設定

google drive の Quickstart(サービスアカウント編) を参考にさせていただきました。 GCPコンソールでの設定は上記の記事をみてください。

依存関係の追加

Google Auth LibraryとDrive API Client Library for Javaを依存関係に追加します。

dependencies {
  ...
    implementation "com.google.apis:google-api-services-drive:$googleApiServicesDriveVersion"
    implementation "com.google.auth:google-auth-library-oauth2-http:$googleAuthLibraryOAuth2HttpVersion"
  ...
}

Credentialの作成

Google Drive APIにリクエストするときにクレデンシャル情報を渡す必要があります。GCPコンソールからダウンロードしたサービスアカウントキーファイル(JSON)からインスタンスを作成します。

GoogleCredentials credentials;
try (InputStream inputStream = new ClassPathResource(CREDENTIALS_FILE_PATH).getInputStream()) {
    credentials = ServiceAccountCredentials.fromStream(inputStream).createScoped(SCOPES);
}

Google Driveで遊ぶ

ディレクトリにあるファイル一覧取得、ファイルのアップロード、ファイルのダウンロードを行なっています。

@Service
public class FileServiceImpl implements FileService {

    private static final Logger LOG = LoggerFactory.getLogger(FileServiceImpl.class);

    private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();

    private final GDriveProperties gDriveProperties;

    public FileServiceImpl(GDriveProperties gDriveProperties) {
        this.gDriveProperties = gDriveProperties;
    }

    @Override
    public void list(GoogleCredentials credentials) throws IOException, GeneralSecurityException {
        HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(credentials);
        Drive service = new Drive.Builder(GoogleNetHttpTransport.newTrustedTransport(), JSON_FACTORY, requestInitializer)
                .setApplicationName("Google Drive Sandbox")
                .build();

        FileList result = service.files().list().setPageSize(10).execute();
        List<File> files = result.getFiles();
        if (CollectionUtils.isEmpty(files)) {
            LOG.info("No files found.");
            return;
        }
        LOG.info("Files:");
        files.forEach(file -> LOG.info("file name: {}, id: {}\n", file.getName(), file.getId()));
    }

    @Override
    public void upload(GoogleCredentials credentials) throws IOException, GeneralSecurityException {
        HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(credentials);
        Drive service = new Drive.Builder(GoogleNetHttpTransport.newTrustedTransport(), JSON_FACTORY, requestInitializer)
                .setApplicationName("Google Drive Sandbox")
                .build();

        File fileMetadata = new File();
        fileMetadata.setName("create.txt");
        fileMetadata.setParents(Collections.singletonList(gDriveProperties.getParentDirId()));
        FileContent mediaContent = new FileContent("text/plain", new ClassPathResource("/static/create.txt").getFile());
        File file = service.files().create(fileMetadata, mediaContent)
                .setFields("id, parents")
                .execute();

        LOG.info("Uploaded: file id: {}\n", file.getId());
    }

    @Override
    public void download(GoogleCredentials credentials) throws IOException, GeneralSecurityException {
        HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(credentials);
        Drive service = new Drive.Builder(GoogleNetHttpTransport.newTrustedTransport(), JSON_FACTORY, requestInitializer)
                .setApplicationName("Google Drive Sandbox")
                .build();

        File file = service.files().get(gDriveProperties.getDownloadFileId()).execute();
        LOG.info("Downloaded: file id: {}, file name: {}", file.getId(), file.getName());
    }
}

ほぼ各ライブラリのREADME.mdを見ながら実装したのでそこまで解説することはないです。詳しくはGitHubレポジトリ を見てください。

実行結果

正常に動いているようです。

.   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/  ___)| |_)| | | | | || (_| |  ) ) ) )
'  |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot ::                (v2.4.4)

2021-04-02 17:38:16.412  INFO 8930 --- [           main] c.b.googledrivesandbox.Application       : Starting Application using Java 11.0.2
2021-04-02 17:38:16.414  INFO 8930 --- [           main] c.b.googledrivesandbox.Application       : No active profile set, falling back to default profiles: default
2021-04-02 17:38:16.798  INFO 8930 --- [           main] c.b.googledrivesandbox.Application       : Started Application in 0.672 seconds (JVM running for 1.172)
2021-04-02 17:38:17.548  INFO 8930 --- [           main] c.b.g.service.impl.FileServiceImpl       : Files:
2021-04-02 17:38:17.548  INFO 8930 --- [           main] c.b.g.service.impl.FileServiceImpl       : file name: create2.txt, id: 1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2

2021-04-02 17:38:17.549  INFO 8930 --- [           main] c.b.g.service.impl.FileServiceImpl       : file name: create1.txt, id: 1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1

2021-04-02 17:38:17.549  INFO 8930 --- [           main] c.b.g.service.impl.FileServiceImpl       : file name: create.txt, id: 1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx0

2021-04-02 17:38:17.823  INFO 8930 --- [           main] c.b.g.service.impl.FileServiceImpl       : Downloaded: file id: 1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2, file name: create2.txt
2021-04-02 17:38:18.185  INFO 8930 --- [           main] c.b.g.service.impl.FileServiceImpl       : Files:
2021-04-02 17:38:18.185  INFO 8930 --- [           main] c.b.g.service.impl.FileServiceImpl       : file name: create2.txt, id: 1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2

2021-04-02 17:38:18.185  INFO 8930 --- [           main] c.b.g.service.impl.FileServiceImpl       : file name: create1.txt, id: 1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1

2021-04-02 17:38:18.185  INFO 8930 --- [           main] c.b.g.service.impl.FileServiceImpl       : file name: create.txt, id: 1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx0

SpringのロギングとExceptionハンドリング再入門

同僚氏のPRレビューをするにあたって、ロギング、Exceptionハンドリングよくわかってないなって思って再入門してみた。 「@ExceptionHandlerのメソッドでハンドリングしたときのレスポンスをログ出力する」というのがPRの内容だった。

「1.どこでアクセス(リクエスト)ログを出力するのがよいのか」、「2.どうやってExceptionハンドリングが行われているのか」について調べたことをまとめる。

※本記事の環境はJava 11, Spring Boot 2.4.4

ロギング

公式ドキュメント

Spring Bootは、Commons Loggingを使ってログ出力をしている。デフォルト設定としては、Java Util LoggingLog4j2Logbackを提供している。

spring-boot-starter-loggingを依存関係に追加している場合は、Logbackが使われる。 spring-boot-starter-loggingには、LogbackLog4j、Slf4jが依存関係に追加されている。

ログのファイル出力

Spring Bootは、デフォルトでコンソールのみにだけログを出力してファイルには出力しない。ファイルに出力したい場合は、application.properties(yml)にlogging.file.pathでパスを指定するだけでよい。デフォルトのファイル名はspring.logなので、変更したい場合はlogging.file.nameでファイル名を指定する。

Logbackを使ったログ出力

howto-logginをそのままマネするだけで、Logbackを使ってログ出力が可能。

このように書けば、ファイルとコンソールにログを出力できる。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH/}spring.log}" />
    <include resource="org/springframework/boot/logging/logback/file-appender.xml" />
    <root level="INFO">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="FILE" />
    </root>
</configuration>

ログレベルで出力するファイルを分ける

logback-spring.xmlでfilter要素を指定すれば、出力ログのフィルタリングができる。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <!-- 標準出力はデフォルト -->
    <include resource="org/springframework/boot/logging/logback/console-appender.xml" />

    <property name="APPLICATION_LOG_FILE" value="${LOG_PATH}/application.log" />
    <appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
            <charset>${FILE_LOG_CHARSET}</charset>
        </encoder>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>INFO</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
        <file>${APPLICATION_LOG_FILE}</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${APPLICATION_LOG_FILE}.%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
            <maxFileSize>10MB</maxFileSize>
        </rollingPolicy>
    </appender>

    <property name="ERROR_LOG_FILE" value="${LOG_PATH}/error.log" />
    <appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
            <charset>${FILE_LOG_CHARSET}</charset>
        </encoder>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
        <file>${ERROR_LOG_FILE}</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${ERROR_LOG_FILE}.%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
            <maxFileSize>10MB</maxFileSize>
        </rollingPolicy>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="FILE_INFO" />
        <appender-ref ref="FILE_ERROR" />
    </root>
</configuration>
  • appender name="FILE_INFO"
    • ログレベルINFOのログを${LOG_PATH}/application.logに出力
  • appender name="FILE_ERROR"
    • ログレベルERRORのログを${LOG_PATH}/error.logに出力

アクセスログの出力

Spring MVCで用意されているCommonsRequestLoggingFilter.javaを使ってアクセスログを出力してみる。

まず、CommonsRequestLoggingFilter.javaをBean登録する。

@Configuration(proxyBeanMethods = false)
public class RequestLoggingFilterConfig {

    @Bean
    public CommonsRequestLoggingFilter logFilter() {
        CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
        filter.setIncludeQueryString(true);
        filter.setIncludePayload(true);
        filter.setMaxPayloadLength(10000);
        filter.setIncludeHeaders(false);
        filter.setAfterMessagePrefix("Request data : ");
        return filter;
    }
}

次に、application.ymlでログレベルDEBUGのログを出力するように設定する。

logging:
  level:
    org.springframework.web.filter.CommonsRequestLoggingFilter: debug

サンプルのREST APIに対してリクエストを送ると、このような感じでログ出力される。

2021-03-21 21:17:10.364 DEBUG 24133 --- [nio-8080-exec-2] o.s.w.f.CommonsRequestLoggingFilter      : Before request [GET /users]
2021-03-21 21:17:10.396 DEBUG 24133 --- [nio-8080-exec-2] o.s.w.f.CommonsRequestLoggingFilter      : Request data : GET /users]
2021-03-21 21:17:14.724 DEBUG 24133 --- [nio-8080-exec-3] o.s.w.f.CommonsRequestLoggingFilter      : Before request [GET /users/1]
2021-03-21 21:17:14.735 DEBUG 24133 --- [nio-8080-exec-3] o.s.w.f.CommonsRequestLoggingFilter      : Request data : GET /users/1]
2021-03-21 21:17:17.829 DEBUG 24133 --- [nio-8080-exec-4] o.s.w.f.CommonsRequestLoggingFilter      : Before request [GET /users/5]
2021-03-21 21:17:17.837 DEBUG 24133 --- [nio-8080-exec-4] o.s.w.f.CommonsRequestLoggingFilter      : Request data : GET /users/5]
2021-03-21 21:17:31.881 DEBUG 24133 --- [nio-8080-exec-5] o.s.w.f.CommonsRequestLoggingFilter      : Before request [POST /users]
2021-03-21 21:17:31.904 DEBUG 24133 --- [nio-8080-exec-5] o.s.w.f.CommonsRequestLoggingFilter      : Request data : POST /users, payload={
    "name": "test"
}]

単にリクエスト情報を知りたい場合は、用意されているクラスを使えば簡単にできる。


ここで、冒頭の「どこでアクセス(リクエスト)ログを出力するのがよいのか」について考えてみる。 先ほど利用した、CommonsRequestLoggingFilter.javaは、AbstractRequestLoggingFilter.javaを継承しておりこのクラスは、OncePerRequestFilter.javaを継承している。

これを踏まえると、OncePerRequestFilter.javaを継承してアクセスログを出力するのが正しいそう。

Exceptionハンドリング

Spring MVCで、Controller以降の処理で発生した例外をハンドリングするコンポーネントとして、org.springframework.web.servlet.HandlerExceptionResolverインターフェースといくつかの実装クラスを提供している。

デフォルトで適用されるHandlerExceptionResolverの実装クラスと呼び出される順序は、「1.ExceptionHandlerExceptionResolver.java」、「2.ResponseStatusExceptionResolver.java」、「3.DefaultHandlerExceptionResolver.java」である。

  • ExceptionHandlerExceptionResolver.java
    • @ExceptionHandlerを指定したメソッドのためのExceptionハンドラ
  • ResponseStatusExceptionResolver.java
    • @ResponseStatusを付与した例外クラスのためのExceptionハンドラ
  • DefaultHandlerExceptionResolver.java
    • Spring MVCのコントローラの処理で発生する例外をハンドリングするためのExceptionハンドラ

Effective Javaを読んで

もっとちゃんとJavaのこと理解したいなと思って、Effective Javaを読んだ。

オブジェクトの生成と消滅

コンストラクタの代わりにstaticファクトリメソッドを検討する

  • staticファクトリメソッドのメリット
    • コンストラクタと異なって、名前を持つから返されるオブジェクトが何者かわかりやすい
    • コンストラクタと異なって、呼び出しごとに新たなオブジェクトを生成する必要がない
    • コンストラクタと異なって、メソッドの戻り値型の任意のサブタイプのオブジェクトを返せる
    • 返されるオブジェクトのクラスは、入力パラメータの値に応じて呼び出しごとに変えられる
    • 返されるオブジェクトのクラスは、そのstaticファクトリメソッドを含むクラスが書かれた時点で存在する必要さえない
  • staticファクトリメソッドのデメリット
    • publicあるいはprotectedのコンストラクタを持たないクラスのサブクラスを作れない制約がある
    • プログラマがstaticファクトリメソッドを見つけるのが難しい
      • 共通する命名規約を遵守することで、軽減できる
        • from:単一パラメータを受け取り、当の肩を持つ対応するインスタンスを返す型変換メソッド
        • of:複数のパラメータを受け取り、それらを含んだ当の型のインスタンスを返す集約メソッド
        • valueOf:fromやofの代わりとなる、冗長な名前のメソッド
        • instance あるいは getInstance:パラメータがあればそのパラメータで表されているインスタンスを返すが、必ずしも同じ値を持つとは限らない
        • create あるいは newInstance:呼び出しごとにメソッドは新たなインスタンスを返す
        • getType:ファクトリメソッドが対象のクラスとは異なるクラスにある場合に使われる。Typeはファクトリメソッドから返されるオブジェクトの型を示している。(getInstanceと類似)
        • newType:ファクトリメソッドが対象のクラスとは異なるクラスにある場合に使われる。Typeはファクトリメソッドから返されるオブジェクトの型を示している。(newInstanceと類似)
        • type:getTypeやnewTypeの代わりとなる、簡潔な名前のメソッド。

多くのコンストラクタパラメータに直面したときはビルダーを検討する

テレスコーピング・コンストラクタ・パターンは、多くのパラメータがある場合、クライアントのコードを書くのが困難になり、可読性が落ちる。JavaBeansパターン(setter)は、生成過程の途中で不整合な状態にあるかもしれない。クラスを不変にする可能性を排除してしまう。 テレスコーピング・コンストラクタ・パターンの安全性とJavaBeansパターンの可読性を組み合わせたのがBuilderパターン。Builderパターンは、コードを書くのも読むのも容易になる。 Builderパターンの欠点は、「最初にビルダーを生成しないといけない」、「記述が長くなってしまう」。将来的にパラメータが増えそうなときは、最初からビルダーで始めるのがたいていよい。 ビルダーによるクライアントのコードは、テレスコーピング・コンストラクタ・パターンよりは読みやすく、JavaBeansバターンよりは安全。

privateコンストラクタかenum型でシングルトン特性を強制する

インターフェースを実装していない限り、シングルトンをモック実装で置き換えることが不可能なため、クラスをシングルトンにすると、クライアントのテストを困難にする。

  • シングルトンを実装するための方法
    • public finalのフィールド
    • staticファクトリメソッド
    • 単一要素を持つenumを宣言

シングルトンを保証し続けるには、すべてのインスタンスフィールドをtransientと宣言して、readResolveメソッドを提供しなければならない。そうしなければ、シリアライズされたインスタンスをデシリアライズするごとに、新たなインスタンスが生成されてしまう。

資源を直接結び付けるよりも依存性注入を選ぶ

多くのクラスが1つ以上の下層の資源に依存している。 クラスの複数のインスタンスをサポートして、それぞれのインスタンスでクライアントが望む資源を使えることが必要で、新しいインスタンスを生成するときにコンストラクタに資源を渡すことが単純な解決策になる。 依存性注入は柔軟性とテスト可能性を大幅に向上するが、普通に数千の依存を含むような大きなプロジェクトを撮り散らかす可能性があるが、依存性注入フレームワークを使えば取り除ける。

不必要なオブジェクトの生成を避ける

機能的に同じオブジェクトが必要な度に新たに生成するよりは、一つのオブジェクトを再利用する方が適切。オブジェクトが不変であれば、常に再利用できる。

使われなくなったオブジェクト参照を取り除く

ガベージコレクションを持つ言語は、オブジェクトを使い終えたときにそれが自動的に回収されることで、プログラマとしての仕事は楽になる。 スタックが大きくなってその後小さくなると、スタックから取り出されたオブジェクトはガベージコレクトされない。スタックはそれらのオブジェクトに対する使われなくなった参照を保持しているため。 クラスが独自のメモリを管理しているときは、プログラマメモリリークに対して注意を払うべき。要素が解放されるときには、その要素に含まれたすべてのオブジェクト参照にnullを設定すべき。

ファイナライズとクリーナーを避ける

ファイナライザは予想不可能で、たいていは危険で、一般的には必要ない。

  • ファイナライザとクリーナーの欠点
    • 即座に実行される保証がない。オブジェクトが到達不可能となってから実行されるまでの時間は、任意の長さ。

セーフティネットとして、あるいは重要でないネイティブピアの資源の解放のためを除いて、クリーナー(またはファイナライザ)は使わない方がよい。

try-finallyよりもtry-with-resourcesを選ぶ

Javaライブラリは、closeメソッドを呼び出して手作業でクローズしなければならない多くの資源を含んでいる。例として、InputStream、OutputStream、java.sql.Connectionが含まれる。 クローズしなければならない資源を扱うときは、いつでもtry-with-resourcesを使うのがよい。その結果、コードは短くて明瞭で、生成される例外は有用なものになる。

すべてのオブジェクトに共通のメソッド

Objectは具象クラスであるにもかかわらず、主に拡張されるために設計されている。 そのfinalでないすべてのメソッド(equals, hashCode, toString, clone, finalize)は、オーバーライドされるように設計されているおり、明示的な一般契約を持っている。

equalsをオーバーライドするときは一般契約を使う

equalsメソッドのオーバーライドによる問題を防ぐ1番簡単な方法は、オーバーライドしないこと。その場合、個々のインスタンスは自分自身とだけ等しくなる。

次の条件のどれかが当てはまるなら、オーバライドしないのが正しいやり方。

  • クラスの個々のインスタンスが本質的に一意である
  • クラスが「論理的等価性」の検査を提供する必要がない
  • スーパークラスがすでにequalsをオーバーライドしており、スーパークラスの振る舞いがこのクラスに対して適切である
  • クラスがprivateあるいはパッケージプライベートであり、そのequalsメソッドが呼び出されないことが確かである

オーバーライドするのが適切なときは、クラスが単なるオブジェクトの同一性とは異なる論理等価性という概念を持っていて、かつスーパークラスがequalsメソッドをオーバーライドしていないとき。一般には、値クラスのとき。

equalsメソッドのオーバーライド時に厳守すべき5要件

  • 反射性
    • オブジェクトがそれ自身と等しくなければならない
  • 対称性
    • いかなる二つのオブジェクトでも、それらが等しいかどうかについて合意しなければならない
  • 推移性
    • 一つ目のオブジェクトが二つ目のオブジェクト等しく、かつ、二つ目のオブジェクトが三つ目のオブジェクトと等しければ、最初のオブジェクトと三つ目のオブジェクトは等しくなければならない
  • 整合性
    • 二つのオブジェクトが等しければ、どちらか片方あるいは両方が変更されない限り、いつまでも常に等しくあり続けなければならない。
  • 非null性
    • すべてのオブジェクトはnullと等しくあってはならない

高品質のequalsメソッドを作成するレシピ

  • 引数が自分自身のオブジェクトへの参照であるか検査するために==演算子を使う
  • 引数が正しい型であるか検査するためにinstanceof演算子を使う
  • 引数を正しい型にキャストする
  • クラスの「意味のある」フィールドのそれぞれについて、引数のオブジェクトのフィールドが、このオブジェクトの対応するフィールドと一致するか検査する

equalsをオーバーライドするときは、常にhashCodeをオーバーライドする

equalsをオーバーライドしているすべてのクラスで、hashCodeをオーバーライドしなければならない。そうしないクラスは、Object.hashCodeの一般契約を破ることになり、HashMap、HashSetなどのコレクションで用いられると適切に機能しない。

toStringを常にオーバーライドする

Object#toStringは、クラス名、「@」、ハッシュコードの符号なし16進数表現から構成されており、一般的にクラスのユーザが見たい内容ではない。実用的な場合、toStringメソッドはオブジェクトに含まれる興味があるすべての情報を含むべき。

cloneを注意してオーバーライドする

メソッドを含んでいないCloneableはObjectのprotectedのcloneメソッドの実装の振る舞いを決定する。Cloneableを実装しているクラスは、適切に機能するpublicのcloneメソッドを提供することが期待されている。

Comparableの実装を検討する

compareToメソッドはObjectでは宣言されていない。正確に言えば、compareToメソッドはComparableインターフェースの唯一のメソッド。Comparableを実装することで、インスタンスが自然な順序を持っていることをクラスは示している。 アルファベット順、数値順、年代順などの明らかに自然な順序を持つ値クラスを書くなら、Comparableインターフェースを実装すべき。compareToメソッドの実装内でフィールドの値を比較するときは、<演算子と>演算子を使わないほうがよい。代わりに、ボクシングされた基本データクラスのstaticのcompareメソッドか、Comparatorインターフェースのコンパレータ構築メソッドを利用する。

クラスとインターフェース

クラスとメンバーへのアクセス可能性を最小限にする

うまく設計されたコンポーネントと下手に設計されたコンポーネントを区別している唯一の最も重要な要素は、内部データと実装の詳細を他のコンポーネントからどの程度隠蔽しているかである。うまく設計されたコンポーネントは、その実装のすべての詳細を隠蔽し、実装とAPIをはっきりと分離している。 情報隠蔽カプセル化)は多くの理由で重要。コンポーネントを並行して開発できるため、情報隠蔽システム開発のスピードを向上させる。各クラスやメンバーをできる限りアクセスできないようにすべき。

publicクラスでは、publicフィールドではなく、アクセサーメソッドを使う

publicクラスの可変なフィールドを公開すべきではない。しかし、可変であろうと不変であろうと、パッケージプライベートのクラスかprivateのネストしたクラスであればフィールドを後悔することが望ましい場合がある。

可変性を最小限にする

不変クラスは、そのインスタンスが変更できないという単なるクラス。不変クラスは、設計・実装、使用が可変クラスよりも容易。不変クラスは誤りにくく安全。

クラスを不変にする5つの規則

  • オブジェクトの状態を変更するためのメソッドを提供しない
  • クラスを拡張できないようにする
  • すべてのフィールドをfinalにする
  • すべてのフィールドをprivateにする
  • 可変コンポーネントに対する独占的アクセスを保証する

不変クラスの唯一の実質的欠点は、個々の異なる値に対して別々のオブジェクトを必要とする。

継承よりもコンポジションを選ぶ

継承はコードの再利用するための強力な方法だが、常に再利用のための最善の道具とはならない。不適切に使われると、継承はもろいソフトウェアを作り出してしまう。 サブクラスとスーパークラスの実装が同じでプログラマの管理下にある場合、パッケージ内の継承を使うのは安全。拡張のために設計されて、かつ拡張のために文書化されているクラスを拡張する場合にも、継承を使うのは安全。 しかし、パッケージをまたがって、普通の具象クラスから継承することは危険。 メソッド呼び出しとは異なり、継承はカプセル化を破る。(= サブクラスは適切に機能するために、スーパークラスの実装の詳細に依存する)

メソッドをオーバーライドすることで発生してしまう問題については、既存のクラスを拡張する代わりに、新たなクラスに既存のクラスのインスタンスを参照するprivateのフィールドを持たせることで解決できる。既存のクラスが新たなクラスの構成要素になるため、この設計はコンポジションと呼ばれる。

コンポジションではなく継承を使うと決める前に、「拡張しようと考えているクラスは、そのAPIに何らかの血管を持っていないか。もし、欠陥があれば、自分のクラスにその血管を伝播させていることに不安はないか。」を自問する必要がある。

継承のために設計および文書化する、でなければ継承を禁止する

クラスはメソッドのオーバーライドの影響を正確に文書化しなければならない。(= クラスはオーバーライド可能なメソッドの自己利用を文書化しなければならない) 継承のためにクラスを設計するのは大変な仕事である。クラスの自己利用をすべて文書化しなければならないし、一旦文書化したら、クラスが存在する限りそれを守らなければならない。サブクラスの必要性があるとわかっていない限り、クラスをfinalと宣言するかアクセスできるコンストラクタがないようにすることで、継承を禁止するのがおそらくよい。

抽象クラスよりもインターフェースを選ぶ

Javaは、複数の実装を許す型を定義するためにインターフェースと抽象クラスの二つの仕組みを提供している。この二つの相違は、抽象クラスで定義された型を実装するには、クラスはその抽象クラスのサブクラスでなければならないこと。 新たなインターフェースを実装するように既存のクラスを変更するのは容易。インターフェースはミックスインを定義するには理想的。インターフェースは、回想を持たない型フレームワークの構築を可能にしている。

将来のためにインターフェースを設計する

主にラムダを活用するために、多くの新たなデフォルトメソッドが、Java8の中核のコレクションインターフェースに追加された。 考えられるすべての実装のすべての不変式を維持するデフォルトメソッドを書くことは必ずしも可能ではない。デフォルトメソッドの存在は、インターフェースをエラーや警告なしでコンパイルできるかもしれないが、実行時に失敗することがある。 インターフェースがリリースされた後に、インターフェースの欠陥によっては修正が可能かもしれないが、そのことに期待すべきではない。

型を定義するためだけにインターフェースを使う

クラスがインターフェースが実装することで、そのインスタンスでクライアントは何ができるのかについてクラスは述べるべき。他の目的のためにインターフェースを定義するのは不適切。 この検査に合格しないインターフェースの種類の一つは、いわゆる定数インターフェース。定数インターフェースパターンは、インターフェースの下手な使い方である。これを避けるために、enum型やインスタンス化不可能なユーティリティクラスで定数を提供するのがよい。

タグ付きクラスよりもクラス階層を選ぶ

タグ付きクラスは、冗長で、誤りやすく、非効率なので、適切であることはほとんどない。明示的なタグフィールドを持つクラスを書きたくなったら、そのタグを取り除いてクラス階層で置き換えられないかを考えるのがよい。

非staticのメンバークラスよりもstaticのメンバークラスを選ぶ

ネストしたクラスは、他のクラス内に定義されたクラス。ネストしたクラスには、staticメンバークラス、非staticのメンバークラス、無名クラス、ローカルクラスの4種類がある。staticのメンバークラス以外は、内部クラスとして知られている。

ネストしたクラスの用途は次の通りである。

  • staticメンバークラス
    • たまたまあるクラス内で宣言され、そのエンクロージングクラスのメンバーのすべてに、たとえそのメンバーがprivateと宣言されていてもアクセスできる通常のクラス。
    • 使い方は、その外部クラスと一緒に使うと有用なpublicのヘルパークラスとしてである。
    • staticと非staticのメンバークラスの構文的な唯一の相違点は、staticのメンバークラスはその宣言にstatic修飾子があること。
    • 非staticのメンバークラスの個々のインスタンスは、それを含むクラスのエンクロージングインスタンスと暗黙に関連づけられている。
    • エンクロージングインスタンスなしで、非staticのメンバークラスのインスタンスを生成することは不可能。
  • 非staticのメンバークラス
  • private staticのメンバークラス
    • 使い方は、エンクロージングクラスが表すオブジェクトの構成要素を表すこと。
  • 無名クラス
    • 無名クラスは名前を持たない。そのエンクロージングクラスのメンバーではない。
    • 無名クラスが宣言された箇所以外で、無名クラスをインスタンス化できない。instanceof検査やクラスの名前を指定する必要がある処理はできない。
    • 無名クラスよりもラムダが好ましい。
    • 使い方は、staticファクトリメソッド内の実装において。
  • ローカルクラス
    • ローカルクラスは、4種類のネストしたクラスの中で、おそらく最も使われてはいけない。

要約すると、4種類の異なるネストしたクラスがあり、それが異なる用途を持っている。 ネストしたクラスが、一つのメソッドの外からも見える必要があったり、メソッド内に問題なく入れるのに長すぎるならば、メンバークラスを使う。 メンバークラスの個々のインスタンスが、エンクロージングインスタンスへの参照が必要ならば、非staticにする。そうでなければstaticにする。 クラスがメソッド内に属しているべきであり、一箇所からのみインスタンスを生成する必要があり、そのクラスを特徴付ける型がすでに存在していれば、無名クラスにする。そうでなければ、ローカルクラスにする。

ソースファイルを単一のトップレベルのクラスに限定する

Javaコンパイラは、単一のソースファイルに複数のトップレベルのクラスを定義することを許すが、そうすることに何も利点はなく、重大なリスクがある。そのリスクは、一つのクラスに対して複数の定義の提供が可能になること。 トップレベルのクラスを、クラス名と同じ名前の別々のソースファイルへ分けることが、問題の解決である。

ジェネリック

原型を使わない

一つ以上の型パラメータを宣言に持つクラスやインターフェースは、ジェネリッククラスやジェネリックインターフェース。 個々のジェネリック型は、クラス名やインターフェース名の後にアングルブラケットで囲んで、そのジェネリック型の仮型パラメータに対応する実型パラメータのリストからなるパラメータ化された型の集合を定義する。

無検査警告を取り除く

ジェネリクスを用いてプログラミングする際には、コンパイラの警告を多く目にします。無検査キャスト警告、無検査メソッド呼び出し警告、パラメータ化された可変引数型警告、無検査変換警告である。取り除けるすべての無検査警告を取り除くのがよい。 SuppressWarningアノテーションを、できる限り最小のスコープに対して使うのがよい。

配列よりもリストを選ぶ

配列は2つの重要な点で、ジェネリック型と異なっている。

  • 配列は共変(covariant)でジェネリックは不変(invariant)
    • SubがSuperのサブタイプならば、配列型 Sub が Super のサブタイプである
    • List は List のサブタイプでもなければスーパータイプでもない
  • 配列は具象化されている
    • 配列は実行時にその要素型を知っていて強制する。Long の配列に String を保存しようとすると ArrayStoreException がスローされる。
    • ジェネリックはイレイジャで実装されている。コンパイル時のみに型制約を強制し、実行時には要素の型情報を廃棄する。

ジェネリック型を使う

ジェネリック型は、クライアントのコードでキャストが必要である型より、安全で使いやすい。

ジェネリックメソッドを使う

クラスをジェネリック化できるように、メソッドもジェネリック化できる。入力パラメータと戻り値をキャストすることをクライアントに要求するメソッドより、安全で使いやすい。 メソッドを型安全にするには、引数と戻り値に対する要素型を表す型パラメータを宣言する。

APIの柔軟性向上のために境界ワイルドカードを使う

最大の柔軟性のためには、プロデューサがコンシューマを表す入力パラメータに対してワイルドカード型を使うのがよい。 広く使われるタイプのライプラリを書くなら、ワイルドカード型を適切に使うことは、必須だと考えるべき。それは、プロデューサ-extends、コンシューマ-super(PECS)。そして、すべての比較可能なものとコンパレータは、コンシューマであることを忘れてない。

ジェネリックと可変長引数を注意して組み合わせる

可変長引数の目的は、クライアントがメソッドへ可変長の引数を渡せるようにすることだが、それは漏出抽象化。 ヒープ汚染は、パラメータ化された型の変数が、その型ではないオブジェクトを参照している場合に発生する。 SafeVarargsアノテーションは、メソッド作成者がそのメソッドが型安全であることを約束することである。この約束とは引き替えに、コンパイラは呼び出しが安全ではないかもしれないメソッドのユーザに警告しなくなる。

次の場合にジェネリック可変長引数メソッドは安全。

  • 可変長パラメータ配列に何も保存していなくて、かつ、
  • 信頼できないコードに対してその配列(あるいは複製を)参照できるようにしていない。

ジェネリック(あるいはパラメータ化された)可変長パラメータを持つメソッドを書くなら、最初にそのメソッドが型安全であるようにするべき。そして、使いやすいように@SafeVarargeアノテーションをつける。

型安全な異種コンテナを検討する

コレクションAPIで示されているジェネリックスの一般的な使い方は、コンテナごとに固定数の型パラメータに制限している。コンテナでなくキーに対して型パラメータを指定することでこの制約をさけられる。 このような型安全異種コンテナに対するキーとしてClassオブジェクトを使える。このように使われるClassオブジェクトは、型トークンと呼ばれる。

enumアノテーション

int定数の代わりにenumを使う

列挙型(enumerated type)は、一年の四季、太陽系の惑星、トランプのスーツなどの固定数の定数からその値が成り立つ型。 int enumパターン、String enumパターンの欠点を避けて、多くの恩恵をもたらす代替がenum型。 Javaenum型は、public static final フィールドを通して、各列挙定数に対して一つのインスタンを公開しているクラス。enum型はアクセス可能なコンストラクタをっ持っていないので、事実上finalである。 定数値は、int enumパターンとは異なり、クライアントの中にコンパイルされない。toStringメソッドを呼び出すことで、enumを表示可能な文字列に変換できる。 int enumパターンの欠点を修正することに加えて、enum型には任意のメソッドやフィールドを追加でき、任意のインターフェースも実装できる。 機能が豊富なenum型を書くのは容易。enum定数にデータを関連付けるために、インスタンスフィールドを宣言して、データを受け取るコンストラクタを書いて、そのフィールドにデータを保存する。

序数(ordinal)の代わりにインスタンスフィールドを使う

すべてのenumはordinalメソッドを持っており、enum型内の各enum定数の数値で表されている位置を返す 序数からenumに関連づけられる値を使う代わりに、インスタンスフィールドに値を保存するのがよい。 ordinalは、EnumSetやEnumMapなどの汎用的なenumに基づくデータ構造により使われるように設計されており、そのようなデータ構造を書いていないのであれば、ordinalメソッドを使わないのが最善。

ビットフィールドの代わりにEnumSetを使う

列挙型の要素が集合で使われるなら、書く定数に異なる2つの要素を割り当てて、int enumパターンを使うのが従来の方法。ビットフィールドはint enum定数の短所だけではなく、さらに多くの短所を持っている。 EnumSetを使うのが良い。EnumSetクラスはビットフィールドの簡潔性とパフォーマンスを、enum型の多くの利点全てと組み合わせてくれる。

序数インデックスの代わりにEnumMapを使う

EnumMapとして知られるenumをキーとして使うように設計された後続なMapの実装がある。 EnumMapのコンストラクタは、キー型のClassオブジェクトを受け取る。これは境界型トークンであり、実行時のジェネリック型情報を提供している。 配列をインデックスするために序数を使うことが適切であることは滅多にない。代わりにEnumMapを使う。

拡張可能なenumをインターフェースで模倣する

拡張可能なenum型を書くことはできないが、基本のenum型に伴うインターフェースを書いて、そのインターフェースをその基本のenum型に実装させることで模倣できる。

命名パターンよりもアノテーションを選ぶ

何らかのプログラム要素がツールやフレームワークによる特別な処理を要求していることを示すために、命名パターンが使われるのが普通だった。アノテーションを利用できる場合、命名パターンを使うのは論外。

常にOverrideアノテーションを使う

一般的なプログラマにとって、それらの中で最も重要なのは@Overrideです。このアノテーションはメソッド宣言にだけ使えて、アノテーションがつけられたメソッド宣言がスーパータイプの宣言をオーバーライドしていることを示す。 スーパークラスの宣言をオーバーライドしているすべtのメソッド宣言に対して@Overrideアノテーションを使うべき。 抽象クラスやインターフェースでは、スーパークラスのメソッドやスーパーインターフェースのメソッドをオーバーライドしているすべてのメソッドにアノテーションをつける価値はある。コンパイラは多くの誤りから皆さんを保護できる。

型を定義するためにマーカーインターフェースを使う

マーカーインターフェースはメソッド宣言を含んでいないインターフェースであり、そのインターフェースを実装しているクラスが何らかの特性を持っていることを単に指定する。 マーカーインターフェースとマーカーアノテーションはどちらも用途がある。関連づけられた新たなメソッドを持たない型を定義したいなら、マーカーアノテーションであるべき。クラスとインターフェース以外のプログラム要素をマークしたい、あるいは、アノテーション型をステに多用しているフレームワーク内にマーカーを適合させたいなら、マーカーアノテーションが正しい選択。

ラムダとストリーム

関数オブジェクトを容易に作成できるように、Java8で関数型インターフェース、ラムダ、メソッド参照が追加された。データ要素のシーケンス処理するライブラリを提供するために、一緒にストリームAPIも追加された。

無名クラスよりもラムダを選ぶ

Java8では、単一の抽象メソッドを持つインターフェースは特別であり、特別に扱うに値するという概念を形式化した。 関数型インターフェースではない型のインスタンスを作成する必要があるときだけ、関数型オブジェクトとして無名クラスを使う。

ラムダよりもメソッド参照を選ぶ

メソッド参照は、たいていラムダよりも簡潔な代替を提供する。メソッド参照の方が短く明瞭である箇所では、メソッド参照を使うのがよい。そうではない箇所では、ラムダを使うのがよい。

標準の関数型インターフェースを使う

java.util.Functionには43個のインターフェスがあるが、6個の基本てインターフェースを覚えてさえいれば、残りのインターフェースは導き出せる。 Operatorインターフェースは、その結果の型が引数の型と同じ関数を表す。 Predicateインターフェースは、引数を受け取りbooleanを返す関数を表す。 Functionインターフェースは、その引数の型と戻り値の型が異なる関数を表す。 Supplierインターフェースは、引数を取らず値を返す関数を表す。 Consumerインターフェースは、引数を受け取り何も返さない関数を表す。\

標準の関数型インターフェースのほとんどは、基本データ型に対するサポートを提供するためだけに存在している。基本データ型の関数型インターフェースの代わりにボクシングされた基本データでもって、基本の関数型インターフェースを使わないようにするのがよい。\

ストリームを注意して使う

Stream APIは、大量操作の逐次処理や並列処理を行いやすくするためにJava 8で追加された。このAPIは二つの主要な抽象化を提供している。データ要素の有限あるいは無限なシーケンスを表すストリームと、データ要素に対する複数ステージの計算を表すストリームパイプラインである。 ストリームパイプラインは、ソースのストリーム、それに続く0個以上の中間操作、その後に一つの終端操作から構成される。 ストリームパイプラインは遅延して評価される。つまり、評価は終端操作が呼び出されるまで開始されないし、終端操作を完了されるために必要のないデータ要素は計算されない。 ストリームの乱用はプログラムの理解や保守を難しくする。ラムダのパラメータは明示的な型がないため、注意深く命名することはストリームパイプラインの可読性にとっては重要。

ストリームで副作用のない関数を選ぶ

ストリームのパラダイムの最も重要な部分は、計算を変換のシーケンスとして構築すること。中間操作と終端操作の両方のストリーム操作に渡される関数オブジェクトには副作用がないべき。 処理のすべてを終端のforEach操作で行い、外部の状態を更新するラムダを使うことは問題。ストリームが行った計算結果を表示する以外の処理を行っているforEach操作は「コード中の悪臭」である。 forEach操作は、最も力を持たない終端操作の一つだし、最もストリーム向きではない。forEach操作は、ストリームの計算結果を報告するためだけに使い、計算を行うために使うべきではない。

戻り値型としてStreamよりもCollectionを選ぶ

要素のシーケンスを返すメソッドを書く場合、ユーザによってはストリーム処理したいかもしれないし、ループで処理したいかもしれない。コレクションを返されるなら、返すのがよい。コレクションを返せないなら、StreamかIterableのどちらか自然と思われる方を返す。 将来のJavaのリリースで、Streamインターフェースの宣言がIterableを拡張するように修正されたら、ストリーム処理とループの両方が可能なのでStreamを返すべき。

ストリームを並列化するときは注意を払う

正しい速い並行プログラムを書くことは困難。安全違反と活性違反は並行プログラムではよくあることであり、並列なストリームパイプラインも例外ではない。 一般に、並列化によるパフォーマンスの向上は、ArrayList、HashMap、HashSet、ConcurrentHashMapの各インスタンス、配列、intの範囲(range)そしてlongの範囲で最も得られる。これらのデータ構造で共通なのは、すべてが望ましい大きさのサブレンジへと正確に低いコストで分割できること。 並列化に対する最善の終端操作はリダクションであり、パイプラインからの要素のすべてがStreamのreduceメソッドの一つ、もしくはmin、max、count、sumといった事前に用意されているリダクションを使ってまとめられる。 短絡操作であるanyMatch、allMatch、noneMatchも、並列化に適している。可変リダクションとして知られるStreamのcollectメソッドによって行われる操作は、並列化に対する優れた候補ではない。コレクションをまとめるオーバーヘッドは高くつくため。 ストリームの並列化は活性エラーを含む貧弱なパフォーマンスをもたらす可能性があるだけはなく、不正な結果や予期できない振る舞い(安全性エラー)になる可能性がある。 計算の正しさを維持し、パフォーマンスを向上させると信じるに値する十分な理由がない限り、ストリームパイプラインの並列化は試みないのがよい。

メソッド

パラメータと戻り値をどのように扱うか、メソッドのシグニチャをどのように設計するか、そしてメソッドをどのように文書化するか。

パラメータの正当性を検査する

メソッドとコンストラクタのパラメータとして渡される値が持つ何らかの制約は、明確に文書化すべきであり、メソッド本体の初めで検査して制約を強制すべき。そうしないと、エラーが検出される可能性が低くなり、エラーが発見されても原因の特定が困難になる。パラメータの正当性を検査しないと、エラーアトミック性を破る。 public と protectedのメソッドに対しては、パラメータ値に関する制約が守られていない場合にスローされる例外をJavadocの @throws タグを使って文書化すべき。 publicでないメソッドは、アサーション(assertion)を用いてパラメータを検査できる。アサーションは条件が成り立たなければAssertionErrorをスローする。 正当性検査が高くつくかもしれないもしくは現実的ではなく、かつ計算の処理の中で正当性検査が暗黙に行われる場合、計算を行う前にメソッドのパラメータを検査すべきという規則の例外である。

必要な場合、防御的にコピーする

Javaの使用を楽しくさせていることの一つは、Javaが型安全な言語だということ。 クラスのクライアントはクラスの不変式を破壊するために徹底した努力をする、と想定して防御的プログラミングをしなければならない。コンストラクタへの個々の変更可能なパラメータを防御的にコピーすることが重要。 クラスがクライアントから得たり、クライアントへ返したりする可変な要素を持っているならば、そのクラスはそれらの要素を防御的にコピーしなければならない。もし、コピーのコストが高く、かつ要素を不適切に変更しないということでクライアントを信頼できるならば、影響を受ける要素を変更しないことがクライアントの責任であることをドキュメンテーションに示すことで、防御コピーの代わりとしてもよい。

メソッドのシグニチャを注意深く設計する

  • メソッド名を注意深く選ぶ
    • 名前は常に標準命名規規約に従うべき。
    • 理解可能で、同じパッケージ内の他の名前と矛盾のない名前を選ぶ
    • 存在する広範囲のコンセンサス(合意)と矛盾がない名前を選ぶ
  • 便利なメソッドを提供しすぎない
    • 個々のメソッドは「自分の役割を果たす」べき
    • 多くのメソッドを持つインターフェースは、ユーザ及び実装者の人生を複雑にする
  • 長いパラメータのリストは避ける
    • 4個以下を目標にする
    • 同一の型のパラメータが何個も続くのは有害
    • 対策として、「1. メソッドを複数分割して、各メソッドはパラメータのサブセットだけを必要とするようにする」、「2. パラメータの集まりを保持するヘルパークラスを作成する」、「3. Builderパターンをオブジェクト生成からメソッドの呼び出しに適用する」などがある。
  • パラメータ型に関しては、クラスよりもインターフェースを選ぶ

オーバーロードを注意して使う

オーバーロードされたどのメソッドが呼び出されるかの選択はコンパイル時に行われる。オーバーロードされたメソッドの選択は静的であり、オーバーライドされたメソッドの選択は動的。困惑させるようなオーバーロードの使用は避けるべき。 安全で保守的な方針は、同じパラメータ数の二つのオーバーロードされたメソッドを提供しないこと。オーバーロードする代わりにメソッドには異なる名前をつける。 同じ引数位置で異なる関数型インターフェースを受け取るようにメソッドをオーバーロードしてはいけない。

可変長引数を注意して使う

可変長引数メソッドは、指定された型の0個以上の引数を受け付ける。最も深刻な問題は、クラアイントが引数なしでこのメソッドを呼び出した場合、コンパイル時でなく実行時に失敗すること。

nullではなく、空コレクションか空配列を返す

空コレクションや空配列の代わりに、nullを返さない方がよい。nullを返すことでAPIの使用が困難となり、誤りやすくなる。そして、パフォーマンスの利点もない。

オプショナルを注意して返す

Java 8 で追加された、Optionalクラスは、単一のnullでないT参照を保持するか、もしくは何も保持していない不変なコンテナを表す。Java 9では、Optionalはstream()メソッドを持つように修正された。 コレクション、ストリーム、マップ、配列、オプショナルを含むコンテナ型をオプショナルで包むべきではない。結果を返せないかもしれなくて、かつ何も結果が返されなければクライアントが特別な処理をせざるを得ないなら、Optionalを返すメソッドを宣言すべき。 戻り値以外でオプショナルを使うのはまれであるべき。

すべての公開API要素に対してドキュメントコメントを書く

APIを使えるようにするなら、それは文書化されなければならない。\

  • APIを適切に文書化するには、すべての公開されているクラス、インターフェース、コンストラクタ、メソッド、フィールドの宣言の前にドキュメントコメントを書かなければならない。
  • メソッドに関するドキュメントコメントは、メソッドとそのクライアント間の契約を簡潔に記述すべき
    • ドキュメントコメントは、メソッドのすべての事前条件と事後条件を列挙すべき(事前条件とはクライアントがメソッドを呼び出すために成立していなければならない事柄で、事後条件とは呼び出しが正常に完了した後に成立していなければならない事柄)
    • すべての副作用も文書化すべき
  • enum型を文書化する際には、型とすべてのpublicメソッドだけではなく定数も文書化するのがよい
  • アノテーションを文書化する際には、その型自身だけではなく、すべてのメンバーも文書化するのがよい

プログラミング一般

ローカル変数、制御構造、ライブラリ、データ型、二つの言語外機能(リフレクション、ネイティブメソッド)について書く。

ローカル変数のスコープを最小限にする

ローカル変数のスコープを最小限にすることで、コードの可読性と保守性が向上し、誤りの可能性が減る。 ローカル変数のスコープを最小限にする最も強力な技法は、ローカル変数が初めて使われたときに宣言すること。ほとんどすべてのローカル変数宣言は、初期化子を含むべき。

従来のforループよりもfor-eachループを選ぶ

for-eachループ(拡張for文)は、コードの散らかっている部分を取り除き、イテレータ変数とインデックス変数を隠蔽することでエラーの機会を排除する。

for-eachループが使えない三つの状況

  • 破壊的フィルタリング
    • 選択された要素を取り除きながらコレクションを操作する必要が場合、明示的なイテレータを使う必要がある
  • 変換
    • リストや配列を操作してその要素の値の一部、あるいは全部を置換する必要がある場合、要素の値を置換するためにリストイテレータや配列インデックスが必要
  • 並列イテレーション
    • 複数のコレクションを並列に操作する必要がある場合、イテレータやインデックス変数に対する明示的な制御が必要

ライブラリを知り、ライブラリを使う

標準ライブラリを使うことで、それを書いた専門家の知識と、それをあなたよりも前に使った人々の経験を利用できる。主要なリリースごとに多くの機能がライブラリに追加されており、それらの追加を知っておくことで得られるものがある。

正確な答えが必要ならば、floatとdoubleを避ける

float 型と double 型は、主に科学計算と工学計算のために設計されている。それらは2進浮動小数点算術を行う。正確な結果を提供しないし、正確な結果が必要な場合には使うべきではない。 BigDecimalを使うことの短所は、基本データの算術型を使うよりは不便で、遅いこと。BigDecimalを使うことで、丸めを制御できるという利点も加わり、丸めを必要とする操作を行う場合に8種類の丸めモードから選択できる。

ボクシングされた基本データよりも基本データ型を選ぶ

Javaは、int、booleanといった基本データ型(primitive type)と、StringやListといった参照型(reference type)の二つから構成される型システムを持っている。すべての基本データ型は、ボクシングされた基本データと呼ばれる対応する参照型を持っている。

基本データ型とボクシングされた基本データ間の三つの主な違い

  • 基本データ型は値だけ持つが、ボクシングされた基本データのインスタンスは値とは別のアイデンティティを持っている
  • 基本データ型は機能する値だけを持つが、個々のボクシングされた基本データは、対応する基本データ型の機能をすべての値に加えて、nullという機能しない値を持っている
  • 基本データ型は、ボクシングされた基本データよりは一般に時間と空間に関してより効率的

自動ボクシングは、ボクシングされた基本データを使うことの冗長性を減らすが、危険性は減らさない

他の型が適切な場所では、文字列を避ける

  • 文字列は、他の値型に対する代替としては貧弱
  • 文字列は、列挙型に対する代替としては貧弱
    • enumの方が文字列よりもはるかに優れた列挙型定数となる
  • 文字列は、集合型に対する代替としては貧弱
  • 文字列は、ケイパビリティに対する代替としては貧弱

文字列のパフォーマンスに用心する

n個の文字列を結合するのに、文字列結合演算子を繰り返し使うと、nに関して二次の時間を必要とする。それは、文字列が不変であるという事実の不幸な結果。 許容できるパフォーマンスを達成するには、Stringの代わりにStringBuilderを使うのがよい

インターフェースでオブジェクトを参照する

適切なインターフェース型が存在するならば、パラメータ、戻り値、変数、およびフィールドはすべてインターフェース型を使って宣言されるべき。 もし、型としてインターフェースを使う癖を身につけたら、プログラムはかなり柔軟になる。 適切なインターフェースがなければ、クラスが改装中で必要な機能を提供している最も上位のクラスを使うのがよい。

リフレクションよりもインターフェースを選ぶ

コア・リフレクション機構であるjava.lang.reflectは、任意のクラスに対するプログラミングによるアクセスを提供している。 リフレクションは、コンパイルされた時点で存在さえしない他のクラスを使えるようにするが、これには次の代価が伴う。

  • 例外の検査も含めて、コンパイル時の型検査の恩恵をすべて失う
  • リフクレションによるアクセスを行うコードは、ぎこちなく冗長
  • パフォーマンスが悪くなる
    • 通常のメソッド呼び出しよりもかなり遅い

大変限られた形式だけでリフレクションを使うことで、リフレクションのコストをほとんど負うことなく、リフレクションの利点を多く得られる。

ネイティブメソッドを注意して使う

CやC++などのネイティブのプログラミング言語で書かれたメソッドであるネイティブメソッドの呼び出しを、Javaアプリケーションから可能にするのが、Java Native Interface(JNI)である。 ネイティブメソッドは3つの主要な用途があった。

  • レジストリやファイルロックなどのプラットフォーム固有の機構へのアクセスを提供
  • 既存のネイティブコードのライブラリへのアクセスを提供
  • パフォーマンス改善のために、アプリケーションのパフォーマンスが重要な部分をネイティブ言語で書くのに使われた

パフォーマンス改善のためにネイティブメソッドを使うことは勧められない。 ネイティブ言語は安全ではないため、ネイティブメソッドを使っているアプリケーションはメモリ破壊エラーの影響を受けるようになる短所がある。 ネイティブコードを利用する機会はほぼないが、もし利用する機会がある場合はネイティブコードの中のたった一つのバグで、アプリケーション全体がだめになってしまうため注意深く利用する必要がある。

注意して最適化する

時期尚早の最適化は、よくなるよりは外になりやすい。

  • 速いプログラムよりも優れたプログラムを書くように努めるべき
    • 優れたプログラムは、情報隠蔽の原則を具体化している
  • パフォーマンスを制限するような設計上の決定を避けるように努めるべき
    • 後になって変更するのが最も困難な設計上の構成要素は、モジュール間および外部とのやりとりを取り決めている部分。API、通信レベルのプロトコル、永続データ形式は後で変更するのが困難であり、また、システムが達成できるパフォーマンスに対して重大な制限を課す可能性がある。
  • API設計の決定によるパフォーマンスの結果を考慮すべき
    • 一般的に優れたAPI設計は、よいパフォーマンスと矛盾していない

一般的に受け入れられている命名規約を守る

Javaプラットフォームの命名規約は、大雑把にいうと活字的と文法的の2種類に分類される。

活字的命名規約は、パッケージ、クラス、インターフェース、メソッド、フィールド、型変数を扱っている。 パッケージ名とモジュール名は、ピリオドで区切られた要素を持ち、階層的であるべき。要素は小文字のアルファベットとまれに数字から構成されるべき。 一般的に8文字以下であるべき。意味を持った省略形は推奨されていおり、utilitiesよりはutilなど。 メソッド名とフィールド名は、クラス名とインターフェース名と同じ活字的規約に従うが、メソッド名やフィールド名の最初の文字は小文字にすべき。 型パラメータ名はたいてい1文字。任意の型に対するT、コレクションの要素型に対するE、マップのキーと値に対するKとV、そして、例外に対するX。関数の戻り値型はたいていR。一連の任意の型に対しては、T、U、VやT1、T2、T3が使える。

文法的命名規約は、活字的規約よりも柔軟で議論の的となる。 enum型も含めてクラスは、一般的に単数名詞あるいは名詞句で命名される。インスタンス化できないユーティリティクラスはたいていCollectorsやCollectionsといったように複数名詞で命名される。 インスタンスとクラスは同じように命名される。あるいは、インターフェースはableやibleで終わる形容詞で命名される。 アノテーション型はどの品詞ということはなく、名詞、動詞、前置詞、形容詞すべてが使われる。 何らかの処理を行うメソッドは、一般に動詞あるいは(目的語を含む)動詞句で命名される。boolean値を返すメソッドは、たいてい単語isで始まり、まれにhasではじまる。その後に形容詞として機能する、名詞、名詞句、あるいは単語か句が続く。 オブジェクトの型を変換し、別の型の無関係なオブジェクトを返すメソッドは、たいていtoTypeと呼ばれる。toString、toArrayなど。 レシーバーオブジェクトの型とは異なる型を持つビューを返すメソッドは、たいていasTypeと呼ばれる。asListなど。 メソッドが呼び出されたオブジェクトと同じ値を持つ基本データを返すメソッドは、たいていtypeValueと呼ばれる。intValueなど staticファクトリメソッドに対する共通の名前は、from、of、valueOf、instance、getInstance、newInstance、getType、newTypeなど。\

例外

最適に使われた場合、例外はプログラムの可読性、信頼性、保守性を改善する。

例外的状態にだけ例外を使う

JVMは配列へのアクセスのすべてを検査するので、通常のループ終了検査は冗長であり避けるべきだ」という推論には三つの誤りがある。

  • 例外は例外的状況で使われるように設計されており、JVM実装者にとって、明示的な検査と同じくらいに例外を速くする動機はほとんどない
  • try-catchブロック内にコードを書くことは、書かれない場合に最新のJVM実装が行うある種の最適化を排除する
  • 配列をループする標準イディオムは、冗長な検査をするとは限らない。最新のJVM実装は、その検査を最適化して取り除く

例外は、例外的条件に対して使うべきであり、通常の制御フローに例外を使うことをクライアントに強制してはいけない。うまく設計されたAPIは、通常の制御フローに例外を使うことをクライアントに強制してはいけない。

回復可能な状態にはチェックされる例外を、プログラミングエラーには実行時例外を使う

Javaは、3種類のスローできる例外を提供している。チェックされる例外、実行時例外、エラーである。

チェックされる例外を使うと、チェックされない例外を使うかを決める基本的な規則は、「呼び出し元が適切に回復できるような状況に対してはチェックされる例外を使うべき」ということ。 チェックされない例外には、実行時例外とエラーの2種類がある。それらはキャッチされる必要はなく、一般的にキャッチされるべきでない例外。プログラムがチェックされない例外やエラーをスローしたならば、それは一般的に回復が不可能で、実行の継続が役立つのではなくむしろ害になるような場合。 プログラミングエラーを示すには、実行時例外を使う。実行時例外のほとんどは、事前条件違反を示している。 実装するすべてのチェックされない例外は、RuntimeExceptionをサブクラス化すべき。

チェックされる例外を不必要に使うのをさける

控えめに使われると、チェックされる例外はプログラムの信頼性を向上できるが、過剰に使われると、APIを使うのが苦痛になる。 呼び出し元が失敗から回復できないなら、チェックされない例外をスローする。もし、回復が可能であり、呼び出し元に例外的状態の処理を強制したいなら、最初にオプショナルを返すことを検討するのがよい。 失敗の場合にオプショナルが十分な情報をて今日しない場合にだけ、チェックされる例外をスローすべき。

標準的な例外を使う

標準的な例外を再利用することは、「APIを学んで使うのが簡単にできる」、「プログラムが見慣れない例外でごちゃごちゃしないため、APIを使うプログラムが読みやすい」、「例外クラスが少ないことは、小さなメモリ量とクラスのロードに費やされる時間が少ない」などの利点がある。 Exception、RuntimeException、Throwable、Errorを直接再利用するのはよくない。これらの例外がスローされたのか、他の例外がスローされたのか区別できない。

抽象概念に適した例外をスローする

メソッドが行っている処理と明らかに関係のない例外を、そのメソッドがスローした場合には混乱が生じる。この問題を避けるためには、上位レイヤは下位レベルの例外をキャッチして、上位レイヤの中で、上位レベルの抽象概念の観点から説明可能な例外をスローすべき(例外翻訳と呼ばれる)。 上位レベルの例外のコンストラクタは、スーパークラスの連鎖可能なコンストラクタへその原因を渡すことで、最終的にはThrowableといったThrowableの連鎖可能なコンストラクタの一つに渡される。 例外翻訳は、下位レイヤから何も考えないで例外を伝播させるよりは優れているが、乱用すべきではない。連鎖は、失敗を分析するための根本原因を捕捉する一方で、適切な上位レベルの例外のスローを可能にしている。

各メソッドがスローするすべての例外を文書化する

常にチェックされる例外を個々に宣言し、Javadocの@throwsタグを使って各例外がスローされる条件を正確に文書化するのがよい。メソッドがスローする可能性のあるチェックされない例外が適切に文書化された一覧は、そのメソッドが首尾よく実行されるための事前条件を効果的に記述する。

詳細メッセージエラーに記録情報を含める

例外の詳細メッセージは、後の分析のためにエラーを記録しておくべき。エラーを記録する際には、例外の詳細メッセージは、その例外の原因となったすべてのパラメータとフィールドの値を含むべき。 詳細メッセージにパスワードや暗号鍵といったものを含めるべきではない。 例外の詳細メッセージと、エンドユーザに対してわかりやすくなければならないユーザレベルのエラーメッセージを混同すべきではない。

エラーアトミック性に努める

失敗したメソッド呼び出しは、オブジェクトをそのメソッド呼び出しの前の状態にしておくべき。このような性質をもつメソッドは、エラーアトミックであると呼ばれる。 エラーアトミック性を達成する方法は次の通り。

  • 不変オブジェクトを設計する
  • 失敗するかもしれない部分が、オブジェクトを変更する部分よりも前に行われるように、計算を順序づけする
  • オブジェクトの一時的コピーに対して操作を行い、操作が完了したらオブジェクトの内容を一時的コピーの内容で置き換える

例外を無視しない

空のcatchブロックでは、例外の目的(「例外的状態の処理を強制する」こと)が達成されない。例外を無視することを選択したら、catchブロックは無視するのが適切である理由を説明しているコメントを含むべきであり、変数もignoredと命名すべき。 チェックされない例外を少なくとも外側に単に伝播させるだけでも、プログラムを速やかにエラーにさせて、そのエラーをデバッグするのに役立つ情報を残してくれる。

並行性

スレッドは複数の処理を並行して行うことを可能にしている。多くのことがうまくいかない可能性があり、失敗を再現するのが困難な場合があるため、並行プログラミングはシングルスレッドプログラミングよりも難しい。

共有された可変データへのアクセスを同期する

複数のスレッドが可変データを共有する場合、そのデータを読み書きするスレッドは同期を行わなければならない。同期なしでは、あるスレッドでの変更が他のスレッドから見えることは保証されない。

過剰な同期は避ける

過剰な同期はパフォーマンス低下、デッドロック、あるいは予想外の振る舞いをもたらす可能性ある。活性エラーと安全性エラーを避けるためには、同期されたメソッドやブロック内で制御をクライアントに譲ってはいけない。

スレッドよりもエグゼキュータ、タスク、ストリームを選ぶ

エグゼキュータサービスを用いて多くのことができる。エグゼキュータサービスは、特定のタスクの完了を待つことができたり、タスクの集まりの中のどれかのタスクやすべてのタスクの完了を待つことができたり、エグゼキュータサービスの完了を待つことができたり、タスクが完了するごとに一つずつタスクの結果を取り出したり、特定の時刻や周期的にタスクを実行するようにスケジューリングできたりと、さまざまなことができる。

waitとnotifyよりも並行処理ユーティリティを選ぶ

waitとnotifyを使う困難さを考えると、代わりに高いレベルの並行処理ユーティリティを使うべき。 java.util.concurrent内の高いレベルのユーティリティは、三つのカテゴリー(エグゼキュータフレームワーク、コンカレントコレクション、シンクロナイザ)に分類される。 コンカレントコレクションは、標準コレクションインターフェースの高パフォーマンスな並行実装。コンカレントコレクションから並行な活動を排除することは不可能。コンカレントコレクションをロックすると、プログラムを遅くするだけ。 シンクロナイザは、スレッド同士が互いに待つことを可能にするオブジェクトであり、スレッドがその活動を調整できるようにする。

シリアライズ

オブジェクトをバイトストリームとして符号化し(シリアライズ)、バイトストリームの富豪からオブジェクトを再構築する(デシリアライズJavaフレームワークであるオブジェクトのシリアライズを扱う。

Javaシリアライズよりも代替手段を選ぶ

シリアライズに関わる根本的な問題は、その攻撃対象領域が広すぎて保護できないのt、常に広がっていること。攻撃対象領域には、Javaプラットフォームライブラリ内、Apache Commons Collections といったサードパーティのライブラリ内、アプリケーション自信内のクラスが含まれる。 シリアライズは危険であり、避けるべき。1からシステムを設計しているなら、代わりにJSONやprotobufといったクロスプラットフォームの構造化データ表現を使うのがよい。

Serializableを細心の注意を払って実装する

クラスのインスタンスシリアライズ可能にするのは、クラスの宣言にimplements Serializableを追加するだけの単純なもの。

Serializableを実装することの主なコストは、一旦リリースされるとクラスの実装を変更する柔軟性を低下させること。 Serializableを実装する際の2つ目のコストは、バグやセキュリティホールの可能性を増大させること。通常、オブジェクトはコンストラクタを使って生成される。シリアライズは、オブジェクトの生成に関しては、言語外の仕組み。 Serializableを実装する際の3つ目のコストは、新しいバージョンのクラスのリリースに関連したテストの負荷を増大させること。 Serializableインターフェースを実装することは、軽く考えて決めることではない。 継承のために設計されたクラスはSerializableをめったに実装すべきではないし、インターフェースもSerializableをめったに拡張すべきではない。

Bitbucket pipelineのドキュメント読んだ

2020年10月にBitbucketのサーバーライセンスの販売終了が発表されました。個人でいただいている仕事の方でBitbucket Serverを利用しており、BitBucket Cloudへの移行が必要になりました。 Bitbucket CloudのデフォルトCI/CDであるBitbucket Pipelinesを利用するので、ドキュメントを読んでまとめます。

Bitbucket Pipelinesとは

Bitbucket Pipelinesとは、Bitbucket内で利用できるCI/CDサービス。ビルド、テスト、デプロイなどを自動的に行うことできる。基本的にはクラウド上に自分専用のコンテナ環境を作成する。 パイプラインは、bitbucket-pipelinens.ymlのファイルに定義して、レポジトリ直下に配置する。

パイプラインの設定

Configure your pipeline

基本的に、ビルドやデプロイするためのスクリプトを書いたり、ビルド時間短縮のためのキャッシュの設定を書いたりする。ステップごとに異なるイメージを利用することができる。 パイプラインの設定を書くために次の注意点がある。

  1. 最低1ステップは必要で、ステップの中に最低1スクリプト必要
  2. どのステップも利用できるメモリは4GB
  3. 1パイプラインあたり最大100ステップまで
  4. ステップ毎に別のDockerコンテナで実行される。

セクションについて

  • default
    • 全ブランチのpush毎に実行される処理を書くセクション。
  • branches
    • 指定したブランチで実行される処理を書くセクション。
  • tags
    • 指定したtagで実行される処理を書くセクション。
  • bookmarks
    • 指定したブックマークで実行される処理を書くセクション
  • pull requests
    • レポジトリ内のプルリクエストが初期化されるときに実行される処理を書くセクション。
  • custom
    • 手動実行や日時実行する処理を書くセクション。

グローバルに設定できるオプション

  • variables
    • customパイプラインのみ定義でできる。パイプラインがローンチされる時に使われる変数。
  • parallel
    • 同時実行させる。
  • step
    • ビルド実行単位を定義する。
    • 生成されたartifactsは14日間保存される。
  • name
    • ステップ名
  • images
    • ビルドするために使われるDockerコンテナ。
      • デフォルトイメージやパブリック、プライベートなDockerイメージを利用できる。
      • globalまたはステップ毎にイメージを定義できる。
  • trigger
    • ステップは自動実行か手動実行か指定できる。triggerのデフォルトは自動。
    • 第1ステップは手動にはできない
  • deployment
    • deploymentステップのための環境をセットする。 - 利用できるあたいは、test, staging, production
  • size
    • sizeオプションを書いたステップは、2倍のメモリを利用できる。
  • script
  • pipe
  • after-script
    • ステップが成功か失敗したときに実行される。クリーンアップコマンドや、テストカバレッジ、通知などに役出つ。
    • BITBUCKET_EXIT_CODEを使って終了コードを取得できる。
  • artifacts
    • レポートやJARなどのステップによって生成されるファイルを定義する。
  • caches
    • ライブラリ等をキャッシュする。

YAML anchors

YAML anchors

bitbucket-pipelines.yml ファイルに繰り返し利用したいステップがある場合、YAML anchorsを利用するのがよい。

Anchors and aliases

'&'で設定のまとまりを定義して、'*'で&で定義した設定のまとまりを利用できる。

ビルド環境としてDockerイメージを利用

Use Docker images as build environments

DockerコンテナでBitbucket Pipelinesのビルドが実行される。Docker Hub, AWS, GCP, Azure, self-hosted registriesにあるパブリック、プライベートイメージを利用できる。

Deployments

Set up and monitor deployments

Set up and monitor deployments

デプロイの設定

  1. Bitbucketにデプロイ環境について設定する。

  2. 環境名

  3. 環境の種類
  4. 環境変数

    2.環境を定義する パイプラインを有効にすると、デフォルトで3環境(Test, Staging, Production)が用意される。 デプロイの変数は、チームとレポジトリの変数を上書きする。

    3.デプロイステップを定義する

bitbucket-pipelines.ymlのstepにdeploymentを追加する。deploymentには、環境名を含む必要がある。

- step:
        name: Deploy to production
        deployment: production-east
        script:
          - python deployscript.py prod

コミットメッセージにissue番号を書けば、Jiraとの連携も可能。

デプロイのロールバック

デプロイにした場合、直近の成功したデプロイバージョンに戻すことができる。

  1. Redeployボタンを有効にする
  2. 環境を選択して、Redeployボタンを押下する

Pipeline triggers

手動実行

bitbucket-pipelines.ymlにtrigger: manualを追加することで、手動ステップを設定できる。最初のステップに手動トリガを設定することはできない。もし、手動実行だけのパイプラインを設定したい場合は、customセクションの中に書く必要がある。

variableセクションに手動実行時に渡したい変数を設定することができる。

スケジューリング

指定した日時に実行させることができる。レポジトリの設定から日時を設定することができる。

ブランチ

ブランチ毎のpushをトリガにしてパイプラインを実行させることができる。

Keywords

  • default
    • 全ブランチの全push毎に実行される
  • tags
    • 指定したタグで実行される。
  • pull-request
    • プルリクエスト初期化時に実行される。defualtで定義されたパイプラインも実行されるため、2パイプラインが並行実行する。
  • custom
    • 手動orスケジューリングトリガを設定する。
  • variables
    • [Custom pipelines only]パイプライン実行時に渡す変数を定義する。
  • bookmarks
    • ブックマーク

Variables and secrets

Variables and secrets

ビルドコンテナ内で利用する環境変数を設定できる。いくつかデフォルトで用意されており自分で設定することもできる。

自分で設定する場合の制約は次の通り。

- 利用可能な文字は、ASCII文字、数字、アンダースコア
- 大文字小文字は区別する
- 数字からはじめることはできない
- シェルによって定義される変数を利用するべきでない

設定できる変数は、ワークスペース変数、レポジトリ変数、デプロイメント変数がある。セキュアに変数を設定すると、ログに値が表示されない。

Bitbucket Pipelines内でSSHキーを利用する

レポジトリのPipelinesのSSH keysで設定できる。

Caches

Caches

Bitbucket Pipelinesはサードパーティライブラリなどの外部の依存関係やディレクトリをキャッシュすることができる。最初のパイプライン実行時にキャッシュしてそれ以降はキャッシュを使う。

Databases and service containers

Databases and service containers

Bitbucket Pipelinesは、ビルドパイプラインから複数のテストなどで利用するためのDockerコンテナを実行することができる。(DBやRedisなど)

Use pipes in Bitbucket Pipelines

Use pipes in Bitbucket Pipelines

Pipesは、パイプラインを設定するためのシンプルな方法を提供する。サードパーティのツールと動かしたい時に特に強力である。

Integrations

Integrations

「Jira - Pipelines」、「Slack - Pipelines」などの連携について

Testing

Testing

Test reporting in Pipelines

ビルドステップの中でテストレポートを生成しているなら、pipelinesは自動的に探してWeb上で見れるようにする。ただし、xUnitのレポートに限る。

Links