kagamihogeの日記

kagamihogeの日記です。

Testcontainers + Spring Boot 3.1 + Oracle XE

従来 Testcontainers とspring-bootを組み合わせる場合は @DynamicPropertySource で接続URLプロパティなどを置き換える処理がやや煩雑だった。しかし https://spring.io/blog/2023/06/23/improved-testcontainers-support-in-spring-boot-3-1 によると有名どころのdockerコンテナであればその必要性が無くなった、とある。今回はそれを試す。

ソースコード

まずは検証用の適当なアプリケーションを作成する。適当なテーブルから一行を返すだけのREST APIを作成する。

build.gradle

plugins {
  id 'java'
  id 'org.springframework.boot' version '3.1.5'
  id 'io.spring.dependency-management' version '1.1.3'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
  sourceCompatibility = '17'
}

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  
  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'
  
  runtimeOnly 'com.oracle.database.jdbc:ojdbc8'
  
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'org.springframework.boot:spring-boot-starter-webflux'
  testImplementation 'org.springframework.boot:spring-boot-testcontainers'
  testImplementation 'org.testcontainers:junit-jupiter'
  testImplementation 'org.testcontainers:oracle-xe'
  
  // https://stackoverflow.com/questions/77241793/nosuchmethoderror-java-util-set-org-junit-platform-engine-testdescriptor-getan
  testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
  useJUnitPlatform()
}

検証用アプリケーション

package springtestsample;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringTestSampleApplication {
  public static void main(String[] args) {
    SpringApplication.run(SpringTestSampleApplication.class, args);
  }
}
package springtestsample.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import springtestsample.entity.Users;
import springtestsample.repository.UsersRepository;

@RestController
@RequiredArgsConstructor
public class UserController {
  final UsersRepository users;

  @GetMapping("/user/{id}")
  public Users user(@PathVariable("id") String id) {
    return users.findById(id).orElse(new Users());
  }
}
package springtestsample.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import springtestsample.entity.Users;

public interface UsersRepository extends JpaRepository<Users, String> {
}
package springtestsample.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.Data;

@Entity
@Data
public class Users {
  @Id
  String id;
  String name;
}

テスト時には使わないが src/main/resources/application.properties も一応載せておく。

spring.datasource.url jdbc:oracle:thin:@localhost:11521/FREEPDB
spring.datasource.username system
spring.datasource.password a

テストコード

package springtestsample.integration;

