torutkのブログ

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

Redmineのテキスト編集をIE上で行う際Escキーで編集内容が消える問題の対処

はじめに

自宅PCや開発PCでは、Webブラウザとして主にFirefoxを使い、たまにChromeを、稀にEdgeを使います。 先月初めに、職場が移って(現職出向から帰任)新しいPCが配布されました。業務システムの対応Webブラウザの関係か、デフォルトがIE 11となっています。IE 11でRedmineのチケットを編集していたところ、漢字変換のON/OFF操作の際に誤ってEscキーを押してしまい、なんとそれまで入力していたテキストが消失してしまいました。

IEのメニューには「元に戻す」的なメニュー項目は存在せず、ググって見つけた元に戻す操作のショートカットキーCtrl-Zでも何故か戻らず、再度入力し直す事態となりました。

IE上でHTMLのFormでテキスト編集をする際にEscキーを押すとそれまで入力した内容が消えるというのは、IEの仕様のようです。しかし、FirefoxChrome、Edgeにはその仕様はありません。

Redmineを使う際、職場では多くの人がIEを使うので、このEsc問題への対処をしたいところです。周囲に聞くと、Escの誤操作で消えるから、エディターでテキストを作成してからコピーするといった対処をしている人もいました。

IEでEscキーによるテキスト消失を避ける手段を調べてみた

調べてみると、対処の手段はHTMLページにJavaScriptでEscキー入力があるとそれを無視する処理を埋め込むというものでした。その実装の多くは、documentのkeydownイベントでキーコードがEscキーの27であればイベントをつぶすというものでした。

Redmineではどう対処するか

Redmineで各ページにJavaScriptを埋め込むぅ? Redmineの内部をいじるのか?と思いましたが、ふとView Customizeプラグインがこの対処に使えそうと思い当たりました。

View Customizeでこの件と似たような処理で、チケットの作成途中にEnterキーが押されるとチケットが登録されてしまうという問題に対処するため、特定の条件でEnterキーを無視するという例を紹介しているブログがありました。

qiita.com

この記事では、次のコードでEnterキーを抑制していました。

$(function(){

    // form内のエンターキー入力を無視
    // トラッカー・ステータス操作時のDOM操作で破棄されないようdocumentにイベントを設定
    $(document).on('keypress', '#issue-form input[type="text"]', function(event) {
        if(event.keyCode == 13) {
            return false;
        }
    })
});

これを真似て、テキスト編集箇所でキー入力があった際、Escキーであれば無視するコードを作成します。 Redmineでは、Wiki機能を持つテキスト編集領域は、HTMLのTextArea要素にclass属性で .wiki-editが指定されています。そこで、次の様に記述しView Customizeに登録しました。なお、パスは .* を指定します。

/*
 テキスト編集中にEscキーを無効にする
(IEでテキスト編集中にEscキーを押すと入力したテキストが消える問題の対処)
*/
$(function() { 
  $(document).on('keydown', '.wiki-edit', function(event) {
    if (event.keyCode == 27) {
      return false;
    }
  });
});

結果

IERedmineのテキスト編集箇所を開いてテキスト編集し、Escキーを押したところ、編集途中のテキストが消えることなく存在し続けました。

WSLでCentOSが利用できたかも?(CentOS 7)

WSLで動くLinuxディストリビューション

現在、WSLの上で動くLinuxディストリビューション(WSLではLinuxカーネルは動かさないので実際にはユーザーランド)は、マイクロソフトストア上にUbuntuOpenSUSE 他いくつか用意されていますが、CentOSはありません。普段CentOSを使っている場合、別なディストリビューションを使うとコマンド体系等が異なるので不便を感じます。

そんな折、WSLへCentOS 7を入れるブログを見かけました。

vogel.at.webry.info

ということで、この方法を追ってみたいと思いました。

方法

WSL上で動かすCentOS 7のユーザーランドを作成・公開しているGitHubリポジトリが次です。

github.com

GitHubのリリースには、バイナリのzipアーカイブCentOS.zip)とソースアーカイブが公開されています。 本日時点では、7.0.1907.1 が最新リリースです。これは、CentOS 7.6.1907 に基づくWSL用ユーザーランドです。

CentOS.zipを解凍し、その中にあるCentOS.exeを管理者権限で実行します(展開したフォルダ上で実行)。 なお、ノートPCのmicroSD上(exFAT)で実行するとエラーとなりました。参考まで。

