CLOVER🍀

That was when it all began.

Logback(+Logstash Logback Encoder)でログ内の文字列を置換する

これは、なにをしたくて書いたもの?

Logbackを使ってログ出力をする時に、ログメッセージを置換する方法はないかな?ということで調べてみました。

以下の方法がありそうです。

  • replace変換指定子を使う
  • 自分でConverterを作成する
  • MaskingJsonGeneratorDecoratorを使う(Logstash Logback Encoder)

これを、通常のLogbackをLogstash Logback Encoderを使った時それぞれで調べてみました。

ログ出力する際に文字列置換する

Logbackでログ出力する際には、replace変換指定子(Conversion specifier)を使うのが良さそうです。

Layouts / PatternLayout

こちらですね。

Layouts / PatternLayout / replace

こちらに使い方の例が載っています。

Layouts / Conversion word options

%replace()内に対象の文字列を、{}内に正規表現と置換後の文字列を与えるようです。

<pattern>%-5level - %replace(%msg){'\d{14,16}', 'XXXX'}%n</pattern>

今まであんまり気にしていなかったのですが、そもそも%msgのようなものを「Conversion word」と呼ぶみたいですね。

PatternLayoutの箇所にConversion wordの一覧が載っています。

Layouts / PatternLayout

そして、変換指定子は自分でも作成することができます。

Layouts / Evaluators / Creating a custom conversion specifier

ドキュメントには、ClassicConverterクラスを継承して作成する例が書かれています。

ClassicConverter (Logback-Parent 1.3.0 API)

他にも、CompositeConverterクラスを継承して作成する方法もあります。

CompositeConverter (Logback-Parent 1.3.0 API)

Conversion wordは、オプションを取ることができます。スタックトレースを表現する%ex{short}などがいい例ですよね。
これはClassicConverterクラスのサブクラスとして実現されています。

replace変換指定子は、さらに()で対象の文字列を指定することができます。こんな感じで。

`%replace(%msg){'\d{14,16}', 'XXXX'}`

これはCompositeConverterクラスのサブクラスとして実現されています。

Mask Sensitive Data in Logs With Logback | Baeldung

たとえば%msgの実体はこちらです。

https://github.com/qos-ch/logback/blob/v_1.4.7/logback-classic/src/main/java/ch/qos/logback/classic/pattern/MessageConverter.java

replace変換指定子の実体はこちらです。

https://github.com/qos-ch/logback/blob/v_1.4.7/logback-core/src/main/java/ch/qos/logback/core/pattern/ReplacingCompositeConverter.java

このあたりの指定子は、以下のパッケージを見るとよいでしょう。

https://github.com/qos-ch/logback/tree/v_1.4.7/logback-core/src/main/java/ch/qos/logback/core/pattern

https://github.com/qos-ch/logback/tree/v_1.4.7/logback-classic/src/main/java/ch/qos/logback/classic/pattern

PatternLayoutには、これらがデフォルトで登録されているので各種指定子が使える、ということになります。

https://github.com/qos-ch/logback/blob/v_1.4.7/logback-classic/src/main/java/ch/qos/logback/classic/PatternLayout.java#L52-L162

https://github.com/qos-ch/logback/blob/v_1.4.7/logback-core/src/main/java/ch/qos/logback/core/pattern/parser/Parser.java#L47-L53

変換指定子を自分で作成した場合は、以下のようにconversionRuleで登録します。

<configuration>

  <conversionRule conversionWord="nanos"
                  converterClass="layouts.MySampleConverter" />

  <appender name="STDOUT" class="ch.qos.logback.;core.ConsoleAppender">
    <encoder>
      <pattern>%-6nanos [%thread] -%kvp -%msg%n</pattern>
    </encoder>
  </appender>

  <root level="DEBUG">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

Layouts / Evaluators / Creating a custom conversion specifier

Spring Bootでも使われているようですね。

https://github.com/spring-projects/spring-boot/blob/v3.0.6/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml

まずはこのあたりを試してみましょう。
※Logstash Logback Encoderは後で扱います

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.6 2023-01-17
OpenJDK Runtime Environment (build 17.0.6+10-Ubuntu-0ubuntu122.04)
OpenJDK 64-Bit Server VM (build 17.0.6+10-Ubuntu-0ubuntu122.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.1 (2e178502fcdbffc201671fb2537d0cb4b4cc58f8)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.6, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-70-generic", arch: "amd64", family: "unix"

準備

Maven依存関係など。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.7</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.4.7</version>
        </dependency>
    </dependencies>

まずはSLF4Jとlogback-classicを依存関係に加えます。

なお、SLF4JとLogbackのバージョン関係は以下のようです。必要なJavaのバージョンや、Java EEJakarta EEのサポートにも
差がありますね。Logback 1.3と1.4の差はそれくらいのようです。

News

お題

文字列置換ということで、今回はログメッセージ内にメールアドレスが出現したら別の文字列に置き換える、というお題で考えたいと
思います。

サンプルプログラム

サンプルプログラムとしては、以下のようなものを用意。

sc/main/java/org/littlewings/logback/App.java

package org.littlewings.logback;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class App {
    public static void main(String... args) {
        Logger logger = LoggerFactory.getLogger(App.class);

        logger.info("Hello World!!");
        logger.info("Hello, isono@example.com, namino@example.com !!");
        logger.info("Hello, {}, {} !!", "isono@example.com", "namino@example.net");

        try {
            throwException("Oops!! namino@example.net");
        } catch (RuntimeException e) {
            logger.info("Hello, {} !!", "isono@example.com", new RuntimeException(e));
        }
    }

    static void throwException(String message) {
        throw new RuntimeException(message);
    }
}

ログ中にメールアドレスが入ったり、置換文字列({})内にメールアドレスを入れてみたり、例外メッセージにメールアドレスを
入れてみたり。

最初のlogback.xmlは、こんな感じでいきます。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

実行結果。

2023-04-23 16:13:09.923 [main] INFO  org.littlewings.logback.App - Hello World!!
2023-04-23 16:13:09.925 [main] INFO  org.littlewings.logback.App - Hello, isono@example.com, namino@example.com !!
2023-04-23 16:13:09.925 [main] INFO  org.littlewings.logback.App - Hello, isono@example.com, namino@example.net !!
2023-04-23 16:13:09.926 [main] INFO  org.littlewings.logback.App - Hello, isono@example.com !!
java.lang.RuntimeException: java.lang.RuntimeException: Oops!! namino@example.net
    at org.littlewings.logback.App.main(App.java:17)
Caused by: java.lang.RuntimeException: Oops!! namino@example.net
    at org.littlewings.logback.App.throwException(App.java:22)
    at org.littlewings.logback.App.main(App.java:15)

ここからいろいろ変えていこうと思います。

Logbackでログ内の文字列を置換する

replace変換指定子を使う

最初に使うのは、replace変換指定子。

Layouts / PatternLayout / replace

logback.xmlは、以下のように変更。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %replace(%msg){'\w+@\w+\.\w+', 'xxxxx@xxxxx'}%n</pattern>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

%msg変換指定子を%replace(){'正規表現', '置換後の文字列}で囲みます。

%replace(%msg){'\w+@\w+\.\w+', 'xxxxx@xxxxx'}

結果。

2023-04-23 16:17:22.211 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello World!!
2023-04-23 16:17:22.213 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, xxxxx@xxxxx, xxxxx@xxxxx !!
2023-04-23 16:17:22.213 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, xxxxx@xxxxx, xxxxx@xxxxx !!
2023-04-23 16:17:22.214 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, xxxxx@xxxxx !!
java.lang.RuntimeException: java.lang.RuntimeException: Oops!! namino@example.net
        at org.littlewings.logback.App.main(App.java:17)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)
        at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.lang.RuntimeException: Oops!! namino@example.net
        at org.littlewings.logback.App.throwException(App.java:22)
        at org.littlewings.logback.App.main(App.java:15)
        ... 2 common frames omitted

例外の部分が変わっていませんね。では、こうしてみましょう。

      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %replace(%msg){'\w+@\w+\.\w+', 'xxxxx@xxxxx'}%n%replace(%ex){'\w+@\w+\.\w+', 'xxxxx@xxxxx'}</pattern>

%exを明示的に追加して、こちらを置換するようにしてみました。

今度はうまくいきます。

2023-04-23 16:18:28.674 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello World!!
2023-04-23 16:18:28.676 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, xxxxx@xxxxx, xxxxx@xxxxx !!
2023-04-23 16:18:28.676 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, xxxxx@xxxxx, xxxxx@xxxxx !!
2023-04-23 16:18:28.677 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, xxxxx@xxxxx !!
java.lang.RuntimeException: java.lang.RuntimeException: Oops!! xxxxx@xxxxx
        at org.littlewings.logback.App.main(App.java:17)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)
        at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.lang.RuntimeException: Oops!! xxxxx@xxxxx
        at org.littlewings.logback.App.throwException(App.java:22)
        at org.littlewings.logback.App.main(App.java:15)
        ... 2 common frames omitted

