Hatena::ブログ(Diary)

CLOVER

2017-03-11

TestContainersを使って、JUnitテスト中にDockerコンテナを起動/停止する

前に、MavenのプラグインとしてDockerの操作を行う、docker-maven-pluginを試してみました。

docker-maven-pluginで、Integration Test時にDockerコンテナの起動/停止をする - CLOVER

今度は、JUnitのRuleを使用してDockerコンテナの起動停止を行う、TestContainersを試してみたいと思います。

GitHub - testcontainers/testcontainers-java: TestContainers is a Java 8 library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

ドキュメントはこちら。

TestContainers

TestContainersの概要

最初に書きましたが、JUnitのRuleを使用してコンテナの起動、停止を行うことで、テスト中にDockerコンテナを利用できるようになります。

想定しているユースケースとしては、データベースを使ったIntegration Test、アプリケーションのIntegration Test、
UIでのテストなどが挙げられるようです。

利用環境については、こちら。

Compatibility

Windowsは、限定的サポートのようです。

また、要求する環境としては、Java 1.8とJUnit、DockerまたはDocker Machineが使える環境を想定しています。

Usage / Prerequisites

TestContainersは基本的なコンテナ操作ができるモジュールを提供しますが、あるユースケースに特化した
モジュールも提供しています。

Usage / Usage modes

  • Temporary database containers
  • Webdriver containers
  • Docker compose
  • Dockerfile containers

あたりが該当します。まあ、説明はこのくらいにして、使っていってみましょう。

基本的な準備として、テストコードを使うことになるのでMaven依存関係にJUnitとAssertJは加えておくものとします。

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.6.2</version>
            <scope>test</scope>
        </dependency>

Generic Containers

最初は、Generic Containersから。

Generic containers

ここでのお題としては、RedisのDockerイメージを使ってテストコードを書くことを考えてみましょう。

Generic Containersを利用するのに、必要なMaven依存関係はこちら。

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <version>1.1.9</version>
            <scope>test</scope>
        </dependency>

Redisへのアクセスは、Jedisを使用します。

        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>2.9.0</version>
            <scope>test</scope>
        </dependency>

で、Generic Containersを使ってRedisを使ったサンプルは、こちら。
src/test/java/org/littlewings/testcontainers/RedisContainerUsingClassRuleTest.java

package org.littlewings.testcontainers;

import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.testcontainers.containers.GenericContainer;
import redis.clients.jedis.Jedis;

import static org.assertj.core.api.Assertions.assertThat;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class RedisContainerUsingClassRuleTest {
    @ClassRule
    public static GenericContainer REDIS =
            new GenericContainer("redis:3.2.8")
            .withExposedPorts(6379);

    @Test
    public void simpleJedis1() {
        Jedis jedis = new Jedis(REDIS.getContainerIpAddress(), REDIS.getMappedPort(6379));

        assertThat(jedis.get("key")).isNull();

        jedis.set("key", "value");
        assertThat(jedis.get("key")).isEqualTo("value");
    }

    @Test
    public void simpleJedis2() {
        Jedis jedis = new Jedis(REDIS.getContainerIpAddress(), REDIS.getMappedPort(6379));

        assertThat(jedis.get("key")).isEqualTo("value");

        jedis.set("key", "value-2");
        assertThat(jedis.get("key")).isEqualTo("value-2");
    }
}

今回は、@ClassRuleとしてGenericContainerクラスのインスタンスを作成しています。コンストラクタ引数には、
Dockerイメージ名とタグを渡し、GenericContainer#withExposedPortsでポートのEXPOSEを行っています。

    @ClassRule
    public static GenericContainer REDIS =
            new GenericContainer("redis:3.2.8")
            .withExposedPorts(6379);

docker runのイメージ的には、次のような感じになります。

$ docker run -p 6379 redis:3.2.8

あとは、作成したGenericContainerから、接続先の情報を得てRedisへアクセスします。

        Jedis jedis = new Jedis(REDIS.getContainerIpAddress(), REDIS.getMappedPort(6379));

GenericContainer#getContainerIpAddressで、接続先のIPアドレスが得られます…が、ローカルで試した感じ「localhost」が
返ってくるようです。

GenericContainer#getMappedPortでは、EXPOSEしたポートに対応するDockerが割り振ったローカルポートが返ってきます。

例えば、「docker run -p 6379 …」としてDockerを起動した場合に、以下のようにポートがマッピングされたとすると、
この例ではGenericContainer#getMappedPortで返ってくるポートは「32825」となります。

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                     NAMES
0ad20edbef69        redis:3.2.8         "docker-entrypoint..."   4 seconds ago       Up 3 seconds        0.0.0.0:32825->6379/tcp   kind_noether

つまりまあ、GenericContainer#getContainerIpAddressでlocalhostが返ってくる感じな以上、基本的にはEXPOSEして
使ってね、という感じでしょうね。
※DockerホストのIPアドレスが返るみたいなので、そりゃそうかと…

また、今回@ClassRuleでGenericContainerを使用していますので、Dockerコンテナの起動のタイミングはテストクラス単位となります。

起動の様子が見たい方は、Logbackあたりを仕込んでログを見てみるとよいでしょう。

Usage / Logging

今回は、Logbackを追加して

        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.2.1</version>
            <scope>test</scope>
        </dependency>

こんな感じに設定すると、
src/test/resources/logback.xml

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

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

    <logger name="org.testcontainers" level="INFO"/>
    <logger name="org.apache.http" level="WARN"/>
    <logger name="com.github.dockerjava" level="WARN"/>
    <logger name="org.zeroturnaround.exec" level="WARN"/>
</configuration>

Dockerコンテナの操作の様子を確認することができます。

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running org.littlewings.testcontainers.RedisContainerUsingClassRuleTest
21:49:06.398 [main] INFO  org.testcontainers.dockerclient.DockerClientProviderStrategy - Found docker client settings from environment
21:49:06.427 [main] INFO  org.testcontainers.dockerclient.DockerClientProviderStrategy - Looking for Docker environment. Tried Environment variables, system properties and defaults. Resolved: 
    dockerHost=unix:///var/run/docker.sock
    apiVersion='{UNKNOWN_VERSION}'
    registryUrl='https://index.docker.io/v1/'
    registryUsername='xyz'
    registryPassword='null'
    registryEmail='null'
    dockerConfig='DefaultDockerClientConfig[dockerHost=unix:///var/run/docker.sock,registryUsername=kazuhira,registryPassword=<null>,registryEmail=<null>,registryUrl=https://index.docker.io/v1/,dockerConfig=/home/kazuhira/.docker,sslConfig=<null>,apiVersion={UNKNOWN_VERSION}]'

21:49:06.439 [main] INFO  org.testcontainers.DockerClientFactory - Docker host IP address is localhost
21:49:07.079 [main] INFO  org.testcontainers.DockerClientFactory - Connected to docker: 
  Server Version: 17.03.0-ce
  API Version: 1.26
  Operating System: Ubuntu 14.04.5 LTS
  Total Memory: 10545 MB
21:49:08.607 [main] INFO  org.testcontainers.DockerClientFactory - Disk utilization in Docker environment is 51% (137068 MB available )
21:49:08.721 [main] INFO  &#128051; [redis:3.2.8] - Creating container for image: redis:3.2.8
21:49:08.844 [main] INFO  &#128051; [redis:3.2.8] - Starting container with ID: 9b2eff6079ecc96646031eed849e3a721611240d1f3aa356d90c1fd87777006b
21:49:09.591 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 is starting: 9b2eff6079ecc96646031eed849e3a721611240d1f3aa356d90c1fd87777006b
21:49:09.682 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 started
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.578 sec

