torutkのブログ

ソフトウェア・エンジニアのブログ

ResultSetがStreamになったら嬉しいかも

はじめに

JDBCでデータベース検索のプログラムを書いていると、テーブル毎に似たような、そしてちょっとずつ異なるコードを書くことになります。 異なる部分は、カラム名、データ型、そして取り出したバラバラの値をJavaのデータクラス(ドメインクラスであったり、DTOであったり)に詰める処理です。

典型的には以下のようなプログラミングになるかと思います。

class DbAccessor {
    Connection conn;

    void initialize() {
        conn = DriverManager.getConnection("...");  // データベース接続用のURL・パラメータを指定しコネクションを取得
    }

    List<MyModel> getMyModelListOfDelta(int delta) {
        List<MyModel> models = new ArrayList<>();
        try (PreparedStatement statement = conn.prepareStatement("SELECT alfa,bravo,charlie,kilo FROM Phonetics WHERE delta=?")) {
            statement.setInt(1, delta);
            ResultSet result = statement.executeQuery();
            while (result.next()) {
                int alfa = result.getInt("alfa");
                String bravo = result.getNString("bravo");
                double charlie = result.getDouble("charlie");
                LocalDateTime kilo = result.getObject("kilo", LocalDateTime.class);
                models.add(new MyModel(alfa, bravo, charlie, kilo));
            }
        } catch (SQLException ex) {
            // ...
        }
        return models;
    }
}

JDBCでは、SQL検索文を発行した結果をResultSetで取得します。ResultSetはnext()でカーソルを進めて、getXXメソッドでカラムの値を取り出す仕様となっています。SQL文、PreparedStatementへのプレースホルダーの値設定、ResultSetからのカラムの取り出し、といった部分はテーブル固有の記載となります。

上述のように結構長々と書くことになり、また、同じテーブルでも検索の条件毎にSQL文とプレースホルダー、戻り値が異なるので、別々なメソッドにResultSetのイテレーション処理を記述することになります。

ResultSetをStreamにしたら

探してみると、ResultSetをjava.util.stream.Streamでラップするというブログ等がいくつか見つかりました。 また、jooq というライブラリも存在します。

ResultSetをStreamにするブログ等

  • Implementing a Spliterator for a JDBC ResultSet
    java.util.Spliterator インタフェースを使ってResultSetをStreamにするという内容。実際にはjava.util.SpliteratorsクラスのネストクラスAbstractSpliteratorを継承し、ResultSetからオブジェクトへのマッピングは著者の別ライブラリSqlMapperを使っている。Stream処理内(tryAdvanceメソッド内)で発生したSQLExceptionは、ResultSetのnextで生じたものはRuntimeExceptionにラップして投げており、ResultSetから値を取り出す際に生じたものは結果を返すオブジェクトにエラーを格納して戻り値としている。

  • 書籍 Java Closures and Lambda | Robert Fischer | Apress
    java.util.Spliterator インタフェースを使ってResultSetをStreamにする内容。こちらもAbstractSpliteratorを継承したResultSetSpliteratorクラスを定義し、ReslutSetからオブジェクトへのマッピングは、そのResultSetSpliteratorクラスの抽象メソッドprocessRowをさらに用途に応じてサブクラスで実装するというアプローチ。Stream処理内(tryAdvanceメソッド内)で発生したSQLExceptionはRuntimeExceptionにラップして投げている。

  • Against Boredom: Java 8: JDBC ResultSet to Stream AbstractSpliteratorのtryAdvanceを実装した無名クラスを生成し、ResultSetからは固定のRecordクラスにマッピングしている。Recordクラスは、カラム名をキーに、値をObject型で格納するMapをフィールドに保持する。

  • ResultSet の Stream 化 - なんとなくな Developer のメモ Spliteratorインタフェースを実装したクラスを定義している。ResultSetから任意の型のオブジェクトに変換するFunction(検査例外をスローする独自インタフェース)を受け取り、Stream処理内からSQLExceptionをスローし外側でキャッチできるようにしている。

お試し実装

AbstractSpliteratorを継承したResultSetSpliteratorを定義し、ResultSetの各行を任意の型のオブジェクトに変換するFunctionを外から渡せるようにします。上述ブログ・書籍の最後の記事にほぼ沿った実装です。

ResultSetSpliterator クラス
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Objects;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.function.Consumer;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

public class ResultSetSpliterator<T> extends Spliterators.AbstractSpliterator<T> {
    public static final int CHARACTERISTICS = Spliterator.IMMUTABLE | Spliterator.NONNULL;
    private final ResultSet resultSet;
    private TryFunction<ResultSet, T, SQLException> converter;

    public ResultSetSpliterator(
            ResultSet resultSet, TryFunction<ResultSet, T, SQLException> converter
    ) {
        this(resultSet, converter, 0);
    }

    public ResultSetSpliterator(
            ResultSet resultSet, TryFunction<ResultSet, T, SQLException> converter, int additionalCharacteristics
    ) {
        super(Long.MAX_VALUE, CHARACTERISTICS | additionalCharacteristics);
        Objects.requireNonNull(resultSet, "result set");
        this.resultSet = resultSet;
        this.converter = converter;
    }

    @Override
    public boolean tryAdvance(Consumer<? super T> action) {
        Objects.requireNonNull(action, "action to be performed");
        try {
            if (resultSet.isClosed() || !resultSet.next()) {
                return false;
            }
            action.accept(converter.apply(resultSet));
            return true;
        } catch (SQLException ex) {
            throw new RuntimeException(ex);
        }
    }

    public Stream<T> stream() {
        return StreamSupport.stream(this, false);
    }
}
TryFunction<T, R, E>クラス
@FunctionalInterface
public interface TryFunction<T, R, E extends Exception> {
    R apply(T t) throws E;
}

使用例