CentOS7.exeを実行すると、コマンドプロンプトが開き、インストールが完了すると Press any key to continue... と表示されます。 何かキーをたたくとコマンドプロンプトが消えます。

Installing...
Installation Complete!
Press any key to continue...

インストール後、再度CentOS7.exeを実行すると、Bashシェルが開きます。

アンインストール(レジストリ登録の消去)は、CentOS.exe clean を実行します。

コマンドプロンプトからwslconfig /lを実行すると、CentOS7が登録されているのが分かります。

C:> wslconfig /l
Windows Subsystem for Linux ディストリビューション:
openSUSE-Leap-15 (既定)
CentOS7

初期設定

Windows上のコマンド環境Cmderへの設定

タブ機能を持つコマンドプロンプトツール Cmder でCentOS7を開けるよう、CentOS7.exeへのパスを設定したTaskを新規作成します。

ユーザー作成

デフォルトはrootユーザーとなっているので、自分用のユーザーを作成します。管理権限をsudoで実行できるようにwheelグループに登録します。

# adduser <自分用のユーザー名>
# gpasswd -a <自分用のユーザー名> wheel

sudoコマンドでパスワードなしに実行できるよう/etc/sudoersを修正します。

%wheel  ALL=(ALL)       NOPASSWD: ALL

先のCentOS7.exeを実行したときに、ここで作成したユーザーでbashが開くよう設定します。

C:\path\to\CentOS7> CentOS7 config --default-user <自分用のユーザー>

うまくいかないときは、CentOS7 config --default-user root でいったんrootユーザーに戻してbashを実行し再設定します。

SoftwareCollections.org リポジトリ設定

OS標準パッケージとは別に、RHELおよびCentOS向けに新しいバージョンのパッケージを提供するSoftware Collectionsを利用できるようにします。

$ sudo yum install centos-release-scl-rh

例えば、rubyの場合、CentOS 7標準のパッケージは ruby-2.0.0ですが、Software Collectionsで提供されるパッケージには、

  • rh-ruby22
  • rh-ruby23
  • rh-ruby24
  • rh-ruby25
  • rh-ruby26

があります。

ruby 2.6のインストール
$  sudo yum install rh-ruby26

Software Collectionsでインストールしたパッケージは、/opt下にインストールされます。rh-ruby26の場合は次になります。

/opt/rh/rh-ruby26/
  +-- enable
  +-- root/

enableファイルには、環境変数定義が記述されているので、これをbash起動時に読み込まれるよう設定します。

$ sudo ln -s /opt/rh/rh-ruby26/enable /etc/profile.d/rh-ruby26.sh

以降新たにbashを起動すると、ruby26が利用できるようになります。

~$ ruby -v
ruby 2.6.2p47 (2019-03-13 revision 67232) [x86_64-linux]

パスを通すには、/opt/rh/rh-ruby26/enable へのシンボリックリンクを、/etc/profile.d/rh-ruby26.sh として作成します。

~$ sudo ln -s /opt/rh/rh-ruby26/enable /etc/profile.d/rh-ruby26.sh
~$ bash
~$ ruby -v
ruby 2.6.2p47 (2019-03-13 revision 67232) [x86_64-linux]

Redmine プラグイン開発環境を作ってみる

Redmineプラグイン開発環境を作ってみます。前に作成した環境は次です。

Redmineプラグイン開発環境 - ソフトウェアエンジニアリング - Torutk

git

RedmineGitHubリポジトリから取得するために、gitをインストールします。 CentOS 7の標準パッケージのgitはバージョンが1.8.3と今や相当古いバージョンです。Software Collectionsでgit 2.9を取得します。

~$ sudo yum install rh-git29

環境設定を/etc/profile.d下に作成します。

~$ sudo ln -s /opt/rh/rh-git29/enable /etc/profile.d/rh-git29.sh

RedmineGitHubレポジトリからクローンしてきます。

~$ mkdir redmine
~$ cd redmine
redmine$ git clone  https://github.com/redmine/redmine.git redmine_glossary_dev
  :

sqlite開発環境

~$ sudo yum install sqlite-devel

ruby bundler

~$ sudo yum install rh-ruby26-rubygem-bundler

Redmineが必要とするgemsのインストール

redmine_glossary_dev$ bundle install --path vendor/bundler
bundle installでエラーになった対処
~$ sudo yum groupinstall "Development Tools"
  :
sudo yum install openssl-devel readline-devel zlib-devel curl-devel libyaml-devel
~$ sudo yum install rh-ruby26-ruby-devel

セッション秘密鍵の生成