テストメソッドは2つですが、Redisのコンテナは1回しか起動していませんね?

よって、今回は意図的にテストの順番を固定していますが、

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class RedisContainerUsingClassRuleTest {

前のメソッドの実行結果を、Redisが引き継いでいたりします。

    @Test
    public void simpleJedis2() {
        Jedis jedis = new Jedis(REDIS.getContainerIpAddress(), REDIS.getMappedPort(6379));

        assertThat(jedis.get("key")).isEqualTo("value");  // nullではない

        jedis.set("key", "value-2");
        assertThat(jedis.get("key")).isEqualTo("value-2");
    }

これを、@Ruleにするとテストメソッド単位のコンテナ起動になります。
src/test/java/org/littlewings/testcontainers/RedisContainerUsingRuleTest.java

package org.littlewings.testcontainers;

import org.junit.FixMethodOrder;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.testcontainers.containers.GenericContainer;
import redis.clients.jedis.Jedis;

import static org.assertj.core.api.Assertions.assertThat;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class RedisContainerUsingRuleTest {
    @Rule
    public GenericContainer redis =
            new GenericContainer("redis:3.2.8")
                    .withExposedPorts(6379);

    @Test
    public void simpleJedis1() {
        Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379));

        assertThat(jedis.get("key")).isNull();

        jedis.set("key", "value");
        assertThat(jedis.get("key")).isEqualTo("value");
    }

    @Test
    public void simpleJedis2() {
        Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379));

        assertThat(jedis.get("key")).isNull();

        jedis.set("key", "value-2");
        assertThat(jedis.get("key")).isEqualTo("value-2");
    }
}

今回はテストメソッドが2つあるので、Redisのコンテナが2回起動/停止します。

Running org.littlewings.testcontainers.RedisContainerUsingRuleTest
21:58:35.331 [main] INFO  &#128051; [redis:3.2.8] - Creating container for image: redis:3.2.8
21:58:35.467 [main] INFO  &#128051; [redis:3.2.8] - Starting container with ID: 4da0b88d130ae01d9658ac0a39d87840922165021b135f07c557d9110c588370
21:58:35.945 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 is starting: 4da0b88d130ae01d9658ac0a39d87840922165021b135f07c557d9110c588370
21:58:35.954 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 started
21:58:36.380 [main] INFO  &#128051; [redis:3.2.8] - Creating container for image: redis:3.2.8
21:58:36.513 [main] INFO  &#128051; [redis:3.2.8] - Starting container with ID: 5f7fa1f54fcc3022f2df4b70ad90cd1e968831c411324a36e82d2c5cee602a44
21:58:36.891 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 is starting: 5f7fa1f54fcc3022f2df4b70ad90cd1e968831c411324a36e82d2c5cee602a44
21:58:36.961 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 started
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.995 sec

提供済みのTestContainersを使う

TestContainersには、用途に応じたいくつかのモジュールが提供されています。

先ほど紹介した、このあたりですね。

  • Temporary database containers
  • Webdriver containers
  • Docker compose
  • Dockerfile containers

GenericContainerを使ってMySQLコンテナを起動してももちろん問題ないとは思いますが、今回はTemporary database containersに
用意してあるMySQL用のモジュールを使用してみたいと思います。

Database containers

Maven依存関係に、MySQL用のTestContainersのモジュールとJDBCドライバを追加します。

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>mysql</artifactId>
            <version>1.1.9</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.41</version>
            <scope>runtime</scope>
        </dependency>

使い方は、先ほどのGenericContainerの時とそう変わりません。@ClassRuleを使ったりして、提供されているMySQLContainerの
インスタンスを操作していきます。

例えば、このように。
src/test/java/org/littlewings/testcontainers/MySqlContainerUsingClassRuleTest.java

package org.littlewings.testcontainers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.testcontainers.containers.MySQLContainer;

import static org.assertj.core.api.Assertions.assertThat;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class MySqlContainerUsingClassRuleTest {
    @ClassRule
    public static MySQLContainer MYSQL =
            new MySQLContainer("mysql:5.7.17");

    @Test
    public void insert() throws SQLException {
        try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword());
             PreparedStatement ddl = connection.prepareStatement("CREATE TABLE test(id VARCHAR(10), PRIMARY KEY(id))");
             PreparedStatement ps = connection.prepareStatement("INSERT INTO test(id) VALUES(?)")) {
            ddl.executeUpdate();

            ps.setString(1, "hello!!");
            assertThat(ps.executeUpdate()).isEqualTo(1);
        }
    }

    @Test
    public void select() throws SQLException {
        try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword());
             PreparedStatement ps = connection.prepareStatement("SELECT id FROM test WHERE id = ?")) {
            ps.setString(1, "hello!!");

            try (ResultSet rs = ps.executeQuery()) {
                rs.next();

                assertThat(rs.getString(1)).isEqualTo("hello!!");
            }
        }
    }
}

テストメソッドselectが、insertメソッドの結果に依存していますが、@ClassRuleを試した例ということで気にしないでください…。

ちなみに、Redisの時と比べるとけっこう重たくなります。このあたりは、ふつうにコンテナの起動時間に左右される感じですね
(そりゃそうだ)。

今回はEXPOSEとかしていませんが、

    @ClassRule
    public static MySQLContainer MYSQL =
            new MySQLContainer("mysql:5.7.17");

それはMySQLContainerの中で設定されるようになっています。

https://github.com/testcontainers/testcontainers-java/blob/testcontainers-1.1.9/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainer.java#L26-L37

これを見ると、データベース名とかアカウント名、パスワードとか一式「test」ですね。

接続先情報については、MySQLContainer#getJdbcUrl、getUsername、getPasswordなどで得ることが可能です。

        try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword());

続いて、@Ruleを使ったサンプルを書いてみます。今回は、2つのMySQLを同時に使う例にしてみました。
※コンテナを複数使えることを示しているだけで、双方のMySQLに関連はありません
src/test/java/org/littlewings/testcontainers/MySqlContainerUsingRuleTest.java

package org.littlewings.testcontainers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

import org.junit.Rule;
import org.junit.Test;
import org.testcontainers.containers.MySQLContainer;

import static org.assertj.core.api.Assertions.assertThat;

public class MySqlContainerUsingRuleTest {
    @Rule
    public MySQLContainer mysqlServer1 =
            new MySQLContainer("mysql:5.7.17");

    @Rule
    public MySQLContainer mysqlServer2 =
            new MySQLContainer("mysql:5.7.17");

    @Test
    public void test() throws SQLException {
        assertThat(mysqlServer1.getJdbcUrl())
                .isNotEqualTo(mysqlServer2.getJdbcUrl());

        String ddl = "CREATE TABLE test(id VARCHAR(10), PRIMARY KEY(id))";

        try (Connection connection1 = DriverManager.getConnection(mysqlServer1.getJdbcUrl(), mysqlServer1.getUsername(), mysqlServer1.getPassword());
             Connection connection2 = DriverManager.getConnection(mysqlServer2.getJdbcUrl(), mysqlServer2.getUsername(), mysqlServer2.getPassword())) {
            connection1.setAutoCommit(false);
            connection2.setAutoCommit(false);

            // MySQL Server1
            try (Statement statement = connection1.createStatement()) {
                statement.executeUpdate(ddl);
                statement.executeUpdate("INSERT INTO test(id) VALUES('hoge')");
                connection1.commit();
            }

            // MySql Server2
            try (Statement statement = connection2.createStatement()) {
                statement.executeUpdate(ddl);
                statement.executeUpdate("INSERT INTO test(id) VALUES('fuga')");
                connection2.commit();
            }

            // MySQL Server1
            try (Statement statement = connection1.createStatement()) {
                try (ResultSet countSet = statement.executeQuery("SELECT COUNT(1) FROM test")) {
                    countSet.next();
                    assertThat(countSet.getInt(1)).isEqualTo(1);
                }

                try (ResultSet selectSet = statement.executeQuery("SELECT id FROM test ORDER BY id")) {
                    selectSet.next();
                    assertThat(selectSet.getString(1)).isEqualTo("hoge");
                }
            }

            // MySQL Server2
            try (Statement statement = connection2.createStatement()) {
                try (ResultSet countSet = statement.executeQuery("SELECT COUNT(1) FROM test")) {
                    countSet.next();
                    assertThat(countSet.getInt(1)).isEqualTo(1);
                }

                try (ResultSet selectSet = statement.executeQuery("SELECT id FROM test ORDER BY id")) {
                    selectSet.next();
                    assertThat(selectSet.getString(1)).isEqualTo("fuga");
                }
            }
        }
    }
}