ResultSetをStreamにしたら、次のように記述できます。

    List<MyModel> getMyModelListOfDelta(int delta) {
        try (PreparedStatement statement = conn.prepareStatement("SELECT alfa,bravo,charlie,kilo FROM Phonetics WHERE delta=?")) {
            statement.setInt(1, delta);
            ResultSet result = statement.executeQuery();
            return new ResultSetSpliterator<>(result, r -> new MyModel(
                    r.getInt("alfa"), r.getNString("bravo"), r.getDouble("charlie"), r.getObject("kilo", LocalDateTime.class)
            ).stream().collect(toList());
        } catch (SQLException ex) {
            return List.of();
        }
    }

ここでは、ResultSetからオブジェクトへの変換をラムダ式でインライン記述しています。 複数個所で同じラムダ式を記述する必要が生じた場合は、ラムダ式をフィールドに定義して再利用する等ができます。

class Xxx {
    private TryFunction<ResultSet, MyModel, SQLException> resultSet2MyModel = r -> new MyModel(
            r.getInt("alfa"), r.getNString("bravo"), r.getDouble("charlie"), r.getObject("kilo", LocalDateTime.class)
    );

    List<MyModel> getAllMyModel() {
        try (var stmt = conn.prepareStatement("SELECT alfa,bravo,charlie, kilo FROM Phonetics")) {
            var resultSet = stmt.executeQuery();
            return new ResultSetSpliterator<>(resultSet, resultSet2MyModel).stream().collect(toList());
        } catch (SQLException ex) {
            return List.of();
        }
    }
}

グラフ表示アプリケーション作成時のトピック

はじめに

次の記事の続きです。 torutk.hatenablog.jp

データベース利用グラフ表示アプリケーションを作成している過程で生じた問題解決のメモです。

RDBMSスキーマにおいて、

  • 外部キーでエンティティ間に関連を持たせる場合
  • 自動採番するIDを主キーとするテーブルに新規レコードをインサートし、採番されたIDを取得したい
  • 種類を表現する場合

JavaFXにおいて、

  • 一つの時系列データ(XYChart.Series<>)を2つのグラフに表示するには

をどう扱ったかをメモします。

外部キーでのエンティティ間に関連

先に作成したスペクトラムテーブルと関連する測定記録テーブルを定義します。 測定記録には、測定日時、測定したスペクトラム、測定条件などを記録します。測定条件には測定に使用した器材、追加の測定条件(アッテネーター、VBW、リファレンスレベル、温度、他)があります。今回は感触をつかむ初版として列を最小限とし、次の設計としました。

  • テーブル: Measurements
列名 データ型 NOT NULL制約 備考
id bigint あり 主キー(自動インクリメント)
measurement_time datetime2(3) あり 時刻帯は日本固定
spectrum_id bigint あり 外部キー
RBW real あり Resolution Band Width

このテーブルは今後、がどんどん増えてくる可能性があります。

測定時刻

日時を扱うデータ型には、SQL Serverの場合、datetime、datetime2、smalldatetime、datetimeoffsetとがあります。 どれを選択すればいいかを判断するため、それぞれの内容を調べてみました。

データ型 有効範囲 分解能 ストレージサイズ
datetime 1753年1月1日から9999年12月31日 1/300秒 8バイト
datetime2 0001年1月1日から9999年12月31日 100ナノ秒 6~8バイト(秒以下の小数部桁数による)
smalldatetime 1900年1月1日から2079年6月6日 1分 4バイト
datetimeoffset 0001年1月1日から9999年12月31日 100ナノ秒 8~10バイト(秒以下の小数部桁数による)

datetimeoffsetは、UTC時刻からのオフセット(例えば日本時間ならUTC+9:00)を持つので、時差を扱う必要があるアプリケーションで選択します。時差は不要(日本時間一択とする、UTC時刻で統一する、等)であれば、測定時刻を比較する際にミリ秒以下の精度を要するか、1753年より古い時刻を扱う必要があるならdatetime2を選択します。 それ以外であれば、datetimeとdatetime2のどちらがいいかは微妙なところです。 smalldatetimeはストレージサイズは小さいですが、60年後には有効範囲が切れるので考えものです。

今回は、日本時刻固定で扱うこととし、精度は秒以下の小数点桁を3桁(ミリ秒)としてdatetime2(3)を採用しました。

スペクトラムテーブルへの参照

エンティティ(テーブル)の関連は、関連先のテーブルの一意となる列の値を関連元の列に格納する外部キーと言われる方法となります。外部キーは、また、制約としてテーブルのメタデータに保管します。

今回、測定記録テーブルからスペクトラムテーブルへの関連を持たせるので、測定記録テーブルの列にスペクトラムテーブルの一意な列(ここでは主キーたるSpectrumsテーブルのid列)の値を格納するため、同じデータ型(bigint)で、名前をspectrum_idとした列を設けます。

測定記録を新規作成しデータベースに登録する際困った事

測定記録に新規登録するレコードを用意するには、外部キーとして参照しているスペクトラムテーブルの関連付けするレコードのid(主キー)が必要です。

通常のユースケースでは、

となります。ここで、スペクトラムテーブルのid列は自動採番(identity)としているので、スペクトラムテーブルへのINSERT時にアプリケーション側ではidが分からず、INSERTしたレコードのidを何とかして取ってくる必要があります。

直前にINSERTしたレコードの自動採番idを取得する方法の調査

SQL ServerSQLで実現方法を調べたところ、次の3つの方法がありました。

  • @@IDENTITY → 使うな!危険!のコメントが付いていることが多い
  • IDENT_CURRENT 指定したテーブルのIDENTITYな値を取る(SQL Serverではidentityによる自動インクリメントな列は1個だけ設定可)
  • SCOPE_IDENTITY

一方、JDBCAPIに自動で生成される値を取り出す方法が用意されていました。

次の引数バリエーションのprepareStatementを使用します。

PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException

2場面の引数には、Statement.RETURN_GENERATED_KEYS を指定します。 そして、通常通りINSERTのSQLを実行した後に、preparedStatementオブジェクトのgetGeneratedKeys()メソッドを実行しResultSetを戻り値として得ます。 ResultSetのnext()を呼んでカレント行を示し、自動採番のキーの列の型に合わせてResultSetから値を取得します。(int型の列が自動採番ならgetInt(1)のように)

    Connection conn;
    :
    try (var stmt = conn.prepareStatement(
            "INSERT INTO Spectrums (name, spectrum, startFrequency, stopFrequency) VALUES (?, ?, ?, ?)",
            Statement.RETURN_GENERATED_KEYS
    )) {
        stmt.setNString(1, spectrum.getName());
        stmt.setBytes(2, spectrum.getData());
        stmt.setDouble(3, spectrum.getStartFrequency());
        stmt.setDouble(4, spectrum.getStopFrequency());
        stmt.executeUpdate();
        ResultSet generatedKeys = stmt.getGeneratedKeys();
        if (generatedKeys.next()) {
            spectrum.setId(generatedKeys.getInt(1));
        } else {
            throw new SQLException("No identity id obtained");
        }
    }

種類をもたせる

Javaでいえば、enum型を定義したいときがあります。Javaenumの解説で良く出てくるのがトランプのスイート(スペード、ダイヤ、ハート、クラブ)です。

RDMBSでも、MySQLにはENUM型があり、SQL Server等では代替としてCHECK制約で格納可能な文字列を指定するといったことがあります。しかし、種類の一覧をとりだすといったことが手間であるなどデメリットが多く、ここでは種類の一覧だけ格納する別テーブルを設けます。

種類が文字列で一意なら、種類文字列を1つだけ列に持つテーブルを定義し、主キーを種類文字列にします。 これだと、外部キーが文字列になるので、外部キーを持つテーブルだけをSELECTで閲覧しても意味が分かる列となります。

JavaFXのチャート2つに同一のXYChart.Seriesを貼ったら

折れ線グラフが二つのウィンドウにあって、同一データ(XYChart.Series)をそれぞれに貼ったところ、おかしな挙動となってしまいました。一つのXYChart.Seriesは、一つのChartにしか使用できません。

そこで、中のデータをコピーしたSeriesを作って回避しました。

XYChart.Series<Float, Float> copy(XYChart.Series<Float, Float> series) {
        var dupSeries = new XYChart.Series<Float, Float>();
        series.getData().stream()
                .map(data -> new XYChart.Data<>(data.getXValue(), data.getYValue()))
                .collect(Collectors.collectingAndThen(
                        Collectors.toCollection(FXCollections::observableArrayList),
                        l -> dupSeries.getData().addAll(l)));
       return dupSeries;
}

データベースを利用するアプリケーション(データベース接続)

はじめに

次の記事の続きです。

torutk.hatenablog.jp

データベースの作成が終わったら、いよいよアプリケーションからデータベースへ接続してデータの読み書きをします。

JDBC API

Java SEではRDBMSを利用する標準APIとしてJDBCJava DataBase Connectivity)が提供されています。これは利用するRDBMS製品には依存しておらず、利用するRDBMSを交換可能とする仕様となっています。