ただ、この方法だと正規表現を設定ファイル内に書くことになります。今回のように簡単な正規表現であればよいですが、たとえば
WHATWGの定義する正規表現のようなものだったりするとちょっと面倒な感じがします。

The following JavaScript- and Perl-compatible regular expression is an implementation of the above definition.

/^[a-zA-Z0-9.!#$%&'+\/=?^_`{|}~-]+@a-zA-Z0-9?(?:.a-zA-Z0-9?)$/

HTML / The elements of HTML / Forms / The input element / States of the type attribute / Email state / valid email address

変換内容をJavaコード側で表現できたらよいな、ということで、別の仕組みも試してみます。

ClassicConverterを使う

次は、ClassicConverterを使ってみましょう。

ClassicConverter (Logback-Parent 1.3.0 API)

ClassicConverterを使うということは、変換指定子を自作するということですね。

Layouts / Evaluators / Creating a custom conversion specifier

ドキュメントの例では、%nanosという変換指定子を作成しています。

今回は、以下のようなClassicConverterのサブクラスを作成。

src/main/java/org/littlewings/logback/MyMessageConverter.java

package org.littlewings.logback;

import ch.qos.logback.classic.pattern.ClassicConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class MyMessageConverter extends ClassicConverter {
    private static final Pattern EMAIL_PATTERN = Pattern.compile(
            "[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*"
    );

    @Override
    public String convert(ILoggingEvent event) {
        //String message = event.getMessage();  // {}のまま
        String message = event.getFormattedMessage();  // {}を解決した後

        Matcher matcher = EMAIL_PATTERN.matcher(message);

        if (matcher.find()) {
            return matcher.replaceAll("[xxxxx]");
        }

        return message;
    }
}

ILoggingEventから取得できるメッセージを正規表現で置換してみます。

これを%mymsgとしてconversionRuleに登録。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <conversionRule conversionWord="mymsg" converterClass="org.littlewings.logback.MyMessageConverter" />

  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %mymsg%n</pattern>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

pattern内で使ってみます。

      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %mymsg%n</pattern>

結果。

2023-04-23 16:31:26.018 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello World!!
2023-04-23 16:31:26.021 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [xxxxx], [xxxxx] !!
2023-04-23 16:31:26.021 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [xxxxx], [xxxxx] !!
2023-04-23 16:31:26.022 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [xxxxx] !!
java.lang.RuntimeException: java.lang.RuntimeException: Oops!! namino@example.net
        at org.littlewings.logback.App.main(App.java:17)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)
        at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.lang.RuntimeException: Oops!! namino@example.net
        at org.littlewings.logback.App.throwException(App.java:22)
        at org.littlewings.logback.App.main(App.java:15)
        ... 2 common frames omitted

あくまでメッセージを置換しているだけなので、この方法だと例外メッセージは手が出ませんね…。

ちなみに、以下の部分を入れ替えると{}を評価する前のメッセージを扱うことになるので

    @Override
    public String convert(ILoggingEvent event) {
        String message = event.getMessage();  // {}のまま
        //String message = event.getFormattedMessage();  // {}を解決した後

結果がこうなります。

2023-04-23 16:32:24.751 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello World!!
2023-04-23 16:32:24.754 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [xxxxx], [xxxxx] !!
2023-04-23 16:32:24.754 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, {}, {} !!
2023-04-23 16:32:24.755 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, {} !!
java.lang.RuntimeException: java.lang.RuntimeException: Oops!! namino@example.net
        at org.littlewings.logback.App.main(App.java:17)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)
        at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.lang.RuntimeException: Oops!! namino@example.net
        at org.littlewings.logback.App.throwException(App.java:22)
        at org.littlewings.logback.App.main(App.java:15)
        ... 2 common frames omitted

こんな使い方はしないとは思いますが…。

CompositeConverterを使う

次は、CompositeConverterクラスを使ってみます。こちらはドキュメントには記載がありませんが、%replace変換指定子はこちらで
実装されています。

CompositeConverter (Logback-Parent 1.3.0 API)

ClassicConverterとの違いは、別の変換指定子などを入力にできることですね。

たとえば、先ほど%replaceを使って例ではこんな感じで%msgを引数にしていました。

%replace(%msg){'\w+@\w+\.\w+', 'xxxxx@xxxxx'}

ちなみにClassicConverterでも{}は使えます(先ほどのサンプルでは扱いませんでした)。

こう考えると、以下のようなものもClassicConverterにオプションとして渡していることがわかります。

%logger{36}
%X{user}

MDCも変換指定子で実現されています。

で、今回はこんなCompositeConverterを作成。

src/main/java/org/littlewings/logback/EmailReplaceConverter.java

package org.littlewings.logback;

import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.pattern.CompositeConverter;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class EmailReplaceConverter extends CompositeConverter<ILoggingEvent> {
    private static final Pattern EMAIL_PATTERN = Pattern.compile(
            "[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*"
    );

    String replacement;

    @Override
    public void start() {
        replacement = getFirstOption() != null ? getFirstOption() : "[xxxxx]";
    }

    @Override
    protected String transform(ILoggingEvent event, String in) {
        Matcher matcher = EMAIL_PATTERN.matcher(in);

        if (matcher.find()) {
            return matcher.replaceAll(replacement);
        }

        return in;
    }
}

