技術ネタはQiitaに移りました。壁もどこぞに。

IntelliJ IDEA 15 の Spring Boot 用 起動構成で ClassNotFoundException

IntelliJ IDEA 15 EAP をしばらく前から使用してみている。

IntelliJ IDEA 15 からは、Spring Boot 人気を受けてか、専用の起動構成が用意されている。アクティブ プロファイルの設定や、パラメーターの上書きなどが構成設定内に用意されており、Spring Boot に最適化されているのがよく分かる。

f:id:Yoichi-KIKUCHI:20150925000052p:plain

また、これまで Maven 経由で起動していたような場合には、多重起動が検出できず、二度目以降の起動時には公開ポートの衝突などが度々発生していたが、これも Spring Boot 用の起動構成では解消されており、なかなか便利になっている。

f:id:Yoichi-KIKUCHI:20150924234653p:plain

ところが、これまで Maven コマンドなどで起動していた場合、Spring Boot 用の起動構成に切り換えても、そのままでは動作しない場合がある。以下の様な例外がスローされてしまう。

java.lang.IllegalStateException: Could not evaluate condition on org.springframework.boot.autoconfigure.PropertyPlaceholderAutoConfiguration#propertySourcesPlaceholderConfigurer due to internal class not found. This can happen if you are @ComponentScanning a springframework package (e.g. if you put a @ComponentScan in the default package by mistake)
    at org.springframework.boot.autoconfigure.condition.SpringBootCondition.matches(SpringBootCondition.java:51)

...

Caused by: java.lang.NoClassDefFoundError: javax/servlet/Filter
    at java.lang.Class.getDeclaredMethods0(Native Method)
    at java.lang.Class.privateGetDeclaredMethods(Class.java:2701)
    at java.lang.Class.getDeclaredMethods(Class.java:1975)

...

起動時に、Servlet API 関連のクラスが見つからない、とのこと。関連キーワードで検索したらすぐに、Stackoverflow がヒットした。

stackoverflow.com

これを修正するには pom.xml を修正する必要がある。組み込み Tomcat などを依存関係に追加している場合、provided スコープになっている設定があれば、これを以下のように除去してやる。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-tomcat</artifactId>
    <!--<scope>provided</scope>-->
</dependency>
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-jasper</artifactId>
    <!--<scope>provided</scope>-->
</dependency>

Spring Boot 用の起動構成では、アプリケーションをスタンドアロン起動するため、アプリケーションコンテナなどに依存するような、ライブラリの取得をしてはいけなかったようだ。これで新たに IntelliJ IDEA 15 で追加された、この便利な起動構成を活用できるようになる。

Docker Remote API を使ってみる (Ubuntu 15.04 の場合)

以前、以下のように Docker Remote API のセットアップを試して書き残していた。

yo1000.hateblo.jp

ところが、Ubuntu 15.04 で同様に試してみたところ、systemd に移行していたため、従来のやり方では Remote API を有効化できなかった。そこで、調べてみたところ以下のサイトが見つかり、手順通りで問題なく有効化できたので残しておく。

参考サイト。

www.campalus.com

コマンド例。

$ sudo vi /etc/systemd/system/docker-tcp.socket
[Unit]
Description=Docker Socket for the API
[Socket]
ListenStream=2375
BindIPv6Only=both
Service=docker.service
[Install]
WantedBy=sockets.target

参考サイトのとおりではあるものの。

$ sudo systemctl enable docker-tcp.socket
$ sudo systemctl enable docker.socket
$ sudo systemctl stop docker
$ sudo systemctl start docker-tcp.socket
$ sudo systemctl start docker

$ systemctl status docker-tcp.socket
● docker-tcp.socket - Docker Socket for the API
   Loaded: loaded (/etc/systemd/system/docker-tcp.socket; enabled; vendor preset: enabled)
   Active: active (running) since Thu 2015-09-24 00:09:55 JST; 1h 33min ago
   Listen: [::]:2375 (Stream)

Sep 24 00:09:55 ubuntu systemd[1]: Listening on Docker Socket for the API.
Sep 24 00:09:55 ubuntu systemd[1]: Starting Docker Socket for the API.

$ netstat -a | grep 2375
tcp6       0      0 [::]:2375               [::]:*                  LISTEN

これでリモートからの接続も確認できるようになった。

$ telnet 10.37.129.3 2375
Trying 10.37.129.3...
Connected to 10.37.129.3.
Escape character is '^]'.