このJDBC APIを使って特定のRDBMS製品へ接続するためには、RDBMS製品固有のJDBCドライバーをSPI(Service Provider Interface)を介して組み込みます。SPIの実現には、これもJava SEの標準APIであるServiceLoaderが使われています。APIとSPIを使った実装提供により、アプリケーションからは標準のAPIのみを使ってプログラミングし、実装の切り替えはSPIで行うということが可能になります。

Microsoft SQL Server JDBCドライバー

Microsoft SQL ServerJDBCで利用する際は、Microsoftから提供されるSQL ServerJDBCドライバーを使います。このJDBCドライバーはJARファイルで提供されており、これをクラスパスまたはモジュールパスに指定することで利用できるようになります。2019年8月現在、SQL ServerJDBCドライバーはバージョン7.4.1が提供され、Java SE 8用、Java SE 11用、Java SE 12用のそれぞれのJARファイルが別々に用意されています。

今回は、Java SE 11でアプリケーションを作成・実行するので、Java SE 11用のJARファイルを使います。

プログラミング

JDBCAPIでは、データベースへ接続するためのクラスが2種類あります。

  1. DataSource
    JNDI(Java Naming and Directory Interface)を使って、外部のディレクトリ・サーバーに置かれたデータベース接続情報・ドライバーを取り出してデータベースへ接続します。アプリケーションサーバー上で動作するプログラム(Java EE環境)では一般的ですが、スタンドアロン・プログラム(Java SE環境)ではディレクトリ・サーバー相当を動かすことがないので利用しづらい方法です。

  2. DriverManager
    データベース接続情報をURL(文字列等)で指定し、ServiceLoaderで取得したJDBCドライバーを使用してデータベースへ接続します。Java SE環境ではこちらを使うことが多いです。

データベース接続の取得

java.sql.DriverManagerクラスのgetConnectionメソッドに接続情報をURL形式で指定して接続(java.sql.Connection)を取得します。

Connection conn;
  :
try {
    conn = DriverManager.getConnection("jdbc:sqlserver://localhost;databaseName=Island;user=foo;password=var");
} catch (SQLException ex) {
    // ...
}

接続情報に記載されたjdbc:sqlserverの情報から、SQL Server用のJDBCドライバーがSPIで利用可能となります。プログラム上では明示的な指定は不要です。

接続情報をハードコーディングするのは実用的ではないので、外部のファイル、環境変数等に定義してプログラムでその情報を取り込みます。DriverManagerのgetConnectionメソッドには、引数のバリエーションがいくつか用意されています。

Connection getConnection(String url) throws SQLException
Connection getConnection(String url, String user, String password) throws SQLException
Connection getConnection(String url, Properties info) throws SQLException

外部ファイルに接続情報を記述して読み込むならば、Javaのプロパティファイル形式で記述し読み込み、3番目のシグニチャでプロパティを指定するのがよさそうです。