transformメソッドの第2引数に、()で指定した値が渡ってきます。

オプションも与え、指定されていなかったら[xxxxx]とするようにしてみました。
※この記述はClassicConverterでも可能です

    @Override
    public void start() {
        replacement = getFirstOption() != null ? getFirstOption() : "[xxxxx]";
    }

これをconversionRule%emailで変換指定子として扱えるように登録。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <conversionRule conversionWord="email" converterClass="org.littlewings.logback.EmailReplaceConverter" />

  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %email(%msg){}%n%email(%ex){}</pattern>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

メッセージとスタックトレースを変換できるようにしました。

      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %email(%msg){}%n%email(%ex){}</pattern>

結果。

機能しているようです。

2023-04-23 16:44:51.020 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello World!!
2023-04-23 16:44:51.022 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [xxxxx], [xxxxx] !!
2023-04-23 16:44:51.022 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [xxxxx], [xxxxx] !!
2023-04-23 16:44:51.024 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [xxxxx] !!
java.lang.RuntimeException: java.lang.RuntimeException: Oops!! [xxxxx]
        at org.littlewings.logback.App.main(App.java:17)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)
        at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.lang.RuntimeException: Oops!! [xxxxx]
        at org.littlewings.logback.App.throwException(App.java:22)
        at org.littlewings.logback.App.main(App.java:15)
        ... 2 common frames omitted

オプションを指定してみましょう。

      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %email(%msg){'[zzzzz]'}%n%email(%ex){'[zzzzz]'}</pattern>

反映されましたね。

2023-04-23 16:46:42.657 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello World!!
2023-04-23 16:46:42.659 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [zzzzz], [zzzzz] !!
2023-04-23 16:46:42.659 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [zzzzz], [zzzzz] !!
2023-04-23 16:46:42.661 [org.littlewings.logback.App.main()] INFO  org.littlewings.logback.App - Hello, [zzzzz] !!
java.lang.RuntimeException: java.lang.RuntimeException: Oops!! [zzzzz]
        at org.littlewings.logback.App.main(App.java:17)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)
        at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.lang.RuntimeException: Oops!! [zzzzz]
        at org.littlewings.logback.App.throwException(App.java:22)
        at org.littlewings.logback.App.main(App.java:15)
        ... 2 common frames omitted

Logback単体だとこんなところでしょうか。

Logstash Logback Encoderを使う

続いては、Logstash Logback Encoderを使った場合をテーマにします。

ここから先は、依存関係にlogstash-logback-encoderを加えているものとします。

    <dependencies>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.7</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.4.7</version>
        </dependency>
        <dependency>
            <groupId>net.logstash.logback</groupId>
            <artifactId>logstash-logback-encoder</artifactId>
            <version>7.3</version>
        </dependency>
    </dependencies>

また、以下のソースコードは特に変更しません。

src/main/java/org/littlewings/logback/App.java

package org.littlewings.logback;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class App {
    public static void main(String... args) {
        Logger logger = LoggerFactory.getLogger(App.class);

        logger.info("Hello World!!");
        logger.info("Hello, isono@example.com, namino@example.com !!");
        logger.info("Hello, {}, {} !!", "isono@example.com", "namino@example.net");

        try {
            throwException("Oops!! namino@example.net");
        } catch (RuntimeException e) {
            logger.info("Hello, {} !!", "isono@example.com", new RuntimeException(e));
        }
    }

    static void throwException(String message) {
        throw new RuntimeException(message);
    }
}
Masking

Logstash Logback Encoderにはマスキングの機能があります。

Logstash Logback Encoder / Masking

用途としては、個人情報や機密情報のマスキングに使うことを想定されているようです。

The MaskingJsonGeneratorDecorator can be used to mask sensitive values (e.g. personally identifiable information (PII) or financial data).

マスキングの機能を使うと、特定のパス(フィールド)をマスキングしたり、値を正規表現でマスキングすることができるようになります。

まずはこちらを使ってみます。

簡単に設定。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <jsonGeneratorDecorator class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
        <defaultMask>****</defaultMask>

        <path>message</path>
        <path>logger_name</path>
      </jsonGeneratorDecorator>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

MaskingJsonGeneratorDecoratorを使います。

defaultMaskでマスキングした時のデフォルト値を指定できますが、これを省略すると****を指定したことになります。

        <defaultMask>****</defaultMask>

最初はmessagestack_traceを指定。

        <path>message</path>
        <path>logger_name</path>

結果。指定したフィールドがマスキングされました。