Ubuntu 15.04 へ Docker のインストール

以前、以下のように Ubuntu 14.04 への Docker インストールを試して、書き残していた。

yo1000.hateblo.jp

Ubuntu 15 もリリースされてしばらく経ったので、今度はこちらも同様に入れてみた。すごく簡単になっていた。簡単すぎたので説明するまでもないが、残しておく。

$ sudo apt-cache search docker
[sudo] password for : 
pidgin - X 向けのグラフィカルでマルチプロトコルなインスタントメッセージングクライアント
docker.io - Linux container runtime
fig - Punctual, lightweight development environments using Docker
golang-docker-dev - Externally reusable Go packages included with Docker
karbon - vector graphics application for the Calligra Suite
python-docker - Python wrapper to access docker.io's control socket
python3-docker - Python 3 wrapper to access docker.io's control socket
ruby-docker-api - Ruby gem to interact with docker.io remote API
vim-syntax-docker - Docker container engine - Vim highlighting syntax files
docker - KDE3/GNOME2 docklet アプリケーション用システムトレイ
kdocker - システムトレイにあらゆるプログラムを dock

$ sudo apt-get install docker.io vim-syntax-docker
...

$ docker --version
Docker version 1.5.0, build a8a31ef

比較的安定的に使用できる 1.5.0 がインストールされた。

念のため、今回の検証環境

$ uname -a
Linux ubuntu 3.19.0-15-generic #15-Ubuntu SMP Thu Apr 16 23:32:37 UTC 2015 x86_64 x86_64 x86_64 GNU/Linux

DBUnit をそのまま使うと ORACLE のシノニムが使えない

表題通りである。(二度目。) DBUnit は、何もせずに使うとシノニムを検出できない。これは、以前紹介した以下エントリの通り、DBUnit が DB のメタデータを必要とするのに影響する。

yo1000.hateblo.jp

シノニムが使用できない理由として、DBUnitメタデータチェックがある。DBUnit は、取得したメタデータとテストデータの照合を行う際に、実テーブルのカラム一覧を取得しなければならないのだが、ここでシノニムがテーブルとみなされず、テストデータで列挙されたカラムがメタデータ内に見つからない、という例外をスローしてしまうのである。

これを、メタデータとして DBUnit に検出させるためには、IDatabaseTester#getConnection() をオーバーライドして、DatabaseConfig.PROPERTY_TABLE_TYPE を設定してやる必要がある。

また、これだけでは完全ではなく、以下のようなシノニムの張り方をしていた場合、さらに DatabaseConfig.PROPERTY_METADATA_HANDLER も設定しなければならない。

f:id:Yoichi-KIKUCHI:20150922154313p:plain

ただし、このような要件に合致する IMetadataHandler の実装は用意されていないため、こちらは自作する必要がある。これらを解消するためのコードは以下のようになる。(例によって、Spring Boot での Bean 定義で使用した場合のコードサンプル。)

DatabaseTester 側の設定コード。

// 中略

  @Bean
  @Autowired
  public DataSourceDatabaseTester tester(DataSource dataSource,
      @Value("${spring.datasource.schema}") String schema) throws Exception {

    DataSourceDatabaseTester tester = new DataSourceDatabaseTester(
        new TransactionAwareDataSourceProxy(dataSource)) {
      @Override public IDatabaseConnection getConnection() throws Exception {
        IDatabaseConnection conn = super.getConnection();

        conn.getConfig().setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY, new OracleDataTypeFactory());
        conn.getConfig().setProperty(DatabaseConfig.PROPERTY_TABLE_TYPE, new String[] {"TABLE", "ALIAS", "SYNONYM"});
        conn.getConfig().setProperty(DatabaseConfig.PROPERTY_METADATA_HANDLER, new OracleResolvedSynonymMetaDataHandler());

        return conn;
      }
    };

    tester.setSchema(schema);
    tester.setSetUpOperation(DatabaseOperation.CLEAN_INSERT);
    return tester;
  }

// 中略

自作した IMetadataHandler のコード。

// The MIT License (MIT)
// 
// Copyright (c) 2015 Yoichi KIKUCHI
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// 
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package com.yo1000.example.component.support.dbunit.oracle;

import org.dbunit.database.DefaultMetadataHandler;

import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * Created by Yoichi.KIKUCHI on 2015/09/15.
 */