ここで取得したConnectionインスタンスは最後にclose()する必要があります。

Spectrumテーブルからidを指定して1件のレコードを取得

Spectrumテーブルから、idを指定して、3つの列(名前、周波数の開始値、周波数の終了値)を取り出します。

public Spectrum getSpectrum(long id) {
    try (PreparedStatement statement = conn.prepareStatement("SELECT name, startFrequency, stopFrequency FROM Spectrums WHERE id=?")) {
        statement.setLong(1, id);
        ResultSet result = statement.executeQuery();
        if (result.next()) {
           String name = result.getNString("name");
           double startFrequency = result.getDouble("startFrequency");
           double stopFrequency = result.getDouble("stopFrequency");
           return new Spectrum(name, startFrequency, stopFrequency);
        }
    } catch (SQLException ex) {
        // ...
    }

安全性から、SQLはPreparedStatementで用意します。プレースホルダーでWHERE句のidの値を設定します。プレースホルダーは複数設けられるので設定に際しては何番目かを示す数値と、埋め込む値を指定します。 PreparedStatementが完成したら、executeQueryで検索系のSQLを実行します。結果はResultSet型のオブジェクトで返ります。 ResultSetの中身を取り出すには、最初にnext()を呼びます。複数レコードが結果として返ってきた場合は、1レコード毎に最初にnext()を呼びます。 ResultSetの中身を取り出すには、列名を指定するか、序数を指定します。序数は、SELECT文の場合、SELECTの次に指定する列名(カンマ区切り)の列挙の順番を示します(最初が1)。最初は序数で指定していましたが、SELECTで取り出す列名を変えたりしておりそのたびに序数を付け直すのが手間でした。そこで、列名で指定するようにしました。列名は定数で一か所に定義しておくのがよいでしょう。

PreparedStatementは実行後にcloseする必要があります。try-with-resource構文で使用すれば自動でcloseを呼び出します。

データベースを利用するアプリケーション(データベース作成)

はじめに

続きです。

torutk.hatenablog.jp

今回は、データベースにテーブルを定義し、アプリケーションから利用できるようにします。 利用するデータベースは先の日記に記載のとおり、SQL Serverです。

データベースの作成ツール

SQL Server Express Edition(またはDeveloper Edition)がインストールされた環境で開発を進めます。 データベースの新規作成、テーブルの新規作成は、SSMS(SQL Server Management Studio)から行えます。 作成後のカラムのデータ型変更などもできるので、設計が固まるまではSSMSで作業します。

ER図を試行錯誤で作成しながらテーブルの設計をしましたが、今回はER図は頭の整理のためのスケッチとしての役割に留まっています。

SQL Serverのコマンド環境は、SQL Serverをインストールしたマシンであれば、コマンドプロンプトからsqlcmdコマンドを実行すると利用できます。対話モードとSQLファイルを実行するモードがあります。

データベース作成

データベース領域の作成

テーブルを定義するために、まずデータベースを1個作成します。ここにテーブル群を定義していきます。

スペクトラムデータのテーブル

まずはデータファイルの内容をデータベースのテーブルに定義します。項目としては次を入れます。

  • 名前(元のファイル名)
  • 周波数の開始値
  • 周波数の終了値
  • 電力値の点列

かなり絞った項目としています。データの取得日時、取得に使用した計測器・配線などの計測系、取得した信号の素性、などなど盛り込みたい情報があります。計測条件、計測対象、計測記録はそれぞれ別テーブル(別エンティティ)として定義する方針とします。

テーブルのスキーマ(現時点の結果)

列名 データ型 NOT NULL制約 備考
id bigint あり 主キー(自動インクリメント)
name nvarchar(50) なし スペクトラム
startFrequency real あり 周波数の開始値
stopFrequency real あり 周波数の終了値
spectrum varbinary(max) あり スペクトラム点列

先に挙げた項目の他に、idを追加しています。RDBMSでは、レコードを一意に識別する主キーが必要です。名前を主キーにするとしたら、名前は任意に付与できるので一意を保つにはアプリケーション側(ユーザー側)で頑張る必要があります。これは運用がやっかいなので避けたいところです。また、他の項目でレコードを一意に表現できるものはありません。そこで、サロゲートキーを設けることとします。

NOT NULL制約は、データを成立させるのに不可欠な項目に付与します。

idの設計

idは、整数でインサートごとに自動でインクリメントして採番します。データ型は最初int(32bit符号付整数)としていましたが、intだとレコード数が21億( 2^{31}-1)を超えられないので、多数のデータを保持する可能性のあるテーブルではbigintにしておきます。 自動インクリメントにするには、SSMS上では列のプロパティから、IDENTITYの指定でIDであるを[いいえ]から[はい]に変更します。

nameの設計

スペクトラムデータの名称を保持します。SQL Serverの文字列は、ユニコード文字と非ユニコード文字とそれぞれのデータ型が用意されています。nchar、nvarcharがユニコード文字のデータ型で、char、varcharが非ユニコード文字のデータ型です。

日本語Windows環境でデフォルトで構築した場合、非ユニコードはデフォルト(既定)ではコードページ932(Windows_31J)で扱われます。アプリケーション側の文字コードユニコードなので、データベース側で非ユニコードのデータ型を使うと文字コード変換が生じていろいろ面倒になりそうです。そこで、ASCII文字に限定できない場合はユニコード文字を扱うデータ型とします。

nvarcharでサイズ50を指定した場合は、最大50文字(UCS-2の範囲)となります。補助文字を使用する照合順序を指定した場合は、UTF-16となるので、サロゲートペア文字が使われた場合は50文字より少なくなります。

なお、SQL Serverでは、文字列を格納するデータ型にtext、ntextもありますが、これらは非推奨となっています。

周波数の開始値、終了値

小数点の数値は、float、real、decimal のデータ型が使えます。(numeric型もありますが、SQL Serverではdecimalと同一です。)

floatとrealは浮動小数点数のデータ型で、decimalは固定長の有効桁数(10進数)と小数点以下の桁数を指定したデータ型です。 floatは有効桁数(仮数部のbit数)を1~53で指定しますが、内部では24もしくは53のどちらかで扱います。 realは、float(24)と同義です。 周波数の値はそれほど有効桁数を必要としないので、今回はストレージ上のサイズが小さくてすむrealを使用します。

spectrumの設計

スペクトラムデータの点列を保持します。バイナリデータですが点数がまちまちのため可変長のバイナリとします。サイズを数値指定する場合は1~8000の範囲なので最大8000バイトとなります。しかし、格納したい点列は1万点を超えることがあるので、この場合はサイズにmaxを指定します。maxを指定すると、最大で  2^{31} - 1 バイトまで格納可能です。

なお、binary、image は非推奨となっています。

計測記録テーブルへの参照を含めるか

後で作成する計測記録のテーブルには、スペクトラムデータを参照する外部キーを設ける予定です。 その逆方向としてスペクトラムのテーブルに計測記録への外部キーを設けるかどうか悩みました。 双方向の関連を持つか、片方向の関連のみとするか、現時点では明確な根拠を持てないでいます。必要性を感じるまでは、スペクトラムのテーブルには計測記録への外部キーは持たせないでおきます。

Microsoft SQL Server JDBCドライバーにmodule-info定義を追加してモジュール対応

はじめに

Microsoft SQL Server JDBCドライバーのモジュール対応(module-info.classの組み込み)を試みました。経過をメモします。

経緯

SQL Server JDBCドライバーは、自動モジュール対応までしており、モジュールアプリケーションから利用できます。自動モジュールとは、JARファイルにはmodule-info.classが組み込まれておらず、マニフェストファイルのAutomatic-Module-Name属性にモジュール名が定義されたJARファイルです。

しかし、アプリケーションの配布イメージをJLinkで作成する際、自動モジュールを使用しているアプリケーションではエラーとなってしまいます。JLinkは、自動モジュールに対応していないためです。

そこで、後付けでmodule-info.classをSQL Server JDBCドライバーのJARファイルに突っ込み、モジュール対応したものとしてJLinkにかけてみます。

module-info.classの生成とJARファイルへの後付け(試行1回目)

まず、JDBCドライバーのJARファイル mssql-jdbc-7.4.1.jre11.jar の依存関係を抽出します。jdepsコマンドを使いますが、オプション指定にいろいろあり、何が正解か分かりませんので試した結果をメモします。

mssql-jdbc-7.4.1.jre11.jarの依存関係を調べる

--generate-module-info
D:\work> mkdir out
D:\work> jdeps --generate-module-info out mssql-jdbc-7.4.1.jre.jar
Missing dependence: D:\work\out\com.microsoft.sqlserver.jdbc\module-info.java not generated
エラー: 依存性が欠落しています
   com.microsoft.sqlserver.jdbc.KeyVaultCredential    -> com.microsoft.aad.adal4j.AuthenticationCallback    見つかりません
  :
--list-deps
D:\work> jdeps --list-deps mssql-jdbc-7.4.1.jre11.jar
   java.base
   java.logging
   java.naming
   java.security.jgss
   java.sql
   java.transaction.xa
   java.xml
--list-reduced-deps
D:\work> jdeps --list-reduced-deps mssql-jdbc-7.4.1.jre11.jar
   java.base
   java.naming
   java.security.jgss
   java.sql

--list-deps オプションと --list-reduced-deps オプションの差は、後者は推移的な依存関係のあるものはリストされないというもののようです。

module-info.javaを作成

module名は、mssql-jdbc-7.4.1.jre11.jar の自動モジュールで公開しているモジュール名 com.microsoft.sqlserver.jdbc とします。 APIとして公開するパッケージ com.microsoft.sqlserver.jdbc をexports宣言し、このAPIが実行時に最低限必要なモジュールをrequires宣言します。必要なモジュールは、先のjdepsコマンド(--list-reduced-depsオプション)で調べたものを記述します。なお、java.baseは暗黙でrequiresされるので省略します。

module com.microsoft.sqlserver.jdbc {
    exports com.microsoft.sqlserver.jdbc;
    requires java.naming;
    requires java.security.jgss;
    requires java.sql;
}

module-info.java から module-info.classを生成し、jarファイルに追加

D:\work> javac --patch-module com.microsoft.sqlserver.jdbc=mssql-jdbc-7.4.1.jre11.jar module-info.java

module-info.class が生成されます。 このmodule-info.class をJARファイルに追加します。

D:\work> jar uvf mssql-jdbc-7.4.1.jre11.jar module-info.class
module-infoが更新されました: module-info.class

JLink で使用

JDBCを使用するアプリケーションの配布イメージをjlinkで作成します。

エラー: 署名済モジュラJAR D:\work\mssql-jdbc-7.4.1.jre11.jarは現在サポートされていないため、エラーを抑止するには--ignore-signing-informationを使用します

とエラーになったので、jlinkのコマンドにオプション --ignore-signing-information を追加します。

警告: 署名済モジュラJAR D:\toru\Documents\work\job\mcc\Glowlink\spectrum_viewer\.\mssql-jdbc-7.4.1.jre11.jarは現在サポートされていません

とjlinkコマンドがエラーから警告に変わり、配布イメージが生成されました。

配布イメージのモジュールにJDBCが含まれるかを確認

D:\work\myapp\out\runtime> bin\java --list-modules
myapp
com.microsoft.sqlserver.jdbc
java.base@11.0.4
java.datatransfer@11.0.4
  :

一応生成できています。

実行

D:\work\myapp\out\runtime> bin\myapp.bat
java.sql.SQLException: No suitable driver found for jdbc:sqlserver://localhost;databaseName=spectrum;user=foo;password=bar
        at java.sql/java.sql.DriverManager.getConnection(Unknown Source)

JDBCドライバが見つからないとのエラーになってしまいました。

回避策(場当たり的な)

本来は不要ですが、プログラム中でJDBCドライバークラスを明示的にnewして登録すると実行可能となりました。

static {
    try {
        DriverManager.registerDriver(new com.microsoft.sqlserver.jdbc.SQLServerDriver());
    } catch (SQLException ex) {
        logger.log(Level.WARNING, "DriverManager cannot register", ex);
    }
}

実行時エラーの調査

JDBCドライバーが見つからないという実行時エラーの原因を探ってみます。

オリジナルのJDBCドライバーのモジュール情報

D:\libs> jar --describe-module --file mssql-jdbc-7.4.1.jre11.jar
モジュール・ディスクリプタが見つかりません。自動モジュールが導出されました。

com.microsoft.sqlserver.jdbc@7.4.1.jre11 automatic
requires java.base mandated
provides java.sql.Driver with com.microsoft.sqlserver.jdbc.SQLServerDriver
contains com.microsoft.sqlserver.jdbc
contains com.microsoft.sqlserver.jdbc.dataclassification
contains com.microsoft.sqlserver.jdbc.dns
contains com.microsoft.sqlserver.jdbc.osgi
contains microsoft.sql
contains mssql.googlecode.cityhash
contains mssql.googlecode.concurrentlinkedhashmap
contains mssql.security.provider

module-info.classを後付けしたJDBCドライバーのモジュール情報

D:\work> jar --describe-module --file mssql-jdbc-7.4.1.jre11.jar
com.microsoft.sqlserver.jdbc jar:file:///D:/work/mssql-jdbc-7.4.1.jre11.jar/!module-info.class
exports com.microsoft.sqlserver.jdbc
requires java.base mandated
requires java.naming
requires java.security.jgss
requires java.sql
contains com.microsoft.sqlserver.jdbc.dataclassification
contains com.microsoft.sqlserver.jdbc.dns
contains com.microsoft.sqlserver.jdbc.osgi
contains microsoft.sql
contains mssql.googlecode.cityhash
contains mssql.googlecode.concurrentlinkedhashmap
contains mssql.security.provider

モジュール情報の違いを分析

元のmssql-jdbc-7.4.1.jre11.jarと、module-info.classを後付け挿入したmssql-jdbc-7.4.1.jre11.jarとの違いを見ると、今回の実行時エラー(ドライバーが見つからない)に関係しそうな相違点は、provies項です。

provides java.sql.Driver with com.microsoft.sqlserver.jdbc.SQLServerDriver

各種JDBCドライバーは、SPI(Service Provider Interface)で提供されており、モジュール仕様以前のクラスパスではJARファイルの中にMETA-INF/services/java.sql.Driver ファイルを置き、このファイルの中にSPIの実装クラスのFQCNを記載します。 mssql-jdbc-7.4.1.jre11.jar の場合は、このファイルの中に次の記述があります。

com.microsoft.sqlserver.jdbc.SQLServerDriver

このJARファイルがクラスパスにあると、サービスローダーがSPIの実装クラスを認識してロードすることができます。

モジュール仕様では、SPIの実装はモジュール定義(module-info.java)にprovides宣言で記述します。

module-info.classの生成とJARファイルへの後付け(試行2回目)

module-info.javaに、provides宣言を追加し、コンパイルしたmodule-info.classをJARファイルに追加します。

module-info.javaの再記述

module com.microsoft.sqlserver.jdbc {
    exports com.microsoft.sqlserver.jdbc;
    provides java.sql.Driver with com.microsoft.sqlserver.jdbc.SQLServerDriver;
    requires java.naming;
    requires java.security.jgss;
    requires java.sql;
}

module-info.classの再生成

D:\work> javac --patch-module com.microsoft.sqlserver.jdbc=mssql-jdbc-7.4.1.jre11.jar module-info.java

module-info.class を mssql-jdbc-7.4.1.jre11.jar に挿入

D:\work>jar uvf mssql-jdbc-7.4.1.jre11.jar module-info.class
module-infoが更新されました: module-info.class

モジュール情報の確認

D:\work> jar --describe-module --file mssql-jdbc-7.4.1.jre11.jar
com.microsoft.sqlserver.jdbc jar:file:///D:/work/mssql-jdbc-7.4.1.jre11.jar/!module-info.class
exports com.microsoft.sqlserver.jdbc
requires java.base mandated
requires java.naming
requires java.security.jgss
requires java.sql
provides java.sql.Driver with com.microsoft.sqlserver.jdbc.SQLServerDriver
contains com.microsoft.sqlserver.jdbc.dataclassification
contains com.microsoft.sqlserver.jdbc.dns
contains com.microsoft.sqlserver.jdbc.osgi
contains microsoft.sql
contains mssql.googlecode.cityhash
contains mssql.googlecode.concurrentlinkedhashmap
contains mssql.security.provider

provides項が見えています。

JLinkで配布イメージ生成後実行

SQL Serverに接続して無事データベースアプリケーションとして実行できるようになりました。

まとめ

  • Microsoft SQL Server用のJDBCドライバー(ライブラリ)は、自動モジュール(Automatic module)形式のJARファイルとして配布されている(Ver. 7.0以降)
  • 自動モジュールのJARは、モジュール対応アプリケーションからモジュール指定でコンパイル・実行ができる
  • 自動モジュールのJARを使用しているモジュール対応アプリケーションは、JLinkで配布イメージを生成することができない
  • 非モジュール(自動モジュールも含めて)のJARをモジュールJARにするには、module-info.javaを記述してコンパイルした後、JARファイルに追加する
  • module-info.javaを記述するには、jdepsコマンドで依存関係を解析する

データベースを利用するアプリケーション(作成準備編)

動機

先月から折れ線グラフで表示するプログラムをJavaFXで作っています(自宅や出張中の宿で細々とです。出張先では通勤時間とか家事とかがないので捗りました)。

torutk.hatenablog.jp

スペクトラム・アナライザのデータをファイルから読み込む機能を持たせていますが、ファイルの数が増えてくると、ファイルの管理が大変になります。例えば、どの信号を解析したものか、それはいつのものか、解析に使った計測器は何か、計測時の条件はどうだったか、などなどをファイルと関連付けて管理しなくてはなりません。

ディレクトリ名とファイル名を工夫する等で多少は関連付けを表現できます。解析対象、計測器、計測日時などをディレクトリとし、条件をファイル名にする、といった工夫です。

信号種類A \ XWV8765-BA \ 20190818-02-51 \ ATT15_SPAN100M_RBW20K_GX.trace

しかし、単一ツリーのディレクトリ管理ではやがて限界に達してしまいます。特にディレクトリの底の条件、ファイル名に含んだ条件を探すときなどです。

そこで、データベースを使ってこれらのデータを系統立てて保存・管理し、表示したいデータを条件を指定してすぐに取り出せるようにします。

データは、ローカルなネットワーク上に置いて複数台のクライアントから利用可能とします。

どんなデータベースを使うか?

スペクトラム・アナライザのデータ(スペクトラム・アナライザの画面に表示されるある瞬間のデータ)は、1GHzの周波数範囲をRBW 20KHzで取得していた場合、 5万点の点列データとなります。周波数とパワー(X軸とY軸)を1点ごとに1レコードで管理すると1回分で5万レコードになります。1時間に1回計測があるとしたら、年間で1台あたり4億レコード超といった計算になります(10秒に1回だと1兆レコード超え)。

もしリアルタイムにこのレコードを記録し検索するのであれば、時系列データベースなどを使うことを検討します。

今回はスペクトラムデータを、計測条件を指定して取り出し表示するアプリケーションで使うことが主なユースケースとなります。スペクトラムデータの格納はオフラインで別途行うため、書き込みは検討外とします。 そこで、条件を指定してデータを取り出すことに向いていると考えるRDBMSを使うことにします。

ただし、点列データを1点1点レコードに入れるとサイズが増大してしまいますので、データベース設計上の課題とします。解決方針は、1回分の点列データをバイナリデータとして1つのレコードに格納します。 点列データの利用方法は、1回分のデータを一括して取り出すもので、点列データの中の特定の値だけ検索して取りだすことはないためです。(アプリケーションの機能として点列データの一部を切り取ることはありますが、データベースには要求しません)

データベースと開発ツール

RDBMSの選択

今回は、ゼロベースの選定はしません。既にある計測器の中で使われており、またローカルのネットワーク上に既にインストールしているデータベースにMicrosoft SQL Serverがあるので、それを使うこととします。

SQL Serverは、大規模(高スペックなサーバー上で稼働)用のEnterprise Edition、中規模用のStandard Editionがあり、それぞれ有償ライセンス(180日間の試用期間あり)となっています。また、小規模(1つのデータベース領域<10GB)のExpress Editionが無償ライセンスで提供されています。開発・テスト用にはDeveloper Editionが無償で提供されています。

SQL Serverの開発環境

開発用に、Express EditionあるいはDeveloper Editionを開発マシンに入れると便利です。 管理ツールとして、SQL Server Management Studio(SSMS)を合わせて入れます。

データベース設計用にはER図も使えるA5:SQL Mk-2を入れてみます。これはWindows用のみですが、無償で利用可能です。自宅環境なのでこれを使うことにしました。

仕事上であれば、有償のデータベース設計用ツールも視野に入れて、ER/Studio、ERWin、SI Object Browserなども候補に挙げるといいかと思います。

JavaからSQL Serverの利用

SQL Server用のJDBCドライバーがMicrosoftから提供されています。

JavaからRDBMSを使うときのAPI選定

JavaJava SE)からRDBMSを使うときに、APIとしていくつかの選択肢があります。

  • JDBC APIを使う JDBCドライバーを入れて、Java SE標準のJDBC APIを使いDBとデータをやり取りする方法
  • Java Persistent APIJPA)を使う JPA仕様に準拠したサードパーティ・ライブラリ(eclipselink等)を使い、JPAのO/Rマッパー経由でDBとデータをやり取りする方法
  • サードパーティ・ライブラリ独自APIを使う 標準APIではなく、サードパーティ・ライブラリが独自に提供するAPIを使います。いろいろ種類がありますが、おおざっぱに分類すると
    • JDBCをラップする比較的小さなライブラリ(iciql、jooq、speedment等)を使う
    • 独自のマッパーを提供する比較的大きなライブラリ(MyBatis、DOMA等)を使う