redmine_glossary_dev$ bundle exec rails generate_secret_token

データベース初期化(スキーマ生成)

redmine_glossary_dev$ bundle exec rails db:migrate

Redmineの実行

redmine_glossary_dev$ bundle exec rails server

Redmineへのアクセス

同一マシン上で、Webブラウザから http://localhost:3000 にアクセスするとRedmineが表示されます。

環境変数NAMEを無効化

WSLで使用するCentOS 7のユーザー環境変数NAMEにはホスト名が定義されています。Redmineプラグインをインストールするときに影響が出るので、未定義にしておきます。

  • ~/.bashrc に次を追記
unset NAME

ファイルシステムをwslfsに更新

WSLでインストールした際のファイルシステムは、デフォルトではlxfsですが、これをWslFsに更新することができます。 更新すると何がいいのかは明確には語られていないようなので、更新して良くなると思ったら実施で・・・。

更新するには、コマンドプロンプトから以下を実行します。

C:\> wslconfig /upgrade CentOS7

更新されたかどうかは、ルートファイルシステムを表示して確認します。

~$ df -T
Filesystem     Type  1K-blocks     Used Available Use% Mounted on
rootfs         wslfs 103472168 81965432  21506736  80% /
none           tmpfs 103472168 81965432  21506736  80% /dev
  :

X Window Systemの利用

コマンドライン環境であれば、これで一通り操作できるようになりました。しかし、GUIを伴う環境も使いたくなります。 そこで、Windows OS側にXサーバー機能を入れ、Linux(WSL)側でXクライアントプログラムを実行したらその画面をWindows側に表示できるようにします。

Windows OSへXサーバー機能(VcXsrv)をインストール

Windows用のXサーバー機能には、今回VcXsrvソフトウェアをインストールしました。

sourceforge.net

インストール後、XLaunchをダブルクリックし、Xサーバーを起動します。

HiDPI画面でXの画面がぼやける

VcXsrvのアイコンを右クリックしプロパティで互換性タブを選択、[高DPI設定の変更]を押し、[高いDPIスケールの動作を上書きします。]にチェックを付け、種類は[アプリケーション]を選択。VcXsrvを再起動します。

ただし、Xのアプリケーションが小さく表示されるので、アプリケーション個別にフォントを大きくするなどして使いやすくする必要があります。

Xクライアントプログラムのインストール

~$ sudo yum install xorg-x11-apps

X Window Systemのライブラリ等を含めてインストールされます。

リモートのXサーバーに表示する場合、環境変数DISPLAYを設定します。

~$ export DISPLAY=0:0

xeyesツールを実行します。

~$ xeyes

目玉が表示されればOKです。

RubyMine Linux

RubyMineのLinux版をインストールしてみます。Windows上であらかじめダウンロードしたアーカイブファイルを読み込むことができます。

~$ cd /opt
opt$ sudo tar xvzf /mnt/c/Users/torutk/Downloads/RubyMine-2019.2.4.tar.gz
  :
opt$ cd RubyMine-2019.2.4/bin
bin$ ./rubymine.sh
OpenJDK 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.

Start Failed: Internal error. Please refer to http://jb.gg/ide/critical-startup-errors
  :(略)
Caused by: java.lang.UnsatisfiedLinkError: /opt/RubyMine-2019.2.4/jbr/lib/libawt_xawt.so: libX
tst.so.6: cannot open shared object file: No such file or directory                           

libXtst.so.6 を探す

~$ yum provides libXtst.so.6
Loaded plugins: fastestmirror
Loading mirror speeds from cached hostfile
 * base: ftp.iij.ad.jp
 * centos-sclo-rh: ftp.iij.ad.jp
 * extras: ftp.iij.ad.jp
 * updates: ftp.iij.ad.jp
libXtst-1.2.3-1.el7.i686 : X.Org X11 libXtst runtime library
Repo        : base
Matched from:
Provides    : libXtst.so.6
~$ sudo yum install libXtst

これで、RubyMingeが起動するようになりました。

Redmine をRubyMine上で実行する

  • RubyMineでRedmineを展開したディレクトリを開きます。([File]メニュー > [Open]から)
  • rubyコマンド(インタープリター)の場所を設定します。([File]メニュー > [Settings]から)
  • 初回実行時は、Railsを指定します。

日本語環境設定

日本語フォント

IPAフォント
~$ sudo yum install ipa-*-fonts
  :