public class OracleResolvedSynonymMetaDataHandler extends DefaultMetadataHandler {
    protected static class ResolvedSynonym {
        private String owner;
        private String tableName;

        public ResolvedSynonym() {}

        public ResolvedSynonym(String owner, String tableName) {
            this.setOwner(owner);
            this.setTableName(tableName);
        }

        public String getOwner() {
            return owner;
        }

        public void setOwner(String owner) {
            this.owner = owner;
        }

        public String getTableName() {
            return tableName;
        }

        public void setTableName(String tableName) {
            this.tableName = tableName;
        }
    }

    private PreparedStatement cachedUserObjectsStatement;
    private PreparedStatement cachedAllSynonymsStatement;

    public PreparedStatement getCachedUserObjectsStatement() {
        return cachedUserObjectsStatement;
    }

    public void setCachedUserObjectsStatement(PreparedStatement cachedUserObjectsStatement) {
        this.cachedUserObjectsStatement = cachedUserObjectsStatement;
    }

    public PreparedStatement getCachedAllSynonymsStatement() {
        return cachedAllSynonymsStatement;
    }

    public void setCachedAllSynonymsStatement(PreparedStatement cachedAllSynonymsStatement) {
        this.cachedAllSynonymsStatement = cachedAllSynonymsStatement;
    }

    @Override public ResultSet getColumns(DatabaseMetaData databaseMetaData, String schemaName,
            String tableName) throws SQLException {
        this.setCachedUserObjectsStatement(databaseMetaData.getConnection()
                .prepareStatement("select OBJECT_TYPE from USER_OBJECTS where OBJECT_NAME like ?"));

        this.setCachedAllSynonymsStatement(databaseMetaData.getConnection().prepareStatement(
                "select TABLE_OWNER, TABLE_NAME from ALL_SYNONYMS where OWNER like ? and SYNONYM_NAME like ?"));

        ResolvedSynonym resolvedSynonym = this.resolveSynonym(schemaName, tableName);

        PreparedStatement statement = databaseMetaData.getConnection().prepareStatement("select "
                + "    NULL as table_cat, "
                + "    ? as table_schem, "
                + "    ? as table_name, "
                + "    column_name as column_name, "
                + "    DECODE (data_type, "
                + "        'CHAR'        ,     1, 'VARCHAR2'    ,     12, 'NUMBER'            ,        3, "
                + "        'LONG'        ,    -1, 'DATE'            ,     93, 'RAW'                 ,     -3, "
                + "        'LONG RAW',    -4, 'BLOB'            , 2004, 'CLOB            '    , 2005, "
                + "        'BFILE'     , -13, 'FLOAT'         ,        6, 'TIMESTAMP(6)',     93, "
                + "        'TIMESTAMP(6) WITH TIME ZONE'            , -101, "
                + "        'TIMESTAMP(6) WITH LOCAL TIME ZONE', -102, "
                + "        'INTERVAL YEAR(2) TO MONTH'                , -103, "
                + "        'INTERVAL DAY(2) TO SECOND(6)'         , -104, "
                + "        'BINARY_FLOAT', 100, 'BINARY_DOUBLE', 101, 1111) as data_type, "
                + "    data_type as type_name, "
                + "    DECODE (data_precision, "
                + "        null, DECODE ("
                + "            data_type    , 'CHAR'         , char_length, 'VARCHAR', char_length, 'VARCHAR2',"
                + "            char_length, 'NVARCHAR2', char_length, 'NCHAR'    , char_length, data_length"
                + "        ), "
                + "        data_precision) as column_size, "
                + "    0 as buffer_length, "
                + "    data_scale as decimal_digits, "
                + "    10 as num_prec_radix, "
                + "    DECODE (nullable, 'N', 0, 1) as nullable, "
                + "    NULL as remarks, "
                + "    data_default as column_def, "
                + "    0 as sql_data_type, "
                + "    0 as sql_datetime_sub, "
                + "    data_length as char_octet_length, "
                + "    column_id as ordinal_position, "
                + "    DECODE (nullable, 'N', 'NO', 'YES') as is_nullable "
                + "from "
                + "    ALL_TAB_COLUMNS "
                + "where "
                + "    OWNER like ? "
                + "and "
                + "    TABLE_NAME like ?");

        statement.clearParameters();
        statement.setString(1, schemaName);
        statement.setString(2, tableName);
        statement.setString(3, resolvedSynonym.getOwner());
        statement.setString(4, resolvedSynonym.getTableName());

        return statement.executeQuery();
    }