今回のAPI選定基準

次の観点を選定基準にします。

  • Java SE 以外のサードパーティ・ライブラリへの依存を少なくしたい
  • アプリケーションのソースコード構成を簡潔に保ちたいので自動生成のソースや設定ファイルは極力無にしたい
  • RDBMSでのテーブル(スキーマ)設計、SQLの書き方をこの機に勉強したい

ということで、JDBC APIを使う方法とします。

RDBMSの勉強を上げたのは、RDBMSアプリ開発での利用経験が少ないからです。 これまでのソフトウェア開発経験で使ったデータベースは、Microsoft AccessのJETエンジン、ObjectStore DB(OODBMS)です。OracleSQLiteMySQL、H2 Database Engine(+JPA)は開発インフラとして整備、チューニングに携わりましたが、これらDBをアプリから使う開発はあまりしていません。 ソフトウェア開発とは別に、Redmineの環境構築・運用管理でMySQLを扱っているので、RDBMSの素養はRedmineの管理経験に拠るところが大きいです。

JDBCドライバーを使ってDBアクセス

Java SEのJDBC APIでは、まず使用するデータベース用のJDBCドライバーを入れる必要があります。

SQL Server用のJDBCドライバーの入手

Microsoft SQL Serverについては、MicrosoftからType 4 JDBCドライバーが提供されています。

