kagamihogeの日記

kagamihogeの日記です。

Testcontainers + Spring Boot + Oracle XE

Testcontainersを使用し、spring-bootのテスト開始時にoracle xeのdockerコンテナを起動してテスト終了時に停止する。

環境

  • Windows 10 Pro x64
  • docker desktop 3.1.0

事前準備

oracleのdocker

まずdockerでoracleを起動可能にする。

kagamihoge.hatenablog.com

oracle xeのpre-builtのdocker準備

oracleの起動は時間かかるので少しでも早くするために行う。もし手動でなく自動テストで起動時間が多少かかっても問題無いならしなくても良い。

kagamihoge.hatenablog.com

ソースコード

spring-bootであればSpring Initializrで作成するのが楽。以下ツイートで教えて頂きました。

plugins {
  id 'org.springframework.boot' version '2.4.3'
  id 'io.spring.dependency-management' version '1.0.11.RELEASE'
  id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '15'
configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}
repositories {
  mavenCentral()
}
ext {
  set('testcontainersVersion', "1.15.1")
}
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  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.testcontainers:junit-jupiter'
  testImplementation 'org.testcontainers:oracle-xe'
}
dependencyManagement {
  imports {
    mavenBom "org.testcontainers:testcontainers-bom:${testcontainersVersion}"
  }
}
test {
  useJUnitPlatform()
}

今回はoracle-xeを使うのでorg.testcontainers:oracle-xeを追加している。

まず、動作確認用のrepositoryを作っておく。

src/main/resources/application.propertiesはいつも通りにoracleを起動済みの前提で接続設定などを記述する。

spring.datasource.url=jdbc:oracle:thin:@//localhost:1521/XE
spring.datasource.username=system
spring.datasource.password=oracle
import javax.persistence.Entity;
import javax.persistence.Id;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Item  {
    @Id
    String id;
}
import org.springframework.data.jpa.repository.JpaRepository;

public interface ItemRepository extends JpaRepository<Item, String> {
}

以下がメインのテストクラス。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.jdbc.Sql;
import org.testcontainers.containers.OracleContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Sql(scripts = "classpath:schema-test.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class DemoApplicationTest {

    @Container
    static OracleContainer oracle = new OracleContainer("oracle/db-prebuilt:18.4.0-xe")
            .withUsername("system")
            .withPassword("oracle");

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        System.out.println(oracle.getJdbcUrl());
        System.out.println(oracle.getUsername());
        System.out.println(oracle.getPassword());

        registry.add("spring.datasource.url", () -> oracle.getJdbcUrl());
        registry.add("spring.datasource.username", () -> oracle.getUsername());
        registry.add("spring.datasource.password", () -> oracle.getPassword());
    }

    @Autowired
    JdbcTemplate template;

    @Autowired
    ItemRepository repository;

    @Test
    void test() {
        System.out.println(template.queryForObject("select 1 from dual", Long.class));
        System.out.println(repository.count());
    }

}

以下はsrc/test/resources/schema-test.sql

CREATE TABLE item (id VARCHAR2(10) NOT NULL);

@Testcontainersにより@Containerで定義するコンテナが自動的に有効になる。今回はoracle-xeなのでtestcontainersのOracleContainerを使用する。javadoc見ると@Testcontainers is a JUnit Jupiter extensionとか書いてあるのでそれ以前のバージョンでは使えないと思われる。

OracleContainerにはoracleのdockerイメージ名とusernameとpasswordを指定している。これらの値は今回pre-built作成時に事前指定する想定なので、その時に設定した値を指定する。

@DynamicPropertySourceを使用してテスト起動時に動的にspring-bootのプロパティを書き換える*1。docker起動後にOracleContainerから各種値が取得できるのでそれをspring.datasource.*に設定する。なお、この例ではusernameやpasswordは固定なので動的に指定しなくてもよい。ポートは上記のコードだとランダムになるのでこうしている。環境によってはポートを固定値にしても良いと思う。

@AutoConfigureTestDatabase(replace = Replace.NONE)は組み込みDBの自動起動が不要なのでこうしている。