ホスト側に割り当てられるポートがランダムなので、2つ以上のコンテナも扱えますね。

MySQLの設定を変更する

MySQLContainerで起動されるMySQLの設定は、デフォルトでこちらになっています。

https://github.com/testcontainers/testcontainers-java/blob/testcontainers-1.1.9/modules/mysql/src/main/resources/mysql-default-conf/my.cnf

この内容を変えたい場合(Character setとかlatin1になるし)、MySQLContainer#withConfigurationOverrideを使用することで
my.cnfの差し替え(というかVolumeマウント)を行うことができます。

例えば、「mysql-conf-override」ディレクトリ配下に次のようなファイルを用意します。
src/test/resources/mysql-conf-override/my.cnf

[mysqld]
user = mysql
datadir = /var/lib/mysql
port = 3306

character-set-server = utf8mb4

innodb_data_file_path = ibdata1:10M:autoextend

で、「mysql-conf-override」をMySQLContainer#withConfigurationOverrideに指定することで、my.cnfを差し替えることができます。
src/test/java/org/littlewings/testcontainers/MySqlContainerCustomConfigurationTest.java

package org.littlewings.testcontainers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

import org.junit.ClassRule;
import org.junit.Test;
import org.testcontainers.containers.MySQLContainer;

import static org.assertj.core.api.Assertions.assertThat;

public class MySqlContainerCustomConfigurationTest {
    @ClassRule
    public static MySQLContainer MYSQL =
            new MySQLContainer("mysql:5.7.17")
                    .withConfigurationOverride("mysql-conf-override");

    @Test
    public void charset() throws SQLException {
        try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword());
             PreparedStatement ps = connection.prepareStatement("SHOW VARIABLES WHERE variable_name = 'character_set_server'");
             ResultSet rs = ps.executeQuery()) {
            rs.next();

            assertThat(rs.getString(1)).isEqualTo("character_set_server");
            assertThat(rs.getString(2)).isEqualTo("utf8mb4");
        }
    }
}

こんな感じです。

    @ClassRule
    public static MySQLContainer MYSQL =
            new MySQLContainer("mysql:5.7.17")
                    .withConfigurationOverride("mysql-conf-override");

今回は、character_set_serverをutf8mb4に変更することを主な目的としたのですが、用意した設定ファイルが適用されていることが
確認できました。

まとめ

JUnitのClassRule/Ruleを使ってDockerコンテナを利用することができる、TestContainersを試してみました。

docker-maven-pluginとは起動停止のタイミングや、設定可能な項目に差がありそうですが、使い方に応じて分けて
いけばいいのかなぁと思います。

参考)
testcontainersで使い捨てのデータベースコンテナを用意してSpring Bootアプリケーションのテストをおこなう - mike-neckのブログ

https://github.com/making/demo-test-container

2017-03-10

docker-maven-pluginで、Integration Test時にDockerコンテナの起動/停止をする

Docker+Mavenで、テスト時にDockerコンテナを起動してテストして…みたいなことを、そういえばやったことが
ないなぁと思いまして。

いつも、わりかしふつうにdocker runしてます。

で、MavenでDockerを使うプラグインの情報をなんか見たことがあったので、軽く調べて試してみました。

今回は、docker-maven-pluginを使います。

https://github.com/fabric8io/docker-maven-plugin

ドキュメントはこちら。

docker-maven-plugin/intro.md at master ? fabric8io/docker-maven-plugin ? GitHub

docker-maven-plugin

docker-maven-pluginを使用するには、Mavenが使えることというのはさることながら、Dockerが
インストールされている、もしくはDocker APIが使えるホストにアクセスできる必要があるという
感じのようです。

このプラグインでできそうなこととして、docker build、run、stop、removeなど、Dockerのコマンドを使った
相応のこと(あと、Docker Composeまで)いろいろできそうですが、ビルドとかはふつうにdocker buildで
いいかなぁと思っているので、今回はrunとstopにフォーカスしてみました。

Docker Compose)
Docker Compose

では、試してみましょう。

お題

今回は、RedisのDockerイメージを使って、Integration Test時にRedisを起動、終了時にコンテナを停止する
といった使い方をしてみます。

RedisのDockerイメージは、こちら。

redis

あとは、テスト中にJedisを使ってRedisへアクセスしてみましょう、と。

準備

Integration Testで使うというのと、そもそもdocker-maven-pluginだという話なので、まずはMaven
プラグイン設定から書いていきます。

        <plugins>
            <plugin>
                <groupId>io.fabric8</groupId>
                <artifactId>docker-maven-plugin</artifactId>
                <version>0.20.0</version>
                <configuration>
                    <images>
                        <image>
                            <alias>redis-server</alias>
                            <name>redis:3.2.8</name>
                            <run>
                                <!-- docker:start -->
                                <namingStrategy>alias</namingStrategy>
                                <ports>6379:6379</ports>
                            </run>
                        </image>
                    </images>

                    <!-- docker:stop -->
                    <keepContainer>false</keepContainer>
                </configuration>
                <executions>
                    <execution>
                        <id>start</id>
                        <phase>pre-integration-test</phase>
                        <goals>
                            <goal>start</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>stop</id>
                        <phase>post-integration-test</phase>
                        <goals>
                            <goal>stop</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-failsafe-plugin</artifactId>
                <version>2.19.1</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>integration-test</goal>
                            <goal>verify</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>

maven-failsafe-pluginはまあいいとして、docker-maven-pluginについては利用するDockerイメージの設定や
runコマンドで指定するオプションなどを書いていきます。こちらは、またあとで。

Maven Failsafe Plugin – Introduction

依存ライブラリとしては、Jedis、JUnit、AssertJを用意。

        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>2.9.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.6.2</version>
            <scope>test</scope>
        </dependency>

テストコードの準備と確認

動作確認に使用したテストコードは、こちら。Integration Testなので、テストクラス名は「〜IT」
としています。
src/test/java/org/littlewings/docker/DockerMavenPluginIT.java

package org.littlewings.docker;

import java.util.concurrent.TimeUnit;

import org.junit.Test;
import redis.clients.jedis.Jedis;

import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;

public class DockerMavenPluginIT {
    @Test
    public void jedisGettingStarted() {
        Jedis jedis = new Jedis("localhost");

        assertThat(jedis.get("key"))
                .isNull();

        jedis.set("key", "value");
        assertThat(jedis.get("key"))
                .isEqualTo("value");
    }
}

確認は、「mvn verify」で。

$ mvn verify

実行すると、pre-integration-testでRedisを起動し、post-integration-testでRedisを停止してくれます。