VLゴシックフォント
~$ sudo yum install vlgothic-*
WindowsのフォントをVcXsrvで使う
~$ sudo ln -s /mnt/c/Windows/Fonts /usr/share/fonts
~$ sudo fc-cache -fv
  :

日本語入力

ibus-kkcを動かそうと試みるが・・・

CentOS 7標準のIbusを入れてみる。

~$ sudo yum install ibus-kkc
  :

79個のパッケージがインストールされます。 ibus-setup を実行すると、ibus-daemonが動いていないので動かすか聞いてきます。動かすを選択したがエラーで動きません。

$ ibus-setup
Gtk-Message: 14:17:48.995: GtkDialog mapped without a transient parent. This is discouraged.

(ibus-setup:2931): IBUS-WARNING **: 14:17:58.140: Unable to connect to ibus: Unexpected lack of content trying to read a line
portal is not running: GDBus.Error:org.freedesktop.DBus.Error.Spawn.ChildExited: Process org.freedesktop.portal.IBus exited with status 1
Gtk-Message: 14:18:03.295: GtkDialog mapped without a transient parent. This is discouraged.
ibus-mozcを動かそうと試みる
~$ sudo yum install ibus-mozc dbus-x11
  :

84パッケージがインストールされます。

.bash_profileに以下を追記

# Input Method
export LANG=ja_JP.UTF-8
export DefaultIMModule=ibus
export XMODIFIERS="@im=ibus"
export GTK_IM_MODULE=ibus
export QT_IM_MODULE=ibus
export IBUS_ENABLE_SYNC_MODE=1
ibus-daemon -d -x

bashを再度開いて、

~$ ibus-setup

ibus-setup を実行すると、ibus-daemonが動いていないので動かすか聞いてきます。動かすを選択したがエラーで動きません。

Windowsマシン上でのLinux環境は混沌と

PC上に直接Linux OSをインストールして起動する(デュアルブート)環境は対象外とします。 Windows OSの動作を前提に設計されたノートPCでは、Linux OS向けドライバが存在しないデバイスが搭載されていることが多く、Windows OSの上でLinux環境を動かすことになります。

Windows 7の頃は、Windows OS上でLinux環境を動かすには、VMwareVirtualBoxなどの仮想マシンソフトウェアを入れて、その上にLinux OSをインストールするか、CygwinなどのLinuxエミュレーションソフトウェアを入れていました。CentOSFedoraUbuntuなど特定のLinuxディストリビューションを動かしたいときは仮想マシンを使い、Linuxコマンド環境を手軽に利用したい(ファイルシステムWindowsと共用したい)ときはCygwinを使う、といった使い分けをしていました。なお、CygwinでもXサーバーのGUI環境を使うことはできました。

Windows 10になると、Proエディションでは標準で仮想マシンHyper-Vが搭載されました。また、WSL(Windows Subsystem for Linux)がWindows 10 1709版からHomeエディションを含めて利用可能となりました。これで、VMware、Virtual Box、Cygwinは不要になるか、と思われましたが、なかなかそうもいきません。

Hyper-Vですが、この上にLinuxを入れてLinuxデスクトップを動かすと動作がもっさりして快適とは程遠いレスポンスです(細い回線でリモートデスクトップ接続して操作している感じ)。さらにHyper-Vを入れると他の仮想マシンソフトウェア(VMwareVirtualBox)が動かなくなるという問題もあります(ハードウェアの仮想化機構 VT-xをHyper-Vが占有するため。今後解消に向かう模様ですが)。

WSLは、利用可能なLinuxディストリビューションが少ない(UbuntuOpenSUSESUSE Enterprise、Kali、有償のWLinux、Fedora Remix、他)ほか、ファイルシステムWindowsと共有することに制約が大きく(ファイルを壊す等)、またファイルI/Oが致命的に遅い問題があります(以前日記に記載)。 WSL(Windows Subsystem for Linux)とHyper-V上のLinuxとの重さの違い - torutkのブログ

来年のWindows 10アップデートで計画されているWSL2は、WSLの問題点を解決すると期待していましたが、なんとHyper-V上でLinuxカーネルを動かす環境となるので、WSLとはコンセプトが随分と変わっています。

というところで、Windows上でLinuxを動かすには選択肢は多くなったものの、これぞという決定打にかけている状況です。

RubyMineでRedmineプラグイン開発をする(Windows環境)

はじめに