    protected boolean isSynonym(String objectName) throws SQLException {
        PreparedStatement statement = this.getCachedUserObjectsStatement();
        statement.clearParameters();
        statement.setString(1, objectName);
        ResultSet resultSet = statement.executeQuery();

        if (!resultSet.next()) {
            return false;
        }

        String objectType = resultSet.getString("OBJECT_TYPE");

        if (objectType == null) {
            throw new IllegalStateException(String.format(
                    "Target object type is unknown. objectName: %s",
                    objectName));
        }

        return objectType.equalsIgnoreCase("SYNONYM");
    }

    protected ResolvedSynonym resolveSynonym(String schemaName, String objectName)
            throws SQLException {
        if (!this.isSynonym(objectName)) {
            return new ResolvedSynonym(schemaName, objectName);
        }

        PreparedStatement statement = this.getCachedAllSynonymsStatement();
        statement.clearParameters();
        statement.setString(1, schemaName);
        statement.setString(2, objectName);
        ResultSet resultSet = statement.executeQuery();

        if (!resultSet.next()) {
            return new ResolvedSynonym(schemaName, objectName);
        }

        String resolvedSchemaName = resultSet.getString("TABLE_OWNER");
        String resolvedObjectName = resultSet.getString("TABLE_NAME");

        return this.resolveSynonym(resolvedSchemaName, resolvedObjectName);
    }
}

処理としてはそう難しいものではなく、実装した IMetadataHandler では、オブジェクトがシノニムであるかぎり、シノニム定義を管理するテーブルを繰り返し検索し、対象の実テーブルが見つかった時点で、カラムの一覧を取得する。

ただし、取得したカラムの一覧と、テストデータで指定されたテーブル名の組み合わせを合致させるため、ALL_TAB_COLUMNS への問い合わせでは、table_schem と、table_name の値を、シノニム解決を始めたときに最初に問い合わせたシノニムの名前で返してやっている。

こうすることで、DBUnit に与えたテストデータで、シノニム名と、実テーブルのカラム名を使用しても、DB メタデータとの照合に成功し、テストデータの処理が問題なく行われるようになる。

しかし、検索してもストレートな解決策を提示している記事が見つからなかったのだけど、他の皆さんはどのように解決しているのだろうか……。

DBUnit をそのまま使うと ORACLE 相手のテストが遅い

表題通りである。ORACLE データベースを相手に DBUnit を使用すると、とにかく遅い。

ただ、これには理由があり、解消方法も存在するが、これに気がつくまでかなり時間をかけてしまったので、残しておくことにする。

まず解消方法から。以下のように、テストデータを挿入するテーブルのスキーマを指定するだけで良い。(コードは Spring Boot で使用している場合の Bean 定義より抜粋)

// 中略

  @Bean
  @Autowired
  public DataSourceDatabaseTester tester(DataSource dataSource,
      @Value("${spring.datasource.schema}") String schema) throws Exception {

    DataSourceDatabaseTester tester = new DataSourceDatabaseTester(dataSource);

    tester.setSchema(schema);
    tester.setSetUpOperation(DatabaseOperation.CLEAN_INSERT);
    return tester;
  }

// 中略

これ、何が起きているかを説明するには、はじめに DBUnit がテストデータの挿入前に、何をしているかを知る必要がある。

  1. まず、DBUnit はテストデータを挿入する前に、与えられたテストデータを読み取る。ここまでは誰でも予想がつく動作だろう。
  2. つぎに、DBUnit はテストデータから得られた、ターゲットテーブル、ターゲットカラムが、対象 DB 内に存在するかどうかを確認する。
  3. このとき、対象 DB 内に該当するメタデータが存在しない場合、DBUnit は例外をスローして終了する。
  4. ここまでのチェックが完了して、はじめて SetUpOperation で指定された通りのテストデータ処理に入る。

以上が DBUnit のおこなう、テストデータに関する処理の流れなのだが、(3) で挙げた、対象 DB メタデータと、テストデータの照合の際に問題が生じるため、ORACLE 相手のテストで著しい遅延が発生する。(環境にもよるが手持ちの環境では、メタデータ取得だけで、10~20秒程度の待機が発生していた。)

問題とはどのようなものかというと、以下のようなものになる。