[INFO] --- docker-maven-plugin:0.20.0:start (start) @ docker-maven-plugin-example ---
[INFO] DOCKER> [redis:3.2.8] "redis-server": Start container fec538d2c43e
[INFO] 
[INFO] --- maven-failsafe-plugin:2.19.1:integration-test (default) @ docker-maven-plugin-example ---

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running org.littlewings.docker.DockerMavenPluginIT
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.162 sec - in org.littlewings.docker.DockerMavenPluginIT

Results :

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

[INFO] 
[INFO] --- docker-maven-plugin:0.20.0:stop (stop) @ docker-maven-plugin-example ---
[INFO] DOCKER> [redis:3.2.8] "redis-server": Stop and removed container fec538d2c43e after 0 ms

この部分ですね。

[INFO] DOCKER> [redis:3.2.8] "redis-server": Start container fec538d2c43e

[INFO] DOCKER> [redis:3.2.8] "redis-server": Stop and removed container fec538d2c43e after 0 ms

OKそうですね。

docker-maven-pluginについて

で、docker-maven-plugin自体については、さらっと流してしまいました。そのあたりを見ていきましょう。

今回利用したのは、docker:startとdocker:stopです。

docker:start

docker:stop

設定としては、ごった煮で書いている感じもしますが、それぞれ見ていきましょう。今回書いた設定を再度書くと、こちらです。

            <plugin>
                <groupId>io.fabric8</groupId>
                <artifactId>docker-maven-plugin</artifactId>
                <version>0.20.0</version>
                <configuration>
                    <images>
                        <image>
                            <alias>redis-server</alias>
                            <name>redis:3.2.8</name>
                            <run>
                                <!-- docker:start -->
                                <namingStrategy>alias</namingStrategy>
                                <ports>6379:6379</ports>
                            </run>
                        </image>
                    </images>

                    <!-- docker:stop -->
                    <keepContainer>false</keepContainer>
                </configuration>
                <executions>
                    <execution>
                        <id>start</id>
                        <phase>pre-integration-test</phase>
                        <goals>
                            <goal>start</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>stop</id>
                        <phase>post-integration-test</phase>
                        <goals>
                            <goal>stop</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

共通的な話としては、イメージ自体の記述はこんな感じで書くみたいですよ。Dockerイメージを複数書いても
いいみたいです。

                <configuration>
                    <images>
                        <image>
                            <name>[利用するDockerイメージ]</name>
                            <run>
                                <!-- 設定 -->
                            </run>
                            <!-- 設定 -->
                        </image>

                        <image>
                            ...
                        </image>
                    </images>
                </configuration>

どのような設定が可能かは、各種ゴールを見ていくことになります。

docker:start

docker:startゴールは、pre-integration-testフェーズで実行するように設定して、このタイミングでコンテナを
起動させています。

                    <execution>
                        <id>start</id>
                        <phase>pre-integration-test</phase>
                        <goals>
                            <goal>start</goal>
                        </goals>
                    </execution>

コンテナに対する設定自体は、こちらを見て書いていきます。

Configuration

今回は、ports(-p相当)と、コンテナ名を設定しておきました。こちらと

                                <ports>6379:6379</ports>

aliasとnamingStrategyですね。

                           <alias>redis-server</alias>
                            <name>redis:3.2.8</name>
                            <run>
                                <!-- docker:start -->
                                <namingStrategy>alias</namingStrategy>

多くの設定は、runタグ内に書くことになります。詳細は、ドキュメントを…。

docker:stop

docker:stopは、あまり設定がありません。まずは、post-integration-test時にコンテナを停止するように設定。

                    <execution>
                        <id>stop</id>
                        <phase>post-integration-test</phase>
                        <goals>
                            <goal>stop</goal>
                        </goals>
                    </execution>

設定は、今回keepContainer(stop時に、コンテナを削除するか)を指定して、stop時にコンテナを削除するように
しています…が、デフォルトがfalseなので、実は書かなくても意味は同じだったりします。

                    <!-- docker:stop -->
                    <keepContainer>false</keepContainer>

まあ、configurationタグ配下に書いて設定する、ということで。

設定をどこに書くかにちょっと悩んだりするのですが、ひとつ場所がわかればあとはドキュメント上での同じ並びの
項目は同じような場所に書いていくことになるので、そう困らないと思います。

まとめ

MavenでDockerを使う、docker-maven-pluginを試してみました。

設定の書き方でちょっと迷ったりしましたが、慣れれば使うのはそう難しくなさそうな気がします。

使えるところでは試していってみましょう。

2017-03-06

Apache KafkaのQuickstartのサンプルを、JavaのClient APIで書き直してみた

Apache KafkaのClient APIを使った、いわゆる「Hello World」的なことを試してみよう…と思ったのですが、
ドキュメントを見てもそれほど書いてありません。

Documentation / APIS

Maven Dependency

		<dependency>
			<groupId>org.apache.kafka</groupId>
			<artifactId>kafka-clients</artifactId>
			<version>0.10.2.0</version>
		</dependency>

Configurationといったところが主な内容ですが。

Documentation / Configuration / Producer Configs

Documentation / Configuration / Consumer Configs

あとは、リポジトリ上に含まれている、examplesを見るのがよいでしょうか?

kafka/examples/src/main/java/kafka/examples at 0.10.2.0 ? apache/kafka ? GitHub

あんまり情報ないですけど、このあたりを参考にQuickstartで書かれていた

Apache Kafka

簡単なサンプル相当のものをClient APIを使って書いてみたいと思います。

Producer。

$ bin/kafka-console-producer.sh --broker-list localhost:9092 --topic test
This is a message
This is another message

Consumer。

$ bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning
This is a message
This is another message

準備

まずは、Maven依存関係から。先ほど記載したとおり、「kafka-clients」への依存関係を追加します。

        <dependency>
            <groupId>org.apache.kafka</groupId>
            <artifactId>kafka-clients</artifactId>
            <version>0.10.2.0</version>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.6.2</version>
            <scope>test</scope>
        </dependency>

テストコードでサンプルは書いてみようと思いますので、JUnitとAssertJも追加。

Topicもあらかじめ作成しておきます。今回使うTopicの名前は、「my-topic」としました。

$ bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic my-topic

あと、こちらの実行上の都合ですが、別ホストで起動させたApache KafkaのBrokerにProducerおよびConsumerからの
接続をしたいと思いますので、config/server.propertiesでのlistenersには次のようにしておきます。

listeners=PLAINTEXT://[Apache Kafkaが稼働しているサーバーのIPアドレス]:9092

今回のサーバーのIPアドレスは、「172.17.0.2」とします。

listeners=PLAINTEXT://172.17.0.2:9092

では、この環境に対してApache KafkaのProducerとConsumerを書いていきましょう。

テストコードの雛形

テストコードの雛形は、以下とします。
src/test/java/org/littlewings/kafka/GettingStartedTest.java

package org.littlewings.kafka;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.IntegerDeserializer;
import org.apache.kafka.common.serialization.IntegerSerializer;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class GettingStartedTest {
    // ここに、テストコードを書く!
}

このテストクラスの中に、ProducerとConsumerを使ったテストメソッドを書いていきます。

Producer

まずは、Producerから。

こんな感じに文字列を放り込むようにいってみましょう。

$ bin/kafka-console-producer.sh --broker-list localhost:9092 --topic test
This is a message
This is another message

Producerを作るにあたり、参考にしたのは次の情報です。

https://github.com/apache/kafka/blob/0.10.2.0/examples/src/main/java/kafka/examples/Producer.java

また、Javadocを見るのもよいでしょう。

kafka 0.10.2.0 API

むしろ、これくらいしかない気もしますが…。