Microsoft SQL Server 用 JDBC Driver のダウンロード - SQL Server | Microsoft Docs

2019-08-18時点では、最新バージョン7.4.1がリリースされています。これは、

となっています。

これをダウンロードし、適切なディレクトリに展開します(リリースノートではC:\Program Files下を推奨)。今回は、@C:\Program Files\Java\sqljdbc_7.4@に展開しました。 JDK 8用、JDK 11用、JDK 12用のJARファイルが存在します。

mssql-jdbc-7.4.1.jre8.jar
mssql-jdbc-7.4.1.jre11.jar
mssql-jdbc-7.4.1.jre12.jar

JDK 11で使用する場合

Javaコンパイル、実行時にモジュールパスにJDBCドライバーのJARファイルを指定します。

C:\work> javac -p "C:\Program Files\Java\sqljdbc_7.4\mssql-jdbc-7.4.1.jre11.jar;out\myapp" ...

JDBC APIを使ったプログラミング

JDBC 4.0(Java SE 6以降)では、JDBCドライバーがクラスパス上にあれば次の2つの方法で接続します。

  • DriverManager を使う
  • DataSource を使う

JDBCのドキュメントによると、DriverManagerよりもDataSourceが推奨されています。ソースコードにデータベース接続設定(ホスト、ユーザー、パスワード等)を記述するDriverManagerよりも、JNDIで接続する外部(ディレクトリサーバー等)に設定を置くDataSourceがよいということです。アプリケーションサーバー上で実行する場合は簡単に利用可能です。