{"@timestamp":"2023-04-23T17:01:27.002749969+09:00","@version":"1","message":"****","logger_name":"****","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000}
{"@timestamp":"2023-04-23T17:01:27.015472912+09:00","@version":"1","message":"****","logger_name":"****","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000}
{"@timestamp":"2023-04-23T17:01:27.015804802+09:00","@version":"1","message":"****","logger_name":"****","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000}
{"@timestamp":"2023-04-23T17:01:27.016798701+09:00","@version":"1","message":"****","logger_name":"****","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000,"stack_trace":"java.lang.RuntimeException: java.lang.RuntimeException: Oops!! namino@example.net\n\tat org.littlewings.logback.App.main(App.java:17)\n\tat org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)\n\tat java.base/java.lang.Thread.run(Thread.java:833)\nCaused by: java.lang.RuntimeException: Oops!! namino@example.net\n\tat org.littlewings.logback.App.throwException(App.java:22)\n\tat org.littlewings.logback.App.main(App.java:15)\n\t... 2 common frames omitted\n"}

他にもパスでの指定方法は種類があるので、詳しくはドキュメントを参照。

Logstash Logback Encoder / Masking / dentifying field values to mask by path

次は、値でマスキングしてみます。

Identifying field values to mask by value

ログ内に出現する値を正規表現でマスキングできますが、パス指定のマスキングよりも実行コストがかかることが注意点です。

Identifying data to mask by value is much more expensive than identifying data to mask by path. Therefore, prefer identifying data to mask by path.

また、値のマスキングは定義された数だけ順次実行されていきますが、この評価順には依存すべきではないことが書かれています。

The value to mask is passed through every value masker, with the output of one masker passed as input to the next masker. This allows each masker to mask specific substrings within the value. The order in which the maskers are executed is not defined, and should not be relied upon.

今回は、シンプルに設定。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <jsonGeneratorDecorator class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
        <defaultMask>****</defaultMask>

        <value>\w+@\w+\.\w+</value>
      </jsonGeneratorDecorator>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

結果。

{"@timestamp":"2023-04-23T17:07:54.178933736+09:00","@version":"1","message":"Hello World!!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000}
{"@timestamp":"2023-04-23T17:07:54.187134752+09:00","@version":"1","message":"Hello, ****, **** !!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000}
{"@timestamp":"2023-04-23T17:07:54.189851624+09:00","@version":"1","message":"Hello, ****, **** !!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000}
{"@timestamp":"2023-04-23T17:07:54.191020147+09:00","@version":"1","message":"Hello, **** !!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000,"stack_trace":"java.lang.RuntimeException: java.lang.RuntimeException: Oops!! ****\n\tat org.littlewings.logback.App.main(App.java:17)\n\tat org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)\n\tat java.base/java.lang.Thread.run(Thread.java:833)\nCaused by: java.lang.RuntimeException: Oops!! ****\n\tat org.littlewings.logback.App.throwException(App.java:22)\n\tat org.littlewings.logback.App.main(App.java:15)\n\t... 2 common frames omitted\n"}

メールアドレスの部分がマスキングされました。

マスキング処理をクラスでも実装することができます。ValueMaskerというインターフェースを実装します。

src/main/java/org/littlewings/logback/EmailValueMasker.java

package org.littlewings.logback;

import com.fasterxml.jackson.core.JsonStreamContext;
import net.logstash.logback.mask.ValueMasker;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class EmailValueMasker implements ValueMasker {
    private static final Pattern EMAIL_PATTERN = Pattern.compile(
                "[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*"
        );

    @Override
    public Object mask(JsonStreamContext context, Object value) {
        if (value instanceof String in) {
            Matcher matcher = EMAIL_PATTERN.matcher(in);

            if (matcher.find()) {
                return matcher.replaceAll("[*****]");
            }
        }

        return null;
    }
}

作成したクラスは、valueMaskerで設定。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <jsonGeneratorDecorator class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
        <defaultMask>****</defaultMask>

        <valueMasker class="org.littlewings.logback.EmailValueMasker" />
      </jsonGeneratorDecorator>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

結果。

{"@timestamp":"2023-04-23T17:16:09.271622188+09:00","@version":"1","message":"Hello World!!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000}
{"@timestamp":"2023-04-23T17:16:09.280521883+09:00","@version":"1","message":"Hello, [*****], [*****] !!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000}
{"@timestamp":"2023-04-23T17:16:09.283502453+09:00","@version":"1","message":"Hello, [*****], [*****] !!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000}
{"@timestamp":"2023-04-23T17:16:09.284884527+09:00","@version":"1","message":"Hello, [*****] !!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","level_value":20000,"stack_trace":"java.lang.RuntimeException: java.lang.RuntimeException: Oops!! [*****]\n\tat org.littlewings.logback.App.main(App.java:17)\n\tat org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)\n\tat java.base/java.lang.Thread.run(Thread.java:833)\nCaused by: java.lang.RuntimeException: Oops!! [*****]\n\tat org.littlewings.logback.App.throwException(App.java:22)\n\tat org.littlewings.logback.App.main(App.java:15)\n\t... 2 common frames omitted\n"}

OKですね。

値でのマスキングの指定方法は、他にもいくつかありますが詳しくはドキュメントを参照、ということで。

Identifying field values to mask by value

変換指定子を使う

Logstash Logback Encoderでも変換指定子を使うことができます。

ただ、指定方法がちょっとプリミティブになります。

これまでencoderLogstashEncoderを指定していました。

    <encoder class="net.logstash.logback.encoder.LogstashEncoder">

今回はLoggingEventCompositeJsonEncoderを指定することになります。

    <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">

LoggingEventCompositeJsonEncoderは、LogstashEncoderの親クラスです。
LogstashEncoderはProviderと呼ばれる出力要素をデフォルトで設定したものであり、LoggingEventCompositeJsonEncoderを使うと
デフォルト設定はなくなりますが、その代わりに柔軟なカスタマイズができるようになります。

Logstash Logback Encoder / Composite Encoder/Layout

たとえば、encoderLoggingEventCompositeJsonEncoderのみを設定してみます。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

すると、ログになにも含まれなくなります。

{}
{}
{}
{}

この時、Logstash Logback EncoderからはProviderが設定されていないとERRORが出ていたりします。

22:42:26,570 |-ERROR in net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder@7f977055 - No providers configured

LogstashEncoder相当の設定をしようとすると、このくらいでしょうか。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
      <providers>
        <timestamp/>
        <version/>
        <message />
        <loggerName />
        <threadName />
        <logLevel />
        <stackTrace />
        <context />
        <mdc/>
        <tags />
        <arguments />
      </providers>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

https://github.com/logfellow/logstash-logback-encoder/blob/logstash-logback-encoder-7.3/src/main/java/net/logstash/logback/encoder/LogstashEncoder.java#L33

https://github.com/logfellow/logstash-logback-encoder/blob/logstash-logback-encoder-7.3/src/main/java/net/logstash/logback/LogstashFormatter.java#L115-L129

結果。

{"@timestamp":"2023-04-23T22:46:39.340368538+09:00","@version":"1","message":"Hello World!!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO"}
{"@timestamp":"2023-04-23T22:46:39.346415333+09:00","@version":"1","message":"Hello, isono@example.com, namino@example.com !!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO"}
{"@timestamp":"2023-04-23T22:46:39.346623562+09:00","@version":"1","message":"Hello, isono@example.com, namino@example.net !!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO"}
{"@timestamp":"2023-04-23T22:46:39.347528225+09:00","@version":"1","message":"Hello, isono@example.com !!","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","stack_trace":"java.lang.RuntimeException: java.lang.RuntimeException: Oops!! namino@example.net\n\tat org.littlewings.logback.App.main(App.java:17)\n\tat org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)\n\tat java.base/java.lang.Thread.run(Thread.java:833)\nCaused by: java.lang.RuntimeException: Oops!! namino@example.net\n\tat org.littlewings.logback.App.throwException(App.java:22)\n\tat org.littlewings.logback.App.main(App.java:15)\n\t... 2 common frames omitted\n"}

このように、Providerを追加するにしたがってログに出力される項目が増えていきます。

指定できるProviderは、このあたりに記載があります。

Logstash Logback Encoder / Composite Encoder/Layout / Providers common to LoggingEvents and AccessEvents

Logstash Logback Encoder / Composite Encoder/Layout / Providers for LoggingEvents

ここにさらにpatternpatternを加えることで、出力するログに項目を「追加」できます。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
      <providers>
        <timestamp/>
        <version/>
        <!-- <message /> -->
        <loggerName />
        <threadName />
        <logLevel />
        <!-- <stackTrace /> -->
        <context />
        <mdc/>
        <tags />
        <arguments />
        <pattern>
          <pattern>
            {
              "message": "%msg",
              "stack_trace": "%ex"
            }
          </pattern>
        </pattern>
      </providers>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

今回はmessagestack_traceを追加したので、Providerの方はコメントアウトしています。これを残したままにすると、同じ内容が
二重に追加されることになります。

ではここで、%replace変換指定子を追加してみます。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
      <providers>
        <timestamp/>
        <version/>
        <!-- <message /> -->
        <loggerName />
        <threadName />
        <logLevel />
        <!-- <stackTrace /> -->
        <context />
        <mdc/>
        <tags />
        <arguments />
        <pattern>
          <pattern>
            {
              "message": "%replace(%msg){'\\w+@\\w+\\.\\w+', 'xxxxx@xxxxx'}",
              "stack_trace": "%replace(%ex){'\\w+@\\w+\\.\\w+', 'xxxxx@xxxxx'}"
            }
          </pattern>
        </pattern>
      </providers>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

ちょっとエスケープが面倒ですね…。

結果。

{"@timestamp":"2023-04-23T23:00:54.515349092+09:00","@version":"1","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","message":"Hello World!!","stack_trace":""}
{"@timestamp":"2023-04-23T23:00:54.520224038+09:00","@version":"1","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","message":"Hello, xxxxx@xxxxx, xxxxx@xxxxx !!","stack_trace":""}
{"@timestamp":"2023-04-23T23:00:54.520538823+09:00","@version":"1","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","message":"Hello, xxxxx@xxxxx, xxxxx@xxxxx !!","stack_trace":""}
{"@timestamp":"2023-04-23T23:00:54.521686038+09:00","@version":"1","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","message":"Hello, xxxxx@xxxxx !!","stack_trace":"java.lang.RuntimeException: java.lang.RuntimeException: Oops!! xxxxx@xxxxx\n\tat org.littlewings.logback.App.main(App.java:17)\n\tat org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)\n\tat java.base/java.lang.Thread.run(Thread.java:833)\nCaused by: java.lang.RuntimeException: Oops!! xxxxx@xxxxx\n\tat org.littlewings.logback.App.throwException(App.java:22)\n\tat org.littlewings.logback.App.main(App.java:15)\n\t... 2 common frames omitted\n"}

自作の変換指定子を使うのもいいかもですね。

src/main/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <conversionRule conversionWord="email" converterClass="org.littlewings.logback.EmailReplaceConverter" />

  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
      <providers>
        <timestamp/>
        <version/>
        <!-- <message /> -->
        <loggerName />
        <threadName />
        <logLevel />
        <!-- <stackTrace /> -->
        <context />
        <mdc/>
        <tags />
        <arguments />
        <pattern>
          <pattern>
            {
              "message": "%email(%msg)",
              "stack_trace": "%email(%ex)"
            }
          </pattern>
        </pattern>
      </providers>
    </encoder>
  </appender>

  <root level="info">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

結果。

{"@timestamp":"2023-04-23T23:14:47.362809176+09:00","@version":"1","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","message":"Hello World!!","stack_trace":""}
{"@timestamp":"2023-04-23T23:14:47.368164723+09:00","@version":"1","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","message":"Hello, [xxxxx], [xxxxx] !!","stack_trace":""}
{"@timestamp":"2023-04-23T23:14:47.36849854+09:00","@version":"1","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","message":"Hello, [xxxxx], [xxxxx] !!","stack_trace":""}
{"@timestamp":"2023-04-23T23:14:47.36955851+09:00","@version":"1","logger_name":"org.littlewings.logback.App","thread_name":"org.littlewings.logback.App.main()","level":"INFO","message":"Hello, [xxxxx] !!","stack_trace":"java.lang.RuntimeException: java.lang.RuntimeException: Oops!! [xxxxx]\n\tat org.littlewings.logback.App.main(App.java:17)\n\tat org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:279)\n\tat java.base/java.lang.Thread.run(Thread.java:833)\nCaused by: java.lang.RuntimeException: Oops!! [xxxxx]\n\tat org.littlewings.logback.App.throwException(App.java:22)\n\tat org.littlewings.logback.App.main(App.java:15)\n\t... 2 common frames omitted\n"}

MaskingJsonGeneratorDecoratorを使ったマスキングと比べると、対象のフィールドを絞れる分だけ効率的ではないのかなと
思うのですが、どうなのでしょう。

とりあえず、Logstash Logback Encoderを使った場合はこんなところでしょうか。

まとめ

LogbackおよびLogstash Logback Encoderを使って、文字列置換を行う方法をいろいろ調べてみました。

いろいろ方法があるんですね。また変換指定子に関する知識の見直しになったという意味でも、ちゃんと見ておいてよかったです。

Database RiderとSpring Frameworkを合わせて使う時に、DirtiesContextを使う場合はcacheConnectionを無効にした方がよいという話

これは、なにをしたくて書いたもの?

Database RiderとSpring Boot(Spring Framework)を合わせて使っていて、@DirtiesContextアノテーションを使うと途端にテストが
失敗するようになってハマったのでメモ。

どういうことか?

Mockitoを使った時など、Spring FrameworkApplicationContextを破棄したい時に@DirtiesContextアノテーションを使うことがあります。

Testing / Appendix / Annotations / Spring Testing Annotations / @DirtiesContext

DirtiesContext (Spring Framework 6.0.7 API)

@DirtiesContextアノテーションを使うと規定のタイミングでApplicationContextをダーティであるとマークし、破棄することが
できます。

例としては、シングルトンであるSpringで管理されたBeanの状態変更を行った時、組み込みデータベースの状態変更を行った時などが
挙げられています。

@DirtiesContextアノテーションはクラスまたはメソッドに付与することができ(それぞれクラスモード、メソッドモードとして
扱われます)、ダーティとマークするタイミングを以下のように制御することができます。

  • 現在のテストクラスの前(BEFORE_CLASS/クラスモード)
  • 現在のテストクラスの各テストメソッドの実行前(BEFORE_EACH_TEST_METHOD/クラスモード)
  • 現在のテストメソッドの実行前(BEFORE_METHOD/メソッドモード)
  • 現在のテストメソッドの実行後(AFTER_METHOD/メソッドモード)
  • 現在のクラスの各テストメソッドの実行後(AFTER_EACH_TEST_METHOD/クラスモード)
  • 現在のテストクラスの後(AFTER_CLASS/クラスモード)

デフォルトは、クラスモードの場合はAFTER_CLASS、メソッドモードの場合はAFTER_METHODです。

これと関連する話題がApplicationContextのキャッシュなのですが、それはこちらに書かれています。

Testing / Spring TestContext Framework / Context Management / Context Caching

で、これとDatabase Riderがどう関係があるのかということですが、Database Riderはデフォルトでデータベース接続をキャッシュします。

Database Rider Documentation / Configuration / DBUnit configuration

cacheConnectionというプロパティですね。こちらがデフォルトでtrueになっています。

Database connection will be reused among tests

データベース接続がテストで再利用される、と書かれていますね。

@DirtiesContextApplicationContextを破棄するようにした場合、Database RiderがSpring Framework経由で取得している
データベース接続が無効になり、Database Riderを使ったテストが軒並み失敗していくようになります。

Rider JUnit 5モジュール、Rider Springモジュールのどちらを使っても発生します。

このあたりのissueを見ていて、問題を把握しました。

Issue using @ExpectedDataSet without @DataSet · Issue #136 · database-rider/database-rider · GitHub

SpringBoot 2.x, JUnit5 and DatabaseRider configuration woes · Issue #183 · database-rider/database-rider · GitHub

この問題にぶつかった場合は、cacheConnectionfalseにすることが回避策になります。

このあたりを見ていきたいと思います。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.6 2023-01-17
OpenJDK Runtime Environment (build 17.0.6+10-Ubuntu-0ubuntu122.04)
OpenJDK 64-Bit Server VM (build 17.0.6+10-Ubuntu-0ubuntu122.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.1 (2e178502fcdbffc201671fb2537d0cb4b4cc58f8)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.6, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-69-generic", arch: "amd64", family: "unix"

データベースには、MySQLを使用します。

 MySQL  localhost:3306 ssl  practice  SQL > select version();
+-----------+
| version() |
+-----------+
| 8.0.32    |
+-----------+
1 row in set (0.0027 sec)

MySQLは172.17.0.2で動作しているものとし、データベースpractice、アカウントはkazuhirapasswordで接続できるものと します。

Spring Bootプロジェクトを作成する

まずはSpring Bootプロジェクトを作成します。依存関係には、jdbcmysqlを含めました。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=3.0.5 \
  -d javaVersion=17 \
  -d type=maven-project \
  -d name=database-rider-with-dirtiescontext \
  -d groupId=org.littlewings \
  -d artifactId=database-rider-with-dirtiescontext \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.dbrider \
  -d dependencies=jdbc,mysql \
  -d baseDir=database-rider-with-dirtiescontext | tar zxvf -

ディレクトリ内に移動。

$ cd database-rider-with-dirtiescontext

生成されたMaven依存関係など。

        <properties>
                <java.version>17</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-jdbc</artifactId>
                </dependency>

                <dependency>
                        <groupId>com.mysql</groupId>
                        <artifactId>mysql-connector-j</artifactId>
                        <scope>runtime</scope>
                </dependency>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-test</artifactId>
                        <scope>test</scope>
                </dependency>
        </dependencies>

        <build>
                <plugins>
                        <plugin>
                                <groupId>org.springframework.boot</groupId>
                                <artifactId>spring-boot-maven-plugin</artifactId>
                        </plugin>
                </plugins>
        </build>

自動生成されたソースコードは削除しておきます。

$ rm src/main/java/org/littlewings/spring/dbrider/DatabaseRiderWithDirtiescontextApplication.java src/test/java/org/littlewings/spring/dbrider/DatabaseRiderWithDirtiescontextApplicationTests.java

ここに、Rider JUnit 5とRider Springの2つのモジュールを追加。

     <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.github.database-rider</groupId>
            <artifactId>rider-junit5</artifactId>
            <version>1.36.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.github.database-rider</groupId>
            <artifactId>rider-spring</artifactId>
            <version>1.36.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

続いて、テーブル定義やソースコードを作成していきます。

今回使うテーブルは、以下のように定義。お題は書籍にしました。

src/main/resources/schema.sql

drop table if exists book;

create table book(
  isbn varchar(14),
  title varchar(100),
  price int,
  primary key(isbn)
);

このSQLが起動時に毎回実行されるように設定。

src/main/resources/application.properties

spring.datasource.url=jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin
spring.datasource.username=kazuhira
spring.datasource.password=password

spring.sql.init.mode=always

エンティティ的なクラス。

src/main/java/org/littlewings/spring/dbrider/Book.java

package org.littlewings.spring.dbrider;

public class Book {
    String isbn;
    String title;
    Integer price;

    public static Book create(String isbn, String title, Integer price) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        return book;
    }

    // getter/setterは省略
}

@SpringBootApplicationアノテーションを付与したクラス。

src/main/java/org/littlewings/spring/dbrider/App.java

package org.littlewings.spring.dbrider;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
}

Database Riderを使ってテストを書く

では、Database Riderを使ってテストを書いていきます。

Database Rider(というかDbUnit)の設定。

src/test/resources/dbunit.yml

cacheConnection: true
properties:
  caseSensitiveTableNames: true

cacheConnectionはまずはtrueにしておきます。

テストは、Spring JDBCを使ったデータ登録とDatabase Riderの@ExpectedDataSetアノテーションでのアサーションを行う簡単なものに
します。

Rider Springモジュールを使ったパターン。

src/test/java/org/littlewings/spring/dbrider/DatabaseRiderSpringTest.java

package org.littlewings.spring.dbrider;

import com.github.database.rider.core.api.dataset.ExpectedDataSet;
import com.github.database.rider.spring.api.DBRider;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@SpringBootTest
@DBRider
public class DatabaseRiderSpringTest {
    @Autowired
    NamedParameterJdbcTemplate jdbcTemplate;

    @Test
    @Transactional
    //@DirtiesContext
    @ExpectedDataSet(value = "datasets/DatabaseRiderSpringTest/expect_addBook1.yml", orderBy = "price")
    void addBook1() {
        List<Book> books = List.of(
                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860),
                Book.create("978-4621303252", "Effective Java 第3版", 4400),
                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278)
        );

        books.forEach(book -> {
            System.out.println(book);
            jdbcTemplate.update("""
                            insert into book(isbn, title, price)
                            values(:isbn, :title, :price)""",
                    new BeanPropertySqlParameterSource(book));
                }
        );
    }

    @Test
    @Transactional
    //@DirtiesContext
    @ExpectedDataSet(value = "datasets/DatabaseRiderSpringTest/expect_addBook2.yml", orderBy = "price")
    void addBook2() {
        List<Book> books = List.of(
                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180),
                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960)
        );

        books.forEach(book -> {
            System.out.println(book);
            jdbcTemplate.update("""
                            insert into book(isbn, title, price)
                            values(:isbn, :title, :price)""",
                    new BeanPropertySqlParameterSource(book));
                }
        );
    }
}

Rider Springの場合は@Transactionalアノテーションと連動できます。

    @Test
    @Transactional
    //@DirtiesContext
    @ExpectedDataSet(value = "datasets/DatabaseRiderSpringTest/expect_addBook1.yml", orderBy = "price")
    void addBook1() {

@DirtiesContextアノテーションは後で有効にします。

アサーションで使うデータセット

src/test/resources/datasets/DatabaseRiderSpringTest/expect_addBook1.yml

book:
  - isbn: "978-4295008477"
    title: "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"
    price: 2860
  - isbn: "978-4774189093"
    title: "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで"
    price: 3278
  - isbn: "978-4621303252"
    title: "Effective Java 第3版"
    price: 4400

src/test/resources/datasets/DatabaseRiderSpringTest/expect_addBook2.yml

book:
  - isbn: "978-4798147406"
    title: "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド"
    price: 3960
  - isbn: "978-4798161488"
    title: "MySQL徹底入門 第4版 MySQL 8.0対応"
    price: 4180

続いて、Rider JUnit 5を使った場合。

src/test/java/org/littlewings/spring/dbrider/DatabaseRiderJUnit5Test.java

package org.littlewings.spring.dbrider;

import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.core.api.dataset.ExpectedDataSet;
import com.github.database.rider.junit5.api.DBRider;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@SpringBootTest
@DBRider
public class DatabaseRiderJUnit5Test {
    @Autowired
    NamedParameterJdbcTemplate jdbcTemplate;

    @Test
    //@Transactional  // Rider JUnit5の場合は@Transactinalは無効
    //@DirtiesContext
    @DataSet(cleanBefore = true)
    @ExpectedDataSet(value = "datasets/DatabaseRiderJUnit5Test/expect_addBook1.yml", orderBy = "price")
    void addBook1() {
        List<Book> books = List.of(
                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860),
                Book.create("978-4621303252", "Effective Java 第3版", 4400),
                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278)
        );

        books.forEach(book -> {
            System.out.println(book);
            jdbcTemplate.update("""
                            insert into book(isbn, title, price)
                            values(:isbn, :title, :price)""",
                    new BeanPropertySqlParameterSource(book));
                }
        );
    }

    @Test
    //@Transactional  // Rider JUnit5の場合は@Transactinalは無効
    //@DirtiesContext
    @DataSet(cleanBefore = true)
    @ExpectedDataSet(value = "datasets/DatabaseRiderJUnit5Test/expect_addBook2.yml", orderBy = "price")
    void addBook2() {
        List<Book> books = List.of(
                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180),
                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960)
        );

        books.forEach(book -> {
            System.out.println(book);
            jdbcTemplate.update("""
                            insert into book(isbn, title, price)
                            values(:isbn, :title, :price)""",
                    new BeanPropertySqlParameterSource(book));
                }
        );
    }
}

Rider JUnit 5の場合、@Transactionalアノテーションとは連動しないので@DataSetで先にデータを削除するようにしています。

    @Test
    //@Transactional  // Rider JUnit5の場合は@Transactinalは無効
    //@DirtiesContext
    @DataSet(cleanBefore = true)
    @ExpectedDataSet(value = "datasets/DatabaseRiderJUnit5Test/expect_addBook1.yml", orderBy = "price")
    void addBook1() {

アサーションで使うデータセットの中身は、Rider Springのものと同じです。

src/test/resources/datasets/DatabaseRiderJUnit5Test/expect_addBook1.yml

book:
  - isbn: "978-4295008477"
    title: "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"
    price: 2860
  - isbn: "978-4774189093"
    title: "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで"
    price: 3278
  - isbn: "978-4621303252"
    title: "Effective Java 第3版"
    price: 4400

src/test/resources/datasets/DatabaseRiderJUnit5Test/expect_addBook2.yml

book:
  - isbn: "978-4798147406"
    title: "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド"
    price: 3960
  - isbn: "978-4798161488"
    title: "MySQL徹底入門 第4版 MySQL 8.0対応"
    price: 4180

テストを行って動作確認。

$ mvn test

すべてパスします。

[INFO] Results:
[INFO]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

@DirtiesContextアノテーションを加える

この状態で、@DirtiesContextアノテーションをテストメソッドに追加します(というか、コメントアウトの解除ですね)。

Rider Spring版。

    @Test
    @Transactional
    @DirtiesContext
    @ExpectedDataSet(value = "datasets/DatabaseRiderSpringTest/expect_addBook1.yml", orderBy = "price")
    void addBook1() {
        List<Book> books = List.of(
                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860),
                Book.create("978-4621303252", "Effective Java 第3版", 4400),
                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278)
        );

        books.forEach(book -> {
            System.out.println(book);
            jdbcTemplate.update("""
                            insert into book(isbn, title, price)
                            values(:isbn, :title, :price)""",
                    new BeanPropertySqlParameterSource(book));
                }
        );
    }

    @Test
    @Transactional
    @DirtiesContext
    @ExpectedDataSet(value = "datasets/DatabaseRiderSpringTest/expect_addBook2.yml", orderBy = "price")
    void addBook2() {
        List<Book> books = List.of(
                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180),
                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960)
        );

        books.forEach(book -> {
            System.out.println(book);
            jdbcTemplate.update("""
                            insert into book(isbn, title, price)
                            values(:isbn, :title, :price)""",
                    new BeanPropertySqlParameterSource(book));
                }
        );
    }

