kagamihogeの日記

kagamihogeの日記です。

docker(WSL2) + Testcontainers + MySQL

WSL2上のdockerでMySQLを起動してユニットテストを記述する。Testcontainersでテスト時にコンテナを自動的に開始・終了をする。

環境

ソースコード

Spring Initializr で「testcontainer」と入力すれば依存性の記述を自動生成してくれる。

plugins {
  id 'org.springframework.boot' version '2.6.2'
  id 'io.spring.dependency-management' version '1.0.11.RELEASE'
  id 'java'
}

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

repositories {
  mavenCentral()
}

ext {
  set('testcontainersVersion', "1.16.2")
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter'
  implementation 'org.springframework.boot:spring-boot-starter-jdbc'

  developmentOnly 'org.springframework.boot:spring-boot-devtools'

  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'org.testcontainers:junit-jupiter'
  testImplementation "org.testcontainers:mysql"
  
  runtimeOnly 'mysql:mysql-connector-java'
}

dependencyManagement {
  imports {
    mavenBom "org.testcontainers:testcontainers-bom:${testcontainersVersion}"
  }
}

test {
  useJUnitPlatform()
}

適当なエントリーポイントと適当なSQL実行を作る。

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

@SpringBootApplication
public class Appliction {
  public static void main(String[] args) {
    SpringApplication.run(Appliction.class, args);
  }
}
@Component
public class SampleJdbc {
  @Autowired
  DataSource ds;

  public void hoge() {
    JdbcTemplate template = new JdbcTemplate(ds);
    System.out.println(template.queryForObject("select count(*) from sample_table", Long.class));
  }
}

プロジェクト直下のinitsqlディレクトリにDB初期化SQLを配置する。

initsql/1_create-database.sql

create DATABASE if not exists sample_db;
USE sample_db;

initsql/2_create-table.sql

CREATE TABLE sample_table (id VARCHAR(20) NOT NULL DEFAULT '');

以下がテストコード。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Testcontainers
public class MysqlSampleTest {
  @Container
  static MySQLContainer mysql = new MySQLContainer<>("mysql")
      .withUsername("root")
        .withPassword("password")
        .withDatabaseName("sample_db")
        .withExposedPorts(3306)
        .withFileSystemBind(
            "/mnt/c/Users/ts-masaharu.kagami/eclipse-expr/testcontainerssample/lib/initsql",
            "/docker-entrypoint-initdb.d",
            BindMode.READ_WRITE);

  @DynamicPropertySource
  static void registerProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", () -> mysql.getJdbcUrl());
    registry.add("spring.datasource.username", () -> mysql.getUsername());
    registry.add("spring.datasource.password", () -> mysql.getPassword());
  }

  @Autowired
  SampleJdbc template;

  @Test
  void test() {
    template.hoge();
  }
}

ホストの初期化SQLディレクトリとコンテナをバインドするためwithFileSystemBindを使用する。docker-entrypoint-initdb.dの詳細は省略するがコンテナ起動時にこのディレクトリ下のSQLを実行する。初期化SQLの実行手段は他にもあり、withClasspathResourceMappingでクラスパス下のディレクトリをバインドしたり、withInitScriptで指定する手段や、springの@Sqlもある。

@DynamicPropertySourceでは、コンテナ起動時にランダムで指定されるポート番号を取得してJDBC URLを上書きする。username, passwordは動的に書き換えなくてもよいが、ここではコンテナから取った値で上書きしている。

docker(WSL2) + Testcontainers + sftp

WSL2上のdockerでsftpサーバを起動してユニットテストを記述する。Testcontainersでテスト時にコンテナを自動的に開始・終了をする。

環境

ソースコード

Spring Initializr で「testcontainer」と入力すれば依存性の記述を自動生成してくれる。com.jcraft:jschはSFTP用のライブラリ。

plugins {
  id 'org.springframework.boot' version '2.6.2'
  id 'io.spring.dependency-management' version '1.0.11.RELEASE'
  id 'java'
}

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

repositories {
  mavenCentral()
}

ext {
  set('testcontainersVersion', "1.16.2")
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter'
  implementation 'com.jcraft:jsch:0.1.55'

  developmentOnly 'org.springframework.boot:spring-boot-devtools'

  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'org.testcontainers:junit-jupiter'
}

dependencyManagement {
  imports {
    mavenBom "org.testcontainers:testcontainers-bom:${testcontainersVersion}"
  }
}

test {
  useJUnitPlatform()
}

ユニットテスト対象となるSFTPアクセスのコードを適当に作成する。ポート番号のみプロパティな点は後述。本来であればホストとかパスワードとかもそうするだろうけど簡略化している。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpException;

@Component
public class SftpDownload {
  @Value("${sftp.port}")
  int port;