しかし、Java SE 環境では、 JNDI経由でDataSourceを使うのは敷居が高いので、DriverManagerを使う方法で実装します。

JavaFXでGUI全体のフォントをCSSで指定

はじめに

前回のブログの続きです。 torutk.hatenablog.jp

画面の国際化対応で日本語プロパティを追加してみました。その際使用される日本語のフォントはJavaのコードやCSSでコントロール毎に指定可能ですが、全体を変更することも可能です。

CSSのrootセレクタに-fx-font-familyでフォントを指定します。 ここで、どのようなフォントファミリ名を指定できるか、OpenJDK 11で試してみました。

CSSの記述例

JavaFXCSSファイルを作成、.rootセレクタに-fx-font-familyでフォント名を指定します。

.root {
    -fx-font-family: "Meiryo";
}

当初いろいろググってみたところ、「外部フォント」の指定には、@font-faceでフォントファイルのURLを記述、なおTTFファイルは有効でTTCファイルは指定できないといった記述をみかけました。 しかし、Windows OS上でOpenJDK 11をにおいては、@font-faceを指定しなくてもWindows OSのフォントとしてインストールされているフォントの指定が可能でした。

動作確認(画面キャプチャ)環境

項目 内容
OS Windows 10 64bit (1903)
JDK Scene Builder 11.0内蔵のOpenJDK 11