@DirtiesContextアノテーションをメソッドに付与しているので、テストメソッドの実行後ごとにダーティとマークされ、
ApplicationContextが破棄されることになります。

Rider JUnit 5版。

    @Test
    //@Transactional  // Rider JUnit5の場合は@Transactinalは無効
    @DirtiesContext
    @DataSet(cleanBefore = true)
    @ExpectedDataSet(value = "datasets/DatabaseRiderJUnit5Test/expect_addBook1.yml", orderBy = "price")
    void addBook1() {
        List<Book> books = List.of(
                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860),
                Book.create("978-4621303252", "Effective Java 第3版", 4400),
                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278)
        );

        books.forEach(book -> {
            System.out.println(book);
            jdbcTemplate.update("""
                            insert into book(isbn, title, price)
                            values(:isbn, :title, :price)""",
                    new BeanPropertySqlParameterSource(book));
                }
        );
    }

    @Test
    //@Transactional  // Rider JUnit5の場合は@Transactinalは無効
    @DirtiesContext
    @DataSet(cleanBefore = true)
    @ExpectedDataSet(value = "datasets/DatabaseRiderJUnit5Test/expect_addBook2.yml", orderBy = "price")
    void addBook2() {
        List<Book> books = List.of(
                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180),
                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960)
        );

        books.forEach(book -> {
            System.out.println(book);
            jdbcTemplate.update("""
                            insert into book(isbn, title, price)
                            values(:isbn, :title, :price)""",
                    new BeanPropertySqlParameterSource(book));
                }
        );
    }