@Sql(scripts = "classpath:schema-test.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)はテスト起動時にテーブルなど作成SQLを実行する。自分の知ってる方法がこれなので、他にやり方あるかもしれない。

起動時の様子

テストコード起動すると下記ような感じに自動的にコンテナが起動&停止する。

docker ps -a
CONTAINER ID   IMAGE                          COMMAND                  CREATED          STATUS                            PORTS                                                                       NAMES
bf02cc55e92f   oracle/db-prebuilt:18.4.0-xe   "/bin/sh -c 'exec $O…"   10 seconds ago   Up 8 seconds (health: starting)   0.0.0.0:49200->1521/tcp, 0.0.0.0:49199->5500/tcp, 0.0.0.0:49198->8080/tcp   zealous_noether
555a00685b91   testcontainers/ryuk:0.3.0      "/app"                   11 seconds ago   Up 10 seconds                     0.0.0.0:49197->8080/tcp                                                     testcontainers-ryuk-9bdd7420-07f0-43ad-848f-fe060aec3298

参考

oracleのpre-builtによるdocker run起動高速化

dockerとはいえoracleは結構な起動時間がかかる。下記URLにあるpre-builtにより時間の短縮が見込めるとのことなので試す。

https://github.com/oracle/docker-images/tree/master/OracleDatabase/SingleInstance/samples/prebuiltdb

環境

  • Windows 10
  • Docker Desktop 3.1.0(51484)

手順

基本的には上記URLの手順通り。ただしこのエントリでは、上記URLはバージョンがその時点の最新19.3.0-eeだが、18.4.0-xeで試している。なので上記手順とばバージョン部分が異なる。

まず、docker runoracleを起動可能な設定が必要。その手順は自分のエントリだと以下を参照。

kagamihoge.hatenablog.com

oracleのコンテナを開始。

docker run --name oracle-build -p 1521:1521 -p 5500:5500 oracle/database:18.4.0-xe

任意でパスワードを変更。ここではoracleに変更。

docker exec oracle-build ./setPassword.sh oracle
The Oracle base remains unchanged with value /opt/oracle

SQL*Plus: Release 18.0.0.0.0 - Production on Wed Jan 20 04:42:11 2021
Version 18.4.0.0.0

Copyright (c) 1982, 2018, Oracle.  All rights reserved.


Connected to:
Oracle Database 18c Express Edition Release 18.0.0.0.0 - Production
Version 18.4.0.0.0

SQL>
User altered.

SQL>
User altered.

SQL>
Session altered.

SQL>
User altered.

SQL> Disconnected from Oracle Database 18c Express Edition Release 18.0.0.0.0 - Production
Version 18.4.0.0.0

コンテナを停止。

docker stop -t 600 oracle-build

pre-builtイメージを新規作成。

docker commit -m "Image with prebuilt database" oracle-build oracle/db-prebuilt:18.4.0-xe
sha256:6a711d0ba(以下略

作業用のコンテナを削除。

docker rm oracle-build

これで準備完了。

以下のようにprebuiltのイメージでコンテナを開始してDATABASE IS READY TO USE!と表示されればOK.

docker run --name oraclexepretest -p 1521:1521 -p 5500:5500 oracle/db-prebuilt:18.4.0-xe
The Oracle base remains unchanged with value /opt/oracle
#########################
DATABASE IS READY TO USE!
#########################
The following output is now a tail of the alert.log:
XEPDB1(3):[104] Successfully onlined Undo Tablespace 2.
XEPDB1(3):Undo initialization online undo segments: err:0 start: 31224287 end: 31224319 diff: 32 ms (0.0 seconds)
XEPDB1(3):Undo initialization finished serial:0 start:31224286 end:31224321 diff:35 ms (0.0 seconds)
XEPDB1(3):Database Characterset for XEPDB1 is AL32UTF8
XEPDB1(3):Opening pdb with Resource Manager plan: DEFAULT_PLAN
Pluggable database XEPDB1 opened read write
Starting background process CJQ0
Completed: ALTER DATABASE OPEN
2021-01-20T12:59:54.643824+00:00
CJQ0 started with pid=53, OS id=377

手元の環境だと元々16分くらいかかるのがpre-builtだと2分くらいで済む。マシンスペック次第ではもうちょい速くなるかも? といった感触。1分切ればoracleを相当に富豪的な使い方が出来るかもなぁ……

ハマった点

Archiving is disabled

現象としては https://github.com/oracle/docker-images/issues/1388 とか https://github.com/oracle/docker-images/issues/1352 とか。

原因はdokcerで使用するoraclerpmが古かったため。最初は2018年にダウンロードしたものをそのまま使って試してダメだった。なので最新のrpmをダウンロードしなおしたら上手くいった。なので、バージョンによって出来る・出来ないが存在する、のだと思われる。

spring.kafka.consumer.auto-offset-resetの挙動確認

spring-kafkaのプロパティspring.kafka.consumer.auto-offset-resetの挙動の違いを確認する。

以下はorg.apache.kafka.clients.consumer.ConsumerConfig#AUTO_OFFSET_RESET_DOCの抜粋だけど https://kafka.apache.org/documentation/#consumerconfigs_auto.offset.resetと同じ事が書いてある。

What to do when there is no initial offset in Kafka or if the current offset does not exist any more on the server (e.g. because that data has been deleted):

  • earliest: automatically reset the offset to the earliest offset
  • latest: automatically reset the offset to the latest offset
  • none: throw exception to the consumer if no previous offset is found for the consumer's group
  • anything else: throw exception to the consumer.

以下抄訳。

Kafkaのoffsetが未初期化またはcurrent offsetがサーバに無い(ex. 削除済みなど)場合の挙動。

  • earliest: 最初のoffsetに自動リセット
  • latest: 最新のoffsetに自動リセット
  • none: consumer groupにoffsetがまだ無い場合consumerに例外をスロー
  • anything else: consumerに例外スロー

挙動確認ソースコード

単にconsumeするだけのspring-bootをつくる。

plugins {
  id 'org.springframework.boot' version '2.3.4.RELEASE'
  id 'io.spring.dependency-management' version '1.0.10.RELEASE'
  id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}
repositories {
  mavenCentral()
}
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter'
  implementation 'org.springframework.kafka:spring-kafka'
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation('org.springframework.boot:spring-boot-starter-test') {
    exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
  }
  testImplementation 'org.springframework.kafka:spring-kafka-test'
  
  // https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind
implementation 'com.fasterxml.jackson.core:jackson-databind'
  
}
test {
  useJUnitPlatform()
}
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;

@Component
public class StringConsumer {
    @KafkaListener(topics = "mytopic")
    public void processMessage(String content, 
            @Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
            @Header(KafkaHeaders.OFFSET) long offset) {
        System.out.println(content + " PARTITION=" + partition + " OFFSET=" + offset);
    }
}

以下のプロパティファイルの値を変更して動作を確認していく。

spring.kafka.bootstrap-servers=localhost:19092
spring.kafka.consumer.group-id=myGroup

#spring.kafka.consumer.auto-offset-reset=earliest
#spring.kafka.consumer.auto-offset-reset=latest
#spring.kafka.consumer.auto-offset-reset=none
#spring.kafka.consumer.auto-offset-reset=aaaa

spring.kafka.consumer.auto-offset-reset

latest

topicを新規作成してメッセージをいくつかpublishしておく。

bash-4.4# $KAFKA_HOME/bin/kafka-topics.sh --bootstrap-server=localhost:9092 --create --partitions=3 --topic=mytopic
Created topic mytopic.
bash-4.4# $KAFKA_HOME/bin/kafka-console-producer.sh --bootstrap-server=localhost:9092 --topic=mytopic
>1
>2
(中略)
>10

次にconsumerを起動する。

2021-01-17 15:17:09.134  INFO 7564 --- [ntainer#0-0-C-1] o.s.k.l.KafkaMessageListenerContainer    : myGroup: partitions assigned: [mytopic-2, mytopic-1, mytopic-0]

何もconsumeされない。

このように、デフォルトのlatestだと最新のメッセージからconsumeする。つまり、すでにtopicにあるメッセージはconsumeしない。

consumerを一旦終了し、再度publishして、もう一度consumerを起動する。

$KAFKA_HOME/bin/kafka-console-producer.sh --bootstrap-server=localhost:9092 --topic=mytopic
>11
(中略)
>15
o.s.k.l.KafkaMessageListenerContainer    : myGroup: partitions assigned: [mytopic-2, mytopic-1, mytopic-0]
15 PARTITION=2 OFFSET=2
(中略)
14 PARTITION=0 OFFSET=4

consumeされる。

これはconsumer-groupにcurrent-offsetが記録された状態なので、そのoffsetからconsumeされる。

earliest

topicを削除してもう一度作り直す。

bash-4.4# $KAFKA_HOME/bin/kaftopics.sh --bootstrap-server=localhost:9092 --delete --topic=mytopic
bash-4.4# $KAFKA_HOME/bin/kafka-topics.sh --bootstrap-server=localhost:9092 --create --partitions=3 --topic=mytopic
Created topic mytopic.

先ほどと同様にいくつかのメッセージをpublishする。

bash-4.4# $KAFKA_HOME/bin/kafka-console-producer.sh --bootstrap-server=localhost:9092 --topic=mytopic
>1
(中略)
>10

consumerを起動する。

o.s.k.l.KafkaMessageListenerContainer    : myGroup: partitions assigned: [mytopic-2, mytopic-1, mytopic-0]
3 PARTITION=2 OFFSET=0
4 PARTITION=2 OFFSET=1
(中略)
8 PARTITION=0 OFFSET=2
9 PARTITION=0 OFFSET=3

メッセージがconsumeされる。

このように、earliestだと最初のメッセージからconsumeする。つまり、すでにtopicにあるメッセージをconsumeする。より正確に言うと、このエントリでは試してないが、未削除メッセージのうち最初のものから、になると思われる。

consumerを一旦終了し、再度publishして、もう一度consumerを起動する。

Cbash-4.4# $KAFKA_HOME/bin/kafka-console-producer.sh --bootstrap-server=localhost:9092 --topic=mytopic
>12
(中略)
>15
o.s.k.l.KafkaMessageListenerContainer    : myGroup: partitions assigned: [mytopic-2, mytopic-1, mytopic-0]
12 PARTITION=2 OFFSET=4
(中略)
15 PARTITION=0 OFFSET=6

consumeされる。

consumer-groupにcurrent-offsetが記録された状態での動作は同様となる。

none

topicを削除してもう一度作り直す。

$KAFKA_HOME/bin/kaftopics.sh --bootstrap-server=localhost:9092 --delete092 --topic=mytopic
bash-4.4# $KAFKA_HOME/bin/kafka-topics.sh --bootstrap-server=localhost:9092 --create --partitions=3 --topic=mytopic
Created topic mytopic.

先ほどと同様にいくつかのメッセージをpublishする。

$KAFKA_HOME/bin/kafka-console-producer.sh --bootstrap-server=localhost:9092 --topic=mytopic
>1
(中略)
>5

consumerを起動すると以下のような例外がスローされる。

org.apache.kafka.clients.consumer.NoOffsetForPartitionException: Undefined offset with no reset policy for partitions: [mytopic-2, mytopic-1, mytopic-0]
    at org.apache.kafka.clients.consumer.internals.SubscriptionState.resetInitializingPositions(SubscriptionState.java:658) ~[kafka-clients-2.5.1.jar:na]
    at org.apache.kafka.clients.consumer.KafkaConsumer.updateFetchPositions(KafkaConsumer.java:2391) ~[kafka-clients-2.5.1.jar:na]

エラーメッセージの通り、noneはまだoffsetが記録されていない状態だとエラーになる。

それ以外

上記以外の値を設定すると以下の例外がスローされる。

Caused by: org.apache.kafka.common.config.ConfigException: Invalid value aaaa for configuration auto.offset.reset: String must be one of: latest, earliest, none
    at org.apache.kafka.common.config.ConfigDef$ValidString.ensureValid(ConfigDef.java:941) ~[kafka-clients-2.5.1.jar:na]

雑感

はじめてkafkaさわったとき、topic作ってpublishしてconsumeしたら何も来ない……何故? となった。なので、なぜデフォルト値はearliestでなくてlatestなのだろう、と疑問だった。

ただ、kafkaの主用途考えると、次々メッセージがpublishされる状態で新規consumer-groupを追加するとき、latestで起動したいはず。たとえば、リアルタイムのログ分析とかならそうなるはず。対してearliestだと全データが来てしまう。リアルタイムのログ分析で一週間前のデータから来られても意味が無いし、現時点まで大量にデータがあったら困った事になりかねない。

てことで、デフォルトlatestで状況に応じてearliestにしてね、って事と思われる。

なので、例えばバッチ処理的なデータバスとして使う場合、初回起動時は先にconsumer-groupを作っておくのが安全と思われる。もし、先にpublishしてからconsumerを起動するとそれ以前のメッセージがconsumeされない、とかになりかねない。