ORACLE データベースの JDBC ドライバでは、メタデータの取得時に、all_objects というシステムテーブルに問い合わせをかける。このテーブルではログインしているユーザーがアクセス可能な、あらゆるオブジェクトの情報が返されるのだが、ここでスキーマ指定がないと、本来テスト対象とはならないスキーマも含めた問い合わせがかけられ、これが遅延につながってしまう。

all_objects で返されるメタデータの中には、アクセス可能なだけで、ログイン中のユーザーでは操作自体は不可能な情報も多分に含まれているため、テストデータを用意するというオペレーションに対しては過大な情報といえる。そこで、ログインユーザーと同様のスキーマ情報を指定してやることで、ドライバ内で組み立てられるSQL で、all_objects に対する WHERE 条件が追加され、遅延は大きく改善されるようになる。(遅延改善後の待機時間は、0~2秒程度。)

ORACLE 相手に DBUnit を使用したいと考えたとき、気になる遅延が発生した場合には、試してみてほしい。

また、参考までにこれらの挙動の詳細を確認したい場合は、JAD などを使用して oracle.jdbc.OracleDatabaseMetaData の内容を確認してみるのも良いかと思う。

Spring で JSON null 値をフィールド型ごとに変換する

せっかく仕事が休みなので、ためていた分を書き残しておこうと思う。

Spring での JSON 変換に、Jackson が使用されているのはどなたもご存知のところかと思う。ところが、null 値が設定されていた場合に、レスポンスをフィールドの型ごとに変換したいような場合の例が非常に少ない。

そこで今回は、Spring Boot を使用したうえで、そんな要件があった場合のコード例を残しておこうと思う。以下全文。

@Configuration
public class JacksonConfiguration {
    protected static final String FILTER_ID = "PropertyConversionFilter";

    @Bean
    @Primary
    @ConditionalOnProperty(prefix = "application.jackson.null-conversion",
            name = "enabled", matchIfMissing = true)
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();

        SimpleFilterProvider filterProvider = new SimpleFilterProvider();
        filterProvider.addFilter(FILTER_ID, new PropertyConversionFilter());

        objectMapper.setFilterProvider(filterProvider);
        objectMapper.registerModule(new PropertyConverterModule());

        return objectMapper;
    }

    @JsonFilter(FILTER_ID)
    protected static class PropertyConverterMixIn {}

    protected static class PropertyConverterModule extends SimpleModule {
        @Override
        public void setupModule(SetupContext context) {
            context.setMixInAnnotations(Object.class, PropertyConverterMixIn.class);
        }
    }

    protected static class PropertyConversionFilter extends SimpleBeanPropertyFilter {
        @Override
        public void serializeAsField(Object pojo, JsonGenerator jgen, SerializerProvider provider,
                PropertyWriter writer) throws Exception {

            if (!(writer instanceof BeanPropertyWriter)) {
                super.serializeAsField(pojo, jgen, provider, writer);
                return;
            }

            BeanPropertyWriter beanPropertyWriter = (BeanPropertyWriter) writer;
            JavaType javaType = beanPropertyWriter.getType();
            Class<?> type = javaType.getRawClass();
            String name = beanPropertyWriter.getName();
            Object value = beanPropertyWriter.get(pojo);

            if (value != null) {
                super.serializeAsField(pojo, jgen, provider, writer);
                return;
            }

            if (Number.class.isAssignableFrom(type)) {
                jgen.writeNumberField(name, 0);
                return;
            }

            if (Boolean.class.isAssignableFrom(type)) {
                jgen.writeBooleanField(name, false);
                return;
            }

          if (String.class.isAssignableFrom(type) || Character.class.isAssignableFrom(type)) {
                jgen.writeStringField(name, "");
                return;
            }

            if (Collection.class.isAssignableFrom(type) || javaType.isArrayType()) {
                jgen.writeArrayFieldStart(name);
                jgen.writeEndArray();
                return;
            }

            super.serializeAsField(pojo, jgen, provider, writer);
        }
    }
}

せっかく Spring Boot を使用しているので、@ConditionalOnProperty アノテーションを使用して、この設定の有効無効を切り替えられるようにしてある。application.propertiesapplication.jackson.null-conversion.enable=true としておけば、レスポンス時のフィールド型に応じた、null 値の変換を行ってくれるようになる。

今回の例であれば、数値型は 0 に、論理型は false に、文字型および文字列型は "" に、それぞれフィールド値が変換されるようになっている。同様に型ごとの値変換処理を追記してやればいくらでも応用がきくので、こんなことも出来るということを覚えておくとどこかで使いどころがありそう。