これでテストを実行。

$ mvn test

すると、テストの半分が失敗します。

[INFO]
[INFO] Results:
[INFO]
[ERROR] Errors:
[ERROR]   DatabaseRiderJUnit5Test.addBook2 » Runtime DataSet comparison failed due to fo...
[ERROR]   DatabaseRiderSpringTest.addBook2 » Runtime Could not get driver information fr...
[INFO]
[ERROR] Tests run: 4, Failures: 0, Errors: 2, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------

どちらも、同じテストクラス内のテストメソッドの片方が失敗しています。

この時、Rider Springの方は以下のようなスタックトレースが出力されていました。

2023-04-16T23:57:11.724+09:00  WARN 38092 --- [           main] com.zaxxer.hikari.pool.ProxyConnection   : HikariPool-3 - Connection com.mysql.cj.jdbc.ConnectionImpl@32dcfeea marked as broken because of SQLSTATE(08003), ErrorCode(0)

java.sql.SQLNonTransientConnectionException: No operations allowed after connection closed.
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:110) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:97) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:89) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:63) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:73) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:73) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.ConnectionImpl.createStatement(ConnectionImpl.java:1089) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.ConnectionImpl.createStatement(ConnectionImpl.java:1079) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.zaxxer.hikari.pool.ProxyConnection.createStatement(ProxyConnection.java:284) ~[HikariCP-5.0.1.jar:na]
        at com.zaxxer.hikari.pool.HikariProxyConnection.createStatement(HikariProxyConnection.java) ~[HikariCP-5.0.1.jar:na]
        at com.github.database.rider.core.dataset.DataSetExecutorImpl.handleConstraints(DataSetExecutorImpl.java:344) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.core.dataset.DataSetExecutorImpl.disableConstraints(DataSetExecutorImpl.java:332) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.core.dataset.DataSetExecutorImpl.clearDatabase(DataSetExecutorImpl.java:576) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.core.dataset.DataSetExecutorImpl.createDataSet(DataSetExecutorImpl.java:108) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.core.RiderRunner.runBeforeTest(RiderRunner.java:44) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.junit5.DBUnitExtension.beforeTestExecution(DBUnitExtension.java:69) ~[rider-junit5-1.36.0.jar:na]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeTestExecutionCallbacks$5(TestMethodTestDescriptor.java:191) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeMethodsOrCallbacksUntilExceptionOccurs$6(TestMethodTestDescriptor.java:202) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeMethodsOrCallbacksUntilExceptionOccurs(TestMethodTestDescriptor.java:202) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeTestExecutionCallbacks(TestMethodTestDescriptor.java:190) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:136) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:68) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) ~[na:na]
        at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) ~[na:na]
        at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:220) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.junit.platform.launcher.core.DefaultLauncher.lambda$execute$6(DefaultLauncher.java:188) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.junit.platform.launcher.core.DefaultLauncher.withInterceptedStreams(DefaultLauncher.java:202) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:181) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:128) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:150) ~[surefire-junit-platform-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:124) ~[surefire-junit-platform-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.booter.ForkedBooter.invokeProviderInSameClassLoader(ForkedBooter.java:384) ~[surefire-booter-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:345) ~[surefire-booter-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:126) ~[surefire-booter-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) ~[surefire-booter-2.22.2.jar:2.22.2]
Caused by: com.mysql.cj.exceptions.ConnectionIsClosedException: No operations allowed after connection closed.
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[na:na]
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499) ~[na:na]
        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480) ~[na:na]
        at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:61) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:105) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:151) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.NativeSession.checkClosed(NativeSession.java:761) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.ConnectionImpl.checkClosed(ConnectionImpl.java:564) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.ConnectionImpl.createStatement(ConnectionImpl.java) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        ... 58 common frames omitted