これまでは、Windowsマシン上でRedmineプラグイン開発環境を用意するのに、仮想マシンHyper-Vなど)にLinuxを入れてその上でLinuxベースの開発環境を整える方法を取っていました。ただしCUI環境が主です。Hyper-VGUI環境を動かすとかなり動作がもっさりして快適とは程遠いので(キー入力も一呼吸待たされる)、開発環境として使う気になれないほどです。 そこで、WSL(Windows Subsystem for Linux)でLinux環境を使う方法を試してみました。こちらはRailsサーバーの起動が著しく重く、やはり開発環境としては使いたくないほどです。また、Windows環境(ツール)からWSL上のファイルを編集するとパーミッションがおかしくなって崩壊することがあります。

そのため、RubyMineが宝の持ち腐れとなっていました。今回、Windows上にRubyを展開して素のWindowsのみでRedmineプラグイン開発環境を用意してみようとおもいました。

インストール

Rubyのインストール

Windows上のRubyは、次のサイトから入手しました。 https://rubyinstaller.org/downloads/

[with Devkit]と書かれている開発キット込みのRubyをインストールします。2019年10月14日現在、2.6系の最新は

rubyinstaller-devkit-2.6.5-1-x64.exe

です。

インストーラーを実行し、ライセンス受諾し、インストール先を指定、PATH、関連付け、UTF-8デフォルトをチェックして(最初の2つはデフォルトでチェック)、MSYS2 development toolchainをチェックして(デフォルトでチェック)インストールします。

f:id:torutk:20191014152015p:plain

f:id:torutk:20191014152028p:plain

インストール完了するときに、MSYS2セットアップを実行するチェックを付けます。

f:id:torutk:20191014152331p:plain

すると、コマンドプロンプトでMSYS2セットアップが実行されます。

f:id:torutk:20191014152523p:plain

インストールするコンポーネントはデフォルトの[1,2,3]を実行(ENTERキーのみ入力でよい)します。

インストールが終わると、ENTERのみ入力してコマンドプロンプトを閉じます。

SQLite3のインストール

Windows用のSQLite3は次のサイトから入手しました。 https://www.sqlite.org/download.html

まず、Precompiled Binaries for Windows にある次のファイルを入手します。ここにはDLLファイルが含まれます。

sqlite-dll-win64-x64-3300100.zip

このDLLファイルを環境変数PATHの通っているディレクトリにコピーします。ここでは暫定でruby.exeのある場所(C:\tools\Ruby26-x64\bin\)に置きました。

次に、Source Codeにある次のファイルを入手します。開発用のヘッダーファイル、ソースファイルが含まれます。

sqlite-amalgamation-3300100.zip

このうちヘッダーファイル(sqlite3.h、sqlite3ext.h)を、rubyのincludeディレクトリ(ここではC:\tools\Ruby26-x64\include\ruby-2.6.0\)にコピーします。

RubyMineでRedmineのセットアップ

Ruby SDKの設定

RubyMineを起動し、「Welcome to RubyMine」画面の右下[Configure]をクリックし、ドロップダウンリストから[Settings]を選択します。 「Settings for New Projects」画面の左側ペインで[Languages & Frameworks] > [Ruby SDK and Gems]を選択、[+]をクリックし、先ほどインストールしたRubyを追加します。環境変数PATHにruby.exeがあれば候補に登場するのでそれを選択します。

RedmineGithubからクローンしプロジェクト作成

「Welcome to RubyMine」画面の[Check out from Version Control]をクリックし、リポジトリURLとクローン先のローカルディレクトリを指定します。

database.ymlの編集

config\database.ymlファイルを作成し、SQLite3の設定を記述します。

production:
  adapter: sqlite3
  database: db/redmine.sqlite3

development:
  adapter: sqlite3
  database: db/redmine.sqlite3

test:
  adapter: sqlite3
  database: db/redmine_test.sqlite3

bundlerでGemをインストール

Redmineが必要とするGemファイル群をプロジェクト配下にインストールします。

[Tools]メニュー > [Bundler] > [Install]をクリックし、「Bundle Install」ダイアログでオプション

--path vendor/bundler

を追記して実行します。

sqlite3でエラー
Fetching sqlite3 1.4.1
Installing sqlite3 1.4.1 with native extensions
Gem::Ext::BuildError: ERROR: Failed to build gem native extension.