で、できあがったのがこちら。

    @Test
    public void gettingStartedProducer() {
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "172.17.0.2:9092");

        try (KafkaProducer<Integer, String> producer =
                     new KafkaProducer<>(properties, new IntegerSerializer(), new StringSerializer())) {

            IntStream
                    .rangeClosed(1, 10)
                    .forEach(i -> {
                        try {
                            producer.send(new ProducerRecord<>("my-topic", i, "value" + i)).get();
                        } catch (InterruptedException | ExecutionException e) {
                            throw new RuntimeException(e);
                        }
                    });
        }
    }

Propertiesでいろいろ設定ができるのですが、今回は接続先のサーバーのみ指定しました。定数値を使っていますが、内容は
「bootstrap.servers」です。

        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "172.17.0.2:9092");

全設定項目は、こちらを参照しましょう。

Documentation / Configuration / Producer Configs

Serializerは、Propertiesでも設定できるのですが、今回はKafkaProducerのコンストラクタの引数として
設定しました。

        try (KafkaProducer<Integer, String> producer =
                     new KafkaProducer<>(properties, new IntegerSerializer(), new StringSerializer())) {

Apache Kafkaに登録するレコードとして、KeyとValueのSerializerがそれぞれ必要になります。今回は、KeyをInteger、
ValueをStringとします。

で、あとはProducerRecordを作成して、KafkaProducer#sendで送り込みます。

            IntStream
                    .rangeClosed(1, 10)
                    .forEach(i -> {
                        try {
                            producer.send(new ProducerRecord<>("my-topic", i, "value" + i)).get();
                        } catch (InterruptedException | ExecutionException e) {
                            throw new RuntimeException(e);
                        }
                    });

ProducerRecordのコンストラクタには、Topic名、Key、Valueをそれぞれ設定します。KafkaProducer#sendの戻り値はFutureなので、
今回はそれぞれFuture#getで待ち合わせるようにしました。

これで、Producer側はおしまいです。

Consumer

続いて、Consumer。こちらに似た内容を目指します。

$ bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning
This is a message
This is another message

「--from-beginning」があるので、「Topicの最初から読む」ですね。

Comsumer側も参考にしたのは、examplesのコードと

https://github.com/apache/kafka/blob/0.10.2.0/examples/src/main/java/kafka/examples/Consumer.java

Javadocになります。

kafka 0.10.2.0 API

もちろん、設定についてのドキュメントも。

Documentation / Configuration / Consumer Configs

で、できあがったコードはこちら。

    @Test
    public void gettingStartedConsumer() {
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "172.17.0.2:9092");
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "my-consumer");
        properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");

        KafkaConsumer<Integer, String> consumer =
                new KafkaConsumer<>(properties, new IntegerDeserializer(), new StringDeserializer());

        List<ConsumerRecord<Integer, String>> received = new ArrayList<>();

        consumer.subscribe(Arrays.asList("my-topic"));

        while (received.size() < 10) {
            ConsumerRecords<Integer, String> records = consumer.poll(1000L);

            records.forEach(received::add);
        }

        assertThat(received)
                .hasSize(10);
        assertThat(received.stream().map(c -> c.value()).collect(Collectors.toList()))
                .containsExactly(IntStream.rangeClosed(1, 10).mapToObj(i -> "value" + i).toArray(String[]::new));
    }

まずは、Producerと同様、Consumer側も設定としてPropertiesにいろいろ登録していきます。

        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "172.17.0.2:9092");
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "my-consumer");
        properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");

Consumer側は、少なくとも「bootstrap.servers」と「group.id」の指定が必要なようです。

また、「auto.offset.reset」についても「earliest」と指定しているのですが、こちらは「--from-beginning」と同じ動きを
させるために指定しています。指定しない場合のデフォルト値は「latest」となり、Consumerが起動した跡にTopicに登録
されたデータを読み出していくことになります。

レコードのKey/Valueは、IntegerとStringなのでProducerと同様に指定。

        KafkaConsumer<Integer, String> consumer =
                new KafkaConsumer<>(properties, new IntegerDeserializer(), new StringDeserializer());

KafkaConsumerで、subscribeするTopicの名前(複数可)を指定します。

        consumer.subscribe(Arrays.asList("my-topic"));

あとは、KafkaConsumer#pollでメッセージの受信を待ちます。今回は、10件以上受信したらpollingをやめるようにしています。
というか、Producer側では10件登録しますので。

        while (received.size() < 10) {
            ConsumerRecords<Integer, String> records = consumer.poll(1000L);

            records.forEach(received::add);
        }

これで、なんとか動かせましたよ、と。

まとめ

Apache KafkaのClient APIを使って、Quickstart相当っぽいサンプルを書いてみました。

とにかくドキュメントにあんまり情報がないのでやり方がわからず苦労しましたが、KafkaProducer/KafkaConsumerともにメソッド数も
それほど多くないので、あとは設定できる項目とかを見ながらなれていく感じなのでしょうかね。

とりあえず、とっかかりとしてはこんなところで。

2017-03-04

WildFly SwarmのConsul+Ribbon IntegrationでService Discovery+Load Balancing

以前、WildFly SwarmとConsulを使った、Service Discoveryを試しました。

WildFly Swarm+Consul(+Dnsmasq)でService Discovery - CLOVER

今度は、WildFly Swarm+Consulの組み合わせに、さらにRibbonを足してロードバランシングまで行ってみます。

NetflixOSS

Ribbonとは?

Netflix OSSのうちのひとつで、クライアントサイドでのロードバランシングを実現してくれるものみたいです。

GitHub - Netflix/ribbon: Ribbon is a Inter Process Communication (remote procedure calls) library with built in software load balancers. The primary usage model involves REST calls with various serialization scheme support.

バックエンドにいるサーバーをどのようにして知るかは、あらかじめ列挙済みのサーバーのリストだったり、同じくNetflix OSS
Eurekaと統合したりする方法があるようです。

今回は、WildFly SwarmのService Discoveryの仕組みと組み合わせて使うことになります。

Topology

WildFly Swarmで使えるService Discoveryには、JGroups、Consulがありますが、今回はConsulを使用します。

Topology using Hashicorp Consul

サンプルを作るにあたり、WildFly SwarmのRibbon/Consulの組み合わせのサンプルも参考にしています。

wildfly-swarm-examples/ribbon-consul at 2017.3.2 ? wildfly-swarm/wildfly-swarm-examples ? GitHub

構成

クライアントサイドロードバランシングを行うということでRibbonと統合するわけですが、今回は次のような構成とします。

f:id:Kazuhira:20170304205145j:image

  • Consulクラスタとして、Consul Serverをひとつ用意
  • Backendの簡単なJAX-RSアプリケーションを2インスタンス、ConsulとRibbonを導入しつつ構成
  • Ribbonによってクライアントサイドロードバランシングを行うJAX-RSアプリケーションをひとつ構成
  • アクセスはcurlで行う