データベース接続はすでに閉じています、ということですね。

Caused by: com.mysql.cj.exceptions.ConnectionIsClosedException: No operations allowed after connection closed.

Rider JUnit 5の場合は@DataSetアノテーションを使った処理で失敗しているようでした。

2023-04-16T23:57:11.725+09:00  WARN 38092 --- [           main] c.g.d.r.c.dataset.DataSetExecutorImpl    : Could not clean database before test.

java.sql.SQLNonTransientConnectionException: No operations allowed after connection closed.
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:110) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:97) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:89) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:63) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:73) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:73) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.ConnectionImpl.createStatement(ConnectionImpl.java:1089) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.ConnectionImpl.createStatement(ConnectionImpl.java:1079) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.zaxxer.hikari.pool.ProxyConnection.createStatement(ProxyConnection.java:284) ~[HikariCP-5.0.1.jar:na]
        at com.zaxxer.hikari.pool.HikariProxyConnection.createStatement(HikariProxyConnection.java) ~[HikariCP-5.0.1.jar:na]
        at com.github.database.rider.core.dataset.DataSetExecutorImpl.handleConstraints(DataSetExecutorImpl.java:344) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.core.dataset.DataSetExecutorImpl.disableConstraints(DataSetExecutorImpl.java:332) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.core.dataset.DataSetExecutorImpl.clearDatabase(DataSetExecutorImpl.java:576) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.core.dataset.DataSetExecutorImpl.createDataSet(DataSetExecutorImpl.java:108) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.core.RiderRunner.runBeforeTest(RiderRunner.java:44) ~[rider-core-1.36.0.jar:na]
        at com.github.database.rider.junit5.DBUnitExtension.beforeTestExecution(DBUnitExtension.java:69) ~[rider-junit5-1.36.0.jar:na]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeTestExecutionCallbacks$5(TestMethodTestDescriptor.java:191) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeMethodsOrCallbacksUntilExceptionOccurs$6(TestMethodTestDescriptor.java:202) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeMethodsOrCallbacksUntilExceptionOccurs(TestMethodTestDescriptor.java:202) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeTestExecutionCallbacks(TestMethodTestDescriptor.java:190) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:136) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:68) ~[junit-jupiter-engine-5.9.2.jar:5.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) ~[na:na]
        at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) ~[na:na]
        at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54) ~[junit-platform-engine-1.9.2.jar:1.9.2]
        at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:220) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.junit.platform.launcher.core.DefaultLauncher.lambda$execute$6(DefaultLauncher.java:188) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.junit.platform.launcher.core.DefaultLauncher.withInterceptedStreams(DefaultLauncher.java:202) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:181) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:128) ~[junit-platform-launcher-1.3.1.jar:1.3.1]
        at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:150) ~[surefire-junit-platform-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:124) ~[surefire-junit-platform-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.booter.ForkedBooter.invokeProviderInSameClassLoader(ForkedBooter.java:384) ~[surefire-booter-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:345) ~[surefire-booter-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:126) ~[surefire-booter-2.22.2.jar:2.22.2]
        at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:418) ~[surefire-booter-2.22.2.jar:2.22.2]