current directory:
D:/work/trunk_redmine/vendor/bundler/ruby/2.6.0/gems/sqlite3-1.4.1/ext/sqlite3
d:/tools/Ruby26-x64/bin/ruby.exe -I d:/tools/Ruby26-x64/lib/ruby/2.6.0 -r
./siteconf20191014-17576-1unxgm.rb extconf.rb
checking for sqlite3.h... yes
checking for pthread_create() in -lpthread... yes
checking for -ldl... no
checking for sqlite3_libversion_number() in -lsqlite3... no
sqlite3 is missing. Install SQLite3 from http://www.sqlite.org/ first.
*** extconf.rb failed ***
Could not create Makefile due to some reason, probably lack of necessary
libraries and/or headers.  Check the mkmf.log file for more details.  You may
need configuration options.

Provided configuration options:
        --with-opt-dir
        --without-opt-dir
        --with-opt-include
        --without-opt-include=${opt-dir}/include
        --with-opt-lib
        --without-opt-lib=${opt-dir}/lib
        --with-make-prog
        --without-make-prog
        --srcdir=.
        --curdir
        --ruby=d:/tools/Ruby26-x64/bin/$(RUBY_BASE_NAME)
        --with-sqlcipher
        --without-sqlcipher
        --with-sqlite3-config
        --without-sqlite3-config
        --with-pkg-config
        --without-pkg-config
        --with-sqlcipher
        --without-sqlcipher
        --with-sqlite3-dir
        --without-sqlite3-dir
        --with-sqlite3-include
        --without-sqlite3-include=${sqlite3-dir}/include
        --with-sqlite3-lib
        --without-sqlite3-lib=${sqlite3-dir}/lib
        --with-pthreadlib
        --without-pthreadlib
        --with-dllib
        --without-dllib
        --with-sqlcipher
        --without-sqlcipher
        --with-sqlite3lib
        --without-sqlite3lib

bundle installでは、--with-sqlite3-libのオプションを指定しても認識できない模様なので、直接gem installで指定してみました。ただしgem installなのでシステム共通のgemとしてインストールされてしまいます。

>gem install sqlite3 --platform=ruby -- --with-sqlite3-include=c:/tools/ruby26-x64/include/ruby-2.6.0 --with-sqlite3-lib=c:/tools/ruby26-x64/bin
Fetching sqlite3-1.4.1.gem
Temporarily enhancing PATH for MSYS/MINGW...
Installing required msys2 packages: mingw-w64-x86_64-sqlite3
Building native extensions with: '--with-sqlite3-include=c:/tools/ruby26-x64/include/ruby-2.6.0 --with-sqlite3-lib=c:/tools/ruby26-x64/bin'
This could take a while...
Successfully installed sqlite3-1.4.1
Parsing documentation for sqlite3-1.4.1
Installing ri documentation for sqlite3-1.4.1
Done installing documentation for sqlite3 after 1 seconds
1 gem installed

セッション秘密鍵の生成

RubyMineからの実行方法が分からないので、コマンドプロンプトから実行しました。

bundle exec rails generate_secret_token

データベーススキーマの生成

bundle exec rails db:migrate

Railsサーバーの実行

RubyMineの[Run]メニュー > [Run] で [Development: trunk_redmine]を選択すると、デフォルトサーバーが起動します。

めでたく起動しました

No Rails とエラーが出てRailsサーバーが実行できない場合

コマンドプロンプト上でbundle installを実行した後に、Runメニューから実行したらエラーとなってしまいました。その後、RubyMineの[Tools]メニューからbundle installを再実行し、Runメニューから実行したら実行できました。

今月のJava読書会は「The Java Module System(洋書)」を読みます

Java読書会の新しい本

毎月1回川崎市Javaに関する技術書籍の読書会を開催しているJava読書会BOFでは、 先月で「Java 11 and 12 - New Features」が読了し、新しい課題図書のWeb投票を実施ました。 その結果得票数4票(!)で第1位となったのがThe Java Module System です。

本書は、Java SE 9で導入されたJava Platform Module Systemについて丸々1冊をかけて述べています。

書籍の入手

Amazon日本サイトでは、紙の書籍が2909円(送料無料)で販売されています。販売はBook Depositoryというところでイギリスにある会社です。どうやら海を渡って送られてくるようです。 電子書籍はと探してみると出版社のMANNINGでPDF/ePub/Kindle/libeBookの形式データが$39.99で販売されています。日本円では4300円ほどでしょうか。

Manning | The Java Module System

ちょっと悩んで安いAmazonのペーパーバックの方を購入しました。ただちょっと時間がかかります。9月21日に注文して、手元に届いたのが10月1日と10日間かかっていました。

予習

読書会の日までにちょっとずつ読んでおこうと思います。 (まえがき:forewordからちょっと難しいぞ)

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;
}