Scene Builderで画面を作成している場合、Scene Builderの[プレビュー]メニュー > [Sceneスタイルシート] > [スタイルシートの追加] でCSSファイルを指定します。 すると、Scene Builder上のデザイン画面、およびプレビュー画面は指定したCSSファイルの設定が反映されます。 また、CSSファイルを変更すると自動で変更を反映してくれます。ですので、CSSでの見栄え設定であれば、アプリケーションを再ビルド・実行することなくScene Builder上で確認できるのでとっても楽に確認ができました。

CSSファイルでのフォント指定とその画面

では、実際にCSSファイルのrootセレクタにフォントファミリを指定して表示を確認していきます。

まずはデフォルト(指定なし)の日本語フォント

まずは、何もフォントファミリを指定しなかった場合の日本語フォントです。最初は、OpenJDKのfontconfig.propertiesで定義されるMSゴシックが使われると思っていました。しかし実行してみるとアンチエイリアスが効いたフォントが表示されました。

f:id:torutk:20190728103937p:plain

デバッグプリントでコントロール(Label)のフォントを調べてみると、次のようになっていました。

Font[name=System Regular, family=System, style=Regular, size=12.0]

これは、javafx.scene.text.FontクラスのgetDefaultメソッドが返すフォントで、JavaFXAPIドキュメント(JavaFX 8 日本語APIドキュメント)には次の記述があります。

デフォルト・フォントのファミリはSystem、スタイルは通常はRegularとなり、サイズについては、判断可能な範囲においてユーザーのデスクトップ環境と一致するものが取得されます。

Windows 10 日本語版では、デスクトップのシステムフォントが「游ゴシック」となっているので、デフォルトで表示されるフォントが游ゴシックとなっています。 Windows 7 日本語版であれば、おそらくMSゴシックではないかと思われます。

フォントファミリにMS Gothicを指定

CSSファイルに以下を記述します。

.root {
    -fx-font-family: "MS Gothic";
}

表示は次の様になります。お馴染みのギザギザフォントですね。

f:id:torutk:20190728104948p:plain

フォントファミリ名は、英語で記述しました。日本語で「MS ゴシック」等と記述した場合は設定が反映されず、デフォルトフォントで表示されました。

フォントファミリにMeiryoを指定

.root {
    -fx-font-family: "Meiryo";
}

f:id:torutk:20190728105456p:plain

フォントファミリにMeiryo UIを指定

.root {
    -fx-font-family: "Meiryo UI";
}

f:id:torutk:20190728105532p:plain

フォントファミリにYu Minchoを指定

.root {
    -fx-font-family: "Yu Mincho";
}

f:id:torutk:20190728105609p:plain

フォントファミリにYu Mincho Demiboldを指定

.root {
    -fx-font-family: "Yu Mincho Demibold";
}

f:id:torutk:20190728105653p:plain

フォントファミリにHGMinchoEを指定(HG明朝E)

.root {
    -fx-font-family: "HGMinchoE";
}

f:id:torutk:20190728105733p:plain