サーバーの種類と名前は、こんな感じで。

  • Consul Server(consulserver)
  • Backend Server(backendserver1、backendserver2)
  • Frontend Server(frontendserver)
  • アクセス元(localhost

curlでFrontend Serverで動作しているアプリケーションを呼び出すと、BackendのServerへの呼び出しが行われることを
確認します。

実装は、さっくりとScalaで行います。

準備

今回は、Mavenのマルチプロジェクトとして構成します。

ディレクトリ構成は、こんな感じ。

pom.xml
backend/pom.xml
backend/src/main/scala
frontend/pom.xml
frontend/src/main/scala

親のpom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.littlewings</groupId>
    <artifactId>ribbon-consul-integration</artifactId>
    <packaging>pom</packaging>
    <version>0.0.1-SNAPSHOT</version>
    <modules>
        <module>frontend</module>
        <module>backend</module>
    </modules>

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

        <scala.major.version>2.12</scala.major.version>
        <scala.version>${scala.major.version}.1</scala.version>
        <scala.maven.plugin.version>3.2.2</scala.maven.plugin.version>

        <wildfly.swarm.version>2017.3.2</wildfly.swarm.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.wildfly.swarm</groupId>
                <artifactId>bom-all</artifactId>
                <version>${wildfly.swarm.version}</version>
                <scope>import</scope>
                <type>pom</type>
            </dependency>
            <dependency>
                <groupId>org.scala-lang</groupId>
                <artifactId>scala-library</artifactId>
                <version>${scala.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>net.alchim31.maven</groupId>
                    <artifactId>scala-maven-plugin</artifactId>
                    <version>${scala.maven.plugin.version}</version>
                    <executions>
                        <execution>
                            <goals>
                                <goal>compile</goal>
                                <goal>testCompile</goal>
                            </goals>
                        </execution>
                    </executions>
                    <configuration>
                        <scalaVersion>${scala.version}</scalaVersion>
                        <args>
                            <arg>-Xlint</arg>
                            <arg>-unchecked</arg>
                            <arg>-deprecation</arg>
                            <arg>-feature</arg>
                        </args>
                        <recompileMode>incremental</recompileMode>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.wildfly.swarm</groupId>
                    <artifactId>wildfly-swarm-plugin</artifactId>
                    <version>${wildfly.swarm.version}</version>
                    <executions>
                        <execution>
                            <goals>
                                <goal>package</goal>
                            </goals>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

では、各アプリケーションを作成していきます。

Backend

最初は、Backendと名づけたアプリケーションを作成してみましょう。

pom.xmlは、こんな感じ。あとで作成するmainクラスをWildFly SwarmのMavenプラグインに設定しておきます。基本的な設定は、上位のpom.xml
設定済みです。
backend/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>ribbon-consul-integration</artifactId>
        <groupId>org.littlewings</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>backend</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>org.scala-lang</groupId>
            <artifactId>scala-library</artifactId>
        </dependency>
        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>jaxrs</artifactId>
        </dependency>
        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>ribbon</artifactId>
        </dependency>
        <dependency>
          <groupId>org.wildfly.swarm</groupId>
          <artifactId>topology-consul</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>net.alchim31.maven</groupId>
                <artifactId>scala-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.wildfly.swarm</groupId>
                <artifactId>wildfly-swarm-plugin</artifactId>
                <configuration>
                    <mainClass>org.littlewings.wildflyswarm.ribbon.backend.App</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

ポイントとしては、WildFly Swarmの提供するRibbonおよびConsulへの依存関係を追加しておくことです。

        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>ribbon</artifactId>
        </dependency>
        <dependency>
          <groupId>org.wildfly.swarm</groupId>
          <artifactId>topology-consul</artifactId>
        </dependency>

まず、簡単なサンプルとして、アクセスされたら時刻とサーバー名を返すJAX-RSリソースクラスを作成します。まあ、WildFly Swarmの
サンプルに習ったものですね。
backend/src/main/scala/org/littlewings/wildflyswarm/ribbon/backend/TimeResource.scala

package org.littlewings.wildflyswarm.ribbon.backend

import java.net.InetAddress
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.ws.rs.core.MediaType
import javax.ws.rs.{GET, Path, Produces}

import scala.collection.JavaConverters._

@Path("time")
class TimeResource {
  @GET
  @Path("now")
  @Produces(Array(MediaType.APPLICATION_JSON))
  def now: java.util.Map[String, String] =
    Map("now" -> LocalDateTime.now.format(DateTimeFormatter.ISO_DATE_TIME),
      "from" -> InetAddress.getLocalHost.getHostName)
      .asJava
}

特にここまで、RibbonやConsulに依存した内容は出てきません。

最後は、アプリケーションのエントリポイント。
backend/src/main/scala/org/littlewings/wildflyswarm/ribbon/backend/App.scala

package org.littlewings.wildflyswarm.ribbon.backend

import org.jboss.shrinkwrap.api.ShrinkWrap
import org.wildfly.swarm.Swarm
import org.wildfly.swarm.jaxrs.JAXRSArchive
import org.wildfly.swarm.netflix.ribbon.RibbonArchive
import org.wildfly.swarm.topology.TopologyArchive

object App {
  def main(args: Array[String]): Unit = {
    val swarm = new Swarm(args: _*)

    val deployment = ShrinkWrap.create(classOf[JAXRSArchive])
    deployment.addResource(classOf[TimeResource])
    deployment.addAllDependencies()
    deployment.as(classOf[TopologyArchive]).advertise("backend")
    // deployment.as(classOf[RibbonArchive]).advertise("backend")  // もしくはこちら

    swarm.start().deploy(deployment)
  }
}

TopologyArchiveもしくはRibbonArchiveを使用するとともに、advertiseメソッドで名前を指定してあげる必要があります。
名前を指定しないと、WildFly Swarmが生成するWARの妙に長い名前になって、扱いづらくなります。ここでは、
「backend」と指定しました。

    deployment.as(classOf[TopologyArchive]).advertise("backend")
    // deployment.as(classOf[RibbonArchive]).advertise("backend")  // もしくはこちら

Frontend

続いて、Backendのアプリケーションを呼び出す側のアプリケーションを作成していきます。

pom.xmlは、こちら。
frontend/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>ribbon-consul-integration</artifactId>
        <groupId>org.littlewings</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>frontend</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>org.scala-lang</groupId>
            <artifactId>scala-library</artifactId>
        </dependency>
        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>jaxrs</artifactId>
        </dependency>
        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>ribbon</artifactId>
        </dependency>
        <dependency>
          <groupId>org.wildfly.swarm</groupId>
          <artifactId>topology-consul</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>net.alchim31.maven</groupId>
                <artifactId>scala-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.wildfly.swarm</groupId>
                <artifactId>wildfly-swarm-plugin</artifactId>
                <configuration>
                    <mainClass>org.littlewings.wildflyswarm.ribbon.frontend.App</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Backendと同じく、RibbonとConsulとの依存関係を足しておきます。

続いて、Backendとの接続部分を作っていきます。

まずは、現在時刻を取得するJAX-RSリソースクラスに対するインターフェースを作成。このインターフェースを元に
Ribbonにプロキシクラスを作成してもらいます(Scalaなのでtraitですが、要求されるのはJavaのinterface相当です)
frontend/src/main/scala/org/littlewings/wildflyswarm/ribbon/frontend/TimeService.scala

package org.littlewings.wildflyswarm.ribbon.frontend

import com.netflix.ribbon.RibbonRequest
import com.netflix.ribbon.proxy.annotation.{Http, Hystrix, ResourceGroup, TemplateName}
import io.netty.buffer.ByteBuf

@ResourceGroup(name = "backend")
trait TimeService {
  @TemplateName("now")
  @Http(method = Http.HttpMethod.GET, uri = "/time/now")
  //@Hystrix(fallbackHandler = Array(classOf[TimeFallbackHandler]))
  def now: RibbonRequest[ByteBuf]
}

ポイントは、@ResourceGroupでBackendのadvertiseで指定した値を設定すること

@ResourceGroup(name = "backend")

背後のHTTP呼び出しを行うためのメソッドを定義することです。実装は、Ribbonがプロキシを生成するので不要です。
@Httpアノテーションで、GETやPOSTなどの指定、リクエスト先のURLを設定します。

  @TemplateName("now")
  @Http(method = Http.HttpMethod.GET, uri = "/time/now")
  //@Hystrix(fallbackHandler = Array(classOf[TimeFallbackHandler]))
  def now: RibbonRequest[ByteBuf]

メソッドの戻り値は、RibbonRequest<ByteBuf>である必要があるみたいですが…。

接続先のIPアドレスやポートは、WildFly Swarm側のService Discoveryに解決してもらいます。

@Hystrixのアノテーションもサンプルを参考に付けているのですが、ちょっとうまく動かなかったので今回はパス…。
実装する場合は、こちらを参考に。
https://github.com/wildfly-swarm/wildfly-swarm-examples/blob/2017.3.2/ribbon-consul/events/src/main/java/org/wildfly/swarm/examples/netflix/ribbon/events/TimeService.java#L24-L26
https://github.com/wildfly-swarm/wildfly-swarm-examples/blob/2017.3.2/ribbon-consul/events/src/main/java/org/wildfly/swarm/examples/netflix/ribbon/events/TimeFallbackHandler.java

また、今回ははしょりますが、パラメータをつける場合は@Varアノテーションを使用するようです。

  @TemplateName("echo")
  @Http(method = Http.HttpMethod.GET, uri = "/message/echo")
  def echo(@Var("message") message: String): RibbonRequest[ByteBuf]

次に、こちらのアプリケーションのエンドポイントとなるJAX-RSリソースクラスも作成。
frontend/src/main/scala/org/littlewings/wildflyswarm/ribbon/frontend/FrontResource.scala

package org.littlewings.wildflyswarm.ribbon.frontend

import java.nio.charset.StandardCharsets
import javax.ws.rs.container.{AsyncResponse, Suspended}
import javax.ws.rs.core.MediaType
import javax.ws.rs.{GET, Path, Produces, QueryParam}

import com.fasterxml.jackson.databind.ObjectMapper
import com.netflix.ribbon.Ribbon
import io.netty.buffer.ByteBufInputStream

@Path("front")
class FrontResource {
  val objectMapper: ObjectMapper = new ObjectMapper

  @GET
  @Path("get-now")
  @Produces(Array(MediaType.APPLICATION_JSON))
  def get: java.util.Map[_, _] = {
    val byteBuf = Ribbon.from(classOf[TimeService]).now.execute()

    objectMapper.readValue(new ByteBufInputStream(byteBuf), classOf[java.util.Map[_, _]])
  }

  @GET
  @Path("get-now-async")
  @Produces(Array(MediaType.APPLICATION_JSON))
  def getAsync(@Suspended asyncResponse: AsyncResponse): Unit = {
    val observable = Ribbon.from(classOf[TimeService]).now.observe

    observable.subscribe { byteBuf =>
      val now = objectMapper.readValue(new ByteBufInputStream(byteBuf), classOf[java.util.Map[_, _]])
      asyncResponse.resume(now)
    }
  }
}

通常のクラスと違うのは、Ribbonを使ってインターフェースに対するプロキシを生成し、プロキシに対してコードを書いていくことです。

  def get: java.util.Map[_, _] = {
    val byteBuf = Ribbon.from(classOf[TimeService]).now.execute()

    objectMapper.readValue(new ByteBufInputStream(byteBuf), classOf[java.util.Map[_, _]])
  }

RibbonRequest#executeを呼び出すことで、同期呼び出しとなります。

RibbonRequest#observeを呼び出すと、Observableが返ってきてNon Blockingとなるので、この場合は非同期に返すように実装します。

  def getAsync(@Suspended asyncResponse: AsyncResponse): Unit = {
    val observable = Ribbon.from(classOf[TimeService]).now.observe

    observable.subscribe { byteBuf =>
      val now = objectMapper.readValue(new ByteBufInputStream(byteBuf), classOf[java.util.Map[_, _]])
      asyncResponse.resume(now)
    }
  }

アプリケーションの起動クラスは、Backend側とそれほど変わりません。
frontend/src/main/scala/org/littlewings/wildflyswarm/ribbon/frontend/App.scala

package org.littlewings.wildflyswarm.ribbon.frontend

import org.jboss.shrinkwrap.api.ShrinkWrap
import org.wildfly.swarm.Swarm
import org.wildfly.swarm.jaxrs.JAXRSArchive
import org.wildfly.swarm.netflix.ribbon.RibbonArchive
import org.wildfly.swarm.topology.TopologyArchive

object App {
  def main(args: Array[String]): Unit = {
    val swarm = new Swarm(args: _*)

    val deployment = ShrinkWrap.create(classOf[JAXRSArchive])
    deployment.addResource(classOf[FrontResource])
    deployment.addClass(classOf[TimeService])
    deployment.addAllDependencies()
    deployment.as(classOf[TopologyArchive]).advertise("frontend")
    // deployment.as(classOf[RibbonArchive]).advertise("frontend")  // もしくはこちら

    swarm.start().deploy(deployment)
  }
}

ここまでで、アプリケーションができあがりました。

Consul Serverの起動。

Consulクラスタを構成するので、最初にConsul Serverを起動しておきます。

$ ./consul agent -server -bootstrap -client=172.17.0.2 -data-dir=/var/lib/consul/data

その他のサーバーでは、単純なConsul Agentを起動しておきます。

## backend
$ ./consul agent -join=172.17.0.2 -data-dir=/var/lib/consul/data
$ ./consul agent -join=172.17.0.2 -data-dir=/var/lib/consul/data

## frontend
$ ./consul agent -join=172.17.0.2 -data-dir=/var/lib/consul/data

Consul Server側で、こんなログが出力されればクラスタに参加できています。

    2017/03/04 12:35:43 [INFO] consul: member 'backendserver1' joined, marking health alive
    2017/03/04 12:36:01 [INFO] serf: EventMemberJoin: backendserver2 172.17.0.4
    2017/03/04 12:36:01 [INFO] consul: member 'backendserver2' joined, marking health alive
    2017/03/04 12:36:05 [INFO] serf: EventMemberJoin: frontserver 172.17.0.5
    2017/03/04 12:36:05 [INFO] consul: member 'frontserver' joined, marking health alive

アプリケーションの起動と動作確認

それでは、構成したアプリケーションをパッケージング、起動して動作確認してみます。

Uber JARを作成して、各ホストにできあがったJARファイルを配り

$ mvn package

Backend側を起動。Consulと組み合わせる場合は、「-Dswarm.bind.address」でバインドするIPアドレスを指定しておくことがポイントです。

## Backend1
$ java -Dswarm.bind.address=172.17.0.3 -jar /path/to/backend-0.0.1-SNAPSHOT-swarm.jar

## Backend2
$ java -Dswarm.bind.address=172.17.0.4 -jar /path/to//backend-0.0.1-SNAPSHOT-swarm.jar

Frontend側を起動。

$ java -Dswarm.bind.address=172.17.0.5 -jar /path/to/frontend-0.0.1-SNAPSHOT-swarm.jar

確認してみます。

現在時刻の同期取得。

$ curl -i http://172.17.0.5:8080/front/get-now
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/json
Content-Length: 57
Date: Sat, 04 Mar 2017 12:54:06 GMT

{"now":"2017-03-04T12:54:06.453","from":"backendserver2"}

取得できるんですけど、アクセスが偏ります…なんででしょう…。

ここで、Backend2を落とすと1号機が現れるので、両方認識はできているみたいです。

$ curl -i http://172.17.0.5:8080/front/get-now
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/json
Content-Length: 57
Date: Sat, 04 Mar 2017 12:54:44 GMT

{"now":"2017-03-04T12:54:44.633","from":"backendserver1"}

で、なんでアクセス先が偏るんだろうと思ったら、前も踏んだんでした…。この構成は、Dockerで実現しています…。

https://forums.docker.com/t/consul-dns-round-robin-works-for-host-but-not-for-containers/6663=title

まあいいか…。

非同期版もOKです。

$ curl -i http://172.17.0.5:8080/front/get-now-async
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/json
Content-Length: 57
Date: Sat, 04 Mar 2017 14:00:15 GMT

{"now":"2017-03-04T14:00:15.741","from":"backendserver2"}

とりあえず、動かせました、と。

WildFly Swarm+Ribbonの仕掛け

依存関係を追加しただけで、RibbonとConsulが連携できたように見えますが、このあたりはRibbon向けのFractionが頑張っています。

RibbonFractionを使用すると、サーバーの一覧取得処理としてWildFly Swarmが提供するTopologyServerListクラスを使用するようになり、
またバランシングのアルゴリズムはラウンドロビンとなるように設定されます。

https://github.com/wildfly-swarm/wildfly-swarm/blob/2017.3.2/fractions/netflix/ribbon/src/main/java/org/wildfly/swarm/netflix/ribbon/RibbonFraction.java

TopologyServerListクラスは、WildFly SwarmのTopologyを使用したサーバーのアドレスとポートの組み合わせの解決をしてくれる
クラスとなり、ここでConsulが隠蔽されています(実際にはtopology-consulが頑張る、と)。

https://github.com/wildfly-swarm/wildfly-swarm/blob/2017.3.2/fractions/netflix/ribbon/src/main/java/org/wildfly/swarm/netflix/ribbon/runtime/TopologyServerList.java

また、アプリケーションの起動クラスを作成する際にTopologyArchiveを使用してもRibbonArchiveを使用してもいいみたいなことを
書いていましたが、RibbonArchiveが指定されていなければ自動的に登録しようとするみたいです。

https://github.com/wildfly-swarm/wildfly-swarm/blob/2017.3.2/fractions/netflix/ribbon/src/main/java/org/wildfly/swarm/netflix/ribbon/runtime/RibbonArchiveAdvertiser.java#L17-L19

Secured Ribbonを使用していると、HTTPSを使用するようです。

Ribbon自体の使い方について

今回、Ribbonを使う際には、インターフェースからプロキシを生成する方法で使いました。

ドキュメント的には、こちらの使い方ですね。

Access HTTP resource using annotations

ribbon/ribbon at v2.1.0 ? Netflix/ribbon ? GitHub

もうちょっと直接APIを使う方法もあるみたいです。

Access HTTP resource using template

プロキシまわりのコードはこちらにあるので、実装内容が気になる方はこちらを。

https://github.com/Netflix/ribbon/tree/v2.1.0/ribbon/src/main/java/com/netflix/ribbon/proxy

アノテーションを解析して、JDKのProxyおよびInvocationHandlerを使って実現していることが確認できると思います。

まとめ

WildFly Swarmの統合の仕組みを使いつつ、ConsulおよびRibbonを組み合わせて使ってみました。

ちょっと用意がおおがかりになるので少々大変ですが、なかなか面白かったです。

今回作成したコード(+実験の跡)はこちらに置いています。

2017-02-26

MySQLでパーティショニングを使用した時の、データの配置状態を確認する

MySQLで、パーティショニングを使った時に、どのパーティションにどれくらいのデータが入っているかを
確認するには、information_schemaにあるpartitionsテーブルを見ればよいみたいです。
※確認は、MySQL 5.7で行っています

こんなテーブルです。

mysql> DESC information_schema.partitions;
+-------------------------------+---------------------+------+-----+---------+-------+
| Field                         | Type                | Null | Key | Default | Extra |
+-------------------------------+---------------------+------+-----+---------+-------+
| TABLE_CATALOG                 | varchar(512)        | NO   |     |         |       |
| TABLE_SCHEMA                  | varchar(64)         | NO   |     |         |       |
| TABLE_NAME                    | varchar(64)         | NO   |     |         |       |
| PARTITION_NAME                | varchar(64)         | YES  |     | NULL    |       |
| SUBPARTITION_NAME             | varchar(64)         | YES  |     | NULL    |       |
| PARTITION_ORDINAL_POSITION    | bigint(21) unsigned | YES  |     | NULL    |       |
| SUBPARTITION_ORDINAL_POSITION | bigint(21) unsigned | YES  |     | NULL    |       |
| PARTITION_METHOD              | varchar(18)         | YES  |     | NULL    |       |
| SUBPARTITION_METHOD           | varchar(12)         | YES  |     | NULL    |       |
| PARTITION_EXPRESSION          | longtext            | YES  |     | NULL    |       |
| SUBPARTITION_EXPRESSION       | longtext            | YES  |     | NULL    |       |
| PARTITION_DESCRIPTION         | longtext            | YES  |     | NULL    |       |
| TABLE_ROWS                    | bigint(21) unsigned | NO   |     | 0       |       |
| AVG_ROW_LENGTH                | bigint(21) unsigned | NO   |     | 0       |       |
| DATA_LENGTH                   | bigint(21) unsigned | NO   |     | 0       |       |
| MAX_DATA_LENGTH               | bigint(21) unsigned | YES  |     | NULL    |       |
| INDEX_LENGTH                  | bigint(21) unsigned | NO   |     | 0       |       |
| DATA_FREE                     | bigint(21) unsigned | NO   |     | 0       |       |
| CREATE_TIME                   | datetime            | YES  |     | NULL    |       |
| UPDATE_TIME                   | datetime            | YES  |     | NULL    |       |
| CHECK_TIME                    | datetime            | YES  |     | NULL    |       |
| CHECKSUM                      | bigint(21) unsigned | YES  |     | NULL    |       |
| PARTITION_COMMENT             | varchar(80)         | NO   |     |         |       |
| NODEGROUP                     | varchar(12)         | NO   |     |         |       |
| TABLESPACE_NAME               | varchar(64)         | YES  |     | NULL    |       |
+-------------------------------+---------------------+------+-----+---------+-------+
25 rows in set (0.00 sec)


例えば、KEYパーティショニング、パーティション数8で構成したテーブルの状態を見てみると、こんな感じ。
※データは適当に入れています

mysql> SELECT table_schema, table_name, partition_name, partition_method, table_rows FROM information_schema.partitions WHERE table_name = 'person';
+--------------+------------+----------------+------------------+------------+
| table_schema | table_name | partition_name | partition_method | table_rows |
+--------------+------------+----------------+------------------+------------+
| practice     | person     | p0             | KEY              |        740 |
| practice     | person     | p1             | KEY              |       2768 |
| practice     | person     | p2             | KEY              |        740 |
| practice     | person     | p3             | KEY              |       1260 |
| practice     | person     | p4             | KEY              |        740 |
| practice     | person     | p5             | KEY              |       1752 |
| practice     | person     | p6             | KEY              |        740 |
| practice     | person     | p7             | KEY              |       1260 |
+--------------+------------+----------------+------------------+------------+

8 rows in set (0.00 sec)


参照する時は、スキーマ名やテーブル名で絞り込むことになるでしょう。

また、単にパーティションを確認するなら、EXPLAINを使うのもありみたいです。
※こちらは、パーティション数が16に…

mysql> EXPLAIN SELECT * FROM person;
+----+-------------+--------+-------------------------------------------------------+------+---------------+------+---------+------+-------+----------+-------+
| id | select_type | table  | partitions                                            | type | possible_keys | key  | key_len | ref  | rows  | filtered | Extra |
+----+-------------+--------+-------------------------------------------------------+------+---------------+------+---------+------+-------+----------+-------+
|  1 | SIMPLE      | person | p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,p10,p11,p12,p13,p14,p15 | ALL  | NULL          | NULL | NULL    | NULL | 10000 |   100.00 | NULL  |
+----+-------------+--------+-------------------------------------------------------+------+---------------+------+---------+------+-------+----------+-------+
1 row in set, 1 warning (0.00 sec)