  public void download() {
    JSch jsch = new JSch();
    Session session = null;
    ChannelSftp channel = null;

    try {
      session = jsch.getSession("sftpuser", "localhost", port);
      session.setConfig("StrictHostKeyChecking", "no");
      session.setPassword("password");
      session.connect();

      channel = (ChannelSftp) session.openChannel("sftp");
      channel.connect();
      channel.cd("target");

      try (InputStream is = channel.get("test.txt");
          BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));) {
        br.lines().forEach(System.out::println);
      }

    } catch (JSchException | SftpException | IOException e) {
      e.printStackTrace();
    } finally {
      if (channel != null) {
        channel.disconnect();
      }
      if (session != null) {
        session.disconnect();
      }
    }
  }
}

プロパティファイル。src\main\resources\application.properties

sftp.port 2222

以下がテストコード。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringJUnitConfig(classes = { SftpDownload.class })
@Testcontainers
public class SftpSampleTest {
  @Container
  static GenericContainer sftpContainer = new GenericContainer("atmoz/sftp")
      .withExposedPorts(22)
      .withFileSystemBind(
        "/mnt/c/Users/(省略)/testcontainerssample/lib/target",
        "/home/sftpuser/target",
        BindMode.READ_WRITE)
      .withCommand("sftpuser:password:::target");

  @DynamicPropertySource
  static void registerProperties(DynamicPropertyRegistry registry) {
    registry.add("sftp.port", () -> sftpContainer.getMappedPort(22));
  }

  @Autowired
  SftpDownload sftp;

  @Test
  public void test() {
    sftp.download();
  }

}

@Containerで指定する各項目はatmoz/sftpでぐぐれば出てくるので省略。

withFileSystemBind でホストとSFTPサーバのディレクトリをバインドする。ここではホストマシンのプロジェクト直下のtargetをコンテナの/home/sftpuser/targetにバインドする。TestcontainersはWSLのdockerに接続してコンテナ起動するからか、WSLから見えるディレクトリの必要があるらしい。ここでは/mnt/c/...絶対パスを指定している。相対パスでも出来ると思うが、色々試してダメだった。

@DynamicPropertySourceでは、コンテナ起動時にランダムで指定されるポート番号を取得してプロパティを上書きする。

spring-batchのMapJobRepositoryFactoryBeanがDeprecated

従来、メタテーブルを永続化しなくするためにMapJobRepositoryFactoryBeanを使う事があったがspring-batch v5.0で削除予定になったようだ。

 * @deprecated as of v4.3 in favor or using the {@link JobRepositoryFactoryBean}
 * with an in-memory database. Scheduled for removal in v5.0.
 */
@Deprecated
public class MapJobRepositoryFactoryBean extends AbstractJobRepositoryFactoryBean {

その対策は上記javadocにある通りインメモリDB使ってね、て事らしい。他にデータソースが無ければ以下のようにH2などの依存性追加するだけでよい。springアプリケーションと共にH2も終了するので事実上永続化はしない。

  runtimeOnly 'com.h2database:h2'

複数データソースがある場合、というか、大抵はメタデータ用とメイン用の2つ以上のデータソースがあると思われる。この場合は、これまでの複数データソースの場合と同様、メタデータ用のH2データソースとそれ以外を用意すればよい。メタデータ用のデータソースには@BatchDataSourceを付与する。

import javax.sql.DataSource;

import org.h2.jdbcx.JdbcDataSource;
import org.springframework.boot.autoconfigure.batch.BatchDataSource;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class DatasourceConfig {
    
    @Bean
    @Primary
    public DataSource primaryDs() {
        return DataSourceBuilder
                .create()
                .url("jdbc:oracle:thin:system/oracle@localhost:11521/XEPDB1")
                .username("system")
                .password("oracle")
                .build();
    }

    @BatchDataSource
    @Bean
    public DataSource dataSource() {
        JdbcDataSource ds = new JdbcDataSource();
        ds.setURL("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE");
        ds.setUser("sa");
        ds.setPassword("");
        return ds;
    }
}

上記はインメモリモードで起動しているが、ファイルモードも選択肢になると思う。コンテナ破棄でファイルも削除が最近は多いだろうし、インメモリで持ちたく無ければファイルもありだろうか?

ただ、常時起動の場合にはインメモリと言えどその影響が気になる。メモリ容量の圧迫もだが、デフォルトのDDLはインデックスを張らないので速度低下も懸念される。もっとも、相当貯めこまないと目に見える影響は出てこないだろうし、再起動という運用で回避も十分現実的と思う。

でまぁ、以下は完全に思いつきでマッタク検証してないのだけど。どうせメタデータ使わないならジョブ実行後に削除で良いんでないかな、と。

    @Bean
    public JobExecutionListener list(DataSource ds) {
        JdbcTemplate t = new JdbcTemplate(ds);
        
        return new JobExecutionListener() {
            
            @Override
            public void beforeJob(JobExecution jobExecution) {
            }
            
            @Override
            public void afterJob(JobExecution jobExecution) {
                // 他テーブルは省略
                t.update("delete from BATCH_JOB_EXECUTION_CONTEXT   where JOB_EXECUTION_ID  = " + jobExecution.getId());
            }
        };
    }