import java.sql.SQLException;
import javax.sql.DataSource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.MediaType;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.testcontainers.containers.OracleContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import springtestsample.SpringTestSampleApplication;

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = SpringTestSampleApplication.class)
@Testcontainers
@Sql(scripts = {"classpath:schema-test.sql", "classpath:data-test.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class IntegrationTest {
   @LocalServerPort
  int port;
  
  @Container
  @ServiceConnection
  static OracleContainer oracle = new OracleContainer("gvenzl/oracle-xe:21.3.0-slim"); 

  @Autowired
  WebTestClient client;
  
  @Test
  @DisplayName("ユーザ取得の正常系")
  void test() {
    client.get().uri("http://localhost:" + port + "/user/{id}", "kagami")
      .accept(MediaType.APPLICATION_JSON)
      .exchange()
      .expectStatus().isOk()
      .expectBody().json("""
        {
          "id":"kagami"
          , "name":"hoge"
        }
        """);
  }
}

テスト開始時に実行されるSQL src/test/resources/schema-test.sql, src/test/resources/data-test.sql は以下。

create table users( 
    id varchar (50) not null primary key
    , name varchar (500) not null
);
insert into users(id, name) values ('kagami', 'hoge');

説明など

大まかには以下と同じ。

kagamihoge.hatenablog.com

https://spring.io/blog/2023/06/23/improved-testcontainers-support-in-spring-boot-3-1 によると、@ServiceConnection の付与と対応クラスであれば、この一行だけでdockerコンテナ開始・終了をやってくれる。

手元の環境だとOracleのコンテナは20秒弱で起動する。integration testでは十分な速度であろう。

他イメージは使用できない?

@ServiceConnection の使用には Testcontainers 用のイメージを使う必要がある(と思う)。他に使用可能なイメージは https://hub.docker.com/r/gvenzl/oracle-xe にある。

Oracle公式のイメージ container-registry.oracle.com/database/free:23.2.0.0 も使用可能とは思うが@ServiceConnectionとしては使用できなかった。ただ、エラーを見るに接続URLが無くてエラーが出てるだけっぽいので、こちらは従来通り @DynamicPropertySource を使うやり方になるのだと思われる。

Oracle 23cは使用できない?

23c用のイメージ自体は https://hub.docker.com/r/gvenzl/oracle-free にあるが対応するtestcontainersの依存性が2023/11/08時点ではまだ無いっぽい。21までは Testcontainers :: JDBC :: Oracle XE を使い、23c以降用の依存性はまだ無いがソースコード https://github.com/testcontainers/testcontainers-java/blob/main/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleContainer.java はあるように見える。なので、そのうちアップロードされるのではないか、と思われる。

spring.datasource.urlは使わない?

テスト内で @Value("${spring.datasource.url}") を取得すると src/main/resources/application.properties の値になる。なので、プロパティの動的置換で DataSource を設定してるわけでは無いらしい。

Spring Cloud Stream(RabbitMQ)でhello-worldレベルのconsumer

Spring Cloud StreamRabbitMQと組み合わせて、RabbitMQ管理画面から文字列をpublishしてspringアプリケーションでconsumeするだけのサンプルを作成する。

ソースコード・手順

build.gradle

https://start.spring.io/ でCloud Stream, Spring for RabbitMQと後はお好みの依存性を追加してbuild.gradleを生成する。

plugins {
  id 'java'
  id 'org.springframework.boot' version '3.1.4'
  id 'io.spring.dependency-management' version '1.1.3'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
  sourceCompatibility = '17'
}

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

ext {
  set('springCloudVersion', "2022.0.4")
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-amqp'
  implementation 'org.springframework.cloud:spring-cloud-stream'
  implementation 'org.springframework.cloud:spring-cloud-stream-binder-rabbit'
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'org.springframework.amqp:spring-rabbit-test'
  testImplementation 'org.springframework.cloud:spring-cloud-stream-test-binder'
}

dependencyManagement {
  imports {
    mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
  }
}

tasks.named('test') {
  useJUnitPlatform()
}

dockerでRabbitMQを起動

手軽にpublishするために管理画面を使用したいのでrabbitmq:3ではなくrabbitmq:3-managementを使用する。username/passwordはデフォルト(guest / guest)のままで良い。

sudo docker run -d --hostname my-rabbit --name some-rabbit -p 15672:15672 -p 5672:5672 rabbitmq:3-management

consumer

import java.util.function.Consumer;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class SpringCloudStreamSample {
  public static void main(String[] args) {
    new SpringApplicationBuilder(SpringCloudStreamSample.class)
      .web(WebApplicationType.NONE)
      .run(args);
  }

  @Bean
  public Consumer<String> consume() {
    return payload -> System.out.println(payload);
  }
}

上記でconsumerを起動する。プロパティファイルが不要な点は後述。

管理画面からpublish

http://localhost:15672/ でRabbitMQ管理画面を開いてデフォルトのusername/passwordでログインする。上記のconsumeというメソッド名によりconsume-in-0というexchangeが自動生成されてるのでそこから適当な文字列をpublishすると、springのconsumerがそれを出力する。

デフォルトで色々自動にやってくれるのでプロパティファイルは無くても起動可能

以下は起動ログの一部だが、consume-in-0.anonymous.jtC4TT2IS_mRDG4XtPEquwというqueueを作ったとか consume-in-0にバインドしたとかlocal_rabbitとかいうバインダーを作っただとか、あれこれ自動生成しているのが分かる。

No bean named 'errorChannel' has been explicitly defined. Therefore, a default PublishSubscribeChannel will be created.
No bean named 'integrationHeaderChannelRegistry' has been explicitly defined. Therefore, a default DefaultHeaderChannelRegistry will be created.
LiveReload server is running on port 35729
Channel 'application.consume-in-0' has 1 subscriber(s).
Adding {logging-channel-adapter:_org.springframework.integration.errorLogger} as a subscriber to the 'errorChannel' channel
Channel 'application.errorChannel' has 1 subscriber(s).
started bean '_org.springframework.integration.errorLogger'
Creating binder: local_rabbit
Constructing binder child context for local_rabbit
Caching the binder: local_rabbit
declaring queue for inbound: consume-in-0.anonymous.jtC4TT2IS_mRDG4XtPEquw, bound to: consume-in-0
Attempting to connect to: [localhost:5672]
Created new connection: rabbitConnectionFactory#24ae5bd5:0/SimpleConnection@4370a547 [delegate=amqp://guest@127.0.0.1:5672/, localPort=51812]
Channel 'rabbit-62463784.consume-in-0.errors' has 1 subscriber(s).
Channel 'rabbit-62463784.consume-in-0.errors' has 2 subscriber(s).
started bean 'inbound.consume-in-0.anonymous.jtC4TT2IS_mRDG4XtPEquw'
Started SpringCloudStreamSample in 2.018 seconds (process running for 2.388)

また、RabbitProperties.javaのusername/passwordあたりのデフォルト値はRabbitMQのdocker runのサンプルと同一になっている。なので一切プロパティが無くても最低限のサンプルコード程度であれば起動する。

package org.springframework.boot.autoconfigure.amqp;

@ConfigurationProperties(prefix = "spring.rabbitmq")
public class RabbitProperties {
    private static final int DEFAULT_PORT = 5672;

    private String host = "localhost";
    private Integer port;
    private String username = "guest";
    private String password = "guest";

spring-integrationでcommons-ioを使用したファイルのtail

Spring Integrationでファイルのtailを実現する。デフォルトではOSのtailコマンドを実行するが、設定によりOS非依存のApache Commons IOに切り替えられる。今回はそのサンプルコードについて。

ソースコードなど

build.gradle

https://start.spring.io/ を使用してspring-initegration関連を含めた依存性を作成する。

plugins {
  id 'java'
  id 'org.springframework.boot' version '3.1.4'
  id 'io.spring.dependency-management' version '1.1.3'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
  sourceCompatibility = '17'
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-integration'
  implementation 'org.springframework.integration:spring-integration-file'
  
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'org.springframework.integration:spring-integration-test'
}

tasks.named('test') {
  useJUnitPlatform()
}

java

import java.nio.file.Path;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.file.dsl.Files;
import org.springframework.integration.file.dsl.TailAdapterSpec;

@SpringBootApplication
public class ApacheCommonsFileTailingApplication {
  public static void main(String[] args) {
    new SpringApplicationBuilder(ApacheCommonsFileTailingApplication.class)
      .web(WebApplicationType.NONE)
      .run(args);
  }

  @Bean
  public IntegrationFlow fileReadingFlow() {
    TailAdapterSpec tailAdapter = Files.tailAdapter(Path.of("sample.txt").toFile())
        .delay(1000)
        .end(true)
        .reopen(false);

    return IntegrationFlow.from(tailAdapter).handle("sampleBean", "sampleHandle").get();
  }
}
import org.springframework.messaging.Message;
import org.springframework.stereotype.Component;

@Component
public class SampleBean {
  public void sampleHandle(Message<String> e) {
    System.out.println(e);
  }
}

これを実行してsample.txtを修正すると以下のような出力が得られる。

GenericMessage [payload=hogehoge, headers={file_originalFile=sample.txt, id=97e015a2-ffdf-28b5-2b3e-b0f9e8119d08, file_name=sample.txt, timestamp=1696850274578}]

基本的にはFiles.tailAdapterを使用するだけ。FileTailInboundChannelAdapterFactoryBean.java#L243 を見ると if (this.delay == null && this.end == null && this.reopen == null) のelseの場合にApacheCommonsFileTailingMessageProducerに切り替わるのが分かる。

ハマった点

windowsでデフォルト設定のFiles.tailAdapterを使用するとtailコマンドが無くてエラーになる

以下のエラーのように、デフォルト設定でwindowsだとtailコマンドが見つからなくて実行時エラーになる。

Exception in thread "SimpleAsyncTaskExecutor-1" org.springframework.messaging.MessagingException: Failed to exec tail command: 'tail -F -n 0 C:\java\workspaces\e202212\d\sp\integfilepolling\sample.txt'
    at org.springframework.integration.file.tail.OSDelegatingFileTailingMessageProducer.runExec(OSDelegatingFileTailingMessageProducer.java:136)
    at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.io.IOException: Cannot run program "tail": CreateProcess error=2, 指定されたファイルが見つかりません。
    at java.base/java.lang.ProcessBuilder.start(ProcessBuilder.java:1143)
    at java.base/java.lang.ProcessBuilder.start(ProcessBuilder.java:1073)
    at java.base/java.lang.Runtime.exec(Runtime.java:594)
    at java.base/java.lang.Runtime.exec(Runtime.java:453)

tailはWSLでwindowsでも実行可能。ただOSDelegatingFileTailingMessageProducer.java#L97を見るとtailとべた書きしてあるのでwindowsでどうにかこうにか動かす方法は分からなかった。

commons-ioのTailerが2回実行される

こちらはspring-integrationと直接の関係は無い。ApacheCommonsFileTailingMessageProducerが裏で使用するライブラリを直接使用する場合、javadocを見てなんとなく以下のように書くとhandleが2回呼ばれてしまう。

import java.io.File;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.apache.commons.io.input.Tailer;
import org.apache.commons.io.input.TailerListener;
import org.apache.commons.io.input.TailerListenerAdapter;

public class SampleTailer extends TailerListenerAdapter {
  @Override
  public void handle(String line) {
    System.out.println(line);
  }
  
  @Override
  public void handle(Exception ex) {
    System.out.println(ex);
  }
  
  public static void main(String[] args) throws InterruptedException {
    TailerListener listener = new SampleTailer();
    Tailer tailer = Tailer.create(new File("sample.txt"), listener, 1000, true); 

    ExecutorService ex = Executors.newFixedThreadPool(1);
    ex.submit(tailer); 
  }
}

この原因はTailer.createの中でThread#startするため。TailerRunnableをimplementsしているので、単にこのクラスを生成して適当なExecutorServiceにsubmitで良い。

Tailer tailer = new Tailer(new File("sample.txt"), listener, 1000, true);
ExecutorService ex = Executors.newFixedThreadPool(1);
ex.submit(tailer);