Caused by: com.mysql.cj.exceptions.ConnectionIsClosedException: No operations allowed after connection closed.
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[na:na]
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499) ~[na:na]
        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480) ~[na:na]
        at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:61) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:105) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:151) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.NativeSession.checkClosed(NativeSession.java:761) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.ConnectionImpl.checkClosed(ConnectionImpl.java:564) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        at com.mysql.cj.jdbc.ConnectionImpl.createStatement(ConnectionImpl.java) ~[mysql-connector-j-8.0.32.jar:8.0.32]
        ... 58 common frames omitted

どっちにしても、データベース接続が使えなくなったというのは同じですね。

Caused by: com.mysql.cj.exceptions.ConnectionIsClosedException: No operations allowed after connection closed.

実行例はmvn testで一括実行していますが、今回の構成だとテストクラス単位でも失敗します。

というわけで、@DirtiesContextアノテーションを使っただけでデフォルトだとDatabase RiderとSpring Frameworkを使ったテストが
不安定になります。

cacheConnectionを無効にする

この対処方法ですが、最初に書いたようにcacheConnectionfalseにしてデータベース接続を再利用することをやめます。

src/test/resources/dbunit.yml

cacheConnection: false
properties:
  caseSensitiveTableNames: true

確認。

$ mvn test

こうすると、@DirtiesContextアノテーションを付与したままでもテストに成功するようになります。

[INFO] Results:
[INFO]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

まとめ

というわけで、Database RiderとSpring Frameworkを合わせて使った時に、@DirtiesContextアノテーションを使うと設定によっては
ハマるという話でした。

最初、データベース接続を使いまわしているのに気づいてなかったので、解決するのにだいぶ苦労しました…。

設定項目はちゃんと見ておかないといけないですね。