torutkのブログ

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

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

はじめに

次の記事の続きです。 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

JavaFXのグラフの凡例部分を操作可能にする

はじめに

先週書いた次のブログの続編です。 torutk.hatenablog.jp

一つのグラフ表示領域に、複数のグラフ(Series)を重畳表示した際に、簡単な操作でそれぞれのグラフを非表示にしたり表示したりとできると使い勝手が向上します。

グラフに凡例を表示した場合、それぞれの凡例をクリックするとその凡例に対応するグラフを表示する、非表示すると切り替えられるようにします。

課題

凡例全体のNodeは、Legendクラス(com.sun.javafx.chart.Legend)で実装されています。しかし、このcom.sun.javafx.chartパッケージは、アプリケーション側のモジュールには公開されていません。

ぐぐって検索した情報は、子ノードを辿ってLegendクラスかどうか判別してLegendを見つけ、Legendにキャストしてそのitemを取得して利用するもので、Java SE 9以降では使えません(--add-exportsオプション指定でコンパイルすれば可能)。

各凡例を表示するNodeを取得する方法

それぞれの凡例は、CSSセレクタ .chart-legend-item が指定されています。JavaFXAPIには、セレクタを指定してNodeを取得する機能(lookupおよびlookupAll)が提供されているので、これを使って凡例のNodeを取得します。

@FXML
LineChart<Float, Float> spectrumChart;
:
Set<Node> legendItems = spectrumChart.lookupAll(".chart-legend-item");

それぞれの凡例にマウスハンドラーを設定するには例えば次のようにします。

legendItems.forEach(item -> item.setOnMouseClicked(event -> System.out.println(event + " clicked")));

各凡例をクリックしたら対応するグラフを表示/非表示する

クリックされた凡例のNodeにマウスハンドラーを設定することはできましたが、次はそこから対応するグラフを取得して、その表示を切り替える方法を考えなくてはなりません。

それぞれの凡例はLabelクラスのインスタンスで、ラベルに表示されるテキストはグラフ(Series)のnameプロパティとなっています。

そこで、取得したそれぞれの凡例をLabel型にキャストしてラベルに表示されるテキストを取得し(getTextメソッド)、次にそのテキストを名前として持つSeriesをLineChartから探して取得します。

Labelにキャストしてテキストを取り出す

legendItems.forEach(item -> item.setOnMouseClicked(event -> {
    if (item instanceof Label) {
        String name = ((Label) item).getText();
    }
}));

Label型へのキャストが可能な場合に、Labelへキャストし、getTextメソッドでラベルに表示されるテキストを取得します。

チャート(LineChart)に登録されているSeriesからテキストと同じ名前のものを取り出す

legendItems.forEach(item -> item.setOnMouseClicked(event -> {
    if (item instanceof Label) {
        String name = ((Label) item).getText();
        Optional<XYChart.Series<Float, Float>> any = spectrumChart.getData().stream()
                .filter(series -> series.getName().equals(name))
                .findAny();
    }
}));

chartのdataプロパティには表示するグラフ(Series)がリストで登録されています。これを順次アクセスしSeriesのnameプロパティが凡例ラベルのテキストと一致するものを取り出します。

クリックした凡例に一致するグラフ(Series)の表示/非表示を切り替える

legendItems.forEach(item -> item.setOnMouseClicked(event -> {
    if (item instanceof Label) {
        String name = ((Label) item).getText();
        Optional<XYChart.Series<Float, Float>> any = spectrumChart.getData().stream()
                .filter(series -> series.getName().equals(name))
                .findAny();
        any.ifPresent(series -> {
            boolean currentVisible = series.getNode().isVisible();
            series.getNode().setVisible(!currentVisible);
        });
    }
}));

取り出したグラフ(Series)のgetNodeメソッドで画面に表示されるグラフのNodeを取得し、その表示状態をisVisibleで取得します。そして、その真偽を反転させてsetVisibleに指定し、表示/非表示を反転させます。

グラフが非表示のときは凡例の表示を薄くしたい

実現方法を悩みましたが、CSSの疑似クラスを使って非表示の際に薄く(透過度を変えて)表示させることにしました。

まず、CSSで、.labelに疑似クラス haze を記述します。

.label:haze {
    -fx-opacity: 0.2;
}

次に、凡例ラベルのマウスハンドラー内でグラフ(Series)の表示/非表示切り替えに合わせて疑似クラスの有効・無効を凡例ラベルに設定します。

private static final PseudoClass HAZE_PSEUDO_CLASS = PseudoClass.getPseudoClass("haze");
    :
legendItems.forEach(item -> item.setOnMouseClicked(event -> {
    if (item instanceof Label) {
        String name = ((Label) item).getText();
        Optional<XYChart.Series<Float, Float>> any = spectrumChart.getData().stream()
                .filter(series -> series.getName().equals(name))
                .findAny();
        any.ifPresent(series -> {
            boolean toBeInvisible = series.getNode().isVisible();
            series.getNode().setVisible(!toBeInvisible);
            item.pseudoClassStateChanged(HAZE_PSEUDO_CLASS, toBeInvisible);
        });
    }
}));