seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

C#でUACの昇格可能なEXEのCOMオブジェクトを作成する方法

概要

本サンプルはC#によるOut-of-processのCOMサーバーの作成と、それをUACの昇格可能なCOMにする手順を示すものである。

※ ビルド可能なプロジェクト一式はGitHub上にあります。

github.com

COM Elevation Monikerについて

Vista以降、管理者権限が必要な場合はUACによる昇格が必要となっているが、 昇格する単位はプロセス単位であり、且つ、プロセスが起動するときに昇格しなければならない。(起動してから昇格することはできない。)

このため、通常権限で起動したアプリケーション内から管理者権限が必要な処理を行うためには、

  • 別の昇格可能なEXEプロセスをShellExecute等で呼び出す。
  • もしくは、COM Elevation Monikerを使う

の、いずれかの方法をとることになる。

ShellExecute を使う場合には親プロセスから引数としてパラメータを渡す以上のことをやろうと思うと、プロセス間通信などめんどくさい仕組みが必要になるが、 COMであれば、COMのインフラストラクチャによって、プロセス間通信が単なるメソッドやプロパティのアクセスという平易な形で実現できる。

したがって、プログラムの中から管理者権限のあれこれをやりたいのであれば、昇格可能なCOMで実装したほうが、いろいろ簡単になるであろう。

この昇格可能COMオブジェクトは、COM Elevation Moniker という、COMの複合モニカの仕組みを使って "Elevation:administrator!new:{CLSID}" のような文字列を指定することで、 Out-Of-ProcessのCOM(つまり、EXEのCOM)をUACで昇格して起動する。

オブジエクトの生成時にモニカによってUAC昇格のための処理が挟み込まれるような感じとなる。

  • なお、原理的にIn-ProcのCOMでは昇格できない。
    • DLLのCOMを作成した場合でも、明示的に CLSCTX_LOCAL_SERVER として dllhost.exeプロセス経由でEXEでサロゲートされるように起動すれば昇格可能ではある。(C++からの利用などはCLSCTXを明示できるため。)
    • ただし、WSHVBAといったCOMクライアントから明示的にCLSCTX_LOCAL_SERVERを指定する方法がなく、その場合は既定でIn-Procで起動されてしまうため、WSHVBAからの利用があるならばDLLのCOMは適していない。
      • (たとえば、x86WSHからx64のdllを呼び出す、もしくは、x64のWSHからx86のdllを呼び出す場合には、In-Procでは成立しないのでLocalServerが試行され、結果的に、偶然、うまくゆく場合もありえる。)

昇格可能なCOMオブジェクトを作成するには比較的簡単で、COMのCLSIDのレジストリエントリに

  • LocalizedString という文字列リソースを示す値
  • Elevation キー
    • Enabled = (DWORD) 1

の2つがあれば良い。(それ以外には何も必要ない。他にもアイコンのオプションなどがあるが、必須ではない。)

LocalizedStringUACの昇格ダイアログで表示されるコンポーネント名をリソースから取得するためのリソースキーである。

C#によるOut-Procサーバー(EXEサーバー)の作成について

DotNETはCOMとの連携が非常に手厚くなっており、C#でCOMのIn-Procサーバ(DLL)を作るのは非常に簡単である。

ところが、標準ではOut-Procサーバー(EXE)を作成する方法は用意されていない。

しかし、COMとしての仕組みは十分に備えているため、C++(Win32)によるEXEサーバと同じ手順を踏むことで、C#でもOut of ProcessなEXEサーバーを実現することができる。

具体的には、以下の手順を行う。

  • EXEが提供するCOMを生成するためのクラスファクトリを、必要なクラス分だけ実装する
  • EXEは、起動したら CoRegisterClassObject でシステムにクラスファクトリを登録する
  • すべてのクラスファクトリを登録したら、CoResumeClassObjects で、クライアントからの要求を受け付け開始する。
  • EXEサーバーは自分が不要と判断できるまで、メッセージループを回すだけの待機状態にはいる。(作成したオブジェクトがなくなるまで)
  • 自分が終了すべきと判断したら、 CoSuspendClassObjects で受付を停止する。
    • 以後は、このEXEに対して要求が入らなくなる。(以後に新しい要求があった場合は、別のEXEが起動される。)
  • CoRevokeClassObject でクラスファクトリの登録を解除する。
  • アプリケーションを終了する。

このあたりの流れは、

などが詳しい。

また、DotNETで作成したCOMは regasm ツールによってレジストリにCOM情報を登録するが、 標準ではDLLのIn-Procサーバーを想定したレジストリが出力される。

そこで、ComRegisterFunction 属性を使って、COMのメソッドでレジストリ登録時の処理をオーバーライドする。

ここで、COMの種別を InprocServer32 から LocalServer32 に変更することで、EXEのCOMとして起動できるようになる。

また、前述したCOM Elevation Monikerのためのレジストリエントリも、ここで追加する。

手順

以下、Visual Studio Express 2017 for Windows DesktopC#UACの昇格可能なEXE-COMを作成する手順を示す。

手順1: プロジェクトの選択

プロジェクトはコンソールとする。

f:id:seraphy:20180606185444p:plain

実際にはメッセージループをもつWindowsフォームのEXEとなるが、フォーム画面は1つも必要なく、かわりにログメッセージ類を画面に表示させたいので、コンソールを選んでおく。

(コンソール画面が必要ないのならば、あとから出力の種類を「Windows アプリケーション」に戻しておけばよい。)

また、プラットフォームは x86 (32ビット版) に固定しておく。

f:id:seraphy:20180606185503p:plain

DLLの場合は呼び出し元のEXEと同じプラットフォームのバイナリを用意する必要があるので、32ビット版と64ビット版の2つが必要となるが、 EXEの場合は、もとからプロセス間通信でやりとりするため、呼び出し元が32ビットであろうが64ビットであろうが、どちらか1つあれば十分である。

なので、とりあえず32ビットでビルドしておけば、どこのマシンでも動くことができるであろう。

手順2: 参照設定でWindowsFormを指定する

コンソールアプリとしてプロジェクトを作成したが、実際にはメッセージループをまわすWindowsFormの仕組みを使う。

また、app.configファイルから設定を読み込みたいので、

参照設定では

  • System.Windows.Forms
  • System.Configuration

の2つのアセンブリ参照を追加しておく。

f:id:seraphy:20180606185542p:plain

手順3: 目的となるCOMオブジェクトを定義する。

COM定義用のファイルを作成する。

C#でのCOMの定義方法は、基本的にはDLLのCOMの場合と変わらない。

f:id:seraphy:20180606185725p:plain

    /// <summary>
    /// 独自COMインターフェイスの定義
    /// </summary>
    [Guid("8CA4F6A2-4BCC-4642-B14A-C2B52E8B3DB6"), ComVisible(true)]
    public interface IMyElevationOutProcSrv
    {
        string Name { get; set; }
        void ShowHello();
    }

    /// <summary>
    /// 独自COMイベントの定義
    /// </summary>
    [Guid("FFD359DD-D03C-4573-9986-FE5E6BDC3A29"), ComVisible(true)]
    [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
    public interface _MyElevationOutProcSrvEvents
    {
        [DispId(1)]
        void NamePropertyChanging(string NewValue, ref bool Cancel);

        [DispId(2)]
        void NamePropertyChanged(string NewValue);
    }

    /// <summary>
    /// 独自のCOMオブジェクトの実装
    /// </summary>
    [ClassInterface(ClassInterfaceType.None)]
    [ProgId("MyElevationOutProcSrv")]
    [Guid("7AEFA37C-4494-4AE8-9378-0157A0B919AE"), ComVisible(true)]
    [ComSourceInterfaces(typeof(_MyElevationOutProcSrvEvents))] // イベント
    public class MyElevationOutProcSrv : IMyElevationOutProcSrv
    {
        private string _Name = "PiyoPiyo";

        public string Name
        {
            get
            {
                return _Name;
            }
            set
            {
                bool cancel = false;
                NamePropertyChanging?.Invoke(value, ref cancel);
                if (!cancel)
                {
                    _Name = value;
                    NamePropertyChanged?.Invoke(value);
                }
            }
        }

        public MyElevationOutProcSrv()
        {
            // 残存オブジェクト数+1
            MyApplicationContext.Current.IncrementCount();
        }

        ~MyElevationOutProcSrv()
        {
            // 残存オブジェクト数-1
            MyApplicationContext.Current.DecrementCount();
        }

        public void ShowHello()
        {
            Console.WriteLine("Hello, {0}!", Name);
        }

        /// <summary>
        /// NamePropertyChangingのイベント用のデリゲート(イベントソースの定義と一致していること)
        /// </summary>
        [ComVisible(false)]
        public delegate void NamePropertyChangingDelegate(string NewValue, ref bool Cancel);

        /// <summary>
        /// NamePropertyCHangedのイベント用のデリゲート(イベントソースの定義と一致していること)
        /// </summary>
        [ComVisible(false)]
        public delegate void NamePropertyChangedDelegate(string NewValue);

        /// <summary>
        /// NamePropertyChangingのイベント(イベントソースの定義と一致していること)
        /// </summary>
        public event NamePropertyChangingDelegate NamePropertyChanging;

        /// <summary>
        /// NamePropertyCHangedのイベント(イベントソースの定義と一致していること)
        /// </summary>
        public event NamePropertyChangedDelegate NamePropertyChanged;

        #region レジストリ登録
        /// <summary>
        /// Regasmツールで登録するCOMのレジストリ。
        /// 既定ではInProc(DLL)用のため、ここでLocalServer(EXE)用に修正する。
        /// </summary>
        /// <param name="typ"></param>
        [ComRegisterFunction()]
        public static void Register(Type typ)
        {
            // このEXEのフルパスを取得する
            var assembly = Assembly.GetExecutingAssembly();
            string exePath = assembly.Location;

            // アセンブリのGUIDをtypelibのIDとする。
            var attribute = (GuidAttribute)assembly.GetCustomAttributes(typeof(GuidAttribute), true)[0];
            string libid = attribute.Value;

            using (var keyCLSID = Registry.ClassesRoot.OpenSubKey(
                @"CLSID\" + typ.GUID.ToString("B"), true)) // 書き込み可能として開く
            {
                // InprocServer32を消す
                keyCLSID.DeleteSubKeyTree("InprocServer32");

                // かわりにLocalServer32とする。
                using (var subkey = keyCLSID.CreateSubKey("LocalServer32"))
                {
                    // この実行ファイル(*.exe)へのパスを登録する
                    subkey.SetValue("", exePath, RegistryValueKind.String);
                }

                // ↓ タイプライブラリの登録も行う場合
                using (var subkey = keyCLSID.CreateSubKey("TypeLib"))
                {
                    // このアセンブリのGUID(LIBID)を登録する
                    subkey.SetValue("", libid, RegistryValueKind.String);
                }

                // ↓ ここから、UACのCOM昇格可能にするための設定

                // LocalizedString 
                // "@" + EXEのフルパス + ",-" + 文字列リソース番号で文字列リソースを指定する。
                keyCLSID.SetValue("LocalizedString",
                    "@" + exePath + ",-101",
                    RegistryValueKind.String);

                // Elevation
                using (var subkey = keyCLSID.CreateSubKey("Elevation"))
                {
                    subkey.SetValue("Enabled", 1, RegistryValueKind.DWord);
                }
            }
        }

        [ComUnregisterFunction()]
        public static void Unregister(Type t)
        {
            // レジストリエントリの削除
            Registry.ClassesRoot.DeleteSubKeyTree(@"CLSID\" + t.GUID.ToString("B"));
        }
        #endregion
    }

ただし、ComRegisterFunction, ComUnregisterFunction 属性のついたメソッドがある。

これは前述のとおり、regasm ツールでCOMのレジストリ情報を登録・登録解除するときに呼び出される処理をオーバーライドするものである。

ここには、COM Elevation Moniker のためのレジストリ設定も含まれている。

コンストラクタとデストラクタで

MyApplicationContext.Current.IncrementCount();
MyApplicationContext.Current.DecrementCount();

というカウンタをとっているところがあるが、これが、このEXEで生成したオブジェクトの残存数をカウントするためのものである。

このカウンタが0になって一定時間経過したら、このEXEサーバーは不要になったものとみなして終了するように実装する。

手順4: クラスファクトリの実装

DLLサーバの場合はDotNETフレームワーク側でクラスファクトリの役割が暗黙で行われていたので用意する必要はなかったのだが、

EXEサーバの場合はWin32で明示的にクラスファクトリを登録する都合上、自前のクラスファクトリを用意する必要がある。

クラスファクトリは以下の定義となる。

    /// <summary>
    /// COMのクラスファクトリ
    /// https://msdn.microsoft.com/en-us/library/windows/desktop/ms694364(v=vs.85).aspx
    /// </summary>
    [ComImport, ComVisible(false),
    InterfaceType(ComInterfaceType.InterfaceIsIUnknown),
    Guid("00000001-0000-0000-C000-000000000046")]
    public interface IClassFactory
    {
        IntPtr CreateInstance([In] IntPtr pUnkOuter, [In] ref Guid riid);

        void LockServer([In] bool fLock);
    }

これを自分のCOMオブジェクト用のクラスファクトリとして実装する。

    /// <summary>
    /// MyElevationOutProcSrvオブジェクトのファクトリ
    /// </summary>
    public class MyElevationOutProcSrvFactory : IClassFactory
    {
        #region WIN32定義
        /// <summary>
        /// IDispatchのGUID
        /// </summary>
        public static readonly Guid GUID_IDispatch = new Guid("00020400-0000-0000-C000-000000000046");

        /// <summary>
        /// IUnknownのGUID
        /// </summary>
        public static readonly Guid GUID_IUnknown = new Guid("00000000-0000-0000-C000-000000000046");

        [Flags]
        public enum CLSCTX : uint
        {
            INPROC_SERVER = 0x1,
            INPROC_HANDLER = 0x2,
            LOCAL_SERVER = 0x4,
            INPROC_SERVER16 = 0x8,
            REMOTE_SERVER = 0x10,
            INPROC_HANDLER16 = 0x20,
            RESERVED1 = 0x40,
            RESERVED2 = 0x80,
            RESERVED3 = 0x100,
            RESERVED4 = 0x200,
            NO_CODE_DOWNLOAD = 0x400,
            RESERVED5 = 0x800,
            NO_CUSTOM_MARSHAL = 0x1000,
            ENABLE_CODE_DOWNLOAD = 0x2000,
            NO_FAILURE_LOG = 0x4000,
            DISABLE_AAA = 0x8000,
            ENABLE_AAA = 0x10000,
            FROM_DEFAULT_CONTEXT = 0x20000,
            ACTIVATE_32_BIT_SERVER = 0x40000,
            ACTIVATE_64_BIT_SERVER = 0x80000
        }

        [Flags]
        public enum REGCLS : uint
        {
            SINGLEUSE = 0,
            MULTIPLEUSE = 1,
            MULTI_SEPARATE = 2,
            SUSPENDED = 4,
            SURROGATE = 8,
        }

        [DllImport("ole32.dll", PreserveSig = false)] // HRESULTの戻り値を例外として受け取る
        protected static extern UInt32 CoRegisterClassObject(
            [In] ref Guid rclsid,
            [MarshalAs(UnmanagedType.Interface), In] IClassFactory pUnk,
            [In] CLSCTX dwClsContext,
            [In] REGCLS flags);

        [DllImport("ole32.dll", PreserveSig = false)] // HRESULTの戻り値を例外として受け取る
        static extern void CoRevokeClassObject([In] UInt32 dwRegister);
        #endregion

        #region IClassFactoryの実装
        public IntPtr CreateInstance([In] IntPtr pUnkOuter, [In] ref Guid riid)
        {
            if (pUnkOuter != IntPtr.Zero)
            {
                // アグリゲーションはサポートしていない
                Marshal.ThrowExceptionForHR(unchecked((int)0x80040110)); // CLASS_E_NOAGGREGATION
            }
            else if (riid == typeof(IMyElevationOutProcSrv).GUID ||
                riid == GUID_IDispatch || riid == GUID_IUnknown)
            {
                // IMyElevationOutProcSrv, IDispatch, IUnknownのいずれかである場合はオブジェクトを生成して返す
                var inst = new MyElevationOutProcSrv();
                return Marshal.GetComInterfaceForObject(inst, typeof(IMyElevationOutProcSrv));
            }

            // サポート外のインターフェイスが要求された場合はエラーとする
            Marshal.ThrowExceptionForHR(unchecked((int)0x80004002)); // E_NOINTERFACE
            throw new InvalidCastException(); // (E_NOINTERFACE同等)
        }

        public void LockServer([In] bool fLock)
        {
            if (fLock)
            {
                MyApplicationContext.Current.IncrementCount();
            }
            else
            {
                MyApplicationContext.Current.DecrementCount();
            }
        }
        #endregion 

        /// <summary>
        /// 登録されたクラスオブジェクトレジスターを識別するクッキー
        /// </summary>
        private uint _cookieClassObjRegister;

        /// <summary>
        /// クラスファクトリをシステムに登録する。
        /// (登録した段階ではサスペンドされている。)
        /// </summary>
        public void CoRegisterClassObject()
        {
            // COMファクトリを登録する
            var clsid = typeof(MyElevationOutProcSrv).GUID;
            _cookieClassObjRegister = CoRegisterClassObject(
                ref clsid,   // 登録するCLSID
                this,        // CLSIDをインスタンス化するクラスファクトリ
                CLSCTX.LOCAL_SERVER, // ローカルサーバーとして実行
                REGCLS.MULTIPLEUSE | REGCLS.SUSPENDED); // 複数利用可・停止状態で作成
        }

        /// <summary>
        /// クラスファクトリの登録解除を行う。
        /// </summary>
        public void CoRevokeClassObject()
        {
            if (_cookieClassObjRegister != 0)
            {
                CoRevokeClassObject(_cookieClassObjRegister);
                _cookieClassObjRegister = 0;
            }
        }
    }

クラスファクトリでは要求されたインターフェイスのIIDがIUnknown, IDispatch, もしくは自分のインターフェイスのGUIDと一致すれば、 オブジェクトを生成して、それを Marshal.GetComInterfaceForObject() を使うことで、 DotNETのオブジェクトからCOM用のインターフェイスのポインタを取得して、それを返す。

CoRegisterClassObjectCoRevokeClassObject は、 アプリケーションの開始、終了時にクラスファクトリのシステムへの登録と登録解除のために呼び出されるものである。

手順5: アプリケーションのエントリポイントの作成

このEXEはCOMサーバーであるため、

  • フォームを持たない。(ユーザーの操作で終了するわけではない。)
  • 起動後は、COMクライアントからの要求を待ち受けする以外は何もしない。
    • COMのマーシャリングのためのメッセージループが必要
  • アクティブな残存オブジェクトがなくなったら終了する

という、普通のフォームアプリとは、すこし違った動きをする必要がある。

このため、独自の「アプリケーションコンテキスト」を用意する。

アプリケーションは、以下のような形で開始される。

    class Program
    {
        [STAThread]
        static void Main(string[] args)
        {
            // 独自のアプリケーションコンテキストを作成してアプリケーションの寿命を管理する
            var appContext = new MyApplicationContext();

            // 設定値(app.config)の取り込み
            appContext.ShutdownDelaySecs = int.Parse(ConfigurationManager.AppSettings["ShutdownDelaySecs"]);

            // アプリケーションのメッセージループを開始する。
            Application.Run(appContext);

            Console.WriteLine("done.");
        }
    }

独自のアプリケーションコンテキストを生成して、設定ファイルから設定値を取り込んだら、それを Application.Run() に渡す。

アプリケーションコンテキストでは、COMのクラスファクトリ登録等々の設定後に、メッセージループに入る。

Application.Exit の呼び出しをもってメッセージループは終了され、この Main メソッドに戻ってきて、アプリケーションが終了となる。

手順6: 独自アプリケーションコンテキストの作成

独自のアプリケーションコンテキストは、以下のような処理を行う。

  • クラスファクトリをシステムに登録する
  • クラスファクトリのクライアントからの受け入れを開始する
  • タイマーを使って、定期的に残存オブジエクト数をカウントする
    • 明示的にGCを呼び出してデストラクタを動かして残存オブジェクト数を確定させる
    • 残存オブジェクト数が0になって一定時間経過したら終了処理を行う
  • 終了する場合は、まず、クライアントからの受け入れをすべて停止して、システムからクラスファクトリの登録を解除する
    • (登録解除後、あらためて残存数が0であれば)メッセージループを終了(Application.Exit)する。

以上のものを実装すると、以下のようになる。

    /// <summary>
    /// 独自のアプリケーションコンテキストを作成してアプリケーションの寿命を管理する。
    /// ここでCOMのクラスファクトリをシステムに登録し、クライアントからのCOM生成要求の待ち受けを行う。
    /// 生きているオブジェクト数が0になって一定時間経過したらクラスファクトリの登録を解除し、
    /// アプリケーションを終了させる。
    /// </summary>
    public class MyApplicationContext : ApplicationContext
    {
        #region P/Invoke
        /// <summary>
        /// 登録した全てのクラスファクトリを一斉に受け入れ可能にする
        /// </summary>
        [DllImport("ole32.dll", PreserveSig = false)] // HRESULTの戻り値は例外として受けとる
        static extern void CoResumeClassObjects();

        /// <summary>
        /// 登録した全てのクラスファクトリの受付を一斉に停止する
        /// </summary>
        [DllImport("ole32.dll", PreserveSig = false)] // HRESULTの戻り値は例外として受けとる
        static extern void CoSuspendClassObjects();
        #endregion

        /// <summary>
        /// MyElevationOutProcSrvFactoryクラスファクトリ
        /// </summary>
        private readonly MyElevationOutProcSrvFactory classFactory = new MyElevationOutProcSrvFactory();

        /// <summary>
        /// 現在のコンテキスト
        /// </summary>
        public static MyApplicationContext Current { get; private set; }

        /// <summary>
        /// GCとアイドル経過時間を計測するためのタイマー
        /// </summary>
        private System.Windows.Forms.Timer gcTimer;

        /// <summary>
        /// アクティブなオブジェクト数のカウンタ
        /// </summary>
        private int activeCount;

        /// <summary>
        /// アクティブなオブジェクト数の最大値
        /// </summary>
        private int highWaterMark;

        /// <summary>
        /// アクティブなオブジェクトが0になってから
        /// サーバーを終了するまでの待機時間
        /// </summary>
        public int ShutdownDelaySecs { set; get; } = 3;

        /// <summary>
        /// 最後にアクティブが確認された時刻
        /// </summary>
        private DateTime LastUseTime = DateTime.Now;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        public MyApplicationContext()
        {
            // このコンテキストを現在のコンテキストとして保存する
            Current = this;

            // アプリケーション終了時
            Application.ApplicationExit += OnApplicationExit;

            // シャットダウン、ログオフが要求された場合のイベント
            SystemEvents.SessionEnding += (object sender, SessionEndingEventArgs e) =>
            {
                Shutdown();
                Application.Exit();
            };

            // クラスファクトリの登録と受付開始
            Start();

            // アクティブなCOM残存数が0になってから一定時間経過したら終了させるためのUIタイマー
            gcTimer = new System.Windows.Forms.Timer();
            gcTimer.Interval = 500; // 500mSec毎
            gcTimer.Tick += OnIntervalTimer;
            gcTimer.Start();
        }

        /// <summary>
        /// COMサーバーのファクトリを登録し、受付を開始する
        /// </summary>
        private void Start()
        {
            Console.WriteLine("Start");

            // クラスファクトリの登録
            classFactory.CoRegisterClassObject();

            // すべてのクラスファクトリで一斉にオブジェクト生成受付を開始する
            CoResumeClassObjects();
        }

        /// <summary>
        /// COMサーバーのファクトリを停止し、登録解除する。
        /// </summary>
        /// <returns>残存オブジェクトが0であるか?</returns>
        private bool Shutdown()
        {
            Console.WriteLine("Shutdown");

            // すべてのクラスファクトリを一斉停止する
            // (この時点で新しいオブジェクトは生成されなくなる)
            CoSuspendClassObjects();

            // クラスファクトリの登録解除
            classFactory.CoRevokeClassObject();

            return activeCount == 0;
        }

        /// <summary>
        /// 定期的に呼び出されるGUIタイマー。
        /// COMオブジェクトの使用中の残存カウントが0になって一定時間経過したらEXEサーバーを終了せさる。
        /// </summary>
        /// <param name="state"></param>
        private void OnIntervalTimer(object Sender, EventArgs e)
        {
            // 参照されているCOMオブジェクトがReleaseされてもGCが走るまで
            // デストラクタは動かないため、定期的に明示的にGCを行う。
            GC.Collect();

            if (activeCount > 0)
            {
                // まだ生きているオブジェクトがある
                LastUseTime = DateTime.Now;
            }
            else if (highWaterMark > 0)
            {
                // 過去に1つ以上のオブジェクトを生成済みであり、
                // 且つ、残存数が0になってから所定時間を経過した場合は
                // アプリケーションを終了する。
                TimeSpan span = DateTime.Now - LastUseTime;
                if (span.TotalSeconds > ShutdownDelaySecs)
                {
                    if (Shutdown())
                    {
                        Application.Exit();
                    }
                }
            }
        }

        /// <summary>
        /// アプリケーション終了時のイベントハンドラ
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void OnApplicationExit(object sender, EventArgs e)
        {
            Console.WriteLine("OnApplicationExit");
        }

        /// <summary>
        /// アクティブオブジェクト数のカウントアップ
        /// </summary>
        public void IncrementCount()
        {
            int cnt = Interlocked.Increment(ref activeCount);
            if (cnt > highWaterMark)
            {
                highWaterMark = cnt;
            }
            Console.WriteLine("incl count={0} waterMark={0}", activeCount, highWaterMark);
        }

        /// <summary>
        /// アクティブオブジェクト数のカウントダウン
        /// </summary>
        public void DecrementCount()
        {
            Interlocked.Decrement(ref activeCount);
            Console.WriteLine("decl count={0}", activeCount);
        }
    }

いくつか、些細な注意点がある。

  • タイマーで定期的にGCを行う必要がある。
    • Application.Idleイベントで、アイドル時にGCすることも試してみたが、適切ではなかった。
      • オブジェクトが全てReleaseされるとマーシャリングのためのメッセージも発生しなくなり、Idleイベントも発生しなくなる。
  • 過去に1つ以上のオブジェクトを生成済み の場合のみ、残存オブジェクトが0になったあとのタイムアウト判定を行う。
    • デバック実行等で、COMによりEXEが起動したのに、オブジェクトが1つも作成されないうちにタイムアウトしてアプリケーションを終了すると、システムが混乱する為。
    • COMによってEXEが起動した場合は、引数として -Embedding フラグが付与されるので、必要ならば、これで判定する。

手順7: Win32リソースのコンパイルとEXEへの埋め込み

手順3で、COM Elevation Moniker が有効となるためには、UACの昇格ダイアログに表示するための、

LocalizedStringキーで、"@" + EXEのフルパス + ",-" + 文字列リソース番号 の文字列リソースを指定する必要がある。

これは、たとえば、文字列リソース番号が 101 の場合、リソースファイルresource.rc は以下のように定義する必要がある。

#define IDS_STRING101   101

STRINGTABLE
BEGIN
IDS_STRING101           "MyElevationOutProcSrv"
END

問題は、VC++プロジェクトの場合であれば、.rc ファイルはWin32リソースファイルとして認識され、コンパイルされるが、 C#プロジェクトの場合は、ソリューションエクスプローラから .rcファイルを追加してもWin32リソースコンパイラのソースとしては認識されない点である。

そこで、開発者コマンドプロンプトを開いて、手作業で rc.exeコンパイルする。

リソースコンパイラによって resource.res ファイルが得られたら、*.csprojファイルを直接開いて、 以下のように <PropertyGroup> の下に <Win32Resource> リソースを追記する。

    <PropertyGroup>
        ....
        <Win32Resource>resource.res</Win32Resource>
    </PropertyGroup>

MSBuildのカスタマイズによるC#プロジェクト中のリソースコンパイラの実行

自動的にビルドさせたいのであれば、MSBuild を手動で書き換えて、C#のプロジェクト中にVC++用のリソースコンパイラのタスクを流用させてしまう手法もある。

(ただし、VSバージョンによって設定内容が異なるので可搬性の高いスクリプトを書くのは難しいようだ。環境が変わったら手直しが必要になるかもしれない。)

今回は、VS2017のC++プロジェクトが参照している Microsoft.CppCommon.targets のタスク定義から、リソースコンパイラのタスク定義部を抽出し、 リソースコンパイル用のターゲットを定義してみる。

  <!--
    VC++のRCタスクを流用する (VS2017)
    (Microsoft.CppCommon.targets の定義から抜粋。)
    https://docs.microsoft.com/ja-jp/visualstudio/msbuild/rc-task
   -->
  <UsingTask TaskName="RC" AssemblyFile="$(VCTargetsPath)Microsoft.Build.CppTasks.Common.dll"/>
  <Target Name="ResourceCompile" BeforeTargets="BeforeCompile" Condition="'@(ResourceCompile)' != ''">
    <RC Source="@(ResourceCompile)" SuppressStartupBanner="False" ResourceOutputFileName="%(RelativeDir)%(filename).res"/>
  </Target>

このResourceCompileタスクは、ResourceCompile というアイテムグループがあれば、これを BeforeCompile ターゲット前に実行する。 (なので、C#ソースがコンパイルされるまえにリソースファイルがコンパイルされる。)

リソースコンパイラはソースとして単一ファイルしか受け付けないため、%(filename).res のアイテムに対する属性を取得することで、 単一ファイルごとの呼び出しへのバッチ化を行っている。 (もしくは、ResourceOutputFileName を指定せず、Source="%(ResourceCompile.Identity)" としても良い。コンパイル結果は暗黙で拡張子RESが付与される。)

リソースファイルは以下のようにアイテムグループを追加する。

  <ItemGroup>
    <ResourceCompile Include="resource.rc" />
  </ItemGroup>

また、コンパイルされたwin32リソースをexeに埋め込むため、<PropertyGroup> の下に <Win32Resource> リソースを設定する。

  <PropertyGroup>
    <Win32Resource>@(ResourceCompile -> '%(RelativeDir)%(filename).res')</Win32Resource>
  </PropertyGroup>

ここでは、リソースグループの各アイテムの属性名から「*.res」という形式の名前に変換している。

(ちなみに、この場合、ファイルが複数ある場合はセミコロン結合の文字列になるが、Win32Resource は単一ファイルしか受け取らないので、複数リソースある場合はエラーになるだろう。)

VS2017が入っている環境で、開発者コマンドプロンプトからMSBuildを実行した場合は正常にコンパイルできるが、Visual Studio上からビルドした場合、 "Microsoft.Build.Shared.InternalErrorException: MSB0001: Internal MSBuild Error: rc.exe unexpectedly not a rooted path" というエラーが出る場合は、 環境変数PATHに、WindowsSDKへのパスを入れると解決する。

参考: xml - Can't find xsd.exe in the path during MSBuild Community Tasks BeforeBuild step in Visual Studio - Stack Overflow

VSExpress2017でもWindowsSDKは自動的に入っているので、SDKへのパスが不明の場合は、開発者コマンドプロンプトを開いてPATHを見てみると良い。

(なお、エンバカデロのRAD Studioとか、その他のWin32をビルドできるツールをいれていると、rc.exeへのパスが環境変数に設定済みの可能性もある。)

はたして、VC++のプロジェクトが環境変数を設定せずともVS上からも rc.exe を起動できるのは、MSBuild内のインポートで

<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props"/> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /><Import Project="$(VCTargetsPath)\Microsoft.Cpp.WindowsSDK.props" /> などのプロパティ設定で、 各種ツールへのパスを検索しており、それを <UsingTask TaskName="SetEnv" AssemblyFile="$(VCTargetsPath)Microsoft.Build.CppTasks.Common.dll"/> で示される、VC++用の SetEnvタスク を使ってスクリプト内で環境変数PATHを設定しているためである。

これを真似すればC#プロジェクト内からの rc.exe へのパスを設定できるとは思われる。

が、このあたりは、かなり込み入ったコードになっており、うまく対応したとしてもバージョンが変われば動かなくなる感じもあるので、やはり、手作業で環境変数PATHを設定するのが無難であろうか。

(もしくはVC++でリソースコンパイルするだけのサブプロジェクトを作るとか。)

なお、このリソース設定箇所は、画面からも確認できるが入力はできない。(ファイル名としてチェックされて、エラーになって保存できないため。)

f:id:seraphy:20180606185915p:plain

手順8: regasmによるレジストリへの登録

EXEがビルドできたら、これをレジストリに登録する。

開発者コマンドプロンプト管理者権限 で開いて、regasmによりレジストリに登録する。

regasm /codebase /tlb Debug\MyElevationOutProcSrv.exe

/codebase を指定する場合は厳密名をつけて署名しろ、みたいな警告がでるが、とりあえずレジストリには登録できているはずである。

/tlb はタイプライブラリのレジストリへの登録を行うものである。 (COMからのイベントをハンドルしないのであれば、これは指定しなくても、メソッドやプロパティの呼び出しには支障ない。)

なお、登録解除する場合は、

regasm /u /codebase /tlb Debug\MyElevationOutProcSrv.exe

のように行う。

動作確認

以上で、COM昇格可能なOut-ProcなCOMサーバーが使えるようになっている。

VBAからの動作確認

ExcelVBAから動作確認してみる。

VBAからCOMのイベントをハンドルするためには、参照設定でタイプライブラリを指定しなければならない。

f:id:seraphy:20180606190017p:plain

Private WithEvents obj As MyElevationOutProcSrv.MyElevationOutProcSrv

Public Sub TestCOM()
    Set obj = GetObject("Elevation:Administrator!new:7AEFA37C-4494-4AE8-9378-0157A0B919AE")
    'Set obj = GetObject("new:7AEFA37C-4494-4AE8-9378-0157A0B919AE")
    obj.name = "FooBar"
    Call obj.ShowHello
    Set obj = Nothing
End Sub

Private Sub obj_NamePropertyChanging(ByVal name As String, ByRef cancel As Boolean)
    If (MsgBox("Changing? " & name, vbYesNo, "Confirm") <> vbYes) Then
        cancel = True
    End If
End Sub

Private Sub obj_NamePropertyChanged(ByVal name As String)
    MsgBox "changed: " & name
End Sub

(なお、イベントをハンドルする必要がなければタイプライブラリの参照設定は不要である。)

VBSからの動作確認

VBSからは、以下のように使える。

Option Explicit
Dim obj

'Set obj = CreateObject("MyElevationOutProcSrv")
'Set obj = GetObject("new:7AEFA37C-4494-4AE8-9378-0157A0B919AE")
Set obj = GetObject("Elevation:Administrator!new:7AEFA37C-4494-4AE8-9378-0157A0B919AE")
WScript.ConnectObject obj, "obj_"

obj.Name = "FooBar"
obj.ShowHello()

Sub obj_NamePropertyChanging(ByVal name, ByRef cancel)
    WScript.Echo("Changing: " & name)
    ' VBSからはbyrefのcancel値は返却できない。
End Sub

Sub obj_NamePropertyChanged(ByVal name)
    WScript.Echo("Changed: " & name)
End Sub

f:id:seraphy:20180606185957p:plain

ただし、Moniker を経由する場合は、直接、スクリプト言語GetObject() を使う必要がある。(WScript.GetObject() は各種モニカをサポートしていないようである。)

参考

以上、メモ終了。

C++でATLを使わずにレジストリフリーのCOMサーバーを作成してWSHから利用する方法

概要

COM DLLを作成するにはATLでウィザードを使ってプロジェクトを作成すると、とても簡単にできる。

しかし、残念ながらVisual Studio Express 2017 For Windows DesktopにはATLは付属していない。

だが、レジストリフリーのCOMを作成するのであれば、ATLの力を借りずともVC++の言語サポートと、Expressの機能だけで、比較的容易に作成できる。

以下は、VS Express 2017のC++レジストリフリーのCOM DLLを作成する手順である。

※ ビルド可能なプロジェクト一式は、GitHub上においてあります。

github.com

※ なぜ、C#で実装しない(できない)のかは、この記事の末尾で少し触れています。

手順1: プロジェクトの作成

プロジェクトの種類には「C++」の「Windows DLL」を選択する。

f:id:seraphy:20180531104412p:plain

"MyRegFreeComSrv"というプロジェクト名にする。

ソリューションも同時に作成して、ソリューション名は「MyRegFreeCom」にしておく。 (あとでCOMを利用するクライアントのプロジェクトも、このソリューションに作るため)

Gitリポジトリの生成も行うと、ウィザードが完了したときに、はじめからローカルリポジトリが作成済みになる。 (VS用のgitignoreの設定もされている。)

動くことを確認したら、ときどきコミットして、いろいろ試行錯誤して間違えても一発で戻れるようにしておくと気が楽である。

手順2: DllMainでモジュールハンドルを待避する

ウィザードで生成した直後の状態では、Windows DLLのエントリポイントの「DllMain」関数だけが定義されている。

f:id:seraphy:20180531104524p:plain

DllMain 関数は、DLLがプロセスにロードされたとき、スレッドにアタッチされたとき等々のイベントで、この関数を呼び出す。

ここで重要なのはDLLのモジュールハンドルが取得されることである。

これは後で使うのでグローバル変数として保存しておく。(自分自身のDLLからリソースを取得する場合などで)

HMODULE g_hModule;

BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
        g_hModule = hModule;
        break;

    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

手順3: DEFファイルによるDLL関数のエクスポート

In-Process COMサーバーとして機能するDLLを作成するには、最低でも以下の2つの関数をエクスポートする必要がある。

  • DllCanUnloadNow (DLLをアンロードしてよいか判定するためのもの)
  • DllGetClassObject (CLSIDを指定してCOMオブジェクトを生成して返すもの)

(このほかにレジストリへの登録、登録解除のためのエントリもあるが、これは今回は不要である)

この2つの関数をエクスポートするために、昔ながらのDEFファイルによるエクスポートを行う。

f:id:seraphy:20180531104558p:plain

LIBRARY "MyRegFreeCOMSrv"

EXPORTS
    DllCanUnloadNow PRIVATE
    DllGetClassObject PRIVATE

LIBRARYには、作成されるDLL名を指定する。 (これはプロジェクトプロパティで指定するDLL名と一致していなければならない。 ウィザードにより生成されたあとは既定では"プロジェクト名.DLL"として設定されている。)

この2つの関数は、(どこでもいいが、今回は)"MyRegFreeComSrv.cpp"で実装するものとする。

#pragma region COM DLLのエクスポート関数

extern "C" HRESULT __stdcall DllCanUnloadNow(void)
{
    return S_OK;
}

extern "C" HRESULT __stdcall DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID *ppv)
{
    return CLASS_E_CLASSNOTAVAILABLE;
}
#pragma endregion

まだ実装の中身はないので、とりあえず、いまは常に定数値を返しておく。

ちなみに、VC++ではpragma regionソースコードの折りたたみ範囲を指定できる。

(この時点で、まずコンパイルできるか確認する。)

手順4: IDLファイルの作成

次に、IDLファイルを作成する。

f:id:seraphy:20180531104622p:plain

IDLとは、インタフェース定義言語(Interface Definition Language)であり、COMインターフェイスの定義と、COMオブジェクトが、どのCOMインターフェイスを実装しているか、などの「型情報」を記述するものである。

これはコンパイルすることで、C++のソースとヘッダを生成し、タイプライブラリという型情報を記録したバイナリ形式のファイルも出力する。

特に「タイプライブラリ」はCOMをスクリプトから利用する場合には型情報を知るためにきわめて重要なものである。

生成されたC++のソースとヘッダはCOMの実装時に使う他、クライアントがCOMインターフェイスを参照する場合にも使える。

idlに、以下のIMyRegFreeCOMSrvというインターフェイスを定義する。

(一般的にインターフェイス名は「I」で始まることになっている。)

これが、今回作成するCOMのインターフェイス定義となる。

import "oaidl.idl";
import "ocidl.idl";

[dual, uuid(E17C4111-F731-44E6-B262-D45D1241DD75)]
interface IMyRegFreeCOMSrv : IDispatch
{
    [propget, id(1)] HRESULT Name([out, retval] BSTR* pVal);
    [propput, id(1)] HRESULT Name([in] BSTR newVal);
    [id(2)] HRESULT ShowHello();
};

この定義が意味するところは、

[dual] というのは、これがスクリプトから呼び出すことのできるデュアルインターフェイスであることを示している。

[uuid(xxxx)] には、このインターフェイスを識別するユニークなIDを割り当てる。

これはVisual Studioのツールメニューから「GUIDの作成」を選んで、そこからランダムなGUIDをもらってくる。

(COMではオブジェクト、インターフェイス、ライブラリに、それぞれ世界で一意になるようなランダムな値をつけ、 このGUIDによって識別するようになっている。異なるものに同じ値は使ってはならない。)

[propget], [propput] は、プロパティのgetter/setterの定義を表す。

COMではプロパティもメソッドとして実装されるので、このような形で定義される。

(C#などでプロパティを扱っていれば分かると思う。)

[id(1)] は、DISPIDの定義である。スクリプトからCOMを呼び出す場合の番号となる。

このDISPIDは1から始まる任意の番号をつけてよいが、いくつか特殊な番号がある。

たとえば「DISPID_VALUE(0)」はデフォルトを表すもので、DISPIDが0のメソッドまたはプロパティは、スクリプト側でメソッド名またはプロパティ名を省略した場合に暗黙で使われるようになったり、あるいは、For Eachで列挙子を返すためのメソッドを表す、DISPID_NEWENUM(-4)などの定義がある。

今回は、とくに使わないので、1以上の適当な番号をふっておけばよい。

また、COMでは全て結果コードをHRESULTとして返すので、すべてのプロパティ、メソッドはHRESULTを返す。

引数にはIn/Outの区別があるので、[in]または[in, out]のように指定し、戻り値は[out, retval]のように指定する。

[out] で指定した引数には、返却用のアドレスをクライアントから渡されるので、そこに書き込めば良い。 (返却用のアドレスを用意するのはクライアントの責務である。)

手順4-2: タイプライブラリとイベントソースの定義

つぎに、このインターフェイスを実装するCOMクラス(CoClass)とイベントソース、タイプライブラリの定義を行う。

まず、COMクラスとイベントソースはタイプライブラリの中に包含するように記述する。

[uuid(CE20ECF4-B344-49DE-AD01-CDCF7098108D), version(1.0)]
library MyRegFreeCOMSrvLib
{
    importlib("stdole32.tlb");

    [
        uuid(8C11D374-E2BF-4DEF-89AB-81756137C1D0)
    ]
    dispinterface _IMyRegFreeCOMSrvEvents
    {
    properties:
    methods:
        [id(1)] HRESULT NamePropertyChanging([in] BSTR Name,[in, out] VARIANT_BOOL *pCancel);
        [id(2)] HRESULT NamePropertyChanged([in] BSTR Name);
    };

    [uuid(4475E395-FA5A-42A3-901C-F3062802B9B2)]
    coclass MyRegFreeCOMSrv
    {
        [default] interface IMyRegFreeCOMSrv;
        [default, source] dispinterface _IMyRegFreeCOMSrvEvents;
    };
}

library がタイプライブラリの指定であり、タイプライブラリのIDをuuidで指定している。

バージョンも指定できるが、今回はレジストリに登録しないCOMであるし、とりあえず1.0にしている。

importlib("stdole32.tlb"); はシステム標準のIUnknownやIDispatchなどの既定のタイプライブラリをインポートするもので常に必要である。

この下に、まずイベントソースを指定する。

イベントソースは、WSHWScript.ConnectObject のようにしてオブジェクトからのイベント通知を受けるためのもので、そのイベントについて定義するものである。

COMにおいてはイベントは、(通常は)IDispatchインターフェイスで実装するので、dispinterfaceで定義する。

イベントソースもインターフェイスの一種であるから、識別子としてuuidを必要とする。

今回は以下の2つのイベントをもつものとする。

  • NamePropertyChanging
  • NamePropertyChanged

手順4-3: COMクラスの定義

つぎに、インターフェイスを実装するCOMクラス(CoClass)の定義を行う。

COMクラスは、libraryの中のcoclassで定義する。

こちらはクラスを識別するCLSIDとなるuuidを指定する必要がある。

中身には、このクラスが実装するインターフェイスと、このクラスが送信するイベントのインターフェイスの指定となる。

[source] と指定されているインターフェイスがイベントソースとなるインターフェイスである。

手順4-4: IDLのコンパイル

ここまでIDLを定義すれば、MIDLによってIDLをビルドすることができる。

ソリューションエクスプローラからidlファイルを右クリックして「コンパイル」を選択すると、コンパイルが行われる。

すると、以下のファイルが生成される。

  • MyRegFreeCOMSrv_h.h C++用に翻訳されたCOMインターフェイス、COMクラスのヘッダファイル
  • MyRegFreeCOMSrv_i.c C++用に翻訳されたCOMインターフェイス、COMクラスのソースファイル(CLSID, LIBID, IIDなどの定数定義)
  • Debug/MyRegFreeCOMSrv.tlb 型情報を格納したタイプライブラリ
  • MyRegFreeCOMSrv_p.c プロキシ・スタブ用のルーチン(今回不要)
  • dlldata.c マーシャリングを行うためのルーチン(今回不要)

なお、これらのヘッダやソースはコンパイル時にも必要となるが、 idlからの生成物であるのでgitignoreには既定で(だいたい)除外設定されている。(*_h.hは入ってないので自分で追加する)

生成された「MyRegFreeCOMSrv_h.h」と「MyRegFreeCOMSrv_i.c」はプロジェクトに加えてビルドされるようにする。

追加する際には、プロジェクトに「生成されたファイル」というフィルタを追加して、そこに登録しておくと良い。

f:id:seraphy:20180531104656p:plain

また、「MyRegFreeCOMSrv_i.c」はstdafx.hを使わないので、プリコンパイルヘッダの使用をOffにしておく。

f:id:seraphy:20180531104727p:plain

手順5: タイプライブラリのリソースへの埋め込み

つぎに生成されたタイプライブラリをwin32リソースに含めるようにする。

f:id:seraphy:20180531104746p:plain

タイプライブラリ(*.tlb)は、スクリプトからCOMを利用する際に型情報を取得するために、よく使われている。

タイプライブラリは、タイプライブラリの保存場所をレジストリに登録して、レジストリを経由してロードする方法があるが、 自分のDLL内にリソースとして格納し、レジストリを介さず、自分自身のリソースからタイプラブラリをロードする方法もある。

今回はレジストリフリーでやりたいので、リソース内におく必要がある。

Visual Studio Express 2017 for Windows Desktopでは、リソースエディタは存在しないが、リソース自身はプロジェクトに追加できるし、コンパイルもできる。

リソースを追加したら、リソースエディタは開かないが、テキストエディタとして開くことは可能である。

f:id:seraphy:20180531104814p:plain

末尾にある「#ifndef APSTUDIO_INVOKED」のブロックの内に、以下のようにタイプライブラリをリソースとして取り込むように指定する。

(リソース的には、どこでもよいのだが、このNOT APSTUDIO_INVOKEDの範囲であれば、Visual Studioのリソースエディタが勝手に書き換えない場所となる。)

#ifndef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// TEXTINCLUDE 3 リソースから生成されました。
//

1 TYPELIB "MyRegFreeCOMSrv.tlb"

/////////////////////////////////////////////////////////////////////////////
#endif    // APSTUDIO_INVOKED でない場合

なお、タイプライブラリのリソースIDは1としておく。

これはLoadTypeLib が、このDLLからタイプライブラリをロードするとき、デフォルトでは、最初に見つかったタイプライブラリを使用するためである。

また、ファイル名として「MyRegFreeCOMSrv.tlb」を指定しているが、タイプライブラリはビルド時の中間フォルダに出力されているので、実際にはDebug, Release、あるいはx64フォルダの下にいる。(タイプライブラリはx64/x86で生成される内容が異なるので。) なので、ビルド時にリソースの参照パスを設定する必要がある。

f:id:seraphy:20180531104845p:plain

プロジェクトのプロパティページからリソースを選択して「追加のインクルードディレクトリ」に$(IntDir)を追加する。

これで、中間ディレクトリ内の"MyRegFreeCOMSrv.tlb"ファイルが参照できるようになる。

(ここまででビルドが通ることを確認しておくと良い。)

手順6: MyRegFreeCOMSrvクラスの実装

つぎに、いよいよ、MyRegFreeCOMSrvクラスの実装を行う。

今回は、先ほどDLLのエクスポート関数を定義した、MyRegFreeCOMSrv.cppファイル内に実装することにする。

以下のようにクラスを書き始める。

#include "stdafx.h"

#include "MyRegFreeCOMSrv_h.h"

class MyRegFreeCOMSrv
    : public IMyRegFreeCOMSrv
{
};

... このうしろに前述のDLLエクスポート関数 ...

インターフェイス名、クラス名は、IDLで指定したinterface, coclassの名前と一致している必要がある。

これは、ヘッダファイル"MyRegFreeCOMSrv_h.h"を見れば分かるが、ここで以下のように先行宣言されている。

class DECLSPEC_UUID("4475E395-FA5A-42A3-901C-F3062802B9B2")
MyRegFreeCOMSrv;

クラス名だけ先行定義をしているが、そこに属性として__declspec(uuid(...))としてIDLで指定したuuidが付与されている。

なので、このクラス名に対してVC++の拡張である__uuidof(MyRegFreeCOMSrv)という関数を呼び出すことで、クラス名から、それに割り当てられているUUID(4475E395-FA5A-42A3-901C-F3062802B9B2)を得られる、という便利な仕組みになっている。

また、今回作成するCOMには_IMyRegFreeCOMSrvEventsというイベントソースをもつことになっている。

C++のCOMでイベントソースを定義するのは、ちょっと面倒くさくて、IConnectionPointというインターフェイスを経由して、クライアント側が提供するイベント受け取り側(イベントシンク)と接続する、という方法を採らなければならない。

そして、このIConnectionPointは、それを保持するIConnectionPointContainerインターフェイスから取得されなければならないので、結局、最低でも、MyRegFreeCOMSrvクラスは、それ自身が実装すべきIMyRegFreeCOMSrvインターフェイスの他に、イベント接続のためのIConnectionPointContainerIConnectionPointも実装する必要がある。

しかし、更にWSHなどのスクリプト言語からイベントを接続可能にするためには、型情報をスクリプト側に教えてやるためのIProvideClassInfo2インターフェイスも実装しなければならない。 (IProvideClassInfo2が実装されていないと、WSHWScript.ConnectObject()を実行すると、接続できませんというようなエラーになる。なお、IProvideClassInfo2がなくても、WScript.CreateObject("progid", "prefix_")だと接続できる。)

なので、結局、以下のような形になる。

class MyRegFreeCOMSrv
    : public IMyRegFreeCOMSrv
    , public IConnectionPointContainer
    , public IConnectionPoint
    , public IProvideClassInfo2
{
};

手順6-2: IUnknownの実装

まずは、もっとも基本となる、IUnknownで参照カウンタとインターフェイスの問い合わせ部について実装する。

class MyRegFreeCOMSrv
    : public IMyRegFreeCOMSrv
    , public IConnectionPointContainer
    , public IConnectionPoint
    , public IProvideClassInfo2
{
private:
    ULONG m_refCount;

    virtual ~MyRegFreeCOMSrv()
    {
    }

public:
    MyRegFreeCOMSrv()
        : m_refCount(0)
    {
    }

#pragma region IUnknownの実装
    virtual HRESULT __stdcall QueryInterface(REFIID riid, void **ppvObject)
    {
        if (riid == IID_IUnknown ||
            riid == IID_IDispatch ||
            riid == IID_IMyRegFreeCOMSrv) {
            *ppvObject = static_cast<IMyRegFreeCOMSrv *>(this);
            AddRef();
            return S_OK;
        }
        else if (riid == IID_IConnectionPointContainer) {
            *ppvObject = static_cast<IConnectionPointContainer *>(this);
            AddRef();
            return S_OK;
        }
        else if (riid == IID_IConnectionPoint) {
            *ppvObject = static_cast<IConnectionPoint *>(this);
            AddRef();
            return S_OK;
        }
        else if (riid == IID_IProvideClassInfo || riid == IID_IProvideClassInfo2) {
            *ppvObject = static_cast<IProvideClassInfo2 *>(this);
            AddRef();
            return S_OK;
        }
        return E_NOINTERFACE;
    }

    virtual ULONG __stdcall AddRef()
    {
        return InterlockedIncrement(&m_refCount);
    }

    virtual ULONG __stdcall Release()
    {
        if (InterlockedDecrement(&m_refCount) == 0) {
            delete this;
            return 0;
        }

        return m_refCount;
    }
#pragma endregion
};

Release で参照カウンタが0になったら、delete thisで自分を破棄するようにしている。

そのため、デストラクタはprivateにおく。(これにより、ヒープ上のみ作成可能なオブジェクトとなる。)

QueryInterface は要求されたインターフェイスに対応するポインタをキャストして返すだけである。

(複数のIUnknown派生インターフェイスを継承しているので、それ単位でポインタが異なる。)

手順6-3: IDispatchの実装

つぎに、IDispatchの実装を行う。

IDispatchはスクリプト言語用のインターフェイスであり、文字列で関数名やプロパティ名を指定して、その番号を取得したり、その番号に対してパラメータを渡すことで、さまざまな処理を行ったり、というような動的な処理が行われることになる。

これらの動的処理のコードを自分で実装すればかなり面倒なコードになるのだが、タイプライブラリにある型情報を使うことで、これらを代行してくれる標準のWindows APIが用意されている。

なので、タイプライブラリをロードして代行するAPIに丸投げするだけでよい。

#include <comdef.h>

#include "MyRegFreeCOMSrv_h.h"

extern HMODULE g_hModule;

class MyRegFreeCOMSrv
    : public IMyRegFreeCOMSrv
    , public IConnectionPointContainer
    , public IConnectionPoint
    , public IProvideClassInfo2
{
private:
    static ITypeLibPtr m_pTypeLib;
    static ITypeInfoPtr m_pTypeInfo;

    ULONG m_refCount;

    virtual ~MyRegFreeCOMSrv()
    {
        if (m_pTypeInfo) {
            m_pTypeInfo.Release();
        }
        if (m_pTypeLib) {
            m_pTypeLib.Release();
        }
    }

public:
    MyRegFreeCOMSrv()
        : m_refCount(0)
    {
    }

    /**
   * タイプライブラリのロードまたはaddRefなど、
   * クラスの実行に事前に必要な初期化処理を行う。
   * (クラス生成失敗理由をHRESULTで返せるようにする)
   */
    HRESULT Init()
    {
        HRESULT hr = S_OK;

        // このDLLのリソースとして埋め込まれているタイプライブラリをロードして
        // Dispatchの実装に利用する.
        if (m_pTypeLib == nullptr) {
            TCHAR szModulePath[MAX_PATH];
            GetModuleFileName(g_hModule, szModulePath, MAX_PATH);

            hr = LoadTypeLib(szModulePath, &m_pTypeLib);
            if (SUCCEEDED(hr)) {
                // IMyRegFreeCOMSrvのディスパッチインターフェイスのタイプライブラリ取得
                hr = m_pTypeLib->GetTypeInfoOfGuid(IID_IMyRegFreeCOMSrv, &m_pTypeInfo);
            }
        }
        else {
            m_pTypeLib.AddRef();
            if (m_pTypeInfo) {
                m_pTypeInfo.AddRef();
            }
        }
        return hr;
    }

....IUnknownの実装....

#pragma region IDispatchの実装(型情報から既定の処理に転送するだけ)

    virtual HRESULT __stdcall GetTypeInfoCount(UINT *pctinfo)
    {
        if (pctinfo == nullptr) {
            return E_POINTER;
        }
        *pctinfo = m_pTypeLib->GetTypeInfoCount();
        return S_OK;
    }

    virtual HRESULT __stdcall GetTypeInfo(
        UINT iTInfo,
        LCID lcid,
        ITypeInfo **ppTInfo)
    {
        return m_pTypeLib->GetTypeInfo(iTInfo, ppTInfo);
    }

    virtual HRESULT __stdcall GetIDsOfNames(
        REFIID riid,
        LPOLESTR *rgszNames,
        UINT cNames,
        LCID lcid,
        DISPID *rgDispId)
    {
        return DispGetIDsOfNames(m_pTypeInfo, rgszNames, cNames, rgDispId);
    }

    virtual HRESULT __stdcall Invoke(
        DISPID dispIdMember,
        REFIID riid,
        LCID lcid,
        WORD wFlags,
        DISPPARAMS *pDispParams,
        VARIANT *pVarResult,
        EXCEPINFO *pExcepInfo,
        UINT *puArgErr)
    {
        return DispInvoke(this, // 呼び出しを、このオブジェクトのメソッドに転送する
            m_pTypeInfo, dispIdMember, wFlags,
            pDispParams, pVarResult, pExcepInfo, puArgErr);
    }
#pragma endregion
};

ITypeLibPtr, ITypeInfoPtr の使用のためにcomdef.h ヘッダをインクルードする。

タイプライブラリは先ほど、自分自身(DLL)にリソースとして埋め込んでいるので、DLL自身のパスを指定してLoadTypeLib APIによってロードすることができる。

DLLのパスを得るには、DLLのモジュールハンドルが必要になるので、最初にグローバル変数として保存したg_hModuleを使うことになる。(別ソースファイルで定義しているので、externで参照している。)

また、タイプライブラリは変更されないものなので、最初に一度読み込めば十分なので、Staticとしている。

DispGetIDsOfNamesDispInvoke が標準のDispatch代行APIである。

DispInvoke ではタイプライブラリからDISPIDに対応するメソッドの呼び出すを行う。

手順6-4: IConnectionPointContainerの実装

次にIConnectionPointContainer の実装を行う。

これは単にイベントソースのIIDであるか判定して、自身のコネクションポイントを返せばよいだけである。

#pragma region ConnectionPointContainer
    virtual HRESULT _stdcall EnumConnectionPoints(IEnumConnectionPoints **ppEnum)
    {
        return E_NOTIMPL; // クライアントはコンパイル時に既知のDIIDを知っているはずなので省略可。
    }

    virtual HRESULT _stdcall FindConnectionPoint(REFIID riid, IConnectionPoint **ppCP)
    {
        if (riid == DIID__IMyRegFreeCOMSrvEvents) {
            return QueryInterface(IID_IConnectionPoint, (void**)ppCP);
        }
        return E_NOINTERFACE;
    }
#pragma endregion

手順6-5: IConnectionPointの実装

次にIConnectionPoint の実装を行う。

IConnectionPointは、自身がサポートしているコネクションインターフェイスのUUIDの返却、自身を含むIConnectionPointContainerの返却、および、クライアントとの接続と接続解除の処理を実装する。

クライアントはIDispatchを実装したイベントシンクを渡すので、それに対してDWORDCookie値を(適当に)割り当てて返却する。

オブジェクトは保持しているイベントシンクに対して必要に応じてIDispatch#Invokeを用いてイベントを通知する。

クライアントから接続解除のためにCookie値を渡されたら、これでイベントシンクを解放する。

cookieと保持しているイベントシンクのIDispatchを保存するためのメンバフィールドと、cookie値を管理するカウンタが必要である。

private:
    // クッキーがかぶらないように管理するクッキーの最終値
    ULONG m_lastCookie;

    // コネクションポイントのアドバイスしているイベントシンク
    std::map<ULONG, IDispatch*> m_adviseMap;

public:
#pragma region ConnectionPoint
    virtual HRESULT __stdcall GetConnectionInterface(IID *pIID)
    {
        *pIID = DIID__IMyRegFreeCOMSrvEvents;
        return S_OK;
    }

    virtual HRESULT __stdcall GetConnectionPointContainer(IConnectionPointContainer **ppCPC)
    {
        return QueryInterface(IID_IConnectionPointContainer, (void**)ppCPC);
    }

    virtual HRESULT __stdcall Advise(IUnknown *pUnkSink, DWORD *pdwCookie)
    {
        if (!pdwCookie || !pUnkSink) {
            return E_POINTER;
        }
        IDispatch *pDisp = nullptr;
        HRESULT hr = pUnkSink->QueryInterface(IID_IDispatch, (void **)&pDisp);
        if (SUCCEEDED(hr)) {
            ULONG cookie = InterlockedIncrement(&m_lastCookie);
            *pdwCookie = cookie;
            m_adviseMap[cookie] = pDisp;
            return S_OK;
        }
        return hr;
    }

    virtual HRESULT __stdcall Unadvise(DWORD dwCookie)
    {
        auto ite = m_adviseMap.find(dwCookie);
        if (ite != m_adviseMap.end()) {
            IDispatch *pDisp = ite->second;
            pDisp->Release();
            m_adviseMap.erase(ite);
        }
        return S_OK;
    }

    virtual HRESULT __stdcall EnumConnections(IEnumConnections **ppEnum)
    {
        return E_NOTIMPL; // 列挙子はサポートしなくても可
    }

    /**
    * すべてのイベントシンクに対してイベントを送信するヘルパ
    */
    HRESULT FireEvent(DISPID dispid, DISPPARAMS *pParams, VARIANT *pVarResult) {
        HRESULT hr = S_OK;
        for (auto ite = m_adviseMap.begin(); ite != m_adviseMap.end(); ++ite) {
            IDispatch *pDisp = ite->second;
            hr = pDisp->Invoke(dispid, IID_NULL, LOCALE_USER_DEFAULT,
                DISPATCH_METHOD, pParams, pVarResult, NULL, NULL);
        }
        return hr;
    }
#pragma endregion

std::map を使っているので、#include <map>STLのmapをインクルードしておく。

FireEvent はイベントシンクに対してイベントを送信するためのヘルパ。

また、デストラクタでイベントシンクの解放漏れがあれば、ここで解放しておく。

 virtual ~MyRegFreeCOMSrv()
    {
        ....省略....

        for (auto ite = m_adviseMap.begin(); ite != m_adviseMap.end(); ++ite) {
            ite->second->Release();
        }
        m_adviseMap.clear();
    }

手順6-6: IProvideClassInfo2の実装

次に、IProvideClassInfo2 の実装を行う。

これは単にタイプライブラリの情報をクライアントに引き渡すだけのものである。

#pragma region IProvideClassInfo2
    virtual HRESULT __stdcall GetClassInfo(ITypeInfo **ppTI)
    {
        // 既定のCoClass(CLSID)の型情報を返す
        return m_pTypeLib->GetTypeInfoOfGuid(CLSID_MyRegFreeCOMSrv, ppTI);
    }

    virtual HRESULT __stdcall GetGUID(DWORD dwGuidKind, GUID *pGUID)
    {
        if (dwGuidKind == GUIDKIND_DEFAULT_SOURCE_DISP_IID) {
            // 既定のイベントソースのDIIDを返却する
            *pGUID = DIID__IMyRegFreeCOMSrvEvents;
        }
        return E_INVALIDARG;
    }
#pragma endregion

手順6-7: IMyRegFreeCOMSrvの実装

最後に、ようやくオブジェクト固有のインターフェイスの実装ができる。

private:
    _bstr_t m_bstrName;

public:
#pragma region オブジェクト固有のインターフェイスの実装
    virtual HRESULT __stdcall get_Name(BSTR *pVal)
    {
        *pVal = m_bstrName.copy();
        return S_OK;
    }

    virtual HRESULT __stdcall put_Name(BSTR newVal)
    {
        HRESULT hr;

        VARIANT_BOOL cancel = VARIANT_FALSE;
        {
            // NamePropertyChangingイベントの送信
            _variant_t varParams1[2];
            varParams1[1] = newVal;
            varParams1[0].byref = &cancel;
            varParams1[0].vt = VT_BOOL | VT_BYREF;
            DISPPARAMS params = { varParams1, nullptr, 2, 0 }; // 引数の積み方は逆順
            _variant_t ret;

            hr = FireEvent(1, &params, &ret);
        }
        if (SUCCEEDED(hr)) {
            if (!cancel) {
                m_bstrName = newVal;

                {
                    // NamePropertyChangedイベントの送信
                    _variant_t varParams2[1];
                    varParams2[0] = newVal;
                    DISPPARAMS params2 = { varParams2, nullptr, 1, 0 };
                    _variant_t ret;
                    hr = FireEvent(2, &params2, &ret);
                }
            }
        }

        return hr;
    }

    virtual HRESULT __stdcall ShowHello(void)
    {
        MessageBoxW(NULL, (LPCWSTR)m_bstrName, L"MyRegFreeCOMSrv", MB_ICONINFORMATION | MB_OK);
        return S_OK;
    }
#pragma endregion

put_Name では、Nameプロパティを更新する際に、イベントシンクに対してイベントを送信している。

手順7: IClassFactoryの実装

これで、MyRegFreeCOMSrvオブジェクトは完成したが、これをDLL外から利用するには、生成するためのファクトリが必要である。 ファクトリは、IClassFactory インターフェイスを実装したCOMオブジェクトであるが、DLLと生存期間が一致するものなので、参照カウンタのような寿命管理のメカニズムは必要ない。

(自分でdelete thisすることはなく、staticでインスタンス化して使う。)

しかし、DLLをアンロードして良いか?というDllCanUnloadNow関数の問い合わせに必要なロックカウントは保持する必要がある。

また、DllGetClassObject 関数ではCLSIDを指定してファクトリを取得する必要があるので、そのあたりも踏まえたコードは、以下のような感じになる。

#pragma region クラスファクトリの実装
// IMyRegFreeCOMSrvのスマートポインタ(IMyRegFreeCOMSrvPtr)の定義
_COM_SMARTPTR_TYPEDEF(IMyRegFreeCOMSrv, __uuidof(IMyRegFreeCOMSrv));

/**
* MyRegFreeCOMSrvのクラスファクトリ。
* 参照カウンタでは寿命を管理しない。
* DLLで一度だけ構築される、staticな生存期間をもつ。
*/
class MyRegFreeCOMSrvFactory
    : public IClassFactory
{
private:
    static ULONG m_lockCount;

public:
#pragma region IUnknownの特殊実装(特殊な参照カウンタ)
    virtual HRESULT __stdcall QueryInterface(REFIID riid, void **ppvObject)
    {
        if (riid == IID_IUnknown ||
            riid == IID_IDispatch ||
            riid == IID_IClassFactory) {
            *ppvObject = static_cast<IClassFactory *>(this);
            AddRef();
            return S_OK;
        }
        return E_NOINTERFACE;
    }

    virtual ULONG __stdcall AddRef()
    {
        LockModule(TRUE);
        return 2;
    }

    virtual ULONG __stdcall Release()
    {
        LockModule(FALSE);
        return 1;
    }
#pragma endregion
#pragma region クラスファクトリの実装
    virtual HRESULT __stdcall CreateInstance(IUnknown *pUnkOuter, REFIID riid, void **ppvObject)
    {
        if (pUnkOuter != NULL) {
            return CLASS_E_NOAGGREGATION;
        }
        MyRegFreeCOMSrv *pImpl = new MyRegFreeCOMSrv();
        IMyRegFreeCOMSrvPtr pIntf(pImpl);
        HRESULT hr = pImpl->Init(); // 初期化処理
        if (SUCCEEDED(hr)) {
            hr = pIntf.QueryInterface(riid, ppvObject);
        }
        return hr;
    }

    virtual HRESULT __stdcall LockServer(BOOL fLock)
    {
        LockModule(fLock);
        return S_OK;
    }
#pragma endregion
#pragma region クラスファクトリのロックカウント制御
public:
    static void LockModule(BOOL fLock)
    {
        if (fLock) {
            InterlockedIncrement(&m_lockCount);
        }
        else {
            InterlockedDecrement(&m_lockCount);
        }
    }

    static ULONG GetLockCount()
    {
        return m_lockCount;
    }
#pragma endregion
};

ULONG MyRegFreeCOMSrvFactory::m_lockCount = 0;
#pragma endregion

#pragma region COM DLLのエクスポート関数
extern "C" HRESULT __stdcall DllCanUnloadNow(void)
{
    // クラスファクトリのロックカウントが0であれば解放可能
    return MyRegFreeCOMSrvFactory::GetLockCount() == 0 ? S_OK : S_FALSE;
}

extern "C" HRESULT __stdcall DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID *ppv)
{
    // クラスファクトリオブジェクトをstaticな寿命をもつオブジェクトとして構築
    static MyRegFreeCOMSrvFactory factory;

    if (rclsid == CLSID_MyRegFreeCOMSrv) {
        return factory.QueryInterface(riid, ppv);
    }

    return CLASS_E_CLASSNOTAVAILABLE;
}
#pragma endregion

クラスファクトリもCOMオブジェクトではあるが、外部からCLSIDを使用してインスタンス化されることはなく、 CLSIDで区別されることもないのでUUID属性の定義は不要である。

以上まで実装すれば、ビルドは可能になっている。

手順8: レジストリフリーCOMのためのSide by side assembly manifestの設定

あとはCLSIDやPROGIDのレジストリを登録すればCOMオブジェクトとして起動できるようになっているはずである。

今回はレジストリフリーのCOMオブジェクトとしたいので、レジストリではなくSide By Side Assemblyのマニフェストを作成する。

ソリューションエクスプローラから追加するが、マニフェストファイルのテンプレートは存在しないので、テキストファイルを選択して名前を「MyRegFreeCOMSrv.manifest」として作成する。

f:id:seraphy:20180531104924p:plain

追加したファイルのプロパティを確認して、項目の種類が「マニフェストツール」になっていればOKである。

(マニフェストツールになっていれば、マニフェストは暗黙でリソースファイルに埋め込まれる。)

f:id:seraphy:20180531104945p:plain

ここにレジストリのかわりにCOMの情報を書き込む。(テキストは初期状態でSJISになっていると思われるので、保存するときはUTF-8であることを確認すること。)

<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">

    <assemblyIdentity
        type="win32"
        name="MyRegFreeCOMSrv"
        version="1.0.0.0" />

  <!-- 自分自身がもっているCOMの情報についてマニフェストにアセンブリとして登録する -->
  <file name="MyRegFreeCOMSrv.dll">
    <typelib tlbid="{CE20ECF4-B344-49DE-AD01-CDCF7098108D}"
          version="1.0" helpdir="" flags="HASDISKIMAGE"/>
    <comClass
          clsid="{4475E395-FA5A-42A3-901C-F3062802B9B2}"
          threadingModel="Apartment"
          tlbid="{CE20ECF4-B344-49DE-AD01-CDCF7098108D}"
          progid="MyRegFreeCOMSrv">
      <progid>MyRegFreeCOMSrv.1</progid>
    </comClass>
    
    <!-- カスタムインターフェイスの場合で、スレッドアパートメントが異なる場合はマーシャリングが必要なため、
      DLLがProxy/Stubをマージしている場合は、このDLLを示す<file>要素の子として、comInterfaceProxyStubの定義が必要。
      (Proxy/Stubが別DLLの場合は、*PS.DLLの<file>要素の子として作成する。)
    <comInterfaceProxyStub name="ISxSCustom" iid="{[IID_ISxSCustom]}" />
    -->
    
  </file>

  <!-- comInterfaceExternalProxyStubは、
  IDispatch派生クラスでスレッドアパートメントの互換性がない場合のマーシャリングのために使われる
  -->
  <comInterfaceExternalProxyStub
   name="_IMyRegFreeCOMSrvEvents"
   iid="{8C11D374-E2BF-4DEF-89AB-81756137C1D0}"
   tlbid="{ECEF3763-1C5F-41A9-AD2D-6BBD335F576C}"
   proxyStubClsid32="{00020420-0000-0000-C000-000000000046}"/>

  <comInterfaceExternalProxyStub
   name="IMyRegFreeCOMSrv"
   iid="{E17C4111-F731-44E6-B262-D45D1241DD75}"
   tlbid="{ECEF3763-1C5F-41A9-AD2D-6BBD335F576C}"
   proxyStubClsid32="{00020420-0000-0000-C000-000000000046}"/>

</assembly>

アセンブリマニフェストの文法については https://msdn.microsoft.com/en-us/library/windows/desktop/aa374191(v=vs.85).aspx のあたりを見る。

要点は

  • <assemblyIdentity>で自分のアセンブリ名を宣言する
  • <file name="*.dll">...</file>で自分のDLL名を指定し、このDLLが提供するCOMの情報を列挙する。
    • <typelib>タイプライブラリのIDとバージョン
    • <comClass> COMオブジェクトのCLSID, 代表ProgIDの宣言と、使用するタイプライブラリの制限。
      • 子要素として<progid>を追加で、必要なだけつけられる。(バージョン別ProgIdなどを表現するため。無くても良い。)
  • comInterfaceExternalProxyStubはアパートメントが異なる場合にシステムがマーシャリングを行う際に利用されるようで、とりあえずつけておくことが推奨されている。(が、今回はIn-ProcのSTAのみを想定しており、今回の場合には使われることはなく厳密には不要であろう。)

また、DLLにマニフェスト中にUAC有効化の設定があるのはおかしいので、必ず削除しておく。

f:id:seraphy:20180531105020p:plain

これでビルドすると、COMの情報が埋め込まれた、Side by Side Assemblyとして利用可能なDLLとして生成される。

手順9: テスト用のコンソールアプリの作成

テスト用のコンソールアプリのプロジェクトを追加する。

f:id:seraphy:20180531105047p:plain

最終的にはWSHから使いたいが、デバッグしたり動作確認するには同じC++で作られた、同一ソリューションのプロジェクトがあったほうが便利である。

ビルド順序を指定して、MyRegFreeCOMSrvが先にビルドされるようにしておく。

f:id:seraphy:20180531105059p:plain

とりあえず、SideBySideでCOMが使えることを確認するため、以下のような単純なクライアントコードを記述する。 (イベントまわりは、とりあえず、いまは省略。)

#include "stdafx.h"

#include <Windows.h>
#include <comdef.h>

#include "..\MyRegFreeCOMSrv\MyRegFreeCOMSrv_h.h"
#include "..\MyRegFreeCOMSrv\MyRegFreeCOMSrv_i.c"

//IMyRegFreeCOMSrvのスマートポインタ(IMyRegFreeCOMSrvPtr)の定義
_COM_SMARTPTR_TYPEDEF(IMyRegFreeCOMSrv, __uuidof(IMyRegFreeCOMSrv));

int main()
{
    CoInitialize(nullptr);
    {
        IMyRegFreeCOMSrvPtr pSrv;
        HRESULT hr = pSrv.CreateInstance("MyRegFreeCOMSrv.1");
        if (SUCCEEDED(hr)) {
            _bstr_t name(L"PiyoPiyo");
            pSrv->put_Name(name);
            pSrv->ShowHello();
        }
    }
    CoUninitialize();
    return 0;
}

COMの定義のヘッダファイルや定数類はサーバ側のプロジェクトから直接参照している。

(本来は、生成されたタイプライブラリを参照して#import "*.tlb"したほうが、いろいろ便利である。)

このまま実行すれば、当然、レジストリに登録されていないので実行は失敗する。

手順9-2: クライアント用のマニフェストの作成

これに対して、アプリケーションマニフェストを追加して、依存アセンブリを明示する。

(app.manifest 等の適当な名前で良い。追加方法はサーバ側と同様である。マニフェストツールとして認識されていればexeのリソースとして自動的に取り込まれる。)

<?xml version="1.0" encoding="utf-8" standalone="yes"?> 
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> 
    <assemblyIdentity
        version="1.0.0.0"
        processorArchitecture="" 
        name="MyRegFreeCOMClient.app"
        type="win32" /> 
    <description>Side By Side RegFree COM Client</description> 

    <dependency>
        <dependentAssembly>
            <assemblyIdentity
                type="win32"
                name="MyRegFreeCOMSrv"
                version="1.0.0.0"
            />
        </dependentAssembly>
    </dependency>
</assembly>

手順9-3: 動作確認方法

これで、クライアントプロジェクトをスタートアップに設定してデバッグ実行すれば、 CoCreateInstanceが成功してCOM呼び出しができていることが確認できる。

失敗するケースとしては、

  • Side by Side Assembly Manifestの設定に不備がある場合は、そもそもEXEが起動できない。
    • バージョンも含めてアセンブリ名が一致しているか?
    • DLLのマニフェストUACの設定が入っているとSxS起動は失敗する。
    • Side by Sideまわりの起動失敗は、SxsTraceというツールを使うとトレースログがとれる。

WinSxs WinSxs トレース ユーティリティ。

使用法: SxsTrace [オプション]

オプション:

Trace -logfile:ファイル名 [-nostop]

   sxs のトレースを有効にします。
   トレース ログはファイル名に保存されます。
   -nostop を指定すると、トレースを停止するかどうかを確認しません。

Parse -logfile:ファイル名 -outfile:解析ファイル [-filter:AppName]

   未処理のトレース ファイルを人が読める形式に変換し、結果を解析ファイルに
   保存します。
   出力をフィルターするには、-filter オプションを使用します。

Stoptrace

   まだ停止していない場合は、トレースを停止します。

例:

   SxsTrace Trace -logfile:SxsTrace.etl
   SxsTrace Parse -logfile:SxsTrace.etl -outfile:SxsTrace.txt

上記のクライアントで動作することを確認する。

手順10: WSHからレジストリフリーで呼び出す

今度はWSHから試してみる。

以下のようなVBScriptを定義する。

Option Explicit

Dim actctx
Set actctx = CreateObject("Microsoft.Windows.ActCtx")
actctx.manifest = "client.manifest"

Dim obj
Set obj = actctx.CreateObject("MyRegFreeCOMSrv.1")
WScript.ConnectObject obj, "obj_"
obj.Name = "PiyoPiyo"
obj.ShowHello()

Sub obj_NamePropertyChanging(name, byref cancel)
    WScript.Echo("name changing: " & name)
End Sub

Sub obj_NamePropertyChanged(name)
    WScript.Echo("name changed: " & name)
End Sub

スクリプトと同じフォルダに以下のclient.manifestファイルを保存する。

<?xml version="1.0" encoding="utf-8" standalone="yes"?> 
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> 
    <dependency>
        <dependentAssembly>
            <assemblyIdentity
                type="win32"
                name="MyRegFreeCOMSrv"
                version="1.0.0.0"
            />
        </dependentAssembly>
    </dependency>
</assembly>

これは先に実験したclient用のマニフェストファイルから、依存アセンブリの部分だけにしたものである。

ここに、コンパイルされたMyRegFreeCOMSrv.dllファイルも一緒に置いておく。

(もし、使用するWSHが32ビット版であれば、このMyRegFreeCOMSrv.dllも32ビットでビルドしておく。WSHが64ビット版であれば、MyRegFreeCOMSrv.dllも64ビットでビルドしなければならない。)

これで実行すると、プロパティの変更通知に対してメッセージが表示され、最終的にメッセージボックスが表示されるはずである。

f:id:seraphy:20180531105131p:plain

手順10-2: Microsoft.Windows.ActCtxについて

ここで使われている、Microsoft.Windows.ActCtxというコントロールは、アクティベーションコンテキストを明示的に開始してプログラム実行中にSide By Side Assemblyの効果を与えるActiveXである。

(これは、Windows2003以降に搭載されているようである。Window7, Windows10での動作を確認した。)

これにより、VBSの中でclient.manifestを後からロードして、レジストリフリーでCOMオブジェクトを起動することができている。

また、取得したオブジェクトはWScript.ConnectObjectによって接続することで、このCOMからのイベント通知も受け取ることができている。

(ただし、VBSのイベントハンドラではByRef引数を受け取っても、それを変更して返却することができないようである。このサンプルでは、本来はNamePropertyChangingで引数cancelに対してTrueを返すと変更をキャンセルできるはずだが、VBSではうまくゆかない。)

以上、VS Express 2017でATLを使わずレジストリフリーなCOMを作成することができた。

Excel/Word等のVBAから利用する場合

WSHから利用可能であるように、VBAからも同様にレジストリフリーで利用可能である。

こちらは、WSHの場合と異なり、ByRefのイベントでcancel値を返却できる。

Private WithEvents obj As MyRegFreeCOMSrv

Sub obj_NamePropertyChanging(ByVal name As String, ByRef cancel As Boolean)
    Dim rslt As VbMsgBoxResult
    rslt = MsgBox("名前の変更: " & name, Title:="名前の変更を許可しますか?", Buttons:=vbYesNo)
    If rslt <> vbYes Then
        cancel = True ' 変更を許可しない
    End If
End Sub

Sub obj_NamePropertyChanged(ByVal name As String)
    MsgBox "name changed: " & name
End Sub

Public Sub sxstest()
    Dim dir As String
    dir = ThisWorkbook.Path
    Dim actctx As Object
    Set actctx = CreateObject("Microsoft.Windows.ActCtx")
    actctx.manifest = dir & "\client.manifest"
    
    Set obj = actctx.CreateObject("MyRegFreeCOMSrv.1")
    obj.name = "PiyoPiyo"
    Call obj.ShowHello
    Set obj = Nothing
    Set actctx = Nothing
End Sub

ただし、これが利用可能になるためには、VBAの参照設定が必要である。

生成されたDLLにはタイプライブラリがリソースとして含まれているので、このDLLを参照設定で直接指定すれば良い。

(Officeが32ビット版であれば、DLLも32ビットでビルドしていなければならない。通常、Officeは32ビット版が推奨されているが、もし64ビット版のOfficeを使っている場合は、64ビットでビルドしたDLLを使用すること。)

f:id:seraphy:20180531140703p:plain

VBAでのイベントハンドリングでは

Private WithEvents obj As MyRegFreeCOMSrv

のように、WithEventsと同時に型の指定も必須であるため、コンパイル時点で型情報が必要である。

このように、レジストリフリーではあるが、DLLを参照設定しなければならない、という手間はある。

なお、イベントを使わない場合は参照設定は不要である。

※ あと、イベントを使う場合は、最後にSet obj = Nothing で明示的に解放していないと2回目以降で、よくわからんエラーになる。(何か実装に問題があるのも?)

注意点、その他

なお、Side by Side Assembly ManifestでレジストリフリーにできるのはCOMのCLSID, ProgID等々のレジストリ参照の仕組みであって、COMオブジェクトが自分のソースの中でレジストリを見ていたりしたら、当然、レジストリフリーにはできない。

ちなみに、ATLで作成する場合でも、IDispatchImplIProvideClassInfo2Implのタイプライブラリのバージョン指定を-1, -1にしておかないと、自分のDLLのタイプライブラリではなく、レジストリ経由のタイプライブラリを見に行く処理が入るため、レジストリフリーにならないので注意が必要である。

なお、In-Proc COM DLLであれば、C#でCOMオブジェクトを作成するほうが簡単なのであるが、DotNETで作成されたCOMオブジェクトはSide by side assemblyとして実行させようとすると、EXEと同じフォルダにDLLを入れておかないと動作しない。

  • (同じフォルダに入れておけばレジストリフリーで動作する。)

  • (原因はよくわからないが、エラーメッセージは「File Not Found」となっている。)

なので、今回のようにWSHで使うには、ちょっと難がある。(wscript.exe, cscript.exeを自分のフォルダにコピーすれば動作はするが...。)

C++/CLIの対応

C++ではなく、C++/CLIにすることで、C++のコードからDotNETのクラスライブラリを自由に呼び出せるようになる。

それには、以下の変更を行う。

  • プロジェクトのプロパティから「共通言語ランタイムのサポート」を「共通言語ランタイム サポート (/clr)」にする。
  • IDLが生成した「MyRegFreeCOMSrv_i.c」をC++としてコンパイルするため、プロジェクトのプロパティの「C/C++」の「詳細設定」から「コンパイル言語の選択」を「既定」から「C++ コードとしてコンパイル (/TP)」にする
  • いくつかの警告に対応する (なくてもコンパイルは可能)
    • DllMain関数に#pragma unmanaged プラグマをつける。
    • プロジェクトのプロパティの「リンカー」の「全般」から「インクリメンタルリンクを有効にする」を「いいえ (/INCREMENTAL:NO)」
    • プロジェクトのプロパティの「リンカー」の「デバッグ」から「デバッグ情報の生成」を「共有と発行用に最適化されたデバッグ情報の生成 (/DEBUG:FULL)」

こうすれば、たとえば、以下のようなコードを書けるようになる。

 virtual HRESULT __stdcall ShowHello(void)
    {
        // C++/CLIでDotNETオブジェクトを使ってみる
        System::Text::StringBuilder buf;
        buf.Append(L"Name: ");
        buf.Append(gcnew System::String((LPCWSTR)m_bstrName));
        pin_ptr<const wchar_t> wname = PtrToStringChars(buf.ToString());

        MessageBoxW(NULL, wname, L"MyRegFreeCOMSrv", MB_ICONINFORMATION | MB_OK);
        return S_OK;
    }

以上、メモ終了。

ExcelのRTDサーバをC#で作る方法

Excelでリアルタイムデータを取得するRTDサーバをC#で作る方法

概要

Excelには動的にリアルタイムデータをセルに取得・表示するための仕組みが2つある

どちらも、ほぼ似たようなワークシート関数で、使い方もほぼ同じ、結果もほぼ同じである。

=DDE("APPLICATION", "TOPIC", "ITEM")
=RTD("PROGRAM_ID", "SERVER", "TOPIC1"....)

Excelでリアルタイムデータを取得できるようになると、刻々と変わる値から動的に計算させたり、状態に応じて表示色を変えたりできるので、多数の計測機器から出力される計測ファイルや、ネット上で刻々と変化する無数の値からリアルタイムに分析・俯瞰するのに使えるようになる。

今回は、このちうちのRTDとして機能するRTDサーバーをC#で作成してみることにする。

DDEの特性

DDEはWindows3.1の16ビット全盛時代によく使われていたレガシーな技術で、データ交換にウィンドウメッセージを用いているところに特徴がある。1

このDDE関数で指定する「APPLICATION」はトップレベルウィンドウの「ウィンドウクラス名」そのものである。 つまり、ExcelのDDE関数は起動している別アプリのウィンドウとの間でメッセージを交換することでリアルタイムにデータを取得している。

数あるウィンドウメッセージのうち、DDEまわりを制御するライブラリはDDEML(Dynamic Data Exchange Management Library)としてまとめられている。

C++で実装する分にはddeml.hをインクルードすれば現在でも問題なく実装できるのであるが、C#(WindowsFormやWPF)では難しい。

DotNETではウィンドウクラスを独自に設定することが想定されておらず、DDEMLに相当するウィンドウメッセージのハンドリングも標準では全くサポートされていないためである。2

RTDの特性

RTD関数はExcel 2002からサポートされており、ダイナミックデータの取得方法としてウィンドウメッセージではなく、COMオートメーションサーバーを用いているところに特徴がある。

要するに、データの送り元としては、別アプリとしてウィンドウを用意する必要はなく、COMのDDLないしEXEを作成すれば良い、ということである。

EXE、またはプロキシ/スタブをもつDLLの場合は適切に設定すればリモートPC上のCOMを扱える(DCOM)ようになる、という利点もある。3

そのため、ExcelのRTD関数の第2引数ではコンピュータ名が指定できるようになっている。(ローカルPCのCOMを指定するのであれば、ここは省略して良い。)

また、DDEではTOPIC, ITEMの2種類のデータしか持つことが出来なかったが、RTDの場合は1つ以上、最大で253個のパラメータをもつことができる。

単純なCOMサーバーを作るのであればDotNETで容易につくることができる。

ということで、今回はC#で簡単なRTDサーバーを作ってみる。

※ 欠点としてはCOMであるため、基本的にレジストリへの登録が必須となる点である。(DDEの場合は現在存在するトップレベルウィンドウのウィンドウクラス名を検索するだけなので、レジストリ等への事前登録が必要ない。)

IRtdServer, IRTDUpdateEvent インターフェイス

RTDはCOMなので当然インターフェイスとして定義されており、RTDサーバーは、このインターフェイスを実装することで実現する。

これらのインターフェイスExcelのタイプライブラリをインポートすることで利用可能になるが、インターフェイスの型やGUID等々は明確になっているので、わざわざExcelからタイプライブラリをインポートせずとも、同じ定義を自分で用意しても何ら問題ない。(そうすればExcelのないビルド環境でもビルドできるようになる。)

また、RTDに必要なインターフェイスは2つだけである。

IRtdServerインターフェイス

IRtdServerが実装すべきRTDサーバーのインターフェイスである。

IRTDUpdateEventインターフェイス

IRTDUpdateEventは、データが更新されたことをExcelに通知するためのコールバック用のインターフェイスである。

サーバー開始時にExcelからIRTDUpdateEventが実装されたCOMオブジェクトが渡されてくるので、これに対してコールバックを行う。

定義例

以下は https://weblogs.asp.net/kennykerr/Rtd7 などから借用した。(DISPIDなどは別のソースコードから検索してつけてみた。)4

↑↑↑ RTDまわりのことは、この人の記事だけで、ほとんど網羅できる。

// Excelを参照せず、IRTDServer, IRTDUpdateEventのインターフェイスの定義を行う.
// Excel RTD Servers: C# without the Excel Assembly Reference
// https://weblogs.asp.net/kennykerr/Rtd7

/// <summary>
/// RTDのイベントのコールバック用インターフェイス.
/// </summary>
[ComImport]
[TypeLibType(TypeLibTypeFlags.FDual | TypeLibTypeFlags.FDispatchable)]
[Guid("A43788C1-D91B-11D3-8F39-00C04F3651B8")]
public interface IRTDUpdateEvent
{
    [DispId(10)]
    void UpdateNotify();

    [DispId(11)]
    int HeartbeatInterval
    {
        get;
        set;
    }

    [DispId(12)]
    void Disconnect();
}

/// <summary>
/// Excelから呼び出されるRTDインターフェイス
/// </summary>
[ComImport]
[TypeLibType(TypeLibTypeFlags.FDual | TypeLibTypeFlags.FDispatchable)]
[Guid("EC0E6191-DB51-11D3-8F3E-00C04F3651B8")]
public interface IRtdServer
{
    [DispId(10)]
    int ServerStart(IRTDUpdateEvent callback);

    [DispId(11)]
    object ConnectData(int topicId,
                        [MarshalAs(UnmanagedType.SafeArray, SafeArraySubType = VarEnum.VT_VARIANT)] ref Array strings,
                        ref bool newValues);

    [DispId(12)]
    [return: MarshalAs(UnmanagedType.SafeArray, SafeArraySubType = VarEnum.VT_VARIANT)]
    Array RefreshData(ref int topicCount);

    [DispId(13)]
    void DisconnectData(int topicId);

    [DispId(14)]
    int Heartbeat();

    [DispId(15)]
    void ServerTerminate();
}

VB6的なAutomation型らしさの色濃いSafeArray配列を多用したり、ref引数を使ったりとしていて、なかなかめんどくさそうな型定義になっている。

IRTDServerの実装

以下、IRTDServerの実装方法を示す。

プロジェクトの作成

まずC#のクラスライブラリプロジェクトを作成する。

ここでは「SeraRTDServerExample」というプロジェクトを作成するものとする。

これは、ソリューションエクスプローラのプロジェクト名を右クリックしてプロパティを開き、アプリケーションタブのアセンブリ情報から「アセンブリをCOM参照可にする」をチェック状態にしておく。

これでビルドされたDLLはCOMとして登録可能になる。

IRTDServer実装コード

IRTDServerは以下のクラスとして実装してみた。

今回はRTDサーバを作ることが目的なので、

  • 引数としてファイル名とフォーマットを渡すと...
    • 日付またはファイルサイズのいずれかを動的に返す
    • もしくはフォルダ名を指定してファイル一覧を文字列として返す

だけの単純なものとした。

Visual Studio 2017 Express for Desktopでビルド可能としている。

ソース一式はgist:51b509592e0b20fc77612673d490f247にも置いている。

/// <summary>
/// シンプルなRTDサーバーの実装例
/// </summary>
[Guid("5B6AA03F-F280-4B06-AFA3-519C9215D6BD")]
[ProgId("SeraRtdSvr")]
[TypeLibType(TypeLibTypeFlags.FDual | TypeLibTypeFlags.FDispatchable)]
[ComVisible(true)]
public class SeraphyRTDServer : IRtdServer
{
    /// <summary>
    /// TopicIdごとの値を保持し更新の有無を判定する値ホルダー
    /// </summary>
    class ValueHolder
    {
        /// <summary>
        /// 値のリゾルバ
        /// </summary>
        private Func<object> resolver;

        /// <summary>
        /// 現在の値
        /// </summary>
        public object Current { get; set; }

        /// <summary>
        /// 更新の有無
        /// </summary>
        public bool Modified { set; get; }

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="resolver"></param>
        internal ValueHolder(Func<object> resolver)
        {
            this.resolver = resolver;
        }

        /// <summary>
        /// 更新する.
        /// 変更がある場合は更新の有無が設定される.
        /// </summary>
        public void Refresh()
        {
            object value;
            try
            {
                value = resolver();
            }
            catch (Exception ex)
            {
                Debug.Print("▼exception {0}", ex.Message);
                // https://groups.google.com/forum/#!topic/exceldna/Z6mmxJ4LSbM
                // ErrDiv0  = -2146826281 
                // ErrNA = -2146826246
                // ErrName = -2146826259
                // ErrNull = -2146826288
                // ErrNum = -2146826252
                // ErrRef = -2146826265
                // ErrValue = -2146826273
                value = new ErrorWrapper(-2146826246); // N/A
            }
            if (!Object.Equals(value, Current))
            {
                Modified = true; // ModifiedはOffにはしない
                Debug.Print("◇ modified old={0}, new={1}", Current, value);
            }
            Current = value;
        }
    }

    /// <summary>
    /// タスクを終了させるためのキャンセラレーショントークンソース
    /// </summary>
    private CancellationTokenSource cancelTokenSource;

    /// <summary>
    /// 定期的にRTDにイベントを通知するための無限ループタスク
    /// </summary>
    private Task task;

    /// <summary>
    /// RTDイベントコールバック
    /// </summary>
    private IRTDUpdateEvent callback;

    /// <summary>
    /// RTDイベントコールバックを使う場合の同期コンテキスト
    /// </summary>
    private SynchronizationContext synchronizationContext;

    /// <summary>
    /// TopicIdごとのValueHolderを保持するマップ.
    /// </summary>
    private Dictionary<int, ValueHolder> TopicMap = new Dictionary<int, ValueHolder>();

    /// <summary>
    /// ファイル名に対する更新日付を取得する。例外の場合は例外メッセージを返す.
    /// </summary>
    /// <param name="name">ファイル名</param>
    /// <returns>更新日付</returns>
    private object GetLastWriteTime(string name)
    {
        return System.IO.File.GetLastWriteTime(name);
    }

    /// <summary>
    /// ファイル名に対するファイルサイズを取得する。例外の場合は例外メッセージを返す.
    /// </summary>
    /// <param name="name">ファイル名</param>
    /// <returns>更新日付</returns>
    private object GetFileSize(string name)
    {
        return new System.IO.FileInfo(name).Length;
    }

    /// <summary>
    /// ファイルまたはディレクトリに対するタブ区切りファイル一覧を返す.
    /// </summary>
    /// <remarks>
    /// 本当は配列を返したいのだが、方法がなさそうなので受け取り側で分割する.
    /// </remarks>
    /// <param name="name">ファイル名</param>
    /// <returns>更新日付</returns>
    private object GetFileList(string name)
    {
        var attr = File.GetAttributes(name);
        if (attr.HasFlag(FileAttributes.Directory))
        {
            var dir = new DirectoryInfo(name);
            return String.Join("\t", dir.GetFiles().Select(f => f.FullName).ToArray());
        }
        return name;
    }

    /// <summary>
    /// リアルタイム データ サーバーから新しいトピックを追加します。
    /// ConnectData メソッドは、リアルタイム データ関数を含むファイルが開かれたとき、
    /// またはユーザーが RTD 関数を含む新しい数式を入力したときに呼び出されます。
    /// </summary>
    /// <param name="topicId">必ず指定します。整数型 (Integer) の値を指定します。
    /// トピックを識別する一意の値を指定します。この値は、Excel によって割り当てられます。</param>
    /// <param name="strings">必ず指定します。オブジェクト型 (Object) の値を指定します。
    /// トピックを識別する文字列の 1 次元配列を指定します。(RTD関数の引数)</param>
    /// <param name="newValues">必ず指定します。ブール型 (Boolean) の値を指定します。
    /// 入力値がFALSEの場合はRTDセルを含むブックをロードしたなどでセル上に既存の値があることを示します。
    /// TRUE(非0)の場合はRTDセルを新規に入力したことを示します。
    /// 返却値は、新規の値を使用するべきかを示し、FALSEの場合は既存のセルの値を変更しないことを示します。</param>
    /// <returns></returns>
    public object ConnectData(
        int topicId,
        [MarshalAs(UnmanagedType.SafeArray, SafeArraySubType = VarEnum.VT_VARIANT)] ref Array strings,
        ref bool newValues)
    {
        PrintMethodInfo();
        try
        {
            // RTD関数の引数(トピック)の取得
            // トピックは1個以上、最大253個までありえる。
            // (なお、同一のパラメータは同一のtopicidが割り当てられるので
            // セルのコピー等を行った場合は本メソッドが重ねて呼び出されることはない。)
            string fname = strings.GetValue(0).ToString();
            string fmt = "time";
            if (strings.Length >= 2) // 2個目以降は省略可
            {
                fmt = strings.GetValue(1).ToString();
            }
            Debug.Print("○ topicId={0} fname={1}, format={2}", topicId, fname, fmt);

            Func<object> func;
            switch (fmt)
            {
                case "time":
                default:
                    func = () => this.GetLastWriteTime(fname);
                    break;

                case "size":
                    func = () => this.GetFileSize(fname);
                    break;

                case "list":
                    func = () => this.GetFileList(fname);
                    break;
            }

            // 割り当てられたTOPICIDと関連づけて保存する
            var valueHolder = new ValueHolder(func);
            TopicMap[topicId] = valueHolder;

            // セルを更新することを示す
            newValues = true;

            // データを取得し返却する.
            valueHolder.Refresh();
            valueHolder.Modified = false;
            return valueHolder.Current;
        }
        catch (Exception ex)
        {
            return ex.ToString();
        }
    }

    /// <summary>
    /// RTD (リアルタイム データ) サーバー アプリケーションに、トピックが使用されなくなったことを通知します。
    /// </summary>
    /// <param name="topicId">必ず指定します。整数型 (Integer) の値を指定します。
    /// トピックに割り当てる一意の値を指定します。この値は、Excel によって割り当てられます。</param>
    public void DisconnectData(int topicId)
    {
        PrintMethodInfo();
        TopicMap.Remove(topicId);
    }

    /// <summary>
    /// リアルタイム データ サーバーがアクティブかどうかを判別します。
    /// </summary>
    /// <returns>ゼロまたは負の値は失敗を示し、正の値は、サーバーがアクティブであることを示します。</returns>
    public int Heartbeat()
    {
        // ハートビート通知
        PrintMethodInfo();
        return 1;
    }

    /// <summary>
    /// このメソッドは、Excel が新しいデータを取得するときに呼び出されます。
    /// </summary>
    /// <param name="topicCount">必ず指定します。整数型 (Integer) の値を指定します。
    /// RTD サーバーは、TopicCount の値を、取得された配列内の要素数に変更する必要があります。</param>
    /// <returns>新しいデータが格納されたバリアント型 (Variant) の配列。</returns>
    [return: MarshalAs(UnmanagedType.SafeArray, SafeArraySubType = VarEnum.VT_VARIANT)]
    public Array RefreshData(ref int topicCount)
    {
        PrintMethodInfo();

        // 更新されているアイテムの抽出
        var modifiedItems = TopicMap.Where(entry => entry.Value.Modified).ToList();

        int count = modifiedItems.Count; // アイテムの個数
        object[,] data = new object[2, count]; // TOPICID, 値のペアからなる2次元配列

        int index = 0;
        foreach (var entry in modifiedItems)
        {
            int topicId = entry.Key;
            object value = entry.Value.Current;
            Debug.Print("* topidId={0}, value={1}", topicId, value);

            data[0, index] = topicId; // topicId
            data[1, index] = value; // 現在値

            entry.Value.Modified = false; // 返却したアイテムはModifiedをクリアする
            ++index;
        }

        topicCount = count;
        return data;
    }

    /// <summary>
    /// ServerStart メソッドは、
    /// リアルタイム データ サーバーがインスタンス化された直後に呼び出されます。
    /// </summary>
    /// <param name="callback">必ず指定します。
    /// IRTDUpdateEvent オブジェクトを指定します。
    /// コールバック オブジェクトを指定します。</param>
    /// <returns>負の値またはゼロはサーバーの起動に失敗したことを示し、
    /// 正の値は起動が成功したことを示します。</returns>
    public int ServerStart(IRTDUpdateEvent callback)
    {
        PrintMethodInfo();
        if ((task == null || task.IsCompleted) && callback != null)
        {
            this.callback = callback;

            // RTDイベントはSTAのため
            // 別スレッドから呼び出すためはマーシャリングする必要がある.
            synchronizationContext = SynchronizationContext.Current ??
                    new WindowsFormsSynchronizationContext();
            Debug.Print("synchronizationContext={0}", synchronizationContext);

            // タイマー的な無限ループタスクを開始する.
            cancelTokenSource = new CancellationTokenSource();
            task = new Task(WatchLoop,
                    cancelTokenSource.Token,
                    TaskCreationOptions.LongRunning);
            task.Start();
            return 1;
        }
        return 0;
    }

    /// <summary>
    /// キャンセルが指示されるまで現在保持しているTopicIdの状態を1秒ごとに検査し、
    /// 更新があればRTDイベントコールバックでExcelに更新を通知する無限ループタスク
    /// </summary>
    private void WatchLoop()
    {
        // キャンセルされるまで無限ループする
        while (!cancelTokenSource.Token.IsCancellationRequested)
        {
            if (!cancelTokenSource.Token.WaitHandle.WaitOne(1000)) // 1000mSec待ち
            {
                // タイムアウトした場合 = キャンセルされていない場合
                PrintMethodInfo("Timeout");

                if (UpdateAllTopic() > 0)
                {
                    // Excelに更新を通知する.
                    synchronizationContext.Post(x => {
                        try
                        {
                            PrintMethodInfo("UpdateNotify");
                            callback?.UpdateNotify();
                        }
                        catch (Exception ex)
                        {
                            // コールバックに失敗した場合は
                            // Excelとの関係が異常になっている
                            Debug.Print("★Callback failed. " + ex.Message);
                            cancelTokenSource.Cancel();
                        }
                    }, null);
                }
            }
        }
        Debug.Print("★★end★★");
    }

    /// <summary>
    /// 保持している、すべてのTopicの更新を行う
    /// </summary>
    /// <returns>更新があった個数を返す</returns>
    private int UpdateAllTopic()
    {
        int modifiedCount = 0;
        foreach (var valueHolder in TopicMap.Values)
        {
            valueHolder.Refresh();
            modifiedCount += valueHolder.Modified ? 1 : 0;
        }
        return modifiedCount;
    }

    /// <summary>
    /// リアルタイム データ サーバーへの接続を終了します。
    /// </summary>
    public void ServerTerminate()
    {
        PrintMethodInfo();
        Dispose();
    }

    public SeraphyRTDServer()
    {
        PrintMethodInfo();
    }

    ~SeraphyRTDServer()
    {
        PrintMethodInfo();
        Dispose(); // ServerTerminate前にCOMがリリースされた場合の対応
    }

    /// <summary>
    /// 後片付け。タスク等を終了させます.
    /// </summary>
    private void Dispose()
    {
        cancelTokenSource?.Cancel();
        task?.Wait();

        cancelTokenSource = null;
        task = null;
        synchronizationContext = null;
    }

    /// <summary>
    /// 診断メッセージ表示
    /// </summary>
    /// <param name="member">関数名等。
    ///  省略された場合は呼び元のメソッド名が自動的に適用される</param>
    private void PrintMethodInfo(
    [System.Runtime.CompilerServices.CallerMemberName] string member = "")
    {
        Debug.Print("☆{0}: @{1:X} thread={3}({2})", member, GetHashCode(),
            Thread.CurrentThread.GetApartmentState(),
            Thread.CurrentThread.ManagedThreadId);
    }
}

ビルド方法

プロジェクトをビルドして生成されたdllは、COMとして登録する必要がある。

管理者権限でコマンドプロンプトを開き、

regasm /codebase SeraRtdSvr.dll

のように、Regasm.exe (アセンブリ登録ツール)を使って登録する。

regasm.exeは、DotNETフレームワークのフォルダ5にある。あるいは、Visual Studioをインストールするとスタートメニューに追加される「Developer Command Line Tool」を使うとパスが通っている。

『codebaseを指定する場合は署名をつけろ』とか警告がでるが、とりあえず登録はできる。

オプションとしては以下のようなものがある。

  • /unregister ... 登録解除する場合
  • /codebase ... DLLの場所(パス)を指定して登録する場合
  • /tlb ... COMオートメーションのタイプライブラリの登録を行う場合
  • regfile ... レジストリに書き込む内容をファイルとして出力する場合6

今回はタイプライブラリの登録は必要ない。既存のインターフェイスIRTDServerに対する実装しか行っていないためである。 (新しいインターフェイスは何も定義されておらず、tlbexpツールでtlbファイルをエクスポートしても中身の実体は何もないものとなる。)

実行例

たとえば、以下のようなシートを作成してみた。

f:id:seraphy:20180208191941p:plain
screencapture_excel_rtd

このRTDではディレクトリへのパスを指定して"list"フォーマットを指定すると、ファイルリストがタブ区切り文字列で返ってくるので、まず、これを配列に分解するための関数を作る。7

標準モジュールを追加して

Public Function ToArray(ByVal str As String) As String()
    ToArray = Split(str, Chr(9))
End Function

とする。

つぎに、B1にフォルダへのパスを入力し、 B2:B10のセルを選択した状態で以下の配列式を入力する。

=IFERROR(TRANSPOSE(toarray(RTD("serartdsvr",,$B$1,"list"))), "")

CTRL+SHIFT+ENTERで配列式を確定する。8

これでリアルタイムでファイル一覧がB2:B10の範囲で表示されるようになる。

次に、C2:C10のセルを選択した状態で以下の配列式を入力する。

=IF(B2:B10 <> "",RTD("serartdsvr",,B2:B10,"size"),"")

CTRL+SHIFT+ENTERで式を確定するとC2:C10の範囲でB2:C10のファイル名に対するファイルサイズがリアルタイムで表示されるようになる。

自作のToArray関数にDebug.Printするような診断メソッドを仕込むと、更新のたびに呼び出されていることが分かると思う。

ちなみに、ファイルの数が増えたら配列セルの範囲を超えた分は無視される。自動で拡張されたりしないので配列セルを手直ししなければならない。めんどくさい。

トレースログの確認

SystemInternalsのDebug View fow Windowsを入れてExcelの実行中のデバッグプリントをキャプチャすると、RTDのメソッドの呼ばれる順序が分かったりして面白い。

[12020] ☆.ctor: @378734A thread=1(STA) 
[12020] ☆ServerStart: @378734A thread=1(STA) 
[12020] synchronizationContext=System.Windows.Forms.WindowsFormsSynchronizationContext 
[12020] ☆ConnectData: @378734A thread=1(STA) 
[12020] ○ topicId=0 fname=c:\temp\testdata\sample2.txt, format=size 
[12020] ◇ modified old=, new=4 
[12020] ☆ConnectData: @378734A thread=1(STA) 
[12020] ○ topicId=1 fname=c:\temp\testdata\sample1.txt, format=size 
[12020] ◇ modified old=, new=14 
[12020] ☆ConnectData: @378734A thread=1(STA) 
[12020] ○ topicId=2 fname=c:\temp\testdata, format=list 
[12020] ◇ modified old=, new=c:\temp\testdata\sample1.txt c:\temp\testdata\sample2.txt 
[12020] ☆ConnectData: @378734A thread=1(STA) 
[12020] ○ topicId=3 fname=, format=size 
[12020] ◇ modified old=, new=パスの形式が無効です。 
[12020] ☆Timeout: @378734A thread=4(MTA) 
[12020] ☆Timeout: @378734A thread=4(MTA) 
[12020] ☆Timeout: @378734A thread=4(MTA) 
[12020] ☆Timeout: @378734A thread=4(MTA) 
[12020] ◇ modified old=14, new=5 
[12020] ☆UpdateNotify: @378734A thread=1(STA) 
[12020] ☆RefreshData: @378734A thread=1(STA) 
[12020] * topidId=1, value=5 
[12020] ☆Timeout: @378734A thread=4(MTA) 
[12020] ☆Timeout: @378734A thread=4(MTA) 
[12020] ☆Timeout: @378734A thread=4(MTA) 
[12020] ☆Timeout: @378734A thread=4(MTA) 
[12020] ☆Timeout: @378734A thread=4(MTA) 
[12020] ☆ServerTerminate: @378734A thread=1(STA) 
[12020] ★★end★★ 
[12020] ☆Finalize: @378734A thread=2(MTA) 

ちなみに、このログをみて気がついたが、topicId=3fnameが空のセルに対してRTDを問い合わせていた。C列でIF関数をつかっているのが原因だろうと思う。ワークシート上のIFは「関数」なので、関数の呼び出し前に、関数の引数である、真・偽のいずれの場合の計算式も事前に評価せざるを得ない為だろう。

ソースの説明

以下、メソッドごとに説明する。

SeraphyRTDServerクラスのヘッダ部

SeraphyRTDServerクラスが、IRtdServerを実装している、本サンプルの本体クラスである。

COMオブジェクトになるため、CLSIDとProgIDとして

[Guid("5B6AA03F-F280-4B06-AFA3-519C9215D6BD")]
[ProgId("SeraRtdSvr")]

のように指定している。

CLSIDは全世界で一意になるべきものなので、本サンプルを真似して別のクラスを作る場合には、Guidは、それぞれ生成し直す必要がある。

ProgIdはCOMオブジェクトの一般的な形式に従うと「ベンダ名.プログラム名.バージョン番号」のようなドット区切りで長い名前になるのが普通だが、今回は、あえて短くしている。

これはRTD関数で指定する名前なので、ProgIDが長いと数式がめんどくさくなるからである。(短すぎてシステムの予約語や一般語と衝突すると、いろいろ問題がでるので注意のこと。)

ServerStartメソッド: 開始処理

ExcelからRTDサーバーの利用が開始されると、最初に呼び出される。

複数ブックに同一RTDサーバへの接続がある場合は同じインスタンスが使われ、同じインスタンスでServerStartが二度呼び出されることはない。

ここでExcel側からIRTDUpdateEventインターフェイスを実装したコールバック用オブジェクトが渡される。

RTDサーバーは、このコールバック用オブジェクトを保存しておいて、監視している値のいずれかが変更された場合には、IRTDUpdateEvent#UpdateNotify()メソッドを呼び出してExcelに変更を通知する必要がある。

ただし、変更有無チェックをUIスレッドで常時実行するとExcelがカクカクして操作性が悪化するため、監視処理はUIスレッドとは別のスレッドで行う必要がある。

このサンプルではRTDサーバの開始時に無限ループタスクを立ち上げて、1000 mSec単位で保持しているTopicIdの値に変更があるかチェックし、もし変更があればUpdateNotifyを呼び出すようにしている。

コールバックで注意しなければならないのは、このIRTDUpdateEventオブジェクトはSTA(Single-Threaded Apartments)であり、ServerStartが呼ばれたときのスレッドと同じスレッドから呼び出されなければならない、ということである。

C++ではCoMarshalInterThreadInterfaceInStream関数などを用いてスレッドをまたいだ場合のマーシャリングを行うが、C#の場合では、SynchronizationContextを使うことになるようである。

    SynchronizationContext synchronizationContext = SynchronizationContext.Current; // 現在の同期コンテキストを保存

    // ... 別スレッドで処理開始 ...

    // 保存した同期コンテキストで実行
    synchronizationContext.Post(x => {
        callback?.UpdateNotify();
    }, null);

ただし、実際にはExcelからRTDサーバーが呼び出された時点ではSynchronizationContext#Currentは初期化されておらず、かわりに明示的にWindowsFormのSystem.Windows.Forms.WindowsFormsSynchronizationContextを生成して使うことになる。(参照設定で、System.Windows.Formsが必要となる。)

これはWindowsFormのUIスレッドにマーシャリングすることを意味しているが、RTDのServerStartメソッドもUIスレッドから呼び出されているので、結果的に同じになるので大丈夫、ということのようである。

(たしかに、トレースログに出ているThreadIdを見ても、ServerStartメソッドのIDと、UpdateNotify時のIDが同一になっている。)

なお、UI用タイマーであるSystem.Windows.Forms.Timerを使うことで、UIスレッドからタイマー処理を実行できるため、明示的にSynchronizationContextを使わずとも良く、実装も単純になるが、前述のとおり、UIスレッド内で重い処理は行うべきではない。

ConnectDataメソッド: Topicの追加

RTD関数に渡された引数の内容がTopicIdとともに渡されてくる。

TopicIdは、Excelが割り当てた番号である。

引数ごとに1つのIDが割り当てられるので、たとえば複数のセルに同一の引数をもつRTD式がある場合でも、TopicIdは1つだけである。(なので呼び出しも1回である)

関数に渡される引数は、1つ以上、最大253個までの配列として渡されてくる。

分かりにくいのはnewValues引数で、これはIn/Out引数である。

引数として渡されてくるとき、この値がTrueである場合、新規にRTDセルが入力されたことを表している。これに対してFalseである場合はRTDセルをもつブックが開かれた場合など、すでにRTDセルが存在しており更新するために呼び出されたことを意味している。

また、このnewValues引数は戻り値としても使われ、Trueを返した場合は、このメソッドが返す値でセルの内容を更新することを意味する。Falseの場合は更新しない。

(現在のセルの内容が何であるかは分からないので通常は常にTrueを返せばよいとおもわれるが、たとえば、すぐには最新情報が得られないケースなど、TopicId登録時はとりあえずFalseを返して、あとでRefreshDataで一括更新する、という実装もありえるだろう。)

DisconnectDataメソッド: Topicの削除

ExcelがRTDセルを消して、同一の引数をもつRTDセルが1つもなくなった場合に呼び出される。

もしくは、ブックを閉じて監視する必要がなくなった場合にも通知される。

以後、このTopicIdは未使用になる。

RefreshDataメソッド: Topicの状態の一括更新

Excel側からTopicの状態を最新化するために呼び出される。

これは一般的にはIRTDUpdateEvent#UpdateNotifyによってExcelに変更通知を行ったあと、Excelから呼び出されるものである。

TopicIdごとの値を格納した二次元配列と、その配列の個数を返す。

なお、すべてのTopicを返す必要はなく、更新の必要のあるTopicIdと、そのデータだけ返せば良い。

Heartbeatメソッド: 生存確認

Excel側からRTDサーバーの生存確認のために定期的に呼び出される。

正の値を返すことで生きていることを応答する。

インプロセスのCOMサーバーの場合には、DLLが死んだときはExcelプロセスも死んだ時なので意味はないが、リモートPC上のDCOMや、EXEのCOMサーバーの場合には意味があると思われる。

とりあえず、いつでも1を返しておけば良い。

ちなみに0を返したりすると、

f:id:seraphy:20180210095400p:plain
RTDサーバーの再起動の確認ダイアログ

のようにExcelからお問い合わせが表示される。

ServerTerminateメソッド: 終了処理

すべてのブックから、このRTDサーバーの持つ全てのTopicIDが削除されるか、すべてのブックが閉じられて、TopicIDが1つも必要としなくなったときに呼び出される。

本サンプルでは無限ループタスクに対してキャンセル指示を出してタスクの終了を行っている。

Finalize: ガベージ処理

COMとしてはServerTerminateメソッドを呼ばずにReleaseすることもありえるので、念のため、ServerTerminateメソッドと同じDispose処理を入れている。

その他

IRTDUpdateEvent.Disconnectについて

UpdateNotifyで更新を通知するかわりに、Disconnectを通知すると、Excel側からServerTerminateが呼び出されてRTDとの接続が終了する。

この場合、シート上のRTDセルは、RTDが接続できなかった場合と同様、エラー「#N/A」になる。

これ以上、リアルタイムデータを取得しつづけることができない、なんらかの事由がある場合に使ったりするのだろう。

例外処理について

DotNETのオブジェクトをCOMとして公開した場合、例外をスローすると受け取り側のCOMクライアントにはHRESULTのエラーコードに変換されて渡される。

https://blogs.msdn.microsoft.com/yizhang/2010/12/17/interpreting-hresults-returned-from-netclr-0x8013xxxx/

たとえば、ApplicationException例外をスローすると、COMの受け取り側のHRESULTはCOR_E_APPLICATION(0x80131600)になる。

ただし、実験では、RTDサーバー内で例外が発生してExcelとの連携が旨くゆかなくなった場合には、Excelも(しばらくしてから)異常終了することが多かったので、エラー処理はきちんとやっておいたほうが良いようだ。

ErrorWrapperの返却について

COMのVariantではVT_ERRORとしてSCODE(HRESULT)を送ることができるが、DotNETではSystem.Runtime.InteropServices.ErrorWrapperを使うことでVT_ERRORを表現できる。

Default Marshaling for Objects | Microsoft Docs

そこで、エラーになった場合に

    catch (Exception ex)
    {
        value = new ErrorWrapper(ex);
    }

のようにしてエラー値を返却してみたところ、Excel側ではRTDからエラー値を受け取ると、セルの値を更新せず無視するようである。(#N/Aにもならない。そもそも更新されない。)

とりあえずC#からVT_ERRORを返す仕組みはわかったので、この線でRTDまわりをGoogle検索したところ、興味深い投稿を見つけた。

https://groups.google.com/forum/#!msg/exceldna/Z6mmxJ4LSbM/ryiWJDjhu90J

どうやら、特定の値を返せば、Excelのセル上でエラーを表現できるらしい。

エラー種別 エラー値
ErrNA -2146826246
ErrName -2146826259
ErrNull -2146826288
ErrNum -2146826252
ErrRef -2146826265
ErrValue -2146826273
    value = new ErrorWrapper(-2146826246); // 「#N/A」と表示される

うまくいった。

まとめ

今回試作してみた感触では、C#ExcelのRTDサーバーを作るのは、とくに難しいところもなかった。 C++よりも容易に作れそうな感じである。

しかし、Excelでリアルタイムデータを引くことの意義は、それなりの有益なデータソースがあってこそ、と思うのだが、最近はラズパイやアルデイーノといったDIY感覚でネットワーク接続できるセンサー類が手に入りやすくなってきたので、むしろ、そっち方面での技術力なり発想力が求められているかもしれない。

参考文献/URL

kennykerrさんのところのブログ

RTDに関する詳細が揃っている。超オススメ

マイクロソフト公式

MSの記事、VB6を使うけれどRTDの作り方の基本が分かる。

以上、メモ終了。


  1. アプリケーション間のデータ交換としてDDEを使うことはなくなったが、Explorerがアプリを起動する際に、すでにアプリが起動している場合は新たにEXEを立ち上げるのではなく既存のEXEに対してドキュメントを開くように依頼する、というようなケースで現在でも使われている。

  2. どうしてもDotNETとDDEの両方を使いたいのであれば、C++/CLIを使えばどうとでもなると思う。あるいは、サードパーティのライブラリとしてはNDdeというのもあるようだ。

  3. リモートでCOMを扱うDCOMに対して、リモートでDDEを扱うNetDDEという仕様もあったが、NetDDEはVista以降は廃止されている。

  4. 今回はIRTDServer, IRTDUpdateEventインターフェイスは、ComImportとして型(vtbl)を合わせているだけであり、Excel側も、IRTDServerにはIDispatchは使っていないか、ちゃんと名前で索引しているようでDISPIDの値はどうでもいいらしいのだが、生成されたSeraphyRTDServerに対してIDispatch#GetIDsOfNamesで名前からDISPIDを取り出したときに、本来の定義されたDISPIDと異なる値が返ってくるのは気持ち悪いので一応、正しいものを返せるようにしておく。

  5. たとえば、"C:\Windows\Microsoft.NET\Framework\v4.0.30319"のような場所にある。

  6. ただしタイプライブラリは除く。もし必要なら、タイプライプラリ用のレジストリエントリは出力されないので手動で作成する必要がある。

  7. RTDサーバから直接、配列を返したかったのだが、方法がみつからなかった。

  8. 確定すると式の見た目は{}で囲まれ、配列式であることを示す。なお、配列式にした範囲では一括して編集・削除しなければならない。配列式の範囲が分からない場合はCTRL+Gで「ジャンプ」ダイアログを開き、「セル選択」ボタンから「アクティブセルの配列」を選ぶと、配列式の入ったセル範囲をまるごと選択してくれる。

Javaのジェネリック型引数をリフレクションによって取得する方法

Javaジェネリック型引数をリフレクションによって取得する方法

概要

Javaは、Java1.5からジェネリクス(総称型)が利用可能になったが、1.4以前のコードとの互換性のためにイレージャ(Erasure)とよばれる手法をとっている。

簡単にいうと、イレージャとは、コンパイラによってジェネリクスの型チェックが行われたあと、コンパイル結果であるJavaバイトコードからは、そのジェネリクス型の型情報を消してしまう、という手法である。

しかし、消されているのはバイトコードだけであり、クラスファイル中に含まれるクラスやインターフェイスの型情報、フィールドの型情報、メソッドの引数や戻り値の型情報などにはジェネリック型の情報が残されている。

これらのジェネリック型引数の情報はリフレクションによってアクセスすることができる。

※ 本ソースコード一式はgist:c8f088fc081054116266011ae56d4bd7に置いてあります。

イレージャは何を消し、何を残しているのか?

Javaジェネリクスコンパイル時のみ型チェックされイレージャによってバイトコードからは型情報が消去される。

したがって、

package jp.seraphyware.example;

import java.util.ArrayList;
import java.util.List;

class Foo<E> {
    private List<E> values = new ArrayList<>();

    public void add(E value) {
        values.add(value);
    }

    @Override
    public String toString() {
        return values.toString();
    }
}

public class GenericErasureExample {

    public static void main(String... args) {
        Foo<String> foo1 = new Foo<>();
        Foo<Integer> foo2 = new Foo<>();
        Foo fooRaw = new Foo();

        foo2.add(1234);
        ((Foo<String>)((Foo)foo2)).add("abcd"); // 大丈夫!?!?
        System.out.println(foo2); // [1234, abcd]と表示する
    }
}

というコードにおいて、foo1, foo2, fooRawの3つのオブジェクトは、いずれも、同じFooクラスからインスタンスが作成されることになる。

コンパイル時しかジェネリックの型チェックは行われていないので、このような強引なコードを書いてもエラーにはならず、実行すれば動作してしまう。

javapでバイトコードを逆アセンブルしてみると、

Classfile /C:/work/workspace/Java8Learn/bin/jp/seraphyware/example/GenericErasureExample.class
  Last modified 2018/01/31; size 1028 bytes
  MD5 checksum 39d5b60dcd560b681380dc774a1af52d
  Compiled from "GenericErasureExample.java"
public class jp.seraphyware.example.GenericErasureExample
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Class              #2             // jp/seraphyware/example/GenericErasureExample
   #2 = Utf8               jp/seraphyware/example/GenericErasureExample
   #3 = Class              #4             // java/lang/Object
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Methodref          #3.#9          // java/lang/Object."<init>":()V
   #9 = NameAndType        #5:#6          // "<init>":()V
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Ljp/seraphyware/example/GenericErasureExample;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Class              #17            // jp/seraphyware/example/Foo
  #17 = Utf8               jp/seraphyware/example/Foo
  #18 = Methodref          #16.#9         // jp/seraphyware/example/Foo."<init>":()V
  #19 = Methodref          #20.#22        // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
  #20 = Class              #21            // java/lang/Integer
  #21 = Utf8               java/lang/Integer
  #22 = NameAndType        #23:#24        // valueOf:(I)Ljava/lang/Integer;
  #23 = Utf8               valueOf
  #24 = Utf8               (I)Ljava/lang/Integer;
  #25 = Methodref          #16.#26        // jp/seraphyware/example/Foo.add:(Ljava/lang/Object;)V
  #26 = NameAndType        #27:#28        // add:(Ljava/lang/Object;)V
  #27 = Utf8               add
  #28 = Utf8               (Ljava/lang/Object;)V
  #29 = String             #30            // abcd
  #30 = Utf8               abcd
  #31 = Fieldref           #32.#34        // java/lang/System.out:Ljava/io/PrintStream;
  #32 = Class              #33            // java/lang/System
  #33 = Utf8               java/lang/System
  #34 = NameAndType        #35:#36        // out:Ljava/io/PrintStream;
  #35 = Utf8               out
  #36 = Utf8               Ljava/io/PrintStream;
  #37 = Methodref          #38.#40        // java/io/PrintStream.println:(Ljava/lang/Object;)V
  #38 = Class              #39            // java/io/PrintStream
  #39 = Utf8               java/io/PrintStream
  #40 = NameAndType        #41:#28        // println:(Ljava/lang/Object;)V
  #41 = Utf8               println
  #42 = Utf8               args
  #43 = Utf8               [Ljava/lang/String;
  #44 = Utf8               list1
  #45 = Utf8               Ljp/seraphyware/example/Foo;
  #46 = Utf8               list2
  #47 = Utf8               listRaw
  #48 = Utf8               LocalVariableTypeTable
  #49 = Utf8               Ljp/seraphyware/example/Foo<Ljava/lang/String;>;
  #50 = Utf8               Ljp/seraphyware/example/Foo<Ljava/lang/Integer;>;
  #51 = Utf8               SourceFile
  #52 = Utf8               GenericErasureExample.java
{
  public jp.seraphyware.example.GenericErasureExample();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #8                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 19: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Ljp/seraphyware/example/GenericErasureExample;

  public static void main(java.lang.String...);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_VARARGS
    Code:
      stack=2, locals=4, args_size=1
         0: new           #16                 // class jp/seraphyware/example/Foo
         3: dup
         4: invokespecial #18                 // Method jp/seraphyware/example/Foo."<init>":()V
         7: astore_1
         8: new           #16                 // class jp/seraphyware/example/Foo
        11: dup
        12: invokespecial #18                 // Method jp/seraphyware/example/Foo."<init>":()V
        15: astore_2
        16: new           #16                 // class jp/seraphyware/example/Foo
        19: dup
        20: invokespecial #18                 // Method jp/seraphyware/example/Foo."<init>":()V
        23: astore_3
        24: aload_2
        25: sipush        1234
        28: invokestatic  #19                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        31: invokevirtual #25                 // Method jp/seraphyware/example/Foo.add:(Ljava/lang/Object;)V
        34: aload_2
        35: ldc           #29                 // String abcd
        37: invokevirtual #25                 // Method jp/seraphyware/example/Foo.add:(Ljava/lang/Object;)V
        40: getstatic     #31                 // Field java/lang/System.out:Ljava/io/PrintStream;
        43: aload_2
        44: invokevirtual #37                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
        47: return
      LineNumberTable:
        line 22: 0
        line 23: 8
        line 24: 16
        line 26: 24
        line 27: 34
        line 28: 40
        line 29: 47
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      48     0  args   [Ljava/lang/String;
            8      40     1 list1   Ljp/seraphyware/example/Foo;
           16      32     2 list2   Ljp/seraphyware/example/Foo;
           24      24     3 listRaw   Ljp/seraphyware/example/Foo;
      LocalVariableTypeTable:
        Start  Length  Slot  Name   Signature
            8      40     1 list1   Ljp/seraphyware/example/Foo<Ljava/lang/String;>;
           16      32     2 list2   Ljp/seraphyware/example/Foo<Ljava/lang/Integer;>;
}
SourceFile: "GenericErasureExample.java"

mainメソッドの中身をみると、(Java1.4以前のように)単純にFooクラスのnewを呼び出して、1234というint値をInteger#valueOfで変換したものと、定数プールから取り出した文字列オブジェクト「abcd」を、addで追加している様子がわかる。

ここではジェネリックの型引数はどこにも考慮されていない。

ところが、LocalVariableTypeTableセクション1をみると、ジェネリック引数が記録されているのが分かる。

つまり、クラスファイル中からジェネリック型引数が全て消去されているわけではない。

あくまでもバイトコードから消去されているだけである。

Fooクラスのクラスファイルもjavapで見てみる。

Classfile /C:/work/workspace/Java8Learn/bin/jp/seraphyware/example/Foo.class
  Last modified 2018/01/31; size 939 bytes
  MD5 checksum 38ecd671af23752df7f1ae96faab521d
  Compiled from "GenericErasureExample.java"
class jp.seraphyware.example.Foo<E extends java.lang.Object> extends java.lang.Object
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Class              #2             // jp/seraphyware/example/Foo
   #2 = Utf8               jp/seraphyware/example/Foo
   #3 = Class              #4             // java/lang/Object
   #4 = Utf8               java/lang/Object
   #5 = Utf8               values
   #6 = Utf8               Ljava/util/List;
   #7 = Utf8               Signature
   #8 = Utf8               Ljava/util/List<TE;>;
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Methodref          #3.#13         // java/lang/Object."<init>":()V
  #13 = NameAndType        #9:#10         // "<init>":()V
  #14 = Class              #15            // java/util/ArrayList
  #15 = Utf8               java/util/ArrayList
  #16 = Methodref          #14.#13        // java/util/ArrayList."<init>":()V
  #17 = Fieldref           #1.#18         // jp/seraphyware/example/Foo.values:Ljava/util/List;
  #18 = NameAndType        #5:#6          // values:Ljava/util/List;
  #19 = Utf8               LineNumberTable
  #20 = Utf8               LocalVariableTable
  #21 = Utf8               this
  #22 = Utf8               Ljp/seraphyware/example/Foo;
  #23 = Utf8               LocalVariableTypeTable
  #24 = Utf8               Ljp/seraphyware/example/Foo<TE;>;
  #25 = Utf8               add
  #26 = Utf8               (Ljava/lang/Object;)V
  #27 = Utf8               (TE;)V
  #28 = InterfaceMethodref #29.#31        // java/util/List.add:(Ljava/lang/Object;)Z
  #29 = Class              #30            // java/util/List
  #30 = Utf8               java/util/List
  #31 = NameAndType        #25:#32        // add:(Ljava/lang/Object;)Z
  #32 = Utf8               (Ljava/lang/Object;)Z
  #33 = Utf8               value
  #34 = Utf8               Ljava/lang/Object;
  #35 = Utf8               TE;
  #36 = Utf8               toString
  #37 = Utf8               ()Ljava/lang/String;
  #38 = Methodref          #3.#39         // java/lang/Object.toString:()Ljava/lang/String;
  #39 = NameAndType        #36:#37        // toString:()Ljava/lang/String;
  #40 = Utf8               SourceFile
  #41 = Utf8               GenericErasureExample.java
  #42 = Utf8               <E:Ljava/lang/Object;>Ljava/lang/Object;
{
  private java.util.List<E> values;
    descriptor: Ljava/util/List;
    flags: ACC_PRIVATE
    Signature: #8                           // Ljava/util/List<TE;>;

  jp.seraphyware.example.Foo();
    descriptor: ()V
    flags:
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: invokespecial #12                 // Method java/lang/Object."<init>":()V
         4: aload_0
         5: new           #14                 // class java/util/ArrayList
         8: dup
         9: invokespecial #16                 // Method java/util/ArrayList."<init>":()V
        12: putfield      #17                 // Field values:Ljava/util/List;
        15: return
      LineNumberTable:
        line 6: 0
        line 7: 4
        line 6: 15
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      16     0  this   Ljp/seraphyware/example/Foo;
      LocalVariableTypeTable:
        Start  Length  Slot  Name   Signature
            0      16     0  this   Ljp/seraphyware/example/Foo<TE;>;

  public void add(E);
    descriptor: (Ljava/lang/Object;)V
    flags: ACC_PUBLIC
    Signature: #27                          // (TE;)V
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: getfield      #17                 // Field values:Ljava/util/List;
         4: aload_1
         5: invokeinterface #28,  2           // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
        10: pop
        11: return
      LineNumberTable:
        line 10: 0
        line 11: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      12     0  this   Ljp/seraphyware/example/Foo;
            0      12     1 value   Ljava/lang/Object;
      LocalVariableTypeTable:
        Start  Length  Slot  Name   Signature
            0      12     0  this   Ljp/seraphyware/example/Foo<TE;>;
            0      12     1 value   TE;

  public java.lang.String toString();
    descriptor: ()Ljava/lang/String;
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #17                 // Field values:Ljava/util/List;
         4: invokevirtual #38                 // Method java/lang/Object.toString:()Ljava/lang/String;
         7: areturn
      LineNumberTable:
        line 15: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Ljp/seraphyware/example/Foo;
      LocalVariableTypeTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Ljp/seraphyware/example/Foo<TE;>;
}
SourceFile: "GenericErasureExample.java"
Signature: #42                          // <E:Ljava/lang/Object;>Ljava/lang/Object;

ここで注目すべき点は、

  • class jp.seraphyware.example.Foo<E extends java.lang.Object> extends java.lang.Object
  • private java.util.List<E> values;
  • public void add(E);

のように、クラス定義、フィールド定義、メソッド定義の、それぞれのジェネリック型引数は、ちゃんと残っている。

クラスまたはインターフェイスといったもののように「型情報」としてクラスファイルに記録される場合にはジェネリックの型引数は保持されるのである。

Type型によるジェネリック型引数へのアクセス

型情報にジェネリックの型引数が記録されているが、これはリフレクションによってアクセス可能になっている。

Java1.5から、その目的のための「Type」というインターフェイスが追加されている。

Typeインターフェイスは以下の5つのいずれかの種類になる

このType情報は、ClassMethod, Field のリフレクション使用時に、ジェネリック対応情報用のメソッドとして呼び出すことで取得できるようになっている。(旧来の呼び方をすると、ジェネリック情報の無い型情報になる。)

たとえば、toStringにしても、toGenericStringというジェネリック対応メソッドが用意されている。

    System.out.println(Foo.class.toString()); // 従来のクラスの表示
    System.out.println(Foo.class.toGenericString()); // ジェネリック型を含むクラスの表示

結果は、

class jp.seraphyware.example.Foo
class jp.seraphyware.example.Foo<E>

のようになる。

準備

以下、ジェネリック型と、それを使うメソッドから、ジェネリックの型引数情報を取得するコードを示す

実験につかうジェネリックの定義

/**
 * ジェネリック型の定義
 * @param <E>
 */
interface GenericIntf<E extends Date> {
    void set(E value);
    E get();
}

/**
 * ジェネリック型を継承した具象クラスの定義
 */
class ConcreteCls implements GenericIntf<Timestamp> {

    private Timestamp value;

    @Override
    public void set(Timestamp v) {
        this.value = v;
    }

    @Override
    public Timestamp get() {
        return value;
    }
}

/**
 * ジェネリック型を継承したジェネリッククラスの定義
 */
class GenericCls<E extends Date> implements GenericIntf<E> {

    private E value;

    @Override
    public void set(E v) {
        this.value = v;
    }

    @Override
    public E get() {
        return value;
    }
}

ジェネリック型引数を解析・表示するコード

   /**
    * 型情報を表示する
    * 型情報がさらにネストした型情報をもっている場合は再帰的に掘り進む.
    * @param typ
    */
    private static void showType(Type typ) {
        showType(typ, "");
    }

    /**
    * 型情報を表示する.
    * 型情報がさらにネストした型情報をもっている場合は再帰的に掘り進む.
    * @param typ
    * @param prefix 印字する文字列のまえに付与する文字列
    */
    private static void showType(Type typ, String prefix) {
        System.out.print(prefix + "typ=" + typ);
        String typeName;
        if (typ instanceof Class) {
            typeName = "Class"; // クラス
        } else if (typ instanceof TypeVariable) {
            typeName = "TypeVariable"; // 型変数型
        } else if (typ instanceof ParameterizedType) {
            typeName = "ParameterizedType"; // ジェネリック型
        } else if (typ instanceof GenericArrayType) {
            typeName = "GenericArrayType"; // ジェネリック配列型
        } else if (typ instanceof WildcardType) {
            typeName = "WildcardType"; // ワイルドカード型
        } else {
            // 不明? (java9時点では、上記5種のみのはず)
            typeName = typ.getClass().getName();
        }
        System.out.println(" -- " + typeName);

        if (typ instanceof ParameterizedType) {
            // ジェネリック型の場合は、その型引数を取得する
            Type[] genericArgs = ((ParameterizedType) typ).getActualTypeArguments(); // 実際の型引数の取得
            for (int idx = 0; idx < genericArgs.length; idx++) {
                showType(genericArgs[idx], prefix + "(" + (idx + 1) + ") ");
            }

        } else if (typ instanceof GenericArrayType) {
            // ジェネリック配列の場合は、その要素型を取得する.
            Type compType = ((GenericArrayType) typ).getGenericComponentType(); // 要素型の取得
            showType(compType, prefix + "(elm) ");

        } else if (typ instanceof TypeVariable) {
            // 型変数型の場合は型の制約(範囲)を取得する
            Type[] bounds = ((TypeVariable<?>) typ).getBounds(); // 範囲の取得
            for (int idx = 0; idx < bounds.length; idx++) {
                showType(bounds[idx], prefix + "(bound#" + (idx + 1) + ") ");
            }

        } else if (typ instanceof WildcardType) {
            // ワイルドカード型の場合は型の制約(上限・下限)を取得する.
            Type[] ups = ((WildcardType) typ).getUpperBounds(); // 範囲(上限)の取得
            for (int idx = 0; idx < ups.length; idx++) {
                showType(ups[idx], prefix + "(upper#" + (idx + 1) + ") ");
            }
            Type[] lows = ((WildcardType) typ).getLowerBounds(); // 範囲(下限)の取得
            for (int idx = 0; idx < lows.length; idx++) {
                showType(lows[idx], prefix + "(lower#" + (idx + 1) + ") ");
            }
        }
    }

クラスのインスタンス化の実験

以下、ジェネリックの型生成方法ごとの、その型情報の取得結果を示す。

ジェネリック派生型をメソッド内で利用する場合

   @Comment("ジェネリック派生型をメソッド内で利用する例1")
    private static void testGenericUse1() {
        System.out.println("☆" + new ThisMethod() {});
        ConcreteCls obj = new ConcreteCls();
        System.out.println("target=" + obj.getClass().toGenericString());
        Type typ = obj.getClass().getGenericInterfaces()[0]; // 実装しているインターフェイスを取得
        showType(typ);
        System.out.println();
    }

結果

☆testGenericUse1(ジェネリック派生型をメソッド内で利用する例1)
target=class jp.seraphyware.example.ConcreteCls
typ=jp.seraphyware.example.GenericIntf<java.sql.Timestamp> -- ParameterizedType
(1) typ=class java.sql.Timestamp -- Class

この例では、ジェネリックインターフェイスGenericIntfを実装した具象クラスConcreteClsのクラス情報から、クラスが実装しているインターフェイス情報のジェネリック版(getGenericInterfaces)でインターフェイスの型を取得している。(1つしかインターフェイスを実装していないので添え字は0で決めうちしている)

ConcreteClsでは、ジェネリックの型引数としてjava.sql.Timestampを指定しており、リフレクションによっても、指定されているのがjava.sql.Timestamp型であることが示されている。

ジェネリック派生型をメソッド内で利用する場合2

   @Comment("ジェネリック派生型をメソッド内で利用する例2")
    private static void testGenericUse2() {
        System.out.println("☆" + new ThisMethod() {});
        GenericCls<java.sql.Date> obj = new GenericCls<>();
        System.out.println("target=" + obj.getClass().toGenericString());
        Type typ = obj.getClass().getGenericInterfaces()[0]; // 実装しているインターフェイスを取得
        showType(typ);
        System.out.println();
    }

結果

☆testGenericUse2(ジェネリック派生型をメソッド内で利用する例2)
target=class jp.seraphyware.example.GenericCls<E>
typ=jp.seraphyware.example.GenericIntf<E> -- ParameterizedType
(1) typ=E -- TypeVariable
(1) (bound#1) typ=class java.util.Date -- Class

この例ではジェネリックの型引数として、型変数を指定したままとなっており、それをメソッド中でjava.sql.Date型として利用している。

リフレクションによって取得できるのはクラスファイルに記録されている型情報であるので、この場合は、java.sql.Date型ではなく、GenericClsのクラス定義で使われた「E」という型変数名として取れている。

また、型変数Eは「E extends Date」という範囲を指定しているため、bounds情報として「java.util.Date」が明らかにされている。

ジェネリック型を匿名クラスとしてメソッド内で利用する例1

   @Comment("ジェネリック型を匿名クラスとしてメソッド内で利用する例1")
    private static void testGenericAnonymous1() {
        System.out.println("☆" + new ThisMethod() {});
        GenericIntf<Date> obj = new GenericIntf<Date>() { // 匿名型の生成
            @Override
            public void set(Date v) {}

            @Override
            public Date get() {
                return null;
            }
        };
        System.out.println("target=" + obj.getClass().toGenericString());
        Type typ = obj.getClass().getGenericInterfaces()[0]; // 実装しているインターフェイスを取得
        showType(typ);
        System.out.println();
    }

結果

☆testGenericUse0(ジェネリック型を匿名クラスとしてメソッド内で利用する例1)
target=class jp.seraphyware.example.GenericReflectionExample$2
typ=jp.seraphyware.example.GenericIntf<java.util.Date> -- ParameterizedType
(1) typ=class java.util.Date -- Class

GenericIntfインターフェイスをメソッド内で匿名クラスとして実体化している。

この場合、クラス定義が作成されることになるため、匿名クラス宣言時に使用した「Date型」が、ジェネリックの型引数として取得できるようになっている。

ジェネリック型を匿名クラスとしてメソッド内で利用する例2

    @Comment("ジェネリック型を匿名クラスとしてメソッド内で利用する例2")
    private static void testGenericAnonymous2() {
        System.out.println("☆" + new ThisMethod() {});
        GenericCls<Date> obj = new GenericCls<Date>() {}; // 匿名クラス化する
        System.out.println("target=" + obj.getClass().toGenericString());
        Type typ = obj.getClass().getGenericSuperclass(); // 匿名クラスの親クラスを取得
        showType(typ);
        System.out.println();
    }

結果

☆testGenericAnonymous2(ジェネリック型を匿名クラスとしてメソッド内で利用する例2)
target=class jp.seraphyware.example.GenericReflectionExample$4
typ=jp.seraphyware.example.GenericCls<java.util.Date> -- ParameterizedType
(1) typ=class java.util.Date -- Class

GenericClsジェネリッククラスをメソッド内で単にnewすると型情報としてはジェネリック引数は残らないが、この例のように、GenericClsジェネリッククラスをメソッド内で匿名クラス化することで、ジェネリック引数つきのクラスとしての型情報を作成することができる。このため、親クラスの型情報としてDate型の型引数を取得できるようになる。

メソッドの引数または戻り値としてのジェネリック型引数の情報の実験例

以下、メソッドの引数または戻り値としてジェネリックの型引数の情報を取得する実験を示す

ジェネリック型を引数にとるメソッドの例1

対象コード

   public static void method1(List<String> args) {
    }

    @Comment("ジェネリック型を引数にとるメソッドの例1")
    private static void testGenericArgument1() throws NoSuchMethodException, SecurityException {
        System.out.println("☆" + new ThisMethod() {});
        Method method = GenericExample.class.getMethod("method1", List.class);
        System.out.println("target=" + method.toGenericString());
        Type typ = method.getGenericParameterTypes()[0]; // 引数の型
        showType(typ);
        System.out.println();
    }

結果

☆testGenericArgument1(ジェネリック型を引数にとるメソッドの例1)
target=public static void jp.seraphyware.example.GenericExample.method1(java.util.List<java.lang.String>)
typ=java.util.List<java.lang.String> -- ParameterizedType
(1) typ=class java.lang.String -- Class

メソッドのジェネリック対応の引数情報はgetGenericParameterTypesで取得することができる。 (ここでは引数は1個なので、添え字0で先頭のTypeを決めうちで取得している)

引数がList<String>であることが明らかとなっている。

ジェネリック型を引数にとるメソッドの例2

対象コード

   public static void method2(List<List<GenericIntf<java.sql.Date>>> args) {
    }

    @Comment("ジェネリック型を引数にとるメソッドの例2")
    private static void testGenericArgument2() throws NoSuchMethodException, SecurityException {
        System.out.println("☆" + new ThisMethod() {});
        Method method = GenericExample.class.getMethod("method2", List.class);
        System.out.println("target=" + method.toGenericString());
        Type typ = method.getGenericParameterTypes()[0]; // 引数の型
        showType(typ);
        System.out.println();
    }

結果

☆testGenericArgument2(ジェネリック型を引数にとるメソッドの例2)
target=public static void jp.seraphyware.example.GenericExample.method2(java.util.List<java.util.List<jp.seraphyware.example.GenericIntf<java.sql.Date>>>)
typ=java.util.List<java.util.List<jp.seraphyware.example.GenericIntf<java.sql.Date>>> -- ParameterizedType
(1) typ=java.util.List<jp.seraphyware.example.GenericIntf<java.sql.Date>> -- ParameterizedType
(1) (1) typ=jp.seraphyware.example.GenericIntf<java.sql.Date> -- ParameterizedType
(1) (1) (1) typ=class java.sql.Date -- Class

リストのリストのジェネリック型のようにネストしたジェネリック型の場合でも、トラバースして末端に至るまでの型情報「List<List<GenericIntf<Date>>>」を特定することができている。

ジェネリック型を戻り値にとるメソッドの例

   public static Map<String, GenericIntf<java.sql.Date>> method3() {
        return Collections.emptyMap();
    }

    @Comment("ジェネリック型を戻り値にとるメソッドの例")
    private static void testGenericReturnType1() throws NoSuchMethodException, SecurityException {
        System.out.println("☆" + new ThisMethod() {});
        Method method = GenericExample.class.getMethod("method3");
        System.out.println("target=" + method.toGenericString());
        Type typ = method.getGenericReturnType(); // 戻り値の型
        showType(typ);
        System.out.println();
    }

結果

☆testGenericReturnType1(ジェネリック型を戻り値にとるメソッドの例)
target=public static java.util.Map<java.lang.String, jp.seraphyware.example.GenericIntf<java.sql.Date>> jp.seraphyware.example.GenericExample.method3()
typ=java.util.Map<java.lang.String, jp.seraphyware.example.GenericIntf<java.sql.Date>> -- ParameterizedType
(1) typ=class java.lang.String -- Class
(2) typ=jp.seraphyware.example.GenericIntf<java.sql.Date> -- ParameterizedType
(2) (1) typ=class java.sql.Date -- Class

ジェネリック対応の戻り値の型情報はgetGenericReturnTypeによって取得することができる。

引数の場合と同様にジェネリック型を特定できている。

この例ではMap型なので「Map<String, GenericIntf<Date>>」という2つの型引数をもっていることが分かる。

ジェネリック型を戻り値にとるメソッドの例2

   @Comment("ジェネリック型を戻り値にとるメソッドの例2")
    private static void testGenericReturnType2() throws NoSuchMethodException, SecurityException {
        System.out.println("☆" + new ThisMethod() {});
        GenericCls<java.sql.Date> obj = new GenericCls<>();
        Method method = obj.getClass().getMethod("get");
        System.out.println("target=" + method.toGenericString());
        Type typ = method.getGenericReturnType(); // 戻り値の型
        showType(typ);
        System.out.println();
    }

結果

☆testGenericReturnType2(ジェネリック型を戻り値にとるメソッドの例2)
target=public E jp.seraphyware.example.GenericCls.get()
typ=E -- TypeVariable
(bound#1) typ=class java.util.Date -- Class

GenericClsクラスの定義のgetメソッドの戻り値は「E extends Date」であるため、 メソッドの型情報として型変数名の「E」と、その範囲である「java.util.Date」が取得されている。

ジェネリック配列を戻り値にとるメソッドの例1

   @SuppressWarnings("unchecked")
    public static GenericIntf<Date>[] getGenericArray() { // メソッド定義の戻り値型の情報としてジェネリック配列型は有効である
        return new GenericIntf[0]; // ただし、ジェネリック配列を直接作成できないのでRAW型配列として作成する.
    }

    @Comment("ジェネリック配列を戻り値にとるメソッドの例1")
    private static void testGenericArray() throws NoSuchMethodException, SecurityException {
        System.out.println("☆" + new ThisMethod() {});
        Method method = GenericExample.class.getMethod("getGenericArray");
        System.out.println("target=" + method.toGenericString());
        Type typ = method.getGenericReturnType(); // 戻り値の型
        showType(typ);
        System.out.println();
    }

結果

☆testGenericArray(ジェネリック配列を戻り値にとるメソッドの例1)
target=public static jp.seraphyware.example.GenericIntf<java.util.Date>[] jp.seraphyware.example.GenericExample.getGenericArray()
typ=jp.seraphyware.example.GenericIntf<java.util.Date>[] -- GenericArrayType
(elm) typ=jp.seraphyware.example.GenericIntf<java.util.Date> -- ParameterizedType
(elm) (1) typ=class java.util.Date -- Class

Javaではジェネリック配列は鬼門であり、たとえば new List<String>[0]のようなジェネリックの配列を直接作成することはできない。3

が、しかし、型情報としては有効であり、そのための専用のタイプであるGenericArrayTypeというTypeも用意されている。

このgetGenericComponentTypeメソッドによって、配列のジェネリック要素型を取得することができる。

ジェネリック配列の配列を戻り値にとるメソッドの例2

   @SuppressWarnings("unchecked")
    public static GenericIntf<Date>[][] getGenericArray2() { // メソッド定義の戻り値型の情報としてジェネリック配列型は有効である
        return new GenericIntf[][]{}; // ただし、ジェネリック配列を直接作成できないのでRAW型配列として作成する.
    }

    @Comment("ジェネリック配列の配列を戻り値にとるメソッドの例2")
    private static void testGenericArray2() throws NoSuchMethodException, SecurityException {
        System.out.println("☆" + new ThisMethod() {});
        Method method = GenericExample.class.getMethod("getGenericArray2");
        System.out.println("target=" + method.toGenericString());
        Type typ = method.getGenericReturnType(); // 戻り値の型
        showType(typ);
        System.out.println();
    }

結果

☆testGenericArray2(ジェネリック配列の配列を戻り値にとるメソッドの例2)
target=public static jp.seraphyware.example.GenericIntf<java.util.Date>[][] jp.seraphyware.example.GenericExample.getGenericArray2()
typ=jp.seraphyware.example.GenericIntf<java.util.Date>[][] -- GenericArrayType
(elm) typ=jp.seraphyware.example.GenericIntf<java.util.Date>[] -- GenericArrayType
(elm) (elm) typ=jp.seraphyware.example.GenericIntf<java.util.Date> -- ParameterizedType
(elm) (elm) (1) typ=class java.util.Date -- Class

ネストしたジェネリック配列は、普通にGenericArrayTypeの中にGenericArrayTypeが入っているだけである。 扱い方は基本的に変わらない。

ジェネリックメソッドの型引数の取得方法

メソッドの引数や戻り値ではなく、ジェネリックメソッド自身のジェネリック型引数の取得例を示す。

   public static <A extends Date & java.io.Serializable, B>
                B genericMethodExample(A arg) {
        return null;
    }

    @Comment("ジェネリックメソッドの例")
    private static void testGenericMethod() throws NoSuchMethodException, SecurityException {
        System.out.println("☆" + new ThisMethod() {});
        Method method = GenericExample.class.getMethod("genericMethodExample", Date.class);
        System.out.println("target=" + method.toGenericString());
        {
            Type typ = method.getGenericParameterTypes()[0]; // 戻り値の型
            showType(typ);
        }
        {
            // メソッドに付与されているパラメータ
            Type[] typs = method.getTypeParameters();
            for (Type typ : typs) {
                System.out.println("*メソッドの型引数: " + typ);
                showType(typ);
            }
        }
        System.out.println();
    }

結果

☆testGenericMethod(ジェネリックメソッドの例)
target=public static <A,B> B jp.seraphyware.example.GenericExample.genericMethodExample(A)
typ=A -- TypeVariable
(bound#1) typ=class java.util.Date -- Class
(bound#2) typ=interface java.io.Serializable -- Class
*メソッドの型引数: A
typ=A -- TypeVariable
(bound#1) typ=class java.util.Date -- Class
(bound#2) typ=interface java.io.Serializable -- Class
*メソッドの型引数: B
typ=B -- TypeVariable
(bound#1) typ=class java.lang.Object -- Class

ジェネリックメソッドの型パラメータは、MethodクラスのgetTypeParametersによって取得することができる。

ワイルドカード型のフィールドの例

   public static final List<? super Number> wildcardFieldSuper = new ArrayList<>();
    public static final List<? extends Number> wildcardFieldExtends = new ArrayList<>();

    @Comment("ワイルドカード型のフィールドの例")
    private static void testWildcard() throws SecurityException, NoSuchFieldException {
        System.out.println("☆" + new ThisMethod() {});
        for (String name : new String[]{"wildcardFieldSuper", "wildcardFieldExtends"}) {
            Field field = GenericExample.class.getField(name);
            System.out.println("target=" + field.toGenericString());
            Type typ = field.getGenericType();
            showType(typ);
        }
        System.out.println();
    }

結果

☆testWildcard(ワイルドカード型のフィールドの例)
target=public static final java.util.List<? super java.lang.Number> jp.seraphyware.example.GenericExample.wildcardFieldSuper
typ=java.util.List<? super java.lang.Number> -- ParameterizedType
(1) typ=? super java.lang.Number -- WildcardType
(1) (upper#1) typ=class java.lang.Object -- Class
(1) (lower#1) typ=class java.lang.Number -- Class
target=public static final java.util.List<? extends java.lang.Number> jp.seraphyware.example.GenericExample.wildcardFieldExtends
typ=java.util.List<? extends java.lang.Number> -- ParameterizedType
(1) typ=? extends java.lang.Number -- WildcardType
(1) (upper#1) typ=class java.lang.Number -- Class

ワイルドカード型の場合は範囲として上限・下限を取得することができる。 上限、または下限はワイルドカードの指定がsuper, extendsのいずれかによって、いずれか一方は暗黙でObjectになる。(なので、superなのかextendsなのかを判定することができる。)

まとめ

Javaジェネリックはイレージャによってバイトコード上からは型引数は消されているが、クラスまたはインターフェイス、および、そのメソッド、引数、戻り値の型情報としては型引数は保持されているため、クラスまたはインターフェイスとして定義されていればジェネリックの型引数を取得することも可能である。

ジェネリックの型引数にアクセスしたいケースでは、匿名クラス化するなどしてジェネリック型引数を含むクラス型を作成するなどの工夫があれば便利にアクセスできるようになる。


おまけ

実験コード中にある

System.out.println("☆" + new ThisMethod() {});

のようなコードは、具体的には、以下のクラスを定義している。

/**
 * この抽象クラスをメソッド内で匿名クラス化した場合、
 * その匿名クラス化したメソッドへの情報にアクセスできるようにするためのヘルパ.
 */
abstract class ThisMethod {

    /**
    * この抽象クラスを匿名クラス化したメソッドを取得する.
    * @return メソッド
    */
    public Method getMethod() {
        return getClass().getEnclosingMethod();
    }

    /**
    * この抽象クラスを匿名クラス化したメソッドがCommentアノテーションをもっている場合、そのコメントを取得する.
    * なければnull
    * @return
    */
    public String getComment() {
        Comment comment = getMethod().getAnnotation(Comment.class);
        return comment != null ? comment.value() : null;
    }

    /**
    * この抽象クラスを匿名クラス化したメソッドのメソッド名とコメントをプリントする.
    */
    public String toString() {
        StringBuilder buf = new StringBuilder();
        buf.append(getMethod().getName());
        String comment = getComment();
        if (comment != null) {
            buf.append("(" + comment + ")");
        }
        return buf.toString();
    }
}

/**
 * メソッドにコメントを付与するアノテーション
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@interface Comment {
    String value();
}

Javaの匿名クラスの奇妙な性質に、匿名クラスを宣言したメソッドを取得する、という変なメソッド getEnclosingMethodが用意されている。

なので、上記のような抽象クラスを定義しておくと、new ThisMethod(){}で、そのメソッドの名前と、そのメソッドに付与されているアノテーション情報をプリントする、といったようなツールを作ることができる。

匿名クラス化は、このほかにも、標準のJavaEEでもAnnotationLiteralといった、匿名クラス化を利用することで型情報を取得する、といったテクニックに活用されている。


  1. なお、LocalVariableTable, LocalVariableTypeTable, は デバッグ情報なので、デバックOffでコンパイルすると出てこない。デバッグ情報に対してリフレクション等でアクセスする標準の手段はないので、コードからの利用価値は(残念ながら)ない。

  2. ClassクラスはJava1.5以降、Typeインターフェイスを実装するように拡張されている。

  3. ジェネリック配列は、RAW型として作成してからジェネリック型にcastすればつくれないことはない。(もちろん、@SuppressWarningsで警告をつぶすなどの汚い処理は必要になるし、むろん安全ではない。)

Excelのリンクが解除できない!!場合の問題箇所の特定方法

Excelのリンクが解除できない!!場合の問題箇所の特定方法

概要

マクロ付きExcel(*.xlsm)を作っていたら、いつのまにか開くたびにリンクエラーが発生して、しかも、それが消せない。という状況に陥った。

いったら、どうしたら消せるのか?

経緯

リンクエラーが発生している場合、ネットではセルのどこかに別bookを参照しているところがあるので、検索ボックスで.xlsを探せ、とか書いてある。

しかし、セルの数式、値いずれも.xlsを含むものは無かった。

excelの非表示オブジェクトで参照しているものがあるかもだから、VBAを使って、図形や名前定義を含めて.xlsを検索して該当アイテムを消すべし」というコード例があったので試してみたが、これでも解決しない。


....


そういえば、*.xlsx, *.xlsmはzip形式になっていて、展開すると中身はXMLだったよなー、ということを思い出した。

zip展開してgrepすれば何か分かるのではないか?

ZIP展開した結果

発見しました!

sheet2.xmlファイル
    ....
    <extLst>
        <ext uri="{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}" 
            xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main">
            <x14:dataValidations disablePrompts="1" count="2" 
                xmlns:xm="http://schemas.microsoft.com/office/excel/2006/main">
                <x14:dataValidation type="list" showInputMessage="1" showErrorMessage="1">
                    <x14:formula1>
                        <xm:f>'C:\Users\seraphy\workbooks\[data201712.xlsm]Master'!#REF!</xm:f>
                    </x14:formula1>
                    <xm:sqref>B184:C184</xm:sqref>
                </x14:dataValidation>
                <x14:dataValidation type="list" showInputMessage="1" showErrorMessage="1">
                    <x14:formula1>
                        <xm:f>Master!$D:$D</xm:f>
                    </x14:formula1>
                    <xm:sqref>B2:C183 B185:C1048576</xm:sqref>
                </x14:dataValidation>
            </x14:dataValidations>
        </ext>
    </extLst>
    ....

(ちなみに、見やすくするためにVisual studio codeのxmltoolでフォーマットした)


uri="{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}"というのは、dataValidationsのことらしく、
まあ、sqrefの値が示すのが列範囲であることをみても、これは「入力規則」の定義であろう。


sqrefをみると、B2:C183、B185:C1048576の範囲で自ブックのMasterシートのD列を参照しているのに、B184:C184の1行分だけ、他ブックをみているようだ。(しかもエラーになってる)


ここに他ブックから入力規則ごとコピーしてきてしまったデータがあるようだ。


場所が特定できれば、元のxlsmを開いて修正するのは容易なことである。

結論

今日の教訓

  • リンクエラーが発生した場合、*.xlsx, *.xlsmならzip展開してgrepすると、場所を特定しやすい。
  • セルの値、オブジェクト、名前定義だけでなく、条件付き書式や、入力規則にも外部リンクが入りえるので要注意である。

関連

探してみると、同じことを指摘されている方が何名もおられました。FAQなのかな。

以上、メモ終了

CompletableFutureの使い方の基本形とFutureTaskとの連携例

Java8から利用可能になったCompluteableFutureとは、他言語におけるDeferredとかPromiseパターンといわれるものと同等のものであり、これにより並列処理を直列的に記述できるようになる。(つまり超便利。)

基本的な使い方

もっとも簡単な使い方としては、CompletableFutureが用意している静的コンビニエンスメソッドであるrunAsyncか、supplyAsyncを使う。


たとえば、以下のようなものになる。

1つのタスクの完了後に処理を継続する例
  ExecutorService es = Executors.newCachedThreadPool();
...
  CompletableFuture<String> cf =
      CompletableFuture.supplyAsync(() -> heavyWork("job1"), es);

  cf.whenComplete((ret, ex) -> {
    if (ex == null) {
      // 成功した場合
      System.out.println("result=" + ret);
    } else {
      // 失敗した場合
      System.err.println("err=" + ex);
    }
  });

このように、CompletableFutureを用いると、「完了したら何かを行う」という処理を容易に記述できる。

2つ以上のタスクを同時並列に実行して、すべての待ち合わせる場合
  List<CompletableFuture<String>> cfs = Arrays.asList(
      CompletableFuture.supplyAsync(() -> heavyWork("job1"), es),
      CompletableFuture.supplyAsync(() -> heavyWork("job2"), es),
      CompletableFuture.supplyAsync(() -> heavyWork("job3"), es));

  CompletableFuture<Void> cf = CompletableFuture.allOf(
      cfs.toArray(new CompletableFuture[cfs.size()]));

  cf.whenComplete((ret, ex) -> {
    if (ex == null) {
      // すべて成功した場合
      String msg = cfs.stream().map(future -> {
        try {
          return future.get();
        } catch (Exception iex) {
          return iex.toString();
        }
      }).collect(Collectors.joining(","));
      System.out.println("result=" + msg);

    } else {
      // いずれかが失敗した場合(いずれか1つの例外のみ取得される)
      System.err.println("err=" + ex);
    }
  });

CompletableFuture#allOf(...)メソッドは、指定された全てのCompletableFutureの完了によって完了するVoid型のCompletableFutureを返す。(つまり、すべてのCompletableFutureが実行し終えることで完了となる。)


返されるCompletableFutureは結果がVoid型なので値を得ることはできないため、個別のCompletableFutureで確認する必要がある。

しかし、いずれかのCompletableFutureが失敗している場合は、少なくとも、その例外の1つは把握できる。(複数例外が発生しているかどうかは、個別に確認する必要がある。)

2つ以上のタスクを非同期、且つ"直列"に実行する場合
private ExecutorService es = Executors.newCachedThreadPool();
...

@FXML
public void onClickLoadButton() {
  CompletableFuture<String> work1 =
      CompletableFuture.supplyAsync(() -> heavyWork("job1"), es);
  CompletableFuture<String> work2 =
      work1.thenCompose(work1Result ->
    CompletableFuture.supplyAsync(() ->
         heavyWork("job2(" + work1Result + ")"), es));

  work2.thenAcceptAsync(result -> {
    // work1, work2が完了した場合
    // ※ UIスレッドで何らかの更新処理を行う
    System.out.println(result);
  }, Platform::runLater);
}

典型的なシチュエーションとしては、たとえば、JavaFXなどのGUIでボタン押下により、job1を非同期で開始し、job1で得られた結果をもとにjob2を起動して、それが完了したら何らかのUI更新を行う、というようなことが比較的容易に記述できる。

(上記例ならば、ボタン押下時はCompletableFutureを起動したら直ちにメソッドを抜けているのでUIスレッドがブロックされることはない。)


※ なお、2段目の結果を受け取るthenAcceptAsyncのExecutorにPlatform::runLaterを指定しているので、この部分はJavaFXのUIスレッドで実行されることになる。

メソッド名の末尾がAsyncになっているメソッドの意味

runAsync, whenCompleteAsyncのようにメソッドの末尾にasyncとあるものは、引数に指定した任意のExecutorで実行することを示している。

AsyncメソッドでExecutorを省略した場合は、ForkJoinPool.commonPool*1が使われる。*2 *3


Asyncがつかないものは、前段の完了後に前段と同じスレッドで続けて呼び出される。

CompletableFutureはイベントリスナではない。

runAsyncでCompletableFutureを受け取った時点で、すでに処理が開始されているので、
もし、whenCompleteAsyncを実行するまえに処理が完了していたらどうなるかと心配する向きもあるかもしれない。

しかし、処理が完了したあとにwhenComplete...を呼び出しても、きちんと処理が継続されるようになっている。

たとえば、コンビニエンスメソッドのcompletedFutureでは、すでに値が確定して完了済みのCompletableFutureを返すので、完了している状態に対して、あとからwhenCompleteなどを呼び出すしかないわけだが、もちろん、ちゃんと機能する。


CompletableFutureは、あくまでも前段の確定した状態に対して後続の処理を接続させる仕組みであり、イベントリスナのようなものではない、ということである。

"未完了"なCompletaleFutureを自前で作成してレガシーなスレッドを"完了可能"にする方法

CompletableFutureが標準で用意しているコンビニエンスメソッドはRunnableかSupplierを受け取るメソッドしかないが、たとえば、Callableを受け取ってCompletableFutureを返すにはどうしたら良いか?


これは比較的簡単に実装できる。

CompletableFutureは、明示的にCompletableFutureのインスタンスを構築することで、自前で完了を通知する仕組みを作ることができるようになっている。

作り方

多くの他のプラットフォームのPromise/Deferredのような仕組みと同様に、

  1. まずコンストラクタで未完了状態のCompletableFutureオブジェクトを作成する。
  2. 処理が完了したらcompleteを呼び出して結果を返して、完了状態にする。
  3. もし処理が失敗により終了したら、completeExceptionallyを呼び出して例外を返して、完了状態にする。

基本、これだけでOK.


たとえば、直接Threadを作成してstartしているようなレガシーな処理に、待ち合わせの仕組みを追加したい場合には、
以下のように未完了なCompletableFutrueを構築し、処理終了したら完了状態への遷移を明示的に行えば良い。

  CompletableFuture<String> cf = new CompletableFuture<>(); // 未完了状態で構築
  new Thread() {
    @Override
    public void run() {
      try {
        IntStream.range(0, 10)
          .peek(System.out::println)
          .forEach(idx -> heavyWork(idx));

        // 正常による完了状態への遷移
        cf.complete("done!");

      } catch (Throwable ex) {
        // 例外による完了状態への遷移
        cf.completeExceptionally(ex);
      }
    }
  }.start();

  cf.whenComplete((ret, ex) -> {
    // 完了時に呼び出される
    System.out.println("finished. ret=" + ret + ", ex=" + ex);
  });


このように、completeまたはcompleteExceptionallyによって正常または例外による完了状態への遷移を行うことができる。

また、複数スレッドでの操作の場合、状態遷移は「先勝ち」であり、また、一度確定した状態はあとから変更されることはない。*4


これにより、CompletableFutureで待ち合わせしている後続処理に対して結果を繋げる事ができる。

CompletableFuture.runAsyncのやっていることとは?

実際のところ、コンビニエンスメソッドであるrunAsyncやsupplyAsyncは、簡略化して言えば、引数で渡されたRunnableやSupplierを以下のようなRunnableでラップしてExecutorに渡しているだけといって良い。

public CompletableFuture<Void> runAsync(Runnable runnable) {
  CompletableFuture<Void> cf = new CompletableFuture<>();
  executor.execute(() -> {
    try {
      runnable.run();
      cf.complete(null);

    } catch (Throwable ex) {
      cf.completeExceptionally(ex);
    }
  });
  return cf;
}

または、

public <T> CompletableFuture<T> supplyAsync(Supplier<T> supplier) {
  CompletableFuture<T> cf = new CompletableFuture<>();
  executor.execute(() -> {
    try {
      cf.complete(supplier.get());

    } catch (Throwable ex) {
      cf.completeExceptionally(ex);
    }
  });
  return cf;
}


これが基本形とわかれば、いろいろ応用ができるだろう。*5

Callableを引数にとるメソッドを作成してみる

ExecutorServiceには、CallableをとってFutureを返すメソッドがあるのに、なぜか、CompletableFutureにはCallableを引数に取るコンビニエンスメソッドがない。

しかし、前述のパターンで実装すれば良いだけなので難しくは無い。

public static <T> CompletableFuture<T> callableAsync(Callable<T> c, Executor e) {
  CompletableFuture<T> cf = new CompletableFuture<>();
  e.execute(() -> {
    try {
      cf.complete(c.call());

    } catch (Throwable ex) {
      cf.completeExceptionally(ex);
    }
  });
  return cf;
}

既存のFutureTaskの置き換え

FutureTask類を使っているコードに対して、CompletableFutureによる待ち合わせを組み込みたい場合もあるかもしれない。


たとえば、ExecutorServiceを使う以下のようなコードがあった場合、

  ExecutorService es = Executors.newCachedThreadPool();
...
  Future<String> future = es.submit(() -> {
    Thread.sleep(1000);
    return "ok";
  });
...
  System.out.println(future.get());
...


CompletableFutrueFutureでもあるので、CompletableFuture.supplyAsyncで置き換えても、基本的な使い方は変わらない。

  ExecutorService es = Executors.newCachedThreadPool();
...
  CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    try {
      Thread.sleep(1000);
    } catch (InterruptedException ex) {
      throw new RuntimeException(ex);
    }
    return "ok";
  }, es);

  cf.whenComplete((ret, ex) -> {
    System.out.println("done.");
  });
...
  System.out.println(cf.get());
...

シンプルである。

通常の用途であれば、それほど労せず単純な置き換えで十分と思われる。

CompletableFutureのcancelはスレッドに割り込みを発生させない

ただし、1つ問題があり、返されるCompletableFutureからは開始されたタスクをキャンセル制御することができない。


CompletableFutureに対してcancelを指示すると、即時にキャンセル例外による例外終了状態となる。(completeExceptionally(new CancellationException())の呼び出しと同義)


しかし、これはCompletableFutureのステートだけが変わることであって、実際に処理中のスレッドに対して割り込みなどは全く行われないため、つまり、処理中のスレッドは、実行中のまま放置されることになる。


これは、CompletableFutureの設計思想なのだろう。


そもそも、CompletableFutureはタスクを多段にするのが容易であるから、1つ1つのタスクが重くならないように細切れで作るのがベターなのだとは思われる。(たとえば単発のI/Oのようなものは、そもそも割り込みが無意味なことが多いだろう。)

そうすれば、処理中のスレッドに割り込みをかけるまでもなく、次段以降の処理はスマートに中止される。


しかし、1つのタスク内でループ等により長期的なワークを実行するようなCompletableFutureではキャンセルによりスレッドに停止を要求するための仕組みは考える価値はあるかもしれない。

既存のFutureTaskとの連携

従来型のFutureTaskではキャンセル処理により処理中のスレッドに対して"割り込み"によって中止を通知することができる仕組みをもっている。


(FutueTaskは、runされた時点のスレッドを記憶することで、cancel時に、そのスレッドに対してinterruptを発行できるようになっている。また、処理終了時には実行中スレッドがなくなったことを記憶するので、たとえばスレッドを使い回しているスレッドプールなどでも、終わったタスクのcancelによって他のタスクを誤爆する恐れはない。)


キャンセルによる割り込みを可能としつつ、CompletableFutureの継続の仕組みを使いたい場合には、このFutureTaskと連携を行うことで実現できる。

シンプルな連携例

FutureTaskに、CompletableFutureによる待ち合わせを追加するには以下のように修正する感じになる。

  ExecutorService es = Executors.newCachedThreadPool();
...
  FutureTask<String> future = new FutureTask<>(() -> {
    Thread.sleep(1000);
    return "ok";
  });

  CompletableFuture<Void> cf = CompletableFuture.runAsync(future, es);
  cf.whenComplete((ret, ex) -> {
    System.out.println("done"); // 完了通知
  });

  // 成功時の結果、もしくは何らかの失敗理由は、ここで取得できる
  System.out.println(future.get()); 
...

ExecutorServiceのsubmitの既定の実装は、実際のところFutureTaskでRunnable/Callableをラップして、それをexecuteに渡しているだけだから、基本的には上記のようにFutureTaskによる置き換えで良い。


タスクの実行結果/エラーへのアクセスや、cancelなどのコントロールは引き続き、このFutureTaskによって行う。

しかし、これに加えて、タスクの終了をもって後段の処理を自動的に開始させる仕組みはCompletableFutureによって実現できるようになる。


※ この方法では、CompletableFutureではFutureTask#run()が処理を終えたことしか分からず、成功か失敗かの結果は判断できない。(結果はFutureTaskの内部でもっているため。)

もう少し手をいれてCompletableFutureで結果の取得ができるようにする。

CompletableFutureでFutureTaskの結果を取得できるようにするには、前述の基本パターンでいける。

  ExecutorService es = Executors.newCachedThreadPool();
...
  FutureTask<String> future = new FutureTask<>(() -> {
    Thread.sleep(1000);
    return "ok";
  });

  CompletableFuture<String> cf = futureTaskAsync(future, es);
  cf.whenComplete((ret, ex) -> {
    System.out.println("done. ret=" + ret + ", ex=" + ex); // 結果の取得
  });

/**
 * FutueTaskをCompletableFutureでラップする.
 */
public static <T> CompletableFuture<T> futureTaskAsync(
      FutureTask<T> ft, Executor e) {
  CompletableFuture<T> cf = new CompletableFuture<>();
  e.execute(() -> {
    try {
      ft.run();
      cf.complete(ft.get());

    } catch (ExecutionException ex) {
      cf.completeExceptionally(ex.getCause());

    } catch (Throwable ex) {
      cf.completeExceptionally(ex);
    }
  });
  return cf;
}

FutureTaskの完了待ち後に、その結果をCompletableFutureに伝搬させている。

これでcancelによる割り込み以外はCompletableFutureとして扱えるようになる。


※ FutureTaskのget時に結果または例外を取得できるが、例外はExecutionException例外にラップされているので、CompletableFutureの結果に設定する場合には、ラップを解除するようにしている。

更に手をいれて、cancelもできるようにしてみる。

CompletableFutureでcancelしても、FutureTask側に伝わらないのが問題であった。

なので、CompletableFutureがキャンセルされたら、これをFutureTaskに明示的に伝えるようにすれば良い。

/**
 * FutueTaskをCompletableFutureでラップする.
 */
public static <T> CompletableFuture<T> futureTaskAsync(
      FutureTask<T> ft, Executor e) {
  CompletableFuture<T> cf = new CompletableFuture<>();
  e.execute(() -> {
    try {
      ft.run();
      cf.complete(ft.get());

    } catch (ExecutionException ex) {
      cf.completeExceptionally(ex.getCause());

    } catch (Throwable ex) {
      cf.completeExceptionally(ex);
    }
  });

  cf.whenComplete((ret, ex) -> {
    if (ex instanceof CancellationException) {
      // CompletableFutureがキャンセル例外によって完了している場合
      if (!ft.isDone()) {
        // FutureTaskが、まだ完了していなければ割り込みをかける
        ft.cancel(true);
      }
    }
  });
  return cf;
}

これで、以下のようにしてキャンセルを実施できる。

  FutureTask<String> future = new FutureTask<>(() -> {
    try {
      Thread.sleep(1000);
      System.out.println("done!!");
      return "ok";

    } catch (InterruptedException ex) {
      System.out.println(ex); // 割り込みされた場合にプリントされる
      throw ex;
    }
  });

  CompletableFuture<String> cf = futureTaskAsync(future, es);
  cf.whenComplete((ret, ex) -> {
    System.out.println("done. ret=" + ret + ", ex=" + ex); // 結果の取得
  });

  cf.cancel(true); // キャンセル

JavaFXのTaskとCompletableFutureの連携

キャンセルを行いたい場合以外でも、既存のFutureTaskとの連携が有用な場合もある。

たとえば、JavaFXのTaskなどとの連携があげられる。


JavaFXには、UIへのコールバックサポートを持つ特殊なTaskである、javafx.concurrent.TaskというFutureTask派生クラスがある。

  Label labelMessage = new Label();
  ExecutorService es = Executors.newCachedThreadPool();
...    
  Task<String> uiTask = new Task<String>() {
    @Override
    protected String call() throws Exception {
      int mx = 10;
      for (int idx = 0; idx < mx; idx++) {
        updateMessage("process " + (idx + 1) + "/" + mx);
        Thread.sleep(300);
      }
      return "done.";
    }
  };

  // UIのラベルのテキストプロパティにuiTaskのmessageプロパティを接続する.
  // タスク実行スレッドからの通知はUIスレッドにマーシャリングされるため安全である.
  labelMessage.textProperty().bind(uiTask.messageProperty());

  // 非UIスレッドでのタスクの実行
  es.execute(uiTask);
...

JavaFXではアプリケーションスレッド以外からシーングラフに組み込み済みのオブジェクトを操作することは許されていないため、ワーカースレッドから直接ラベルテキストなどを変更することはできない。


しかし、javafx.concurrent.Taskクラスの内部から呼び出されるupdateMessage, updateTitle, updateProgressといったメソッドはアプリケーションスレッドで通知されるようにマーシャリングされているため、直接、UIに接続可能になっている。
(といっても、単に、内部でPlatform.runLaterしているだけだが。)


このTaskクラスの特性を活かしたまま、CompletableFutureによる待ち合わせに加えたい場合にはどうするか?


先に示したFutureTaskの例と同じようにすれば良い。

(ここでは割り込みは必要ないのでシンプルな方法を用いている。)

  ExecutorService es = Executors.newCachedThreadPool();
...    
  Task<String> uiTask = new Task<String>() {
    @Override
    protected String call() throws Exception {
    ....
    }
  };

  // UIのラベルのテキストプロパティにuiTaskのmessageプロパティを接続する
  labelMessage.textProperty().bind(uiTask.messageProperty());

  // 非UIスレッドでのタスクの実行とCompletableFutureの取得
  CompletableFuture<Void> cf = CompletableFuture.runAsync(uiTask, es);
  
  // 待ち合わせ後、UIスレッドで実行
  cf.whenCompleteAsync((ret, ex) -> {
    labelMessage.textProperty().unbind();
    labelMessage.setText("finished!");
  }, Platform::runLater);

これにより、uiTaskは、そのまま機能しつつ、CompletableFutureによる待ち合わせにも参加できるようになる。*6


(なお、javafx.concurrent.Taskクラスにはスレッドの開始終了イベントをハンドルできる仕組みが備わっているが、スレッドの完了を把握して後続の処理を行うという用途であれば、断然にCompletableFutureのほうが使いやすい。)

結論

CompletableFutureは、コンストラクタによる「不完全なCompletableFuture」による基本形と、その仕組みが分かれば、いろいろ応用が利くし、制限事項も理解しやすいように思われる。

他のFutureまわりの仕組みとは異質ではあるが、他にはない特性を持った、とても使い勝手の良いAPIだと思われる。


以上、メモ終了。

*1:システムが既定で持っている論理CPU分のスレッドプール

*2:なお、ForkJoinPool.commonPoolはサンドボックス状態になっておりパーミッションが必要な処理はできないことに注意。

*3:また、JavaEEではアプリケーションコンテナで管理されたExecutorを明示的に使う必要がある。

*4:CompletableFutureは完了が確定したら、その後、状態が変わらないのが原則だが、obtrudeValue, obtrudeExceptionというイレギュラーなメソッドもある。

*5:なお、FingBugsではCompletableFutureのcomplete(null)を引数がnullによる潜在エラーとして報告するバグがあるようである。(2017/3現在) https://github.com/findbugsproject/findbugs/issues/79 ちなみにjdkの内部では内部用のcompleteNullというメソッドを呼んでいるため問題にはならない。

*6:ちなみに、CompletableFutureの***async系メソッドは引数としてExecutorを取るが、Executorは単一メソッドインターフェイス(single abstract method = SAM)であるため、メソッド参照によるPlatform.runLaterを、そのまま指定できる。

CDIで@Dependentなクラスを@Injectするときに引数を渡す方法(メモ)

概要

CDIで、@Dependentなクラスを定義し、利用する側のクラスのフィールドで@Injectするときに、任意のパラメータを渡したい。

@Dependent
public class Foo {

    @Inject
    private AnyService srv;

    private String arg;
    
    public void setArg(String arg) {
        this.arg = arg;
    }
}

@Dependent
public class Bar {

    @Inject
    private Foo foo; // ← ここでパラメータargを渡したい。
}

解決方法

引数つきの@Qualifierを定義し、@Producesを使うことで実現できる。

@Qualifier
@Retention(RUNTIME)
@Target({ TYPE, METHOD, FIELD, PARAMETER })
public @interface FooParameter {
    @Nonbinding String value();
}

@ApplicationScoped
public class FooProducer {

    @Inject
    @Default
    private Instance<Foo> fooProvider;

    @Dependent
    @Produces
    @FooParameter("")
    public Foo create(InjectionPoint ip) {
        FooParameter fooParam = ip.getAnnotated()
                .getAnnotation(FooParameter.class);
        String arg = fooParam.value();

        Foo inst = fooProvider.get();
        inst.setArg(arg);
        return inst;
    }
}

@Dependent
public class Bar {

    @Inject
    @FooParameter("baz") // ← Foo構築時にパラメータを渡せる
    private Foo foo;
}

@Qualifierを定義するとき、@Nonbindingを指定することで、マッチングする場合に引数の中身は無視されるようになる。

なので、@FooParameter("")の引数にかかわらず、@FooParameterの限定子をつけているものは、すべて、この@Producesメソッドにマッチするようになる。

@Producesでは、InjectionPoint引数を受け取ることでアノテーションを取得できる(@Qualifier以外も可)ので、ここでパラメータを受け取ることができる。

あとは、@DefaultにマッチするFooのインスタンスを生成して明示的にパラメータを渡してから返せば良い。

@Disposesの実装

Fooインスタンスが不要になったタイミングでの後始末が必要ならば、以下のように@Disposesを実装する。

    public void dispose(@Disposes @FooParameter("") Foo inst) {
        fooProvider.destroy(inst);
    }

Instance#get()で取得されたインスタンスは、対となるInstance#destroyメソッドによって明示的な破棄処理を行うことができる。

これにより、Fooクラス内に@PreDestroyメソッドが定義されている場合、これが呼び出されることになる。


(この処理を入れないと@PreDestroyを呼び出すべきタイミングがなくなってしまう。)

別解、もしくは、引数つきコンストラクタで使う場合

Apache DeltaSpikeBeanProvider.injectFieldsで直接入れることも可。


DeltaSpikeではinjectFielsで以下のようなことをやっている。

    public static <T> T injectFields(T instance) {
        BeanManager beanManager = CDI.current().getBeanManager();

        CreationalContext<T> creationalContext =
                beanManager.createCreationalContext(null);

        @SuppressWarnings("unchecked")
        AnnotatedType<T> annotatedType = beanManager
                .createAnnotatedType((Class<T>) instance.getClass());

        InjectionTarget<T> injectionTarget = beanManager
                .createInjectionTarget(annotatedType);
        injectionTarget.inject(instance, creationalContext);

        return instance;
    }


もし、Fooを以下のように引数つきコンストラクタにしてパラメータなしでのインスタンス化をできないようにしたとする。

また、CDIの管理外とする。

public class Foo {

    @Inject
    private AnyService srv;

    private String arg;
    
    public Foo(String arg) {
        this.arg = arg;
    }
}

これは、@Producesによって明示的に非管理Beanをインスタンス化できる。

@ApplicationScopred
public class FooProducer {

    @Dependent
    @Produces
    @FooParameter("")
    public Foo create(InjectionPoint ip) {
        FooParameter fooParam = ip.getAnnotated().getAnnotation(
                FooParameter.class);
        String arg = fooParam.value();

        Foo inst = new Foo(arg);

        // Fooクラスのフィールド中の@Injectを実施する。
        BeanProvider.injectFields(inst);

        return inst;
    }
}

newによって生成されたインスタンスなので、Fooの@Injectの指定のあるフィールドは未初期化状態のままである。

ここでDeltaSpikeのinjectFieldsによって@Injectのフィールドを明示的に注入する。*1


ただし、この方法では注入ができるだけで、@PostConstruct, @PreDestroyなどのイベント的な処理も行うわけではないので、既存の非管理クラスをインスタンス化する場合でなければ、javax.enterprise.inject.Instanceを使って管理ビーンとして生成するのが結局は簡単になると思われる。


また、もし、マニュアルでinjectしたビーンの@PreDestroyを動かしたいのであれば、BeanProvider#injectFieldsで使用したCreationalContextを保存しておいて、@Disposes内で、creationContext.releaseする必要がある。(javax.enterprise.inject.Instanceは、それを行っている。)

*1:メソッド インジェクションも可

Javaのダイレクトバッファの利用上の注意点と、各種バッファを使った読み込みパフォーマンスの比較

概要

前回では、ヒープ上に確保したByteBufferと、ダイレクトバッファとして作成したByteBufferでのデータの読み取りパフォーマンスを比較した結果、圧倒的にダイレクトバッファが速いことが分かった。


しかし上記ベンチマークは、あえてByteBuffer単体でのパフォーマンスの比較を行ったものであり、実際のファイル入出力処理を含んだ比較ではない。
いわば、純粋なByteBufferの性能比較である。


では、実際にファイルの読み取りを含んだ場合でも、はたしてダイレクトバッファを使ったほうが速いのであろうか?


今回は、実際の利用方法を意識して、このあたりを調べてみることとする。

ヒープバッファとダイレクトバッファの違い

ByteBufferには大きく分けて2種類がある。

  • ヒープ上の作成されるヒープバッファ
  • ネイティブ領域に作成されるダイレクトバッファ

ヒープバッファは以下のように作成される。

ByteBuffer buf = ByteBuffer.alloc(siz);
// または
ByteBuffer buf = ByteBuffer.wrap(new byte[siz]);

(allocは単純に内部でnew byte[]を実行しているだけである。)

これは、当然、Javaのヒープ領域からバッファが割り当てられる。
バッファとして確保した分、利用可能なヒープサイズは減ることになる。
その特性はJavaの一般的なオブジェクトと何ら変わることは無い。


これに対して、ダイレクトバッファは以下のように作成される。

    ByteBuffer buf = ByteBuffer.allocDirect(siz);

(そのほか、RandomAccessFileから直接MappedByteBufferを取得する方法もある。)


こちらはJavaのヒープではなく、ネイティブメモリ上から割り当てられる。


ダイレクトバッファはヒープ上にバッファは作らないので、ヒープはほとんど減らない。
そのかわり、ネイティブメモリ、つまりOS側からJavaVMが借りているコミットメモリが増える。

ネイティブメモリはガベージコレクタが管理しているメモリの範囲外であるため、ガベージコレクトなどのメカニズムが効かない領域である。

実験コード
public class BufferAllocExample {

    /**
     * バイトバッファをallocする方法
     */
    public enum ByteBufferAllocStrategy {

        /**
         * ヒープのByteBufferを構築する.
         */
        heap() {
            @Override
            public ByteBuffer alloc(int siz) {
                return ByteBuffer.allocate(siz);
            }
        },

        /**
         * ダイレクトのByteBufferを構築する.
         */
        direct() {
            @Override
            public ByteBuffer alloc(int siz) {
                return ByteBuffer.allocateDirect(siz);
            }
        };

        public abstract ByteBuffer alloc(int siz);
    }

    /**
     * エントリポイント.
     * 第一引数にheapかdirectを指定する.
     * @param args
     * @throws Exception
     */
    public static void main(String... args) throws Exception {
        ByteBufferAllocStrategy allocator = ByteBufferAllocStrategy
                .valueOf(args[0]);

        Runtime rt = Runtime.getRuntime();

        List<ByteBuffer> buffers = new ArrayList<>();

        // 10 MiBの割り当てを10回繰り返す
        for (int idx = 0; idx < 10; idx++) {
            System.gc();
            System.out.println(rt.freeMemory() + "/" + rt.totalMemory());

            ByteBuffer buf = allocator.alloc(1024 * 1024 * 10);
            buffers.add(buf);
        }

        // 100 MiB割り当て後のヒープの残量
        System.gc();
        System.out.println(rt.freeMemory() + "/" + rt.totalMemory());

        // OSのコミットサイズ、プライベートワーキングセットのサイズを
        // タスクマネージャで見られるように、アプリ終了前に一旦停止する。
        System.console().readLine();
    }
}

第一引数にallocかallocDirectを指定して実行すると、10 MiBごとのバッファを確保しながらヒープの残量を表示し、最後に入力待ちになって停止するアプリである。

ヒープバッファ確保の場合
>java -Xms128m -Xmx128m -cp classes jp.seraphyware.jmhexample.BufferAllocExample heap
127831384/128974848
118026720/128974848
107540896/128974848
97055072/128974848
86569248/128974848
76083424/128974848
65597600/128974848
55111776/128974848
44625952/128974848
34140128/128974848
23654304/128974848 

バッファを確保するたびにヒープがどんどん減ってゆく順当な動きである。


このときのタスクマネージャで取得したOSから予約されたコミットサイズは、

  • コミットサイズ = 184,444 k

となっていた。

ダイレクトバッファの場合
>java -Xms128m -Xmx128m -cp classes jp.seraphyware.jmhexample.BufferAllocExample direct
127831360/128974848
128512256/128974848
128512120/128974848
128511984/128974848
128511848/128974848
128511712/128974848
128511576/128974848
128511440/128974848
128511304/128974848
128511168/128974848
128511032/128974848

ヒープはぜんぜん減っていない。

このときのタスクマネージャで取得したOSから予約されたコミットサイズは、

  • コミットサイズ = 286,676 k

となっている。


ヒープバッファのときと比較して、コミットサイズ = OSからJavaVMが予約しているメモリとしては確実に100 MB(102,232 k)ほど増えている。

ダイレクトバッファの最大サイズの指定方法

ちなみに、ダイレクトバッファの最大値はJVMのオプション"-XX:MaxDirectMemorySize "のように明示的に設定することができる。


省略した場合は自動(≒ 制限なし)で、既定ではヒープの最大値と同じようになるため、通常は気にしなくてもよいらしい。

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html


ただし、最大ヒープサイズを超えてダイレクトバッファサイズを指定することもできるらしく、以下のように最大ヒープサイズを64 MBにしても、それを超える100 MBのダイレクトバッファを確保することが可能であった。(Java8u60で確認。)

>java -Xms64m -Xmx64m -XX:MaxDirectMemorySize=512m -cp classes jp.seraphyware.jmhexample.BufferAllocExample direct
63689944/64487424
64020424/64487424
63684664/64487424
63684528/64487424
63684392/64487424
63684256/64487424
63684120/64487424
63683984/64487424
63683848/64487424
63683712/64487424
63683656/64487424

バッファ確保のパフォーマンス比較

ヒープ上のByteBufferは、単に普通にヒープ上でnew byte[…]するだけのオーバーヘッドと同等である。
ヒープメモリは事前にJavaVMがOSよりメモリをもらっており、そこでJavaVMが領域を割り当てるだけである。


これに対して、ダイレクトバッファはネイティブ領域に都度、OSよりバッファを確保してもらっているような動きになっている。
(実際は、もう少し効率は良いかもしれないが、前述の実験からすると、要求されてから都度、OSからメモリをもらっている感じである。)


両者はメモリの確保方法が異なるため、確保するまでにかかる処理時間も異なると予想される。

ベンチマーク

そこで、以下のようなベンチマークコードを書いて計測してみた。

public class BufferAllocBenchmark {

    @State(Scope.Thread)
    public static class BufferAllocContext {

        private ByteBuffer buf;

        @Setup(Level.Trial)
        public void setup() {
        }

        public void setByteBuffer(ByteBuffer buf) {
            this.buf = buf;
        }

        public ByteBuffer getButeBuffer() {
            return this.buf;
        }
    }

    @Benchmark
    public void testAllocDirect8(BufferAllocContext ctx) {
        ctx.setByteBuffer(ByteBuffer.allocateDirect(8 * 1024));
    }

    @Benchmark
    public void testAllocHeap8(BufferAllocContext ctx) {
        ctx.setByteBuffer(ByteBuffer.allocate(8 * 1024));
    }
  … (中略) …
}

ByteBuffer.alloc()と、ByteBuffer.allocDirect()をバッファサイズを変えて計測している。

計測するバッファサイズは、8, 16, 32, 64, 128, 256, 512, 1024 kibである。


(過度な最適化が起きないように、得られたByteBufferは状態インスタンスに1世代のみ保存している。)


Javaはjdk1.8u60で実行し、オプションとして-Xmx128m -Xmx128m -XX:MaxDirectMemorySize=256mを指定したものである。

計測結果
size Direct Heap Direct Error Heap Error
8 183090.216 2018841.709 320.863 49630.037
16 93116.245 1096969.848 191.754 8257.477
32 47854.178 547637.302 82.986 13006.356
64 22689.129 266632.138 224.02 5163.652
128 10988.465 69412.152 518.684 864.918
256 5536.215 33852.752 223.108 84.935
512 2897.219 28651.803 24.236 406.872
1024 1574.254 14013.506 51.023 104.658

数値は、1秒あたりのベンチマークメソッドの実行回数の平均である。

激しくガベージコレクタが動作することが予想されるので、gcによる揺らぎを考慮して測定回数(イテレーション)は50回まわすことにした。


これをチャート化すると、以下のようになる。

チャート化するにあたり、数値は実行回数ではなく確保したバイト数に換算している。
(= 実行した回数 x 確保したバイト数)


これにより、バッファ確保の効率を比較できるようにしている。

これを見ると、明らかにダイレクトバッファはヒープバッファよりも時間がかかっている。


差が10倍ほど開いてしまったので、縦軸は対数表示している。
ダイレクトバッファは、確保するまでの時間が、むちゃくちゃ遅い。


この点については、そもそもJavaDocに書かれている。

ダイレクト byte バッファーは、このクラスのファクトリメソッド allocateDirect を呼び出すと作成されます。通常は、こちらのバッファーのほうが、非ダイレクトバッファーよりも割り当ておよび解放コストがやや高くなります。ダイレクトバッファーの内容が標準のガベージコレクトされたヒープの外部にあるなら、アプリケーションのメモリーフットプリントに対する影響はわずかです。このことから、ダイレクトバッファーには、基本となるシステム固有の入出力操作に従属する、寿命が長く容量の大きいバッファーを指定することをお勧めします。一般に、ダイレクトバッファーの割り当ては、プログラムの性能を十分に改善できる見込みがある場合にのみ行うべきです。

http://docs.oracle.com/javase/jp/8/docs/api/java/nio/ByteBuffer.html


チャートを見ると揺らぎがあるが、だいたいメモリ確保のスピードは確保するバッファサイズに係わらず、ほぼ一定のように思われるので、バッファが小さければ、あるいは、大きければバッファ確保が速くなるとか、そうゆうことはなさそうである。


いずれにしても、ヒープとのダイレクトバッファの確保時のパフォーマンスの差はかなり大きい。

DirerctByteBufferの連続確保と破棄の問題点

ヒープ上のbyte[]配列であるヒープバッファは連続確保・破棄したとしても、その動きは通常のJavaオブジェクトと変わらないことが予想される。

また、OSから見た使用メモリには増減は発生しないはずである。
(すでに確保済みのヒープ内で処理されるため。)


これに対して、ダイレクトバッファはOSより都度メモリを借り、そのメモリを解放するためには、そのダイレクトバッファのハンドルをもつヒープ上のByteBufferオブジェクトがgcされるまで待たなければならないはずである。
(ByteBufferのメソッドにはI/O処理につきものの「close」のようなメソッドがないため、バッファが使用中であるかどうかを判断するにはgcによるしかない。)

また、OSから見た使用メモリは、ダイレクトバッファは確保されるたびにコミットサイズが増加し、gcが発生するたびに解放されるような動きを示すはずである。

実験

そこで、以下のようなコードを書いて試してみた。

public class BufferStressExample2 {

    /**
     * 最新のバイトバッファを保持するスレッドローカル
     */
    public static ThreadLocal<ByteBuffer> lastBufTLS = new ThreadLocal<>();

    /**
     * バイトバッファをallocする方法
     */
    public enum ByteBufferAllocStrategy {

        /**
         * ヒープのByteBufferを構築する.
         */
        heap() {
            @Override
            public ByteBuffer alloc(int siz) {
                return ByteBuffer.allocate(siz);
            }
        },

        /**
         * ダイレクトのByteBufferを構築する.
         */
        direct() {
            @Override
            public ByteBuffer alloc(int siz) {
                return ByteBuffer.allocateDirect(siz);
            }
        };

        public abstract ByteBuffer alloc(int siz);
    }

    /**
     * バイトバッファの破棄方法.
     */
    public enum ByteBufferDeallocStrategy {

        /**
         * 何もしない.システムにお任せ.
         */
        none() {
            @Override
            public void dealloc(ByteBuffer buf) {
            }
        },

        /**
         * 明示的にgcを呼び出す.
         */
        gc() {
            @Override
            public void dealloc(ByteBuffer buf) {
                System.gc();
            }
        },

        /**
         * 明示的にcleanerを呼び出す.
         */
        cleaner() {
            @Override
            public void dealloc(ByteBuffer buf) {
                if (buf != null && buf.isDirect()) {
                    destroyDirectByteBuffer(buf);
                }
            }
        };

        public abstract void dealloc(ByteBuffer buf);
    }

    /**
     * エントリポイント
     * @param args
     * @throws Exception
     */
    public static void main(String... args) throws Exception {

        ByteBufferAllocStrategy allocator =
                ByteBufferAllocStrategy.valueOf(args[0]);
        ByteBufferDeallocStrategy deallocator;
        if (args.length > 1) {
            deallocator = ByteBufferDeallocStrategy.valueOf(args[1]);
        } else {
            deallocator = ByteBufferDeallocStrategy.none;
        }

        int mxthread = (args.length > 2) ? Integer.parseInt(args[2]) : 2;

        // 10 MiBの割り当てを永久に繰り返すジョブ
        Runnable job = () -> {
            for (;;) {
                try {
                    // ByteBufferの構築
                    ByteBuffer buf = allocator.alloc(10 * 1024 * 1024); // 10 MiB

                    // ByteBufferの破棄
                    deallocator.dealloc(lastBufTLS.get());

                    // 最後に確保したByteBufferの保存
                    lastBufTLS.set(buf);

                } catch (Exception ex) {
                    throw new RuntimeException(ex);
                }
            }
        };

        IntFunction<Thread> makeThread = (idx) -> {
            Thread t = new Thread(job);
            t.setDaemon(false);
            t.setName("bufferStress:" + idx);
            return t;
        };

        IntStream.range(0, mxthread)
                .mapToObj(makeThread)
                .forEach(Thread::start);
    }
}

JDK1.8u60で実行した。

テストコードでは2スレッドの同時バッファ確保を行っている。

またコマンドは以下のオプションで実行した。

java -cp classes -Xms128m -Xmx128m -XX:MaxDirectMemorySize=256m jp.seraphyware.jmhexample.BufferStressExample2 heap
ヒープによるByteBufferの連続確保と破棄

以下はWindowsのパフォーマンスモニタによるPage File Bytes(≒コミットサイズ)と、ワーキングセットサイズの推移グラフである。
(縦軸の単位は10 MiBである。以下のパフォーマンスモニタのチャートはすべて同様である。)

new byte[]の破棄と解放はJavaVMの中の出来事であり、OSから見てメモリ消費の増減はないのが分かる。

ダイレクトバッファによるByteBufferの連続確保と破棄と、OutOfMemory問題

このテストコードでは、ダイレクトバッファは1回につき10 Mbytesを割り当て、直近の1つのByteBufferだけを保存し、それ以前のByteBufferは参照がなくなりガベージコレクト対象としている。

これが2スレッドで動いているので、理想的にはダイレクトバッファは1世代前の10 Mbytes x 2と、作成されたばかりの10 Mbytes x 2の、計40 Mbytesのバッファ以外は不要のはずである。


しかし、パフォーマンスモニタによると、瞬間的には450 Mbytesほどのコミットサイズになっている。(ヒープサイズが128MB、これに加えてMaxDirectMemorySizeが256 MBなので、384 MB+α ぐらいが最大値になっていると思われる。)

つまり、不要になったネイティブメモリは、すぐには消えていない。


また、このテストでは、MaxDirectMemorySizeで十分なサイズを指定しておかないと、「OutOfMemoryError: Direct buffer memory」とメモリ不足エラーが発生しやすくなる。
また、スレッド数を増やすと、如実にOutOfMemoryErrorが発生するようになる。

実際には10 Mbytesしか要求しておらず、最大バッファサイズが256 MBもあり、十分に空きがあると考えられるにも係わらず、である。

おそらく確保するスピードが解放するスピードを上回っているのだろう。

ダイレクトバッファでOutOfMemoryを避ける方法

原理的には、ByteBufferがgcされるまでネイティブに確保されたダイレクトバッファも解放されないであろうため、最大ダイレクトメモリサイズよりヒープが大きい場合は、場合によってはヒープのgcが発生する前にダイレクトメモリが枯渇する可能性があるように思われる。(まさか、そんなナイーブな実装ではないとは思うし、OutOfMemoryをあげる前にgcは試行されるはずだが…。)

現実に、Windows7/8.1上のjdk1.8u60では、MaxDirectMemorySizeが十分に大きくないと、このテストでは、すぐにOutOfMemoryが発生する状況である。

System.gc()によるOutOfMemoryの回避

ダイレクトバッファを確保する直前にSystem.gc()をしてあげると、この事象はでてこない。
ByteBufferがgcされ、ネイティブの解放も逐次行われて、パフォーマンスモニタの波形も比較的穏やかなものになる。

ただし、はたして実運用コードの中に「System.gc()」のようなコードを書いて良いものか?
(あるいは、-XX:+DisableExplicitGC オプションによりgcが無視される可能性もある。)
、というような課題はある。

DirectBuffer内部のcleanerを直接使用した早期解放を使う場合

実は、どうやらダイレクトバッファの破棄が遅延されてOutOfMemoryErrorが発生する問題は、かなりメジャーな問題のようで、Stackoverflowあたりを検索すると沢山出てくる。


System.gc()を使うのが、おそらくもっとも正式な方法だが、DirectByteBufferが内部で持っているcleanerメソッドを呼び出して、それに対してcleanを実行することで、ネイティブのメモリ破棄処理を強制的に早期に実行させる、という方法もあるようである。


以下に引用する、ApacheHadoopのソースにも、ダイレクトバッファを破棄するためのコードが書かれている。
(および、どうしてダイレクトバッファの破棄が遅延するのかの理由も。)

https://hbase.apache.org/0.94/apidocs/src-html/org/apache/hadoop/hbase/util/DirectMemoryUtils.html#line.80

    /**
      * DirectByteBuffers are garbage collected by using a phantom reference and a
      * reference queue. Every once a while, the JVM checks the reference queue and
      * cleans the DirectByteBuffers. However, as this doesn't happen
      * immediately after discarding all references to a DirectByteBuffer, it's
      * easy to OutOfMemoryError yourself using DirectByteBuffers. This function
      * explicitly calls the Cleaner method of a DirectByteBuffer.
      * 
      * @param toBeDestroyed
      *          The DirectByteBuffer that will be "cleaned". Utilizes reflection.
      *          
      */
    public static void destroyDirectByteBuffer(ByteBuffer toBeDestroyed)
        throws IllegalArgumentException, IllegalAccessException,
        InvocationTargetException, SecurityException, NoSuchMethodException {
    
      Preconditions.checkArgument(toBeDestroyed.isDirect(),
          "toBeDestroyed isn't direct!");
    
      Method cleanerMethod = toBeDestroyed.getClass().getMethod("cleaner");
      cleanerMethod.setAccessible(true);
      Object cleaner = cleanerMethod.invoke(toBeDestroyed);
      Method cleanMethod = cleaner.getClass().getMethod("clean");
      cleanMethod.setAccessible(true);
      cleanMethod.invoke(cleaner);
    
    }

このコメントによると、参照がすべて切れたからといって、ただちにネイティブメモリが解放されるわけではなく、ダイレクトバッファはファントムリファレンスによって保持され、そのリファレンスキューをJavaVMがチェックして、gcによりキューに入れられたバッファがあれば、そのネイティブメモリを解放しているようである。
このコメントから察するに、タイミングは不明だが、ともかくリファレンスキューを見に行く必要のあるパッシブな動きになっているのだろう。


ためしに、このcleanerを呼び出すコードを使ってみると、パフォーマンスモニタの波形は、大変穏やかなものとなり、スレッド数を増やしてもOutOfMemoryも発生しなくなる。


ただし、はたして実運用コードの中にJavaVMの特定の実装にべったり依存するかのようなコードを書いてよいものかどうか、そのあたりの課題は残る。

ヒープバッファとダイレクトバッファのファイルの読み込み速度の比較

以上の考察から、ダイレクトバッファを使うには2つ注意点があることが分かった。

  1. ダイレクトバッファは確保するのが遅い。
  2. ダイレクトバッファは破棄するのが難しい。

つまり、ファイルを開くたびに、ダイレクトバッファを毎回確保するような方法では、バッファ確保時のオーバーヘッドと、バッファ破棄のトラブルによって、あまり良いパフォーマンスは得られない可能性がある。


しかし、「単にファイルをシーケンシャルに読み込みたい」という、ありふれたニーズであれば、これを対策するのは比較的容易である。


単に、ダイレクトバッファは確保したらキャッシュし、破棄しなければ良いだけである。
(同時アクセスするファイルの最大数分だけダイレクトバッファをキャッシュすれば良い。)
*1


これを実装した上で、ダイレクトバッファとヒープバッファでのファイル読み取りのパフォーマンスを比較してみたいと思う。

実験内容
  • テストデータはテンポラリ上に作成される10 Mbytesのファイルである。
  • テストデータはLittle EndianとBig Endianの2種類作成する
  • データはIntとDoubleの2種類作成する。
  • バッファサイズは、8, 16, 32, 64, 128, 256, 512, 1024 KiB
  • 読み取り方式として、
    • ダイレクトバッファ (バッファは@Setup時に予め初期化しておき、各イテレーションでは確保しない)
    • ヒープバッファ (バッファは@Setup時に予め初期化しておき、各イテレーションでは確保しない)
    • BufferedInputStream
    • InputStream (バッファ忘れケース)
  • ウォームアップとして10回、計測として10回のイテレーションを行う

なお、ウォームアップを10回も繰り返すと、10 MBytes程度のファイルならOSのファイルキャッシュに確実に乗っかることになる。
しかし、今回はディスクの性能を比較するわけではないので、これで良いものと考える。
ある意味、理想的なディスクを使った場合の読み取り方式の違いによるパフォーマンスの違いを計測するようなものといえるだろう。

データ作成コード

(Intデータ部のみ。Doubleデータのソースコードの記載は省略)

データ共通部

/**
 * テスト中にスレッドごとベンチマークごとの状態を保持するインスタンス.
 *
 * @author seraphy
 */
@State(Scope.Thread)
public abstract class AbstractFileBenchContext {

    protected final ByteOrder byteOrder;

    protected final int bufsiz;

    private Path tempFile;

    private ByteBuffer bufHeap;

    private ByteBuffer bufDirect;

    protected AbstractFileBenchContext(ByteOrder byteOrder, int bufsiz) {
        this.byteOrder = byteOrder;
        this.bufsiz = bufsiz;
    }

    /**
     * データの初期化.
     * Trialの場合、スレッドごとベンチマークの開始ごとに一度呼び出される.<br>
     * (ウォームアップイテレーション、および計測イテレーション中には呼び出されない。)
     * (Trialがデフォルト、そのほかにIteration, Invocationが指定可能.)
     */
    @Setup(Level.Trial)
    public void setup() throws IOException {

        // バッファの確保 (ヒープ)
        byte[] data = new byte[bufsiz];
        bufHeap = ByteBuffer.wrap(data);
        bufHeap.order(byteOrder);

        // バッファの確保 (ダイレクト)
        bufDirect = ByteBuffer.allocateDirect(data.length);
        bufDirect.order(byteOrder);

        // テストデータファイルの作成
        tempFile = initTempFile();
    }

    protected abstract Path initTempFile() throws IOException;

    /**
     * スレッドごとベンチマーク終了ごとに呼び出される.
     */
    @TearDown
    public void teardown() {
        try {
            //System.out.println("★delete tempFile=" + tempFile);
            Files.deleteIfExists(tempFile);

        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }

    public Path getTempFile() {
        return tempFile;
    }

    public abstract void verify(Object actual);

    public ByteBuffer getBufDirect() {
        return bufDirect;
    }

    public ByteBuffer getBufHeap() {
        return bufHeap;
    }
}

テストごとのデータ部(一部)

    public static class BenchContextBase extends AbstractFileBenchContext {

        public static final int reqSize = 1024 * 1024 * 10; // 10 MiBytes

        private long total;

        protected BenchContextBase(ByteOrder byteOrder, int bufsiz) {
            super(byteOrder, bufsiz);
        }

        /**
         * テストデータファイルの作成
         * @throws IOException
         */
        @Override
        protected Path initTempFile() throws IOException {
            Path tempFile = Files.createTempFile("filebufferbench", ".tmp");
            //System.out.println("★create tempFile=" + tempFile);

            ByteBuffer buf = ByteBuffer.allocate(bufsiz);
            buf.order(byteOrder);

            total = 0;
            int capacity = buf.capacity();
            long siz;
            try (FileChannel channel = (FileChannel) Files
                    .newByteChannel(tempFile, CREATE, TRUNCATE_EXISTING, WRITE)) {
                int idx = 0;
                int mx = reqSize / capacity;
                for (int loop = 0; loop < mx; loop++) {
                    buf.clear();
                    while (buf.position() < capacity) {
                        buf.putInt(idx);
                        total += idx;
                        idx += 1;
                    }
                    buf.flip();
                    channel.write(buf);
                }
                siz = channel.size();
            }

            if (reqSize != siz) {
                throw new RuntimeException();
            }
            //System.out.println("★total=" + total + "/size=" + siz);
            return tempFile;
        }

        @Override
        public void verify(Object actual) {
            if ((Long) actual != total) {
                throw new RuntimeException("actual=" + actual);
            }
         }
    }

    public static class BenchContextLE8k extends BenchContextBase {

        public BenchContextLE8k() {
            super(ByteOrder.LITTLE_ENDIAN, 1024 * 8);
        }
    }

    public static class BenchContextBE8k extends BenchContextBase {

        public BenchContextBE8k() {
            super(ByteOrder.BIG_ENDIAN, 1024 * 8);
        }
    }

※ このあと、バッファサイズ、LE/BEごとのコンテキストクラスが延々と続く。

テスト実行部

FileChannelを使ってByteBufferでファイルの中身を取り出しテストする部分。

    /**
     * バイトバッファからint値を読み出すテスト
     * @param buf
     */
    private static long runByteBuffer(FileChannel channel, ByteBuffer buf)
            throws IOException {
        long total = 0;
        for (;;) {
            buf.clear();
            int len = channel.read(buf);
            if (len < 0) {
                break;
            }
            buf.flip();
            int limit = buf.limit();
            while (buf.position() < limit) {
                total += buf.getInt();
            }
        }
        return total;
    }

データファイルを読み取り、ファイルチャネルを取り出して中身を走査する。

    private static void doBench(AbstractFileBenchContext ctx, ByteBuffer buf)
            throws IOException {
        try (FileChannel channel = (FileChannel) Files.newByteChannel(
                ctx.getTempFile(), READ)) {
            ctx.verify(runByteBuffer(channel, buf));
        }
    }

ヒープバッファ、ダイレクトバッファによるテスト

    @Benchmark
    public void testHeapBufferLE8k(BenchContextLE8k ctx) throws IOException {
        doBench(ctx, ctx.getBufHeap());
    }

    @Benchmark
    public void testDirectBufferLE8k(BenchContextLE8k ctx) throws IOException {
        doBench(ctx, ctx.getBufDirect());
    }
旧式のストリーム系APIによる読み取り

FileInputStream → BufferedInputStream → DataInputStream の連携でデータを読み取るテスト。

DataInputStreamはBigEndian専用なので、BigEndianのデータのみテストする。


DataInputStreamは一般的にいって使用頻度が高いとはいえないと思うが、byte[]からint値を取り出すための、昔からある、由緒正しい方法の1つなので、とりあえず、これで計測してみる。

    private static long readInts(InputStream is, int cnt) throws IOException {
        long total = 0;
        try (DataInputStream dis = new DataInputStream(is)) {
            for (int idx = 0; idx < cnt; idx++) {
                total += dis.readInt();
            }
        }
        return total;
    }

    @Benchmark
    public void testOldBufferedIO8k(BenchContextBE1m ctx) throws IOException {
        long total;
        try (BufferedInputStream bis = new BufferedInputStream(
                new FileInputStream(ctx.getTempFile().toFile()))) {
            total = readInts(bis, BenchContextBase.reqSize / 4);
        }
        ctx.verify(total);
    }

    @Benchmark
    public void testOldBufferedNIO8k(BenchContextBE1m ctx) throws IOException {
        long total;
        try (BufferedInputStream bis = new BufferedInputStream(
                Files.newInputStream(ctx.getTempFile()))) {
            total = readInts(bis, BenchContextBase.reqSize / 4);
        }
        ctx.verify(total);
    }

なお、Java7から、FilesクラスのnewInputStreamメソッドを使って、簡易にファイルを開くことができるようになったが、従来のFileInputStreamのコンテストラクタで開く場合と変わりないのか気になったので、そのテストも追加している。


また、FileInputStreamをBufferedInputStreamでラップしなかった場合、パフォーマンスの低下がどの程度なものなのか気になったので、こちらもテストを追加している。

計測結果

Intデータの読み取り
Benchmark                                                Mode  Cnt    Score    Error  Units
FileBufferAccessIntBenchmark.testDirectBufferBE128      thrpt   10  312.589 ±  4.157  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE16       thrpt   10  242.295 ±  3.186  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE1m       thrpt   10  317.480 ±  2.527  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE256      thrpt   10  313.295 ±  8.739  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE32       thrpt   10  294.236 ±  1.012  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE512      thrpt   10  312.779 ±  5.954  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE64       thrpt   10  307.159 ±  4.509  ops/s
FileBufferAccessIntBenchmark.testDirectBufferBE8k       thrpt   10  202.106 ±  0.781  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE128      thrpt   10  364.260 ± 10.193  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE16       thrpt   10  264.034 ±  0.895  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE1m       thrpt   10  360.495 ± 13.502  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE256      thrpt   10  362.586 ±  9.963  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE32       thrpt   10  327.563 ±  4.147  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE512      thrpt   10  359.784 ± 15.424  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE64       thrpt   10  355.960 ±  9.823  ops/s
FileBufferAccessIntBenchmark.testDirectBufferLE8k       thrpt   10  221.521 ±  0.949  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE128        thrpt   10  144.247 ±  0.341  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE16         thrpt   10  132.583 ±  0.299  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE1m         thrpt   10  162.469 ±  0.401  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE256        thrpt   10  162.569 ±  0.869  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE32         thrpt   10  135.383 ±  8.522  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE512        thrpt   10  158.935 ±  1.510  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE64         thrpt   10  136.175 ±  1.539  ops/s
FileBufferAccessIntBenchmark.testHeapBufferBE8k         thrpt   10  110.557 ±  1.032  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE128        thrpt   10  139.430 ±  0.641  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE16         thrpt   10  141.571 ±  0.264  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE1m         thrpt   10  161.376 ±  1.468  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE256        thrpt   10  164.129 ±  0.593  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE32         thrpt   10  139.513 ±  1.686  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE512        thrpt   10  164.563 ±  0.675  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE64         thrpt   10  142.430 ±  0.715  ops/s
FileBufferAccessIntBenchmark.testHeapBufferLE8k         thrpt   10  115.688 ±  0.349  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO128k      thrpt   10   21.981 ±  0.201  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO16k       thrpt   10   21.746 ±  0.176  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO32k       thrpt   10   21.734 ±  0.249  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO64k       thrpt   10   21.992 ±  0.109  ops/s
FileBufferAccessIntBenchmark.testOldBufferedIO8k        thrpt   10   21.298 ±  0.168  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO128k     thrpt   10   22.111 ±  0.117  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO16k      thrpt   10   21.814 ±  0.188  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO32k      thrpt   10   22.020 ±  0.252  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO64k      thrpt   10   22.072 ±  0.300  ops/s
FileBufferAccessIntBenchmark.testOldBufferedNIO8k       thrpt   10   21.420 ±  0.185  ops/s
FileBufferAccessIntBenchmark.testOldPlainIO             thrpt   10    0.077 ±  0.001  ops/s
FileBufferAccessIntBenchmark.testOldPlainNIO            thrpt   10    0.072 ±  0.001  ops/s
Doubleデータの読み取り
Benchmark                                                Mode  Cnt    Score    Error  Units
FileBufferAccessDoubleBenchmark.testDirectBufferBE128   thrpt   10  350.916 ±  2.222  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE16    thrpt   10  273.792 ±  2.641  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE1m    thrpt   10  350.331 ±  2.718  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE256   thrpt   10  351.351 ±  3.570  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE32    thrpt   10  322.476 ±  1.697  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE512   thrpt   10  347.777 ±  5.238  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE64    thrpt   10  347.016 ±  2.038  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferBE8k    thrpt   10  220.529 ±  0.839  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE128   thrpt   10  346.273 ±  7.606  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE16    thrpt   10  275.622 ±  1.436  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE1m    thrpt   10  345.511 ±  6.223  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE256   thrpt   10  348.193 ± 14.641  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE32    thrpt   10  321.897 ±  2.498  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE512   thrpt   10  354.947 ±  2.515  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE64    thrpt   10  339.376 ±  5.021  ops/s
FileBufferAccessDoubleBenchmark.testDirectBufferLE8k    thrpt   10  218.614 ±  2.389  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE128     thrpt   10  135.477 ±  0.477  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE16      thrpt   10  128.826 ±  0.493  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE1m      thrpt   10  133.019 ±  1.100  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE256     thrpt   10  135.249 ±  0.382  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE32      thrpt   10  138.792 ±  0.382  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE512     thrpt   10  133.881 ±  1.120  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE64      thrpt   10  135.730 ±  0.320  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferBE8k      thrpt   10  110.442 ±  0.632  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE128     thrpt   10  134.690 ±  0.560  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE16      thrpt   10  128.883 ±  0.583  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE1m      thrpt   10  132.606 ±  1.049  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE256     thrpt   10  134.812 ±  0.587  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE32      thrpt   10  138.150 ±  0.572  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE512     thrpt   10  134.951 ±  0.408  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE64      thrpt   10  135.497 ±  0.874  ops/s
FileBufferAccessDoubleBenchmark.testHeapBufferLE8k      thrpt   10  111.449 ±  0.286  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO128k   thrpt   10   46.183 ±  0.480  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO16     thrpt   10   42.791 ±  0.701  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO32     thrpt   10   45.116 ±  0.071  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO64     thrpt   10   45.764 ±  0.452  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedIO8k     thrpt   10   43.101 ±  0.208  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO128k  thrpt   10   46.364 ±  0.373  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO16    thrpt   10   44.852 ±  0.131  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO32    thrpt   10   46.075 ±  0.125  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO64    thrpt   10   46.895 ±  0.301  ops/s
FileBufferAccessDoubleBenchmark.testOldBufferedNIO8k    thrpt   10   43.116 ±  0.320  ops/s
FileBufferAccessDoubleBenchmark.testOldIO               thrpt   10    0.560 ±  0.006  ops/s
FileBufferAccessDoubleBenchmark.testOldNIO              thrpt   10    0.563 ±  0.008  ops/s
パフォーマンスの比較

10MbytesのファイルのInt値の連続読み取りパフォーマンスのチャート。
縦軸はテストメソッド(10 MBytesファイルの読み取り)の1秒間あたりの実行回数の平均を示す。


やはり、ダイレクトバッファはヒープバッファよりも2倍以上性能が高い。

またバッファサイズとしては256 KiB以上確保したほうが読み込み性能は良いように見える。しかし、256 KiB以上確保しても、それ以上は性能は変わらないようでもある。


10 MbytesのファイルのDouble値の連続読み取りパフォーマンスのチャート。
縦軸はテストメソッド(10 MBytesファイルの読み取り)の1秒間あたり実行回数の平均を示す。


やはり、ダイレクトバッファはヒープバッファよりも2倍以上性能が高い。

また、こちらもバッファサイズとしては256 KiB以上確保したほうが読み込み性能は良いように見える。しかし、256 KiB以上確保しても、やはり、それ以上は性能は変わらないようでもある。

OldBufferedIOの性能が思った以上に低い

今回のベンチマークで思い知ったことは、Javaの古典的なストリーム系のBufferedInputStreamとDataInputStreamの組み合わせが、相当にパフォーマンスが低い、ということだった。
本当に予想外に性能がかなり悪い。
ヒープ上のByteBufferと比較しても、ここまでパフォーマンスが悪いとは思わなかった。


また、うっかりBufferedInputStreamで包むのを忘れてしまったFileInputStreamの性能が大変悲惨であることをチャートで見るとよく分かる。
(これは想定されていたことだが、実際にチャートで比較してみると"悲惨"という言葉に尽きる性能である。)


ただ、Java7で追加されたFiles#newInputStream()はPathで示されるファイルをオープンしてInputStreamを返すコンビニエンスなメソッドだが、コンビニエンスメソッドとして用意されたのであれば、何らかのパフォーマンスのために最低限の構成ができたものが返ってきている可能性もあるのでは?と思ってみたのだが、ぜんぜんそうゆうことはなくて、ただのnew FileInputStream()の代わりにすぎなかったようだ。
(ドキュメントにかかれてないので、まあ、そうだろう、とは思っていたけれど、全然コンビニエンスじゃないところが違和感があるというか。)


また、BufferedInputStreamはデフォルトで8 KiBのバッファサイズをもっているが、これを増やしても、ほとんど性能は向上しなさそうだということも判明した。
(なので、たぶん、大多数の人が引数でバッファサイズを指定していないのだろう。)

結論

今回の実験により、以下のことが分かった。

  • ダイレクトバッファは確保と破棄に注意が必要である。
    • ダイレクトメモリサイズはヒープとは別のJavaVMオプションで設定できる。
    • 確保と破棄が連続する場合では解放が追い付かずOutOfMemoryErrorの発生の恐れがある。
      • System.gc()または内部クラスのCleanerを利用する対策がある。
    • ダイレクトバッファは逐次構築するのではなくキャッシュすると効率が良い。
  • ダイレクトバッファ > ヒープバッファ >> 古典的なストリームI/O という性能差のようである。


実のところ、ダイレクトバッファのベンチマークをとるのは、これで2〜3回目ぐらいである。
しかし、過去のテストでは、ダイレクトバッファは使いどころが難しい、うまくパフォーマンスを発揮させられない、という結論を出してしまっていた。
(今思えばベンチマークとして適切でない部分の時間も含んでいたような気もする。)
また、ヒープ上のByteBufferについても、同じくヒープ上で動作する古典的ストリームI/Oとパフォーマンスに大きな差はないだろう、という先入観のようなものもあったのかもしれない。


だが、今回のテスト結果をみると、どうも過去のテストは明らかに何かを間違えていたかのように思われる。
今回、確実に、かなり高速でデータの読み込みができることの確証を得た。


今後は、ByteBufferがうまくマッチする場合は、優先的にByteBufferによるアクセスに切り替えて行く方が良さそうだ、という教訓が得られたと思う。

今思うことは、どうして、もっと早く再評価してみようと思わなかったのか、ということだろうか。


※ 今回のベンチマーク等のソースコードは、https://gist.github.com/seraphy/c173e3d82afd1ad3f81c にあります。


以上、メモ終了。

*1:ただし、RandomAccessFileから取得されるMappedByteBufferについては、特定のファイルに結びついているので、これは不要になったら、すぐに破棄するべきである。また、ネイティブメモリを早期に解放させるためにgcをかけることが望ましいだろう。これはOSのネイティブなハンドルが閉じられていないと使用中のままになるため、ファイルの読み書き削除に支障がでるためである。

jmhマイクロベンチマークツールを使ったByteBufferのバイトオーダによるパフォーマンスの違いの比較

(ヒープおよびダイレクトバッファによるByteBufferの性能差、およびUnsafeとのパフォーマンスも比較するよ!)
(とりあえず計測結果を先に知りたい人は結論を見てね!)
(ファイルのシーケンシャルリードを含むダイレクトバッファの使い方やパフォーマンス比較については次の日記に書きました。)

概要

思うところあって、ヒープ上のbyte[]をラップしたByteBufferと、ダイレクトバッファを確保しているByteBufferの場合とのint型としての連続アクセスした場合のパフォーマンスの違いについて調べてみることにした。


私の認識では、ダイレクトバッファへのアクセスは非ネイティブ側(つまりJavaコード側)からのアクセスではオーバヘッドが大きいため、特殊なシチュエーション以外では遅くなってしまうため、あまり使いどころのない機能だというものだったのだが、では、その使いどころとなる「特殊なシチュエーション」とは何か、ということについては、いまいちわからなかった。

とくに、ByteBufferを連続的にintアクセスするような用途には、ヒープ上のバッファよりも、むしろ遅くなるのではないか?という漠然とした印象があった。


だが、実際にためしたところ、予想外にも、"ダイレクトバッファはリトルエンディアンのint型をバイト列から読み取るにはヒープ上のバッファよりもものすごく良いパフォーマンスが得られるかも?"という、個人的には非常に興味深い示唆に富む結果を得ることができた。
そこで、ベンチマークツールの使い方の備忘録も兼ねて、ここに記録しておくことにする。


※ なお、JAVAベンチマークは条件が少しでも変われば結果が大きく変わったりするので、あくまでも、「私が試したケース」ではという前提であり、また、どうして、このような結果になったかについては納得のゆく確証が得られていないため、もし、この記事を参考にすることがあるのならば、これを鵜呑みにせず、実際には個々のケースに応じてベンチマークをとるべきだろう。
(その際には、ここで紹介するベンチマークツールjmhが有益だと思われる。)

ベンチマークツールの準備

今回は、ベンチマークツールとして "jmh"を久しぶりに使うことにした。

このツールは、「まともに作るのが案外難しい」ベンチマークコードの作成をアシストして、さらにベンチマーク結果の統計情報(標準偏差や信頼区間)も計算して出してくれるようにしてくれる、大変便利なベンチマークジェネレータツールである。


1年以上つかってなかったのだが、久しぶりに使ってみたら、ちょっと変化があって、古いコードとは、ほんの少し書き方が変わっているようだ。
ネット上の資料でも古い書き方をしているものがあるので、その点は最新の書き方に改める必要がある。
とはいえ、基本的な考え方や使い方などは変わっていない。


jmhの概要や使い方については、少し古い記事だが以下のページが大変参考になる。

(あとは公式ページと、沢山ある付属サンプルから、なんとかする。)

jmhとは?

jmhは、openjdkで開発されている、java用のマイクロベンチマークツールである。

http://openjdk.java.net/projects/code-tools/jmh/


JAVAではコンパイル時最適化や実行時最適化によってソースコード上から予想される動きよりも、はるかに最適化された挙動を示すことが多々ある。

パフォーマンス比較するつもりで書いた単純なコードが実運用ではありえない形にアグレッシブに最適化されたり、あるいは逆に、通常ではJITによる最適化が行われるべきところをベンチマークの実行が浅くて最適化されてない状態で比較する、もしくは、評価したい部分とは関係ない部分でのパフォーマンスを計測に含んでしまうなど、頓珍漢なパフォーマンス比較になっていたりしたら、大変な判断ミスを犯すことになるだろう。

このような最適化がされるような状況でベンチマークをとるのは、めんどくさいことは容易に想像できる。


これらを比較的容易にベンチマークをとれるようにお膳立てしてくれるのが、jmhである。


参考:


ただし、jmhはjunitのようなテストライブラリではない。


jmhは、マイクロベンチマークを行うためのソースコードを自動生成して、その自動生成されたテストコードの上から、ユーザーが定義したベンチマークコードを実行するための、実行可能jarを生成する、いうなれば「テストベンチジェネレータ」ツールなのである。

ベンチマークプロジェクトの準備

jmhを使うのは簡単で、mavenから新規アーキタイプとして"jmh-java-benchmark-archetype"を指定してプロジェクトを作成すれば、必要なものは自動的に手に入るようになっている。

<dependency>
	<groupId>org.openjdk.jmh</groupId>
	<artifactId>jmh-java-benchmark-archetype</artifactId>
	<version>1.11.2</version>
</dependency>

最近のLunaとかMarsあたりのEclipseであればmavenは初めから使えるようになっている。*1



初期状態では、このアーキタイプはローカルマシン上のMavenリポジトリには登録されていないと思われるので、まずは、アーキタイプを登録しておく。(登録後、一度ダイアログを閉じて再度新規プロジェクト作成を開きなおすと、一覧に出てくるようになる。)


※ ここでは現時点の最新であるバージョン1.11.2を使っている。(最新はリポジトリを確認のこと。)



こうしてプロジェクトが作成されるので、まずはpom.xmlを修正して、
java8をターゲットに変えておく。

※ 当然ながら、javaのバージョンが違えばパフォーマンスも変わってくるだろう。



(pom.xmlの抜粋)

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <jmh.version>1.11.2</jmh.version>
        <javac.target>1.8</javac.target>
        <uberjar.name>benchmarks</uberjar.name>
    </properties>
空のベンチマークの実行

初期状態で生成されたクラスは空のベンチマークであるが、ビルドすればパフォーマンスの計測は可能である。

package jp.seraphyware;

import org.openjdk.jmh.annotations.Benchmark;

public class MyBenchmark {

    @Benchmark
    public void testMethod() {
        // This is a demo/sample template for building your JMH benchmarks. Edit
        // as needed.
        // Put your benchmark code here.
    }
}

プロジェクト内にあるクラスがスキャンされ、@Benchmarkアノテーションがついているすべてのメソッドがベンチマークメソッドとして認識される。

(※ ちなみに、以前は@Benchmarkではなく、@GenerateMicroBenchmarkというアノテーションが使われていたように思う。)


ただし、このベンチマークを実行するためには、まずはmavenによるビルドが必要である。


先に説明したとおり、jmhはライブラリではなく、テストベンチジェネレータであり、mavenでビルドすることによりさまざまなファイルを自動生成する。
これらのファイルがないとベンチマークを動かすことができない。

mvn clean package



(画面キャプチャを取り損ねたので素のEclipseで撮りなおしています。)


ビルドが完了すると、targetフォルダ上に実行可能なjar"benchmarks.jar"が生成されている。

コマンドプロンプトを開き、これを実行することができる。


以下はコマンド実行と、その結果である。

>set PATH=C:\java\jdk1.8.0_60\bin;%PATH%
>java -jar benchmarks.jar "MyBenchmark.testMethod" -f1 -wi 1 -i 1
# JMH 1.11.2 (released 15 days ago)
# VM version: JDK 1.8.0_60, VM 25.60-b23
# VM invoker: C:\java\jdk1.8.0_60\jre\bin\java.exe
# VM options: <none>
# Warmup: 1 iterations, 1 s each
# Measurement: 1 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: jp.seraphyware.MyBenchmark.testMethod

# Run progress: 0.00% complete, ETA 00:00:02
# Fork: 1 of 1
# Warmup Iteration   1: 3680300939.059 ops/s
Iteration   1: 3687070753.959 ops/s


Result "testMethod":
  3687070753.959 ops/s


# Run complete. Total time: 00:00:02

Benchmark                Mode  Cnt           Score   Error  Units
MyBenchmark.testMethod  thrpt       3687070753.959          ops/s

>

オプション引数は"-h"で確認できる。


上記の場合の引数は、テストする「クラス.メソッド名」を明示して、ウォームアップイテレーションx1, イテレーションx1, フォークx1、という指定である。
(いうまでもなく、これは単なる動作確認で、計測するには不十分な回数である。)


また、テストするクラス・メソッド名は正規表現で判断されるので、この場合、たとえば、"MyBenchmark"としても、それにマッチするすべてのベンチマークが実行されることになる。(何も指定しないと、すべてのベンチマークメソッドが実行される。)


実行結果の「Score 3687070753.959 ops/s」というのは、デフォルトのテストモードが「スループット計測モード」であるため、これは1秒間に何回テストベンチを処理できたかの回数を表すものである。
(1イテレーションベンチマークはデフォルトでは1秒間となっている。)
(ベンチマークメソッドが空なので、ものすごい回数が回っている。)


今回はイテレーション数が1のためベンチマーク実行回数が1回しかないため表示されていないが、
複数回のイテレーションを行った場合には、最小値、最大値、平均値、標準偏差、99.9%信頼区間などの数値が計算される。

IDEからの実行

一度mavenでビルドすると、テストベンチ用のソースコードも生成されているのでEclipseからも実行できるようになっている。

Mainメソッドとして、ベンチマークを起動するために、以下のようなものを追加する。

    /**
     * IDEから実行する場合は、
     * まず先にmvn package等を実行してサポートファイル類を生成しておく必要がある.
     * (少なくともクラスまたはメソッドのシグネチャを変更追加するたびにmvnでビルドする必要がある。)
     * その後、IDEから、このメイン関数を呼び出すことができるようになる.
     * @param args
     * @throws RunnerException
     */
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                // 実行すべきベンチマークが定義された"クラス名.メソッド名"を正規表現で指定
                .include(MyBenchmark.class.getSimpleName())
                // ウォームアップイテレーション数 "-wi"オプションに相当
                .warmupIterations(20)
                // イテレーション数 "-i"オプションに相当
                .measurementIterations(10)
                // フォーク数 "-f"オプションに相当
                .forks(1)
                // 1回のテストメソッド実行時間 "-r"オプションに相当
                .measurementTime(TimeValue.seconds(1))
                // 同時実行スレッド数 (1測定毎のスレッド数 (サンプル数が増える))
                // .threads(Math.max(1, Runtime.getRuntime().availableProcessors() / 2))
                // 測定するモード = スループット
                .mode(Mode.Throughput)
                .build();
        new Runner(opt).run();
    }

これで、このクラスを実行クラスに選択すれば、ベンチマークを実行することができるようになる。


ただし、ベンチマーククラスまたはメソッド(およびメソッドの引数等のシグネチャ)を追加削除変更した場合には、再度、mavenによるビルドが必要となる。(その後、Eclipseのパッケージエクスプローラのリフレッシュを忘れずに。)


※ OptionsBuilderのincludeメソッドで実行するベンチマークの「クラス名.メソッド名」を正規表現で絞りこめるので、ベンチマークコードを試行錯誤している間は、メソッド名まで指定すると良いだろう。

ByteBufferのパフォーマンス比較コードの作成

ByteArrayには、ヒープ上のbyte[]配列をラップしたものと、ヒープ外に確保したダイレクトバッファによるものの2種類がある。


ダイレクトバッファはヒープ外にあるため、OSなどネイティブなヒープ外のコードからアクセスする場合にはオーバヘッドがないかわりに、逆に、Javaのコードからアクセスするにはオーバヘッドが生ずる。
このトレードオフを見極めるのが難しく、ダイレクトバッファを使えばなんでも速くなるけではない。(むしろ、普通のバイトバッファのように扱えば、大抵遅くなるだろう。)


ただし、この2種類はインターフェイスは共通であり、同じようなコードで扱うことができる。


今回は、バイトバッファからint値を連続して読み込むために以下のようなコードを用いる。

    /**
     * バイトバッファからint値を読み出すテスト
     * @param buf
     */
    private static void runByteBuffer(ByteBuffer buf) {
        buf.rewind();
        int limit = buf.limit();
        long total = 0;
        while (buf.position() < limit) {
            total += buf.getInt();
        }
        if (total != 8589869056L) {
            throw new RuntimeException("actual=" + total);
        }
    }

このコードは単純に、ByteBufferのバッファ上から、すべてのint値を読み込んでtotal変数に足しこんでいるだけである。

最後に、total変数を8589869056Lという謎の数値と照合させているが、これはテストデータとして用意したバイト列を正しく読み出した場合に計算される値である。


ヒープ上のbyte[]配列をByteBufferにラップしたものと、ダイレクトバッファの双方で、このコードを使って計測する。


テストデータは以下のようにして生成している。

(バッファサイズは今回は512KiBとしている。)

    /**
     * データを初期化する.
     * @param buf
     */
    private void initData(ByteBuffer buf) {
        IntBuffer intBuf = buf.asIntBuffer();
        long total = 0;
        for (int idx = 0; idx < data.length / 4; idx++) {
            intBuf.put(idx);
            total += idx;
        }
        //System.out.println("★total=" + total);
        if (total != 8589869056L) {
            throw new RuntimeException("actual=" + total);
        }
    }

テストの状態を保持する@Stateアノテーションの利用

このテストデータを生成するタイミングをコントロールするために、jmhの@Stateという仕組みを用いる。

前述のとおり、テストベンチメソッドは繰り返し大量に呼び出されるものなので、都度、テストデータを作成していては計測がおぼつかない。

このため、テストベンチに対してテストの状態を保持するインスタンスを持たせることができるようになっている。


@Stateというアノテーションがつけられたクラスを、テストベンチメソッドの引数にすることで、
jmhテストベンチによってベンチマークメソッドが呼び出されるときに自動的に、そのインスタンスが引き渡されてくるようになる。

@Stateアノテーションがつけられたクラスは、内部に「@Setup」「@TearDown」のアノテーションがついたメソッドを定義することができ、

の3種類のタイミングでメソッドを呼びすように設定できる。

(前述のとおり、テストベンチメソッドは繰り返し大量に呼び出されるものなので、Invocationの使用には注意が必要となる。)


ここで、Trialレベルの@Setupメソッドでテストデータを準備すれば、
純粋にByteBufferをint値でアクセスする部分だけを大量に繰り返しテストできるようになる。

※ なお、テストベンチはマルチスレッドでテストすることもできるので、その場合、@StateアノテーションにScope.Threadを指定することにより、スレッドスコープとしてスレッド間の衝突なく使えるようにすることができる。(このほかに、Benchmark, Groupのスコープがある。)

    /**
     * テスト中にスレッドごとベンチマークごとの状態を保持するインスタンス.
     *
     * @author seraphy
     */
    @State(Scope.Thread)
    protected static class BenchContextBase {

        /**
         * バイトオーダー
         */
        private final ByteOrder byteOrder;

        /**
         * ヒープ
         */
        private byte[] data;

        /**
         * ヒープをラップしたバッファ
         */
        private ByteBuffer bufHeap;

        /**
         * ダイレクトバッファ
         */
        private ByteBuffer bufDirect;

        /**
         * バイトオーダを指定して構築する.
         * @param byteOrder
         */
        protected BenchContextBase(ByteOrder byteOrder) {
            this.byteOrder = byteOrder;
        }

        /**
         * データの初期化.
         * Trialの場合、スレッドごとベンチマークの開始ごとに一度呼び出される.<br>
         * (ウォームアップイテレーション、および計測イテレーション中には呼び出されない。)
         * (Trialがデフォルト、そのほかにIteration, Invocationが指定可能.)
         */
        @Setup(Level.Trial)
        public void setup() {
            // ヒープ上にデータを作成する
            byte[] data = new byte[512 * 1024]; // 512kib
            this.data = data;

            // ヒープ上のByteBufferを作成する.
            bufHeap = ByteBuffer.wrap(data);
            bufHeap.order(byteOrder);
            // ※ テストデータを作成する
            initData(bufHeap);

            // ダイレクトメモリを確保してByteBufferを作成する
            // (データはヒープのものをコピーする)
            bufDirect = ByteBuffer.allocateDirect(data.length);
            bufDirect.order(byteOrder);
            bufDirect.put(data);
        }
   ... (中略) ...
   }

今回は、ByteBufferにつめるデータ形式として、JavaのデフォルトであるBigEndianと、Intel系CPUのデフォルトであるLittleEndianの2種類のデータを作成できるようにしている。(上記コード中のbyteOrder変数が、それにあたる。)

    /**
     * データの並びがLittleEndianのバッファをもつテストデータを作成する.
     */
    public static class BenchContextLE extends BenchContextBase {

        public BenchContextLE() {
            super(ByteOrder.LITTLE_ENDIAN); // Unsafe(intel)にあわせる
        }
    }

    /**
     * データの並びがBigEndianのバッファをもつテストデータを作成する.
     */
    public static class BenchContextBE extends BenchContextBase {

        public BenchContextBE() {
            super(ByteOrder.BIG_ENDIAN); // JAVA標準にあわせる
        }
    }

Unsafeによるbyte[]へのint値としての直接アクセスの実験もしてみる

今回はByteBufferでのアクセスの計測の他に、sun.misc.Unsafeというネイティブ的な動作をするAPI群があり、この中にオブジェクト上の任意のバッファをint値として読み取ることのできる機能があるらしいので、これも、ためしに使って計測してみる。


参考元:


もちろん、sun.misc.Unsafeは、非公開、非推奨のベンダ固有APIである。

OpenJDKとSun系のJDKにはとりあえず現在は存在しており、各種有名どころのライブラリも使っていたりするらしい。

(また、JavaVMまわりの制御を行えるので、特にロックまわりではsun.misc.Unsafeを使わないとどうしても実現できないようなものもあるといううわさも聞いた。)*2

    /**
     * ヒープからUnsafeを使ってint値を読み出すテスト
     * @param unsafe
     * @param data
     */
    @SuppressWarnings("restriction")
    private static void runUnsafe(sun.misc.Unsafe unsafe, byte[] data) {
        int offset = sun.misc.Unsafe.ARRAY_BYTE_BASE_OFFSET;
        int len = data.length + offset;
        int pos = offset;
        long total = 0;
        while (pos < len) {
            total += unsafe.getInt(data, (long) pos);
            pos += 4;
        }
        if (total != 8589869056L) {
            throw new RuntimeException("actual=" + total);
        }
    }

このコードはbyteを4バイト飛ばしでint値として無理やり読み込んでいるものである。

byte配列の読み出しには、オフセットとしてsun.misc.Unsafe.ARRAY_BYTE_BASE_OFFSETが必要になるらしい。*3


また、そもそもUnsafeを扱うためには、直接、Unsafeを取り出そうとするとヌルクラスローダ上のクラスからの呼び出しであるか判定して、システム外クラスからの呼び出しはセキュリティエラーにする実装となっているため、リフレクションを経由して無理やり取ってくる必要がある。

    /**
     * アンセーフ
     */
    @SuppressWarnings("restriction")
    private sun.misc.Unsafe unsafe;

    /**
     * アンセーフをリフレクションによって取得する.
     */
    @SuppressWarnings("restriction")
    private void initUnsafe() {
        try {
            Field field = sun.misc.Unsafe.class
                    .getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe = (sun.misc.Unsafe) field.get(null);

        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

さらに、byte[]に格納されたLittle Endianを自力でint値にしてみる

何回かBenchmarkをとったところ、予想外にヒープのByteBufferの性能が悪そうなので、比較のために、自力でbyte[]からLittle Endianのint値をアクセスするコードのベンチマークもとってみることにする。

    @Benchmark
    public void testHeapByteArray(BenchContextLE ctx) {
        byte[] data = ctx.getData();
        int len = data.length;
        int pos = 0;
        long total = 0;
        while (pos < len) {
            total += ((data[pos + 3] & 0xFF) << 24)
                    | ((data[pos + 2] & 0xFF) << 16)
                    | ((data[pos + 1] & 0xFF) << 8)
                    | (data[pos + 0] & 0xFF);
            pos += 4;
        }
        if (total != 8386560) {
            throw new RuntimeException("actual=" + total);
        }
    }

※ 参考: https://www.jpcert.or.jp/java-rules/fio12-j.html

ベンチマークメソッドの定義

これで、

  • ヒープ/ダイレクトのByteBuffer
  • BigEndian/LittleEndian
  • Unsafe#getIntによるintアクセス
  • byte[]の自力でのintアクセス

の準備が整った。

このテストベンチは以下のようになる。

    /**
     * LittleEndianのデータをヒープ上のバイトバッファから読み出すベンチマーク.
     * @param ctx
     */
    @Benchmark
    public void testHeapByteBufferLE(BenchContextLE ctx) {
        runByteBuffer(ctx.getHeapByteBuffer());
    }

    /**
     * LittleEndianのデータをダイレクトバイトバッファから読み出すベンチマーク.
     * @param ctx
     */
    @Benchmark
    public void testDirectByteBufferLE(BenchContextLE ctx) {
        runByteBuffer(ctx.getDirectByteBuffer());
    }

    /**
     * LittleEndianのデータをアンセーフを使って読み出すベンチマーク
     * @param ctx
     */
    @Benchmark
    public void testUnsafeLE(BenchContextLE ctx) {
        runUnsafe(ctx.getUnsafe(), ctx.getData());
    }

    /**
     * BigEndianのデータをヒープ上のバイトバッファから読み出すベンチマーク.
     * @param ctx
     */
    @Benchmark
    public void testHeapByteBufferBE(BenchContextBE ctx) {
        runByteBuffer(ctx.getHeapByteBuffer());
    }

    /**
     * BigEndianのデータをダイレクトバイトバッファから読み出すベンチマーク.
     * @param ctx
     */
    @Benchmark
    public void testDirectByteBufferBE(BenchContextBE ctx) {
        runByteBuffer(ctx.getDirectByteBuffer());
    }

ベンチマークの実行結果

これでベンチマークを実行したところ、以下のような結果が得られた。(抜粋)


実行したのはWindows8.1(x64)で、CPUはXeon E3-1270v2@3.5GHzの環境である。

(Windows7(x64) で、i7-3770@3.40GHzでも、ほぼ同じ結果となった。)

# JMH 1.11.2 (released 16 days ago)
# VM version: JDK 1.8.0_60, VM 25.60-b23
# VM invoker: C:\Java\jdk1.8.0_60\jre\bin\java.exe
# VM options: -Dfile.encoding=UTF-8
# Warmup: 20 iterations, 1 s each
# Measurement: 10 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: jp.seraphyware.jmhexample.BufferAccessBenchmark.testDirectByteBufferBE

Result "testDirectByteBufferBE":
  14749.779 ±(99.9%) 54.380 ops/s [Average]
  (min, avg, max) = (14690.298, 14749.779, 14806.503), stdev = 35.969
  CI (99.9%): [14695.400, 14804.159] (assumes normal distribution)

Result "testDirectByteBufferLE":
  21970.174 ±(99.9%) 154.358 ops/s [Average]
  (min, avg, max) = (21833.488, 21970.174, 22122.518), stdev = 102.098
  CI (99.9%): [21815.816, 22124.532] (assumes normal distribution)

Result "testHeapByteArray":
  4744.254 ±(99.9%) 83.476 ops/s [Average]
  (min, avg, max) = (4618.953, 4744.254, 4799.570), stdev = 55.214
  CI (99.9%): [4660.778, 4827.730] (assumes normal distribution)

Result "testHeapByteBufferBE":
  4518.875 ±(99.9%) 491.083 ops/s [Average]
  (min, avg, max) = (4124.723, 4518.875, 4919.513), stdev = 324.821
  CI (99.9%): [4027.792, 5009.958] (assumes normal distribution)

Result "testHeapByteBufferLE":
  4581.578 ±(99.9%) 618.657 ops/s [Average]
  (min, avg, max) = (4104.452, 4581.578, 4948.241), stdev = 409.203
  CI (99.9%): [3962.921, 5200.234] (assumes normal distribution)

Result "testUnsafeLE":
  21981.865 ±(99.9%) 125.801 ops/s [Average]
  (min, avg, max) = (21841.979, 21981.865, 22068.251), stdev = 83.209
  CI (99.9%): [21856.064, 22107.665] (assumes normal distribution)


# Run complete. Total time: 00:03:02

Benchmark                                      Mode  Cnt      Score     Error  Units
BufferAccessBenchmark.testDirectByteBufferBE  thrpt   10  14749.779 ±  54.380  ops/s
BufferAccessBenchmark.testDirectByteBufferLE  thrpt   10  21970.174 ± 154.358  ops/s
BufferAccessBenchmark.testHeapByteArray       thrpt   10   4744.254 ±  83.476  ops/s
BufferAccessBenchmark.testHeapByteBufferBE    thrpt   10   4518.875 ± 491.083  ops/s
BufferAccessBenchmark.testHeapByteBufferLE    thrpt   10   4581.578 ± 618.657  ops/s
BufferAccessBenchmark.testUnsafeLE            thrpt   10  21981.865 ± 125.801  ops/s


※ このベンチマークは512kibのバッファの読み込みを繰り返すものであり、上記スコア値はops/sの平均、つまり1秒間あたりの実行回数の平均なので、簡単に処理速度を求めるなら、単純に512kibをかければ良い。

他の計測モード

jmhでは計測モードとして、デフォルトのスループットの他に、平均時間、サンプルタイム、シングルショットタイムを選択できる。

  • スループット(ops/s)はベンチマークを1秒間に何回実行したかを示すものである。
  • 平均時間は、これを時間に換算したものである。(ops/sで1を割れば時間が出てくる。)
  • サンプルタイムは、実行時間の分散を調べるものであり、たとえば実行したベンチマークのうち、50%は最大で、どのぐらいの所要時間が必要だったか?また、実行したベンチマークの90%は最大で、どのくらい所要時間が必要だったか?といった値を見ることができる。また、結果としては最大、最小、平均の所要時間と標準偏差、サンプル総数などが得られるので、時間を計測するならばサンプルタイムのほうが良いかもしれない。
  • シングルショットタイムは、イテレーションではなくバッチサイズで指定し、バッチサイズ分実行したトータルタイムを出す。(単純なパフォーマンス計測に向く?)


以下はサンプルタイムで計測しなおした結果である。

計測モードとして、サンプルタイムを指定し、単位はマイクロ秒とする。
(コマンドラインから実行する場合は、オプションとして-bmオプションでモード指定する。)

Options opt = new OptionsBuilder()
    .(中略)
    .timeUnit(TimeUnit.MICROSECONDS)
    .mode(Mode.SampleTime)
    .build();

サンプルタイムの結果 (抜粋)

# JMH 1.11.2 (released 16 days ago)
# VM version: JDK 1.8.0_60, VM 25.60-b23
# VM invoker: C:\Java\jdk1.8.0_60\jre\bin\java.exe
# VM options: -Dfile.encoding=UTF-8
# Warmup: 20 iterations, 1 s each
# Measurement: 10 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Sampling time
# Benchmark: jp.seraphyware.jmhexample.BufferAccessBenchmark.testDirectByteBufferBE

Iteration   1: n = 14259, mean = 70 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 68, 73, 85, 129, 164, 180, 181 us/op
Iteration   2: n = 14022, mean = 71 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 68, 76, 87, 138, 176, 377, 485 us/op
Iteration   3: n = 14341, mean = 70 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 67, 70, 84, 135, 175, 342, 390 us/op
Iteration   4: n = 14122, mean = 71 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 68, 76, 87, 137, 172, 241, 241 us/op
Iteration   5: n = 14138, mean = 71 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 68, 76, 87, 139, 165, 198, 205 us/op
Iteration   6: n = 14624, mean = 68 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 66, 69, 77, 103, 145, 181, 184 us/op
Iteration   7: n = 14589, mean = 68 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 66, 69, 78, 103, 153, 173, 174 us/op
Iteration   8: n = 14578, mean = 69 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 66, 69, 78, 107, 148, 163, 164 us/op
Iteration   9: n = 14606, mean = 68 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 66, 69, 76, 105, 147, 165, 169 us/op
Iteration  10: n = 14259, mean = 70 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 66, 68, 75, 85, 121, 155, 207, 233 us/op

Result "testDirectByteBufferBE":
  69.583 ±(99.9%) 0.088 us/op [Average]
  (min, avg, max) = (65.664, 69.583, 484.864), stdev = 10.159
  CI (99.9%): [69.495, 69.672] (assumes normal distribution)
  Samples, N = 143538
        mean =     69.583 ±(99.9%) 0.088 us/op
         min =     65.664 us/op
  p( 0.0000) =     65.664 us/op
  p(50.0000) =     67.712 us/op
  p(90.0000) =     70.912 us/op
  p(95.0000) =     83.840 us/op
  p(99.0000) =    126.492 us/op
  p(99.9000) =    162.304 us/op
  p(99.9900) =    207.104 us/op
  p(99.9990) =    443.401 us/op
  p(99.9999) =    484.864 us/op
         max =    484.864 us/op


Iteration   1: n = 10857, mean = 46 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 53, 88, 108, 149, 149 us/op
Iteration   2: n = 10619, mean = 47 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 45, 47, 55, 97, 117, 150, 151 us/op
Iteration   3: n = 10738, mean = 47 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 55, 90, 110, 142, 143 us/op
Iteration   4: n = 10942, mean = 46 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 47, 72, 106, 133, 133 us/op
Iteration   5: n = 10874, mean = 46 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 53, 73, 105, 136, 136 us/op
Iteration   6: n = 10878, mean = 46 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 53, 74, 106, 119, 119 us/op
Iteration   7: n = 10955, mean = 46 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 48, 71, 106, 107, 107 us/op
Iteration   8: n = 10945, mean = 46 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 47, 71, 106, 107, 108 us/op
Iteration   9: n = 10842, mean = 46 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 54, 86, 110, 172, 174 us/op
Iteration  10: n = 10852, mean = 46 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 53, 86, 111, 122, 122 us/op

Result "testDirectByteBufferLE":
  46.035 ±(99.9%) 0.064 us/op [Average]
  (min, avg, max) = (43.968, 46.035, 174.336), stdev = 6.423
  CI (99.9%): [45.971, 46.099] (assumes normal distribution)
  Samples, N = 108502
        mean =     46.035 ±(99.9%) 0.064 us/op
         min =     43.968 us/op
  p( 0.0000) =     43.968 us/op
  p(50.0000) =     44.224 us/op
  p(90.0000) =     46.592 us/op
  p(95.0000) =     53.312 us/op
  p(99.0000) =     81.408 us/op
  p(99.9000) =    107.520 us/op
  p(99.9900) =    131.635 us/op
  p(99.9990) =    172.333 us/op
  p(99.9999) =    174.336 us/op
         max =    174.336 us/op


Iteration   1: n = 6413, mean = 156 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 162, 169, 220, 326, 342, 342 us/op
Iteration   2: n = 6407, mean = 156 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 161, 170, 240, 321, 344, 344 us/op
Iteration   3: n = 6409, mean = 156 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 159, 169, 288, 343, 444, 444 us/op
Iteration   4: n = 6436, mean = 155 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 162, 169, 218, 313, 354, 354 us/op
Iteration   5: n = 6473, mean = 154 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 158, 165, 216, 333, 413, 413 us/op
Iteration   6: n = 6445, mean = 155 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 159, 168, 220, 317, 344, 344 us/op
Iteration   7: n = 6462, mean = 155 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 159, 168, 209, 322, 360, 360 us/op
Iteration   8: n = 6460, mean = 155 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 159, 168, 214, 316, 334, 334 us/op
Iteration   9: n = 6473, mean = 154 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 159, 168, 220, 319, 352, 352 us/op
Iteration  10: n = 6440, mean = 155 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 150, 151, 160, 168, 220, 325, 344, 344 us/op

Result "testHeapByteArray":
  155.058 ±(99.9%) 0.194 us/op [Average]
  (min, avg, max) = (149.504, 155.058, 444.416), stdev = 14.943
  CI (99.9%): [154.864, 155.251] (assumes normal distribution)
  Samples, N = 64418
        mean =    155.058 ±(99.9%) 0.194 us/op
         min =    149.504 us/op
  p( 0.0000) =    149.504 us/op
  p(50.0000) =    150.528 us/op
  p(90.0000) =    159.488 us/op
  p(95.0000) =    168.448 us/op
  p(99.0000) =    227.072 us/op
  p(99.9000) =    325.632 us/op
  p(99.9900) =    357.947 us/op
  p(99.9990) =    444.416 us/op
  p(99.9999) =    444.416 us/op
         max =    444.416 us/op


Iteration   1: n = 4607, mean = 217 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 212, 226, 243, 359, 433, 477, 477 us/op
Iteration   2: n = 4666, mean = 214 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 207, 221, 233, 330, 454, 470, 470 us/op
Iteration   3: n = 4706, mean = 212 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 207, 219, 227, 311, 423, 442, 442 us/op
Iteration   4: n = 4708, mean = 212 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 207, 218, 227, 306, 418, 498, 498 us/op
Iteration   5: n = 4697, mean = 213 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 207, 225, 228, 289, 444, 565, 565 us/op
Iteration   6: n = 4701, mean = 213 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 207, 218, 227, 314, 460, 479, 479 us/op
Iteration   7: n = 4707, mean = 212 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 207, 219, 226, 295, 397, 446, 446 us/op
Iteration   8: n = 4703, mean = 212 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 207, 219, 227, 307, 436, 467, 467 us/op
Iteration   9: n = 4695, mean = 213 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 207, 225, 226, 339, 454, 461, 461 us/op
Iteration  10: n = 4608, mean = 217 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 207, 212, 225, 243, 362, 464, 481, 481 us/op

Result "testHeapByteBufferBE":
  213.519 ±(99.9%) 0.304 us/op [Average]
  (min, avg, max) = (206.848, 213.519, 565.248), stdev = 19.993
  CI (99.9%): [213.215, 213.823] (assumes normal distribution)
  Samples, N = 46798
        mean =    213.519 ±(99.9%) 0.304 us/op
         min =    206.848 us/op
  p( 0.0000) =    206.848 us/op
  p(50.0000) =    207.360 us/op
  p(90.0000) =    220.672 us/op
  p(95.0000) =    229.888 us/op
  p(99.0000) =    323.072 us/op
  p(99.9000) =    437.760 us/op
  p(99.9900) =    479.888 us/op
  p(99.9990) =    565.248 us/op
  p(99.9999) =    565.248 us/op
         max =    565.248 us/op


Iteration   1: n = 5174, mean = 193 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 200, 207, 261, 382, 426, 426 us/op
Iteration   2: n = 5205, mean = 192 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 197, 203, 260, 398, 419, 419 us/op
Iteration   3: n = 5196, mean = 192 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 197, 203, 262, 392, 408, 408 us/op
Iteration   4: n = 5180, mean = 193 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 201, 207, 268, 381, 398, 398 us/op
Iteration   5: n = 5196, mean = 192 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 197, 206, 259, 399, 514, 514 us/op
Iteration   6: n = 5183, mean = 193 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 199, 206, 263, 389, 414, 414 us/op
Iteration   7: n = 5169, mean = 193 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 201, 207, 261, 390, 429, 429 us/op
Iteration   8: n = 5185, mean = 193 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 199, 207, 252, 395, 426, 426 us/op
Iteration   9: n = 5186, mean = 193 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 199, 206, 253, 360, 392, 392 us/op
Iteration  10: n = 5177, mean = 193 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 188, 188, 200, 207, 257, 398, 435, 435 us/op

Result "testHeapByteBufferLE":
  192.723 ±(99.9%) 0.216 us/op [Average]
  (min, avg, max) = (187.904, 192.723, 513.536), stdev = 14.930
  CI (99.9%): [192.508, 192.939] (assumes normal distribution)
  Samples, N = 51851
        mean =    192.723 ±(99.9%) 0.216 us/op
         min =    187.904 us/op
  p( 0.0000) =    187.904 us/op
  p(50.0000) =    188.416 us/op
  p(90.0000) =    198.656 us/op
  p(95.0000) =    206.592 us/op
  p(99.0000) =    259.328 us/op
  p(99.9000) =    389.632 us/op
  p(99.9900) =    424.656 us/op
  p(99.9990) =    513.536 us/op
  p(99.9999) =    513.536 us/op
         max =    513.536 us/op


Iteration   1: n = 11004, mean = 45 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 45, 47, 70, 98, 107, 107 us/op
Iteration   2: n = 11028, mean = 45 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 45, 47, 69, 106, 146, 148 us/op
Iteration   3: n = 11026, mean = 45 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 46, 47, 65, 105, 114, 115 us/op
Iteration   4: n = 11000, mean = 45 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 45, 47, 71, 106, 114, 115 us/op
Iteration   5: n = 11001, mean = 45 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 45, 47, 72, 106, 156, 158 us/op
Iteration   6: n = 11084, mean = 45 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 45, 47, 63, 106, 114, 115 us/op
Iteration   7: n = 10998, mean = 45 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 46, 47, 71, 100, 131, 133 us/op
Iteration   8: n = 11009, mean = 45 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 45, 47, 71, 105, 106, 106 us/op
Iteration   9: n = 10933, mean = 46 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 44, 47, 47, 73, 106, 117, 117 us/op
Iteration  10: n = 10578, mean = 47 us/op, p{0.00, 0.50, 0.90, 0.95, 0.99, 0.999, 0.9999, 1.00} = 44, 45, 47, 63, 88, 116, 145, 146 us/op

Result "testUnsafeLE":
  45.545 ±(99.9%) 0.053 us/op [Average]
  (min, avg, max) = (43.968, 45.545, 157.696), stdev = 5.347
  CI (99.9%): [45.492, 45.598] (assumes normal distribution)
  Samples, N = 109661
        mean =     45.545 ±(99.9%) 0.053 us/op
         min =     43.968 us/op
  p( 0.0000) =     43.968 us/op
  p(50.0000) =     44.224 us/op
  p(90.0000) =     46.592 us/op
  p(95.0000) =     46.912 us/op
  p(99.0000) =     72.064 us/op
  p(99.9000) =    105.856 us/op
  p(99.9900) =    124.071 us/op
  p(99.9990) =    156.756 us/op
  p(99.9999) =    157.696 us/op
         max =    157.696 us/op


# Run complete. Total time: 00:03:02

Benchmark                                       Mode     Cnt    Score   Error  Units
BufferAccessBenchmark.testDirectByteBufferBE  sample  143538   69.583 ± 0.088  us/op
BufferAccessBenchmark.testDirectByteBufferLE  sample  108502   46.035 ± 0.064  us/op
BufferAccessBenchmark.testHeapByteArray       sample   64418  155.058 ± 0.194  us/op
BufferAccessBenchmark.testHeapByteBufferBE    sample   46798  213.519 ± 0.304  us/op
BufferAccessBenchmark.testHeapByteBufferLE    sample   51851  192.723 ± 0.216  us/op
BufferAccessBenchmark.testUnsafeLE            sample  109661   45.545 ± 0.053  us/op

結果

int型の場合

まず、(驚いたことに)DirectBufferでのint型(32bit)のLittleEndianのデコードが、かなり速い



そして、Unsafeが、当然速いというのは分かったが、DirectByteBuffer(Little Endian)も、Unsafeとかわらないぐらい速い。
これはバッファサイズが512kibの場合だが、16kibなど小さいバッファで比較しても同様である。

(このくらい速ければ、わざわざ非推奨なベンダ依存のAPIを使う必要もなさそうである。)


正直、この速さは意外だった。
しかし、どうして速いのかは、よく分からない。


CPUのネイティブなレイアウトであるLittle Endianと同じだから変換不要で速くなるとかかな?と思ってみたものの、同じDirectByteBufferのBigEndianのケースでも、byteをラップしたヒープによるByteBufferよりも速いので、そうゆう問題ではないのかもしれない。

byteをラップしたByteBufferに問題があるのかと思って、自前でbyte[]をLittle Endianのintとしてアクセスするベンチマークとも比較してみたが、似たような性能となったので、特別にByteBufferの仕組みに問題があるわけではなさそう。


どうやら、StackOverflowで見つけた記事によると、ヒープのByteBufferとダイレクトのByteBufferでは、以下のように読み取り方法が違うらしい。
http://stackoverflow.com/questions/6943858/bytebuffer-getint-question

// HeapByteBuffer

public int getInt() {
    return Bits.getInt(this, ix(nextGetIndex(4)), bigEndian);
}

public int getInt(int i) {
    return Bits.getInt(this, ix(checkIndex(i, 4)), bigEndian);
}

// DirectByteBuffer

private int getInt(long a) {
    if (unaligned) {
        int x = unsafe.getInt(a);
        return (nativeByteOrder ? x : Bits.swap(x));
    }
    return Bits.getInt(a, bigEndian);
}

public int getInt() {
    return getInt(ix(nextGetIndex((1 << 2))));
}

public int getInt(int i) {
    return getInt(ix(checkIndex(i, (1 << 2))));
}

これから考えると、

  1. 単純にUnsafe#getIntで読み取れれば最速である。
  2. DirectBufferでは、もし、エンディアンが一致しない場合には、後続にswap処理が入る分、遅くなる。

これがDirectBufferでのintアクセス時のリトルエンディアンとビッグエンディアンとのパフォーマンスの違いのようだ。

Double型の場合

ついでに、Double(8バイト)型の場合も計測してみた。

Benchmark                                       Mode  Cnt      Score     Error  Units
BufferAccessBenchmark2.testDirectByteBufferBE  thrpt   10  18947.259 ± 179.970  ops/s
BufferAccessBenchmark2.testDirectByteBufferLE  thrpt   10  19048.996 ±  50.217  ops/s
BufferAccessBenchmark2.testHeapByteBufferBE    thrpt   10   3955.089 ±   6.115  ops/s
BufferAccessBenchmark2.testHeapByteBufferLE    thrpt   10   3869.472 ±  57.203  ops/s
BufferAccessBenchmark2.testUnsafeLE            thrpt   10  18790.156 ±  24.251  ops/s


BigEndianとLittleEndianとでのパフォーマンスの違いが見られなくなっているが、UnsafeとDirectBufferのパフォーマンスが同等であることはintの場合と同じようである。(BE/LEの違いが見られない理由はわからないのだが...)


バッファサイズは512kibのままであり、double型は8バイト表現となるので、データの読み取り回数としてはintの場合に対して半分になるが、パフォーマンスとしてはダイレクトバッファを使うことにより、intの場合と遜色ない高速な読み取りができるように見える。


ヒープ上のバッファからのdouble読み取りは、やはり遅いようだ。

Float型の場合

intと同様に4バイト表現であるfloatでは、以下のような結果になった。

Benchmark                                       Mode  Cnt     Score    Error  Units
BufferAccessBenchmark2.testDirectByteBufferBE  thrpt   10  9538.018 ± 45.020  ops/s
BufferAccessBenchmark2.testDirectByteBufferLE  thrpt   10  9528.538 ± 25.403  ops/s
BufferAccessBenchmark2.testHeapByteBufferBE    thrpt   10  4981.542 ± 85.246  ops/s
BufferAccessBenchmark2.testHeapByteBufferLE    thrpt   10  5059.595 ± 74.801  ops/s
BufferAccessBenchmark2.testUnsafeLE            thrpt   10  9533.223 ± 22.078  ops/s

BigEndianとLittleEndianとでのパフォーマンスの違いが見られなくなっているが、UnsafeとDirectBufferのパフォーマンスが同等である傾向は同じようである。

やはり、double同様に、ヒープからfloatを取得するよりも、ダイレクトバッファから直接floatを読み取ったほうが速い。


ただし、doubleと比較すると、ダイレクトバッファの場合は、floatのほうがパフォーマンスが低いように見える。

これはJavaでは内部的には実数がdouble型として扱われているところに起因するのだろうか?(根拠はないが...。)

(1つの推測としては、バッファサイズよりも、項目数 = get回数が効いているのかもしれない。)


※ ヒープ同士の比較では、doubleよりはfloatのほうがパフォーマンスは良いようである。

Long型の場合

long値(8バイト)の場合は、以下のようになった。

Benchmark                                       Mode  Cnt      Score      Error  Units
BufferAccessBenchmark2.testDirectByteBufferBE  thrpt   10  29425.796 ±  659.844  ops/s
BufferAccessBenchmark2.testDirectByteBufferLE  thrpt   10  36945.695 ± 1012.917  ops/s
BufferAccessBenchmark2.testHeapByteBufferBE    thrpt   10   3924.688 ±   66.375  ops/s
BufferAccessBenchmark2.testHeapByteBufferLE    thrpt   10   3929.151 ±   57.227  ops/s
BufferAccessBenchmark2.testUnsafeLE            thrpt   10  38615.889 ±  186.971  ops/s


(intよりもパフォーマンスが高くなったのでグラフの横幅が変わっていることに注意。)


long型は8バイトなのでdouble型のケースと同様に、同じ512kibバッファサイズではintやfloatと比較して半分の項目数しかない。


intのケースよりも一秒間当たりの実行回数が倍近く伸びているので、やはり、Unsafe#getX() メソッドの呼び出し回数がパフォーマンスの大きな因子のような感じである。

結論

これらの結果をみるかぎり、整数、実数ともに連続的に大量の項目を読みとる必要がある場合は、ダイレクトバッファを使うのが良さそうである。
(というより、このグラフを見てしまうとダイレクトバッファを使わないのは、もはや間違いといえるレベルかも...。)


とりあえず、ファイル等から直接データを読み取る場合には、一旦バイト配列としてヒープにもってきてからint等に展開するよりも*4直接、ダイレクトバッファ上からint値などを取得したほうが速くなりそうな結果が得られたように思える。

(ただし、本当にアテにしていいのかは、実際にコードを書いて比較するべきだろう。)


この結果は、個人的には、なかなか興味深い、今まで気にも留めなかったダイレクトバッファの正しい使い道が分かったような新鮮な発見があり、
今後のI/Oまわりのパフォーマンスチューニングに使えそうな、とても有意義な勉強になったと思う。


※ 今回のソースコードは、以下のgistに置いた。
(pom.xmlと、ベンチマークjavaクラスのみ。それ以外のソースはmavenで自動生成されたものなので手は入れていない。追試する場合は、上記手順に従ってmavenからjmhのアーキタイプを指定してプロジェクトを構築してから、本ソースを追加すれば良い。)
https://gist.github.com/seraphy/6276dbae1eb4ee96325d


以上、メモ終了。

*1:便利だと思うので、最近は、だいたいPleiades (Eclipse 4.5 Mars)を使っている

*2:こちらは、jdk9でUnsafeから公開APIになるかもしれない? http://openjdk.java.net/jeps/193 http://openjdk.java.net/jeps/260

*3:http://www.docjar.com/docs/api/sun/misc/Unsafe.html#objectFieldOffset

*4:FileChannelからByteBuffer/MappedByteBufferなどを使ってダイレクトバッファを取得できる。ただし、MappedByteBufferは最速らしいが、unmapする方法が現状ではgcしかないので、開きっぱなしにするファイルでないと使いどころは難しいかも?

JavaFX8で簡単なアプリケーションを簡単に作る方法

動機、なぜJavaFXでアプリを作るのか?

Javaには、Swingという、十分に成熟しておりパフォーマンスも悪くないUIライブラリがある。

現状、普通に使っている分には何の不自由もない。

また、Swingは今後発展することはないとはいえ、(すぐに)廃止されるわけではない。*1

※ 2018/9以降、JavaFXはOpenJDK, OracleJDKともに同梱されないことになった。すなわち、JavaFXJavaの標準のツールキットではなくなる。なので、無理にJavaFXを勉強しなくてもいいかもね。(というか、Oracleの業態からいってクライアント向けの機能は必要としていないから、今後はGuiまわりは全部切られるかも?) 2018/6/2追記

ならば、どうしてJavaFXなどという、新しいUIライブラリを覚えなければならないのか。

見るからにめんどくさそうなFXMLというマークアップ言語まで付いている。

....。

と、思っていたのだけれど、

実際に使ってみたらSwingよりも思いのほか簡単だった。


案ずるより産むが易し、というやつである。

JavaFXを使うメリット

実際にJavaFXを使ってみて、Swingに対して以下のようなメリットを感じることができた。

  • FXMLを使うことによりUIのレイアウト構造をコードから分離することができる
    • Swingではロジックとレイアウトがごちゃまぜになっていて、作るのは良いが、修正する場合に困難が伴わなかったことがない。
    • その点、JavaFXはコードではレイアウトしないので保守性が高そうである。
  • FXMLはXML構造であるため、GUIのようなオブジェクトツリー構造を記述するのに適している
    • XMLであるためツールとの親和性が高い。(Scene Builderという外部ツールでレイアウトを編集できる。)
    • やってることも、実は単純だったりする。(手で作るのも難しくないと思われる。やりたくはないけど。)
  • バインディングはめんどくさい反面、とても便利で強力だったりする。
  • CSSによる細かなレイアウトの調整やデコレーションが、プログラムコードとは分離して表現できる。
    • (ただしウェブ的なcssとは、ちょっと違う。)

上記のようなメリットを実感したため、今後はJavaFX8を優先的に使ってゆこうと思い始めている。


ここでは、これまで経験した、JavaFXで簡単にアプリを作るための簡単な方法について、軽くまとめておきたいと思う。

(また、先日のJava Day Tokyo 2015の櫻庭さんのセッションで得られた新たな知見も追記しておく。)

用意するもの

  • JDK8u40以降
  • Scene Builder 2.0
  • IDE
    • Eclipse 4.4 Luna + e(fx)clipse
    • NetBeans8
JDK8u40を使う理由

JavaFX2はJava7より利用可能になっているが、ここではJava8u40以降を使う。


Java7からJavaFX2が同梱され利用可能だったが、JavaFXのランタイムがクラスパスに通っていないため、通常の方法ではアプリケーションを配布することも実行することもめんどくさかった。

しかし、Java8からはJavaFX8は標準のクラスパスに入っており、単純に実行可能jarに丸めて配布しても動くようになっている。

JavaFX2(Java7)時代のJavaFXのビルドと配備方法は大変複雑だったが、JavaFX8では、ただのアプリと同じである。


また、JavaFX2にはメッセージボックスのような、よく使われるダイアログが(何故か)存在しない。


これも、Java8u40から、JavaFX Dialogsの成果物Alertクラスが標準に取り込まれたので、メッセージボックスの表示も標準でできるようになっている。

http://code.makery.ch/blog/javafx-dialogs-official/

※ Java8u40未満では上記のJavaFXDialogsのライブラリを使うと良いだろう。

Scene Builder 2.0

Scene Builderは、JavaFXGUIをレイアウトするためのツールである。

このツールを使うことにより、コンポーネントの配置状態を表すFXMLというXMLファイルをグラフィカルに編集、保存することができる。


これはJDKには付属していないので、別途ダウンロードする必要がある。

http://www.oracle.com/technetwork/java/javase/downloads/javafxscenebuilder-info-2157684.html

ただし、現行のSceneBuilder2はFXMLのすべての機能をサポートしているわけではなく、
手作業で手の込んだFXMLを作るとScene Builderでは開けなくなったりするので注意が必要である。


簡単なアプリケーションを作るうえでは、Scene Builderだけで十分ではないか、と思う。


なお、最新のScene BuilderのバイナリはOracleからは配布されておらず、OpenJDK側でソースとして提供されている。

これを使うには、自分でビルドするか、だれかがビルドしてくれたバイナリをもらってくる必要がある。*2

Scene Builder自身もJavaFXで書かれているので、jdk8環境があればビルドできるようである。*3

IDE

JavaFX8は、ふつうのJavaコード + FXMLというXMLファイルで作れるので、原理的には何のIDEでも良い。


Eclipseの場合は、Java8をサポートするLuna以降が必要であり、且つ、FXMLのサポートがつく"e(fx)clipse"プラグインを入れる。

Eclipsejavafxで始まるパッケージを非公開クラスの利用だとしてエラー表示するが、このプラグインを入れると解消される。

設定から、Scene Builderの実行ファイルの位置などを指定する必要がある。


NetBeans8の場合でも、FXMLの編集にはScene Builderを使うので、ツールメニューからオプションを開き、JavaのタブからScene Builderのホームディレクトリを指定する必要がある。

これでIDEとScene Builderとの連携ができるようになる。

(なお、NetBeans8でJavaFXプロジェクトをMavenで作ると、Java7時代のめんどくさいビルドスクリプトが出来上がるの注意。JavaFX8では、普通のアプリのビルド方法と同じで良い。)

JavaFXのお約束

エントリポイント

従来、SwingのJFrameを使った簡単なアプリケーションは以下のような形で作っていたと思う。

class MyFrame extends JFrame {

    public MyFrame() {
        // 初期化処理
    }

    public static void main(String... args) throws Exception {
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                MyFrame inst = new MyFrame();
                inst.setLocationByPlatform(true);
                inst.setVisible(true);
            }
        });
    }
}

mainメソッドでイベントディスパッチスレッド(EDT)に入ってJFrameを作成する、という流れである。

(手抜きなコード例だと、EDTに入らずに、いきなりnew JFrameしていたりする例も散見されるが、これは禁止事項である。)


これに対して、JavaFXでは、アプリケーションのエントリポイントは、かならず以下のような形式になる。

package jp.seraphyware.simpleapp0;

import javafx.application.Application;
import javafx.stage.Stage;

public class SimpleApp extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception {
        // ここからスタートする
    }

    public static void main(String... args) {
        launch(args);
    }
}

まず、アプリケーションは、必ずApplicationを継承したクラスになる。

JavaFXに対応したランチャは、Javaアプレットのようにmainは経由せず、startから開始する。*4


mainから開始する古典的な実行形式の場合には上記のように単にlaunchを呼ぶ形にしておく。
これにより、JavaFXアプリケーションスレッドに入ったあとでstartメソッドが呼び出されるようになる。

(この場合でSwingと異なる点は、launchメソッドを呼んだ後、JavaFXアプリケーションスレッドが終了するまで制御が返らない点であろう。)


startメソッドは呼び出された時点でJavaFXアプリケーションスレッド上にいる。


引数にはStageが1つ受け取られているが、これがSwingでいうところのJFrameに相当する、メインウィンドウとなる。

このstartメソッド内でJavaFXコンポーネントを作成して、Stageに上に乗せることで、ウィンドウを作成する。

(アプリケーションは、必ずしも、このprimaryStageを使う必要はなく、任意にStageを作成して使うこともできるが、アプレット等でブラウザに埋め込まれている場合は、このprimaryStageが、その埋め込み領域を表している。)


コンポーネントを作成する方法としては、

のいずれの方法でもよい。

必ずFXMLを使わなければならない、というものではない。


JavaFXアプリケーションスレッドが終了する場合にはstopメソッドが呼び出されるので、ここで後始末ができる。

UIスレッド/JavaFXアプリケーションスレッド

JavaFX8では、JavaFXアプリケーションスレッド以外からUIを操作したい場合には、Swingの場合と同様にJavaFXアプリケーションスレッド上で実行するRunnableを引き渡す必要がある。

Platform#runLater(Runnable r)


JavaFXアプリケーションスレッドは、Swing/AWTのEDT(UIスレッド)とは別のスレッドである。

JavaFX8では、SwingNodeによって、JavaFXの中でSwingを利用することもできるが、
SwingとJavaFXのスレッドは別物なので、SwingNode上のSwingコンポーネントを作成、操作する場合には、必ず、SwingのUIスレッドで行う必要がある。


この場合、Swing用にはSwingUtilities#invokeLaterを引き続き使用する必要がある。


全部がObservableである。

JavaFXでは従来のJava Beansと異なり、"なんとかProperty"という形式のプロパティを持つ。

たとえば、textというプロパティを持つJavaFX Beansであれば、だいたい以下のようになる。

package jp.seraphyware.simpleapp0;

import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public class JavaFXSimpleModel {

    private SimpleStringProperty textProperty;

    public StringProperty textProperty() {
        if (textProperty == null) {
            textProperty  = new SimpleStringProperty(this, "text");
        }
        return textProperty;
    }

    public String getText() {
        return textProperty().get();
    }

    public void setText(String text) {
        textProperty().set(text);
    }
}

javafx.beans.***Propertyクラスは、値の変更を検知可能な(Observable)値のホルダーであり、
JavaFXでは、あらゆるプロパティがObservableである。


また、これはバインド可能(一方向、双方向)であり、ある値が変更されると連結されている別のコンポーネントのプロパティも自動的に変更される、という動作ができる。

バインドは、かなり強力で、複数のプロパティの演算結果をバインドする、というようなこともできるようになっている。


JavaFXでは、とにかく全部がObservableである。

JavaFXによる簡単なGUIアプリの作成

まず、以下のような簡単なGUIを作りたいと思う。

まずはFXMLによるGUIのレイアウトを行う

画面のレイアウトのデザインにはScene Builderを使う。


EclipseNetBeans上で適当に空のFXMLファイルを作成し、そのファイルを右クリックして「Scene Builderで開く」みたいなメニューがあれば、それでScene Builderが開かれる。*5

Swingの場合はJPanelの中にレイアウトマネージャを設定してレイアウトを組んでいたが、JavaFXの場合では、はじめからGridPane, BorderPane, FlowPane, VBox, HBoxのようなレイアウトペインになっている。

この上に必要なコンポーネントを乗せて行くことで画面をデザインしてゆくことになる。


実際に画面のレイアウトするうえでの注意は「アンカーペインには頼りすぎない」ことが使ってみたところの教訓といえようか。


アンカーペイン(AnchorPane)は、Visual Studioのフォームエディタのような感覚でコンポーネントを並べてゆけるうえに、アンカーを指定することで画面サイズにあわせて拡縮するようなレイアウトも容易に作れる。

しかし、コンポーネント自身の適切なサイズは使用するフォントや文字数により変わるため、ローカライズした場合やWindows/Linux/Mac間でフォントが変わった場合などでサイズが変わるため、なかなか調整が難しい。


なので、今のところの感触では、Swingを手動でレイアウトを組んでいたころのように、GridPane, BorderPane, FlowPane, VBox, HBoxなどのレイアウトを使ってコンポーネントのサイズを自動調整させつつ、それらを組み合わせて配置してゆくのが良いだろう、と思う。

fx:idの割り当て

プログラム側からコンポーネントを制御できるようにするため「fx:id」属性で名前をつけておく必要がある。


これによりFXMLとコントローラクラス上のフィールドとが結び付けられるようになる。

  • txtDir
  • txtNamePattern
  • btnBrowse
  • chkSubdir
  • btnOK
アクション名の割り当て

ボタンのアクションに対しては、以下のようにメソッド名を割り当てておく。


これによりFXMLとコントローラクラス上のメソッドと連結されることになる。

  • #onBrowse
  • #onOK
  • #onCancel

※ ここにスクリプトを直接書くことも可能。


SceneBuilderの画面を示す。

出来上がったFXMLの例

できあがったFXMLは以下のようなものである。

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import java.lang.*?>
<?import javafx.scene.layout.*?>

<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
   <children>
      <GridPane hgap="3.0" vgap="3.0">
        <columnConstraints>
          <ColumnConstraints hgrow="SOMETIMES" percentWidth="0.0" />
          <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
            <ColumnConstraints hgrow="SOMETIMES" percentWidth="0.0" />
        </columnConstraints>
        <rowConstraints>
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
        </rowConstraints>
         <children>
            <Label text="ディレクトリ" GridPane.halignment="RIGHT">
               <GridPane.margin>
                  <Insets right="5.0" />
               </GridPane.margin>
            </Label>
            <Label text="ファイル名パターン" GridPane.halignment="RIGHT" GridPane.rowIndex="1">
               <GridPane.margin>
                  <Insets right="5.0" />
               </GridPane.margin>
            </Label>
            <TextField fx:id="txtDir" GridPane.columnIndex="1" />
            <Button fx:id="btnBrowse" mnemonicParsing="false" onAction="#onBrowse" text="参照" GridPane.columnIndex="2" />
            <TextField fx:id="txtNamePattern" text="*.*" GridPane.columnIndex="1" GridPane.rowIndex="1" />
            <CheckBox fx:id="chkSubdir" mnemonicParsing="false" text="サブディレクトリを含む" GridPane.columnIndex="1" GridPane.rowIndex="2" />
         </children>
      </GridPane>
      <FlowPane alignment="TOP_RIGHT" hgap="5.0" vgap="5.0">
         <children>
            <Button fx:id="btnOK" defaultButton="true" mnemonicParsing="false" onAction="#onOK" text="実行" />
            <Button cancelButton="true" mnemonicParsing="false" onAction="#onCancel" text="キャンセル" />
         </children>
         <padding>
            <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
         </padding>
      </FlowPane>
   </children>
   <padding>
      <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
   </padding>
</VBox>

FXMLはとても汎用的にできていて、fxで始まる名前空間のもの以外は、特殊なものはない。

これはオブジェクトツリーを作成するための表記そのものといってよい。

http://docs.oracle.com/javafx/2/api/javafx/fxml/doc-files/introduction_to_fxml.html


FXMLのルールは、ざっくりといえば、

  • 大文字のタグはクラス名であり、そのクラスをnewすることを意味する
  • 小文字のタグまたは属性名がプロパティを意味する
    • 属性名を使う場合は文字列または数値のリテラルのみ指定可能
    • 小文字のタグを使う場合には、リスト表現や、fxで始まる名前空間を利用してJavaの定数を参照することも可能。

これらをつなげてツリーを作ることでUI要素の親子関係を表現しているのである。


また、要素の出現順で、タブキーによるフォーカス移動順(フォーカストラバーサル)が決まるなどの性質もある。


上記例では、このFXMLをロードすると、ルート要素として各種UI要素を配置済みのVBoxが返されることになる。

FXMLに対応するコントローラクラスの作成

前述のように、コンポーネントに名前を割り当てたり、ボタンのアクションのメソッド名を割り当てた場合、それらを受ける「コントローラ」クラスの定義を行う必要がある。

前述の例であれば、以下のようなコントローラが必要となる。

public class SimpleAppController implements Initializable {

    @FXML
    private TextField txtDir;

    @FXML
    private TextField txtNamePattern;

    @FXML
    private CheckBox chkSubdir;

    @FXML
    private Button btnOK;

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        // OKボタンの活性制御
        btnOK.disableProperty().bind(
                Bindings.or(
                        txtDir.textProperty().isEmpty(),
                        txtNamePattern.textProperty().isEmpty()
                        ));
    }

    @FXML
    protected void onBrowse(ActionEvent evt) {
        // ここにBrowseボタンのアクションを定義する
    }

    @FXML
    protected void onOK(ActionEvent evt) {
        // ここにOKボタンのアクションを定義する
    }

    @FXML
    protected void onCancel(ActionEvent evt) {
        // 現在ボタンを表示しているシーンを表示しているステージに対して
        // クローズを要求する.
        ((Stage) ((Button) evt.getSource()).getScene().getWindow()).close();
    }
}

コントローラにするクラスには、フィールドまたはメソッドに対して@FXMLアノテーションをつけたものが、FXMLLoaderによってJavaFXコンポーネントと結び付けられる。

また、Initializableインターフェイスを実装する場合、
各種フィールドやメソッドがコンポーネントと関連づけられたあとに、initializeメソッドが呼び出されるので、ここで追加の初期化処理を行うことができる。

ここでは、2つのテキストフィールドに何らかの文字が入力されていないかぎりOKボタンを有効としないような活性制御のバインドを設定している。
(txtDirが空か、txtNamePatternが空の場合に、btnOKのdisableプロパティをTrueに設定するバインドを設定している。)


なお、onCancelメソッドでは、ウィンドウを閉じるために、やや回りくどいことをしているが、
コントローラはどこからも継承していない独立しているクラスなので、あらかじめフィールドにステージの情報を入れておくか、何かしらのUI要素を遡って、現在表示しているウィンドウを取得するなどの方法を取る必要がある。

コントローラクラスの指定方法

コントローラクラスの指定方法には、いくつかあり

  • FXMLLoaderで直接、コントローラのインスタンスを指定する (fx:controller属性は書かない)
  • FXMLコード中のルート要素のfx:controller属性にクラス名を指定する
    • FXMLLoaderでコントローラのファクトリを指定することもできる.
      • SpringなどDIコンテナ管理のコントローラを作りたい場合などは、ここでインジェクションしたあとのインスタンスを渡すなどのファクトリクラスの定義ができる。


今回は、FXMLにはコントローラクラスは記述せず、FXMLLoaderで直接コントローラのインスタンスを指定する方法を用いる。

FXMLのロード

FXMLは以下のようにしてロードすることができる。

/**
 * 簡単なFXMLアプリケーション作成例.
 *
 * @author seraphy
 */
public class SimpleApp extends Application implements Initializable {
....(前略)....

    /**
     * アプリケーション開始
     */
    @Override
    public void start(Stage primaryStage) throws Exception {
        // FXMLをリソースから取得する.
        // (タブオーダーもFXMLの定義順になる.)
        // (FXML中に「@参照」による相対位置指定がある場合は、このURL相対位置となる.)
        URL fxml = getClass().getResource(getClass().getSimpleName() + ".fxml");

        // FXMLをロードする.
        // (ローカライズしない場合はリソースバンドルを指定しなくて良い)
        FXMLLoader ldr = new FXMLLoader(fxml, null);

        // このインスタンス自身をコントローラとする.
        // @FXMLアノテーションによりFXMLと結び付けられる.
        ldr.setController(this);

        // FXMLをロードする.
        Parent root = ldr.load();

        // ステージのタイトル
        primaryStage.setTitle("SimpleApp Sample");

        // ステージの表示
        primaryStage.setScene(new Scene(root));
        primaryStage.show();

        // ディレクトリ選択テキストにフォーカスを当てる
        txtDir.requestFocus();
    }

....(後略)....
}

FXMLLoaderクラスによってFXMLをロードできる。

FXMLファイルの指定方法にはURLによる方法とストリームによる方法などがあるが、基本はURLによる指定を使うほうが良い。


これはFXML表記で"@picture1.png"のような形式でFXMLファイルからの相対位置でリソースのありかを指定する表記方法があるが、
ストリームでFXMLを読み込ませる場合には、その位置の指定が取れなくなるためである。


FXMLをロードしフィールドやメソッドは、setControllerメソッドに対してthisを渡すことで明示している。

これにより、このクラス自身がコントローラを兼ねることになる。


あとは、これをloadすると、FXMLの最初の要素である"VBox"のインスタンスが得られるので、
これをSceneオブジェクトに入れて、それをStageにセットすることで、画面上に表示できるようになる。


※なお、FXMLLoaderはオブジェクトツリーの汎用ファクトリであり、任意の独自のクラスや、あるいはStageそのものをルートとしてオブジェクトツリーを作成することも可能である。
(ただし、その場合はSceneBuilderでは編集できなくなるので、実用性は少ないかもしれない。)

完全なソースコード
package jp.seraphyware.simpleapp1;

import java.io.File;
import java.net.URL;
import java.util.ResourceBundle;

import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.TextField;
import javafx.stage.DirectoryChooser;
import javafx.stage.Stage;
import jp.seraphyware.utils.ErrorDialogUtils;

/**
 * 簡単なFXMLアプリケーション作成例.
 *
 * @author seraphy
 */
public class SimpleApp extends Application implements Initializable {

    @FXML
    private TextField txtDir;

    @FXML
    private TextField txtNamePattern;

    @FXML
    private CheckBox chkSubdir;

    @FXML
    private Button btnOK;

    /**
     * ディレクトリ選択ダイアログ
     */
    private DirectoryChooser dirChooser = new DirectoryChooser();

    /**
     * アプリケーション開始
     */
    @Override
    public void start(Stage primaryStage) throws Exception {
        // FXMLをリソースから取得する.
        // (タブオーダーもFXMLの定義順になる.)
        // (FXML中に「@参照」による相対位置指定がある場合は、このURL相対位置となる.)
        URL fxml = getClass().getResource(getClass().getSimpleName() + ".fxml");

        // FXMLをロードする.
        // (ローカライズしない場合はリソースバンドルを指定しなくて良い)
        FXMLLoader ldr = new FXMLLoader(fxml, null);

        // このインスタンス自身をコントローラとする.
        // @FXMLアノテーションによりFXMLと結び付けられる.
        ldr.setController(this);

        // FXMLをロードする.
        Parent root = ldr.load();

        // ステージのタイトル
        primaryStage.setTitle("SimpleApp Sample");

        // ステージの表示
        primaryStage.setScene(new Scene(root));
        primaryStage.show();

        // ディレクトリ選択テキストにフォーカスを当てる
        txtDir.requestFocus();
    }

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        // ダイアログのタイトルの設定
        dirChooser.setTitle("フォルダの選択");

        // OKボタンの活性制御
        btnOK.disableProperty().bind(
                Bindings.or(
                        txtDir.textProperty().isEmpty(),
                        txtNamePattern.textProperty().isEmpty()
                        ));
    }

    @FXML
    protected void onBrowse(ActionEvent evt) {
        try {
            String srcDir = txtDir.textProperty().get();
            if (srcDir != null && !srcDir.isEmpty()) {
                File initDir = new File(srcDir);
                if (initDir.isDirectory()) {
                    dirChooser.setInitialDirectory(initDir);
                }
            }
            File selectedDir = dirChooser.showDialog(((Button) evt.getSource())
                    .getScene().getWindow());
            if (selectedDir != null) {
                txtDir.setText(selectedDir.getAbsolutePath());
                txtDir.requestFocus();
            }

        } catch (Exception ex) {
            ErrorDialogUtils.showException(ex);
        }
    }

    @FXML
    protected void onOK(ActionEvent evt) {
        Alert alert = new Alert(AlertType.INFORMATION);
        alert.setHeaderText("実行!");

        StringBuilder buf = new StringBuilder();
        buf.append("dir=").append(txtDir.getText()).append("\r\n")
                .append("namePattern=").append(txtNamePattern.getText())
                .append("\r\n").append("subdir=")
                .append(chkSubdir.isSelected() ? "Yes" : "No");
        alert.setContentText(buf.toString());

        alert.showAndWait();
    }

    @FXML
    protected void onCancel(ActionEvent evt) {
        // 現在ボタンを表示しているシーンを表示しているステージに対して
        // クローズを要求する.
        ((Stage) ((Button) evt.getSource()).getScene().getWindow()).close();
    }

    /**
     * エントリポイント
     * @param args
     * @throws Exception
     */
    public static void main(String... args) throws Exception {
        launch(args);
    }
}

このように、単純なアプリであればコントローラ兼メインクラスとなるクラス1つと、レイアウトを定めているFXMLリソースが1つあれば簡単に作ることができる。

FXMLファイルの中身もわかってしまえば簡単である。


JavaFXでやってみると、同等な画面をSwingで手作業で作るよりも簡単であることが分かった。

リソースバンドルを使った簡単なアプリの作成例

JavaFXアプリケーションをローカライズする場合にはリソースバンドルを指定することで簡単に対応できるようになっている。

FXML上に文字列リテラルを書く代わりに、"%resource.key"のような形式とすることで、リソースバンドルより自動的に文字列を引き当てることができるのである。

文字列リテラルからリソースキーに変更したFXML
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import java.lang.*?>
<?import javafx.scene.layout.*?>

<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
   <children>
      <GridPane hgap="3.0" vgap="3.0">
        <columnConstraints>
          <ColumnConstraints hgrow="SOMETIMES" percentWidth="0.0" />
          <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
            <ColumnConstraints hgrow="SOMETIMES" percentWidth="0.0" />
        </columnConstraints>
        <rowConstraints>
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
        </rowConstraints>
         <children>
            <Label text="%directory" GridPane.halignment="RIGHT">
               <GridPane.margin>
                  <Insets right="5.0" />
               </GridPane.margin>
            </Label>
            <Label text="%namePattern" GridPane.halignment="RIGHT" GridPane.rowIndex="1">
               <GridPane.margin>
                  <Insets right="5.0" />
               </GridPane.margin>
            </Label>
            <TextField fx:id="txtDir" GridPane.columnIndex="1" />
            <Button fx:id="btnBrowse" mnemonicParsing="false" onAction="#onBrowse" text="%browse" GridPane.columnIndex="2" />
            <TextField fx:id="txtNamePattern" text="*.*" GridPane.columnIndex="1" GridPane.rowIndex="1" />
            <CheckBox fx:id="chkSubdir" mnemonicParsing="false" text="%includeSubDirs" GridPane.columnIndex="1" GridPane.rowIndex="2" />
         </children>
      </GridPane>
      <FlowPane alignment="TOP_RIGHT" hgap="5.0" vgap="5.0">
         <children>
            <Button fx:id="btnOK" defaultButton="true" mnemonicParsing="false" onAction="#onOK" text="%ok" />
            <Button cancelButton="true" mnemonicParsing="false" onAction="#onCancel" text="%cancel" />
         </children>
         <padding>
            <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
         </padding>
      </FlowPane>
   </children>
   <padding>
      <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
   </padding>
</VBox>


これは前述のFXMLから文字列リテラルを全て%resource.keyの形式に置き換えただけである。

リソースバンドルの定義

リソースバンドルには、ここではXML形式のリソースバンドルを使用するものとする。

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>SimpleApp Resource</comment>
<entry key="title">SimpleApp サンプル#2</entry>
<entry key="dirChooser.title">フォルダの選択</entry>

<entry key="directory">ディレクトリ</entry>
<entry key="browse">参照</entry>
<entry key="namePattern">ファイル名パターン</entry>
<entry key="includeSubDirs">サブディレクトリを含む</entry>

<entry key="ok">実行</entry>
<entry key="cancel">キャンセル</entry>
</properties>

※ 本サンプルで使っているXMLResourceBundleControlは、XMLプロパティファイル形式をリソースバンドルとして読み込めるようにリソースバンドルコントローラを拡張した実装であるが、本質的に、ただのリソースバンドルでも同じである。
(実装例: https://gist.github.com/seraphy/b421ac03d64d541667b2)

FXMLLoader側の修正

FXMLLoader側は、リソースバンドルを指定すれば良いだけである。

    @Override
    public void start(Stage primaryStage) throws Exception {
        // FXMLをリソースから取得する.
        // (タブオーダーもFXMLの定義順になる.)
        // (FXML中に「@参照」による相対位置指定がある場合は、このURL相対位置となる.)
        URL fxml = getClass().getResource(getClass().getSimpleName() + ".fxml");

        // XMLリソースバンドルを読み込む
        ResourceBundle rb = ResourceBundle.getBundle(getClass().getName(),
                new XMLResourceBundleControl());

        // FXMLをロードする.
        // (ローカライズするのでリソースバンドルも指定する)
        FXMLLoader ldr = new FXMLLoader(fxml, rb);

        // このインスタンス自身をコントローラとする.
        // @FXMLアノテーションによりFXMLと結び付けられる.
        ldr.setController(this);

        // FXMLをロードする.
        Parent root = ldr.load();

        // ステージのタイトル
        primaryStage.setTitle(rb.getString("title"));

        // ステージの表示
        primaryStage.setScene(new Scene(root));
        primaryStage.show();

        // ディレクトリ選択テキストにフォーカスを当てる
        txtDir.requestFocus();
    }

見ての通り、コード側は、ほとんど修正はない。


ただリソースバンドルを読み込むだけで、%resource.keyを取得する部分はFXMLLoaderが全部やってくれている。


JavaFXでやればFXMLのリテラル修正とリソースバンドルの読み込み、これだけでローカライズに対応できるので、
SwingでリソースバンドルのgetStringを延々と手作業で記述するよりもはるかに簡単にできることが分かる。

カスタムコンポーネントの作成

JavaFXではFXMLを使ったカスタムコンポーネントの定義も考慮されている。

ここでは、テキストフィールドと参照ボタンの2つのコンポーネントを組み合わせた「DirTextField」というカスタムコンポーネントを定義してみる。


2つのコンポーネントをもつカスタムコンポーネント用のFXML
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import java.lang.*?>
<?import javafx.scene.layout.*?>

<fx:root type="javafx.scene.layout.BorderPane" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
   <center>
      <TextField fx:id="txtDir" BorderPane.alignment="CENTER">
         <opaqueInsets>
            <Insets />
         </opaqueInsets>
         <BorderPane.margin>
            <Insets right="5.0" />
         </BorderPane.margin></TextField>
   </center>
   <right>
      <Button mnemonicParsing="false" onAction="#onBrowse" text="%browse" BorderPane.alignment="CENTER" />
   </right>
</fx:root>

こちらも、Scene Builder2で編集可能である。


サイズ可変でも対応できるように、BorderPaneでセンターにテキスト、Rightにボタンを配置している。


今までと違うのは、ルート要素が「fx:root」というタグになっており、type属性にBorderPaneのクラスを指定している。

これにより、Scene Builder上では、type属性に示されたBorderPaneであるかのようにふるまう。


この意味するところは、FXMLLoaderによってロードされるときルート自身はnewされずに、親として指定された作成済みインスタンスに対して属性や子アイテムを追加してゆく、ということである。

カスタムコンポーネント用FXMLのロード

このFXMLはルート、すなわち自分自身がBorderPaneを継承するカスタムクラスであることを示しており、以下のようにBorderPaneを継承するカスタムクラスのコンストラクタでロードする形となる。

package jp.seraphyware.simpleapp3;

import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URL;
import java.util.ResourceBundle;

import javafx.beans.property.StringProperty;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.Button;
import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane;
import javafx.stage.DirectoryChooser;
import jp.seraphyware.utils.ErrorDialogUtils;
import jp.seraphyware.utils.XMLResourceBundleControl;

public class DirTextField extends BorderPane {

    @FXML
    private TextField txtDir;

    /**
     * ディレクトリ選択ダイアログ
     */
    private DirectoryChooser dirChooser = new DirectoryChooser();

    /**
     * コンストラクタ
     */
    public DirTextField() {
        URL url = getClass().getResource(getClass().getSimpleName() + ".fxml");
        ResourceBundle rb = ResourceBundle.getBundle(getClass().getName(),
                new XMLResourceBundleControl());
        FXMLLoader ldr = new FXMLLoader(url, rb);

        // このインスタンス自身がルートオブジェクト
        ldr.setRoot(this);

        // このインスタンス自身がコントローラ
        ldr.setController(this);

        try {
            // ルートを指定済みなので、このインスタンスにFXMLがロードされる.
            ldr.load();

        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }

        // ダイアログのタイトル設定
        dirChooser.setTitle(rb.getString("dirChooser.title"));

        // フォーカスリスナ
        focusedProperty().addListener((o, old, newval) -> {
            if (newval) {
                txtDir.requestFocus();
            }
        });
    }

    @FXML
    protected void onBrowse(ActionEvent evt) {
        try {
            String srcDir = txtDir.textProperty().get();
            if (srcDir != null && !srcDir.isEmpty()) {
                File initDir = new File(srcDir);
                if (initDir.isDirectory()) {
                    dirChooser.setInitialDirectory(initDir);
                }
            }
            File selectedDir = dirChooser.showDialog(((Button) evt.getSource())
                    .getScene().getWindow());
            if (selectedDir != null) {
                txtDir.setText(selectedDir.getAbsolutePath());
                txtDir.requestFocus();
            }

        } catch (Exception ex) {
            ErrorDialogUtils.showException(ex);
        }
    }

    public StringProperty textProperty() {
        return txtDir.textProperty();
    }

    public String getText() {
        return textProperty().get();
    }

    public void setText(String text) {
        textProperty().set(text);
    }
}

自分自身がコントローラであることは先の例と同様である。


今回は、それに加えてルートが自分自身であることを示すためにFXMLLoader#setRoot()で自分自身をルートであるように指定する。


また、このクラス専用のリソースバンドルを読み込んでいる。


rootがthisとなっていることにより、loadメソッドによって自分自身に設定することになるため、loadメソッドの戻り値は無視して構わない。

textPropertyなどのプロパティは、このカスタムコンポーネントの持つ値を外部からアクセス可能にするためのものである。

カスタムコンポーネントの利用

カスタムコンポーネントの利用は、FXML上で、カスタムクラス名を指定すれば良いだけである。

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import java.lang.*?>
<?import javafx.scene.layout.*?>
<?import jp.seraphyware.simpleapp3.*?>

<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
   <children>
      <GridPane hgap="3.0" vgap="3.0">
        <columnConstraints>
          <ColumnConstraints hgrow="SOMETIMES" percentWidth="0.0" />
          <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
            <ColumnConstraints hgrow="SOMETIMES" percentWidth="0.0" />
        </columnConstraints>
        <rowConstraints>
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
        </rowConstraints>
         <children>
            <Label text="%directory" GridPane.halignment="RIGHT">
               <GridPane.margin>
                  <Insets right="5.0" />
               </GridPane.margin>
            </Label>
            <Label text="%namePattern" GridPane.halignment="RIGHT" GridPane.rowIndex="1">
               <GridPane.margin>
                  <Insets right="5.0" />
               </GridPane.margin>
            </Label>
            <DirTextField fx:id="dirTextField" text="C:\" GridPane.columnIndex="1" />
            <TextField fx:id="txtNamePattern" text="*.*" GridPane.columnIndex="1" GridPane.rowIndex="1" />
            <CheckBox fx:id="chkSubdir" mnemonicParsing="false" text="%includeSubDirs" GridPane.columnIndex="1" GridPane.rowIndex="2" />
         </children>
      </GridPane>
      <FlowPane alignment="TOP_RIGHT" hgap="5.0" vgap="5.0">
         <children>
            <Button fx:id="btnOK" defaultButton="true" mnemonicParsing="false" onAction="#onOK" text="%ok" />
            <Button cancelButton="true" mnemonicParsing="false" onAction="#onCancel" text="%cancel" />
         </children>
         <padding>
            <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
         </padding>
      </FlowPane>
   </children>
   <padding>
      <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
   </padding>
</VBox>

この中にある「DirTextField」というタグがカスタムコンポーネントのクラス名になる。

★ちなみに、Scene Builderでは、カスタムコンポーネントは、コンポーネントペインの歯車アイコンから「Import JAR/FXML File...」を選択し、そのFXMLを内包するjarクラスをインポートすることにより、左ペインに「カスタム」の一覧にカスタムコンポーネントが使えるようになり、グラフィカルエディタ上でもコンポーネントを認識するようになる。

しかし、とくにカスタムコンポーネントをインポートしない場合でも、SceneBuilderでは得体の知らないクラスとして画面上では穴が開いた状態になり、グラフィカルな画面からの操作は不可能ではあるが、とくにエラーになるわけではない。

ここでは手抜きのため、SceneBuilderにはインポートしないものとする。

※ なお、SceneBuilder2でインポートしたカスタムコンポーネントを消すには、現状、消すコマンドがないので、%APPDATA%\Scene Builder\Library上にあるFXMLファイルかJARファイルを手動で削除する必要がある。


「fx:id」で名前を「dirTextField」と指定しているので、コントローラ側では以下のように、カスタムクラスのフィールド名で受けることができる。

    @FXML
    private DirTextField dirTextField;
実行例

カスタムコンポーネントのFXMLによる定義方法も利用方法も簡単である。


また、このカスタムコントロールの手法は、FXMLにカスタムコントロールを直接埋め込む場合だけでなく、VBoxのようなペインに対して、プログラム的に2個以上連続して作成させるような場合にも、とても有効な手法ではないかと考えられる。

ネストしたコントローラの利用

カスタムコンポーネントに似た別の方法として、ネストしたFXMLの利用がある。

ネストしたFXMLとは、FXMLの中で別のFXMLをincludeするものである。


includeといってもC言語プリプロセッサ的な外部ファイルを流し込むだけの単純なincludeではなく、外部の独立したコントローラを生成して外部のFXMLを親のFXMLに埋め込むものである。


親子関係をもった状態で作成されるため、実質的に、カスタムコントロールとほとんど同じような感じとなる。

FXMLの定義

FXMLの定義は前述のカスタムコントロールの例をモデルとするが、異なるのはルート要素が「fx:root」ではなく、普通のFXMLと同様に明示的に「BorderPane」のようなクラス指定になっている、ということである。


これの意味するところは、FXMLLoaderによって実際にBorderPaneがnewされる、ということである。

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import java.lang.*?>
<?import javafx.scene.layout.*?>

<BorderPane fx:id="root" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1"
	fx:controller="jp.seraphyware.simpleapp4.DirTextFieldController">
   <center>
      <TextField fx:id="txtDir" BorderPane.alignment="CENTER">
         <opaqueInsets>
            <Insets />
         </opaqueInsets>
         <BorderPane.margin>
            <Insets right="5.0" />
         </BorderPane.margin></TextField>
   </center>
   <right>
      <Button mnemonicParsing="false" onAction="#onBrowse" text="%browse" BorderPane.alignment="CENTER" />
   </right>
</BorderPane>

もう一つ、重要な点は、ルート要素に「fx:controller」による、コントローラクラスの指定がある点である。


これにより、このFXMLを構築するときに自動的に"jp.seraphyware.simpleapp4.DirTextFieldController"クラスがデフォルトコンストラクタでインスタンス化され、コントローラとして使用される。

(※ FXMLLoaderでコントローラのファクトリを指定している場合は、インスタンス取得のためにファクトリによってコントローラのインスタンスを取得する。)

コントローラの定義

FXMLの受けるコントローラは以下のように定義する。

package jp.seraphyware.simpleapp4;

import java.io.File;
import java.net.URL;
import java.util.ResourceBundle;

import javafx.beans.property.StringProperty;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane;
import javafx.stage.DirectoryChooser;
import jp.seraphyware.utils.ErrorDialogUtils;

public class DirTextFieldController implements Initializable {

    @FXML
    private BorderPane root;

    @FXML
    private TextField txtDir;

    /**
     * ディレクトリ選択ダイアログ
     */
    private DirectoryChooser dirChooser = new DirectoryChooser();

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        // ダイアログのタイトル設定
        dirChooser.setTitle(resources.getString("dirChooser.title"));

        // フォーカスリスナ
        root.focusedProperty().addListener((o, old, newval) -> {
            if (newval) {
                txtDir.requestFocus();
            }
        });
    }

    @FXML
    protected void onBrowse(ActionEvent evt) {
        try {
            String srcDir = txtDir.textProperty().get();
            if (srcDir != null && !srcDir.isEmpty()) {
                File initDir = new File(srcDir);
                if (initDir.isDirectory()) {
                    dirChooser.setInitialDirectory(initDir);
                }
            }
            File selectedDir = dirChooser.showDialog(((Button) evt.getSource())
                    .getScene().getWindow());
            if (selectedDir != null) {
                txtDir.setText(selectedDir.getAbsolutePath());
                txtDir.requestFocus();
            }

        } catch (Exception ex) {
            ErrorDialogUtils.showException(ex);
        }
    }

    public StringProperty textProperty() {
        return txtDir.textProperty();
    }

    public String getText() {
        return textProperty().get();
    }

    public void setText(String text) {
        textProperty().set(text);
    }
}

実質的に、カスタムコントローラの場合と、ほとんど同じである。


なお、"fx:id"属性を使って、BorderPane自身を"root"という名前にしてコントローラのフィールドに割り当てているので、initializeによる初期化時にルートに対する操作も可能としている。

(この方法はネストしたコントローラのケースに限らず、FXML全般で使える。)


なお、initializeメソッドで受け取るリソースバンドルは、fx:includeタグのresources属性で指定可能であり、省略した場合は親のリソースバンドルが、そのまま子にも渡ってくる。

ただし、includeタグで指定したリソースの場合は内部的には

resources = ResourceBundle.getBundle(childResourceName, Locale.getDefault(),
    parentResources.getClass().getClassLoader());

と同等な処理で取得されるため、取得方法が柔軟ではないことに注意が必要である。

親のFXML

ネストしたコントローラを使うFXMLは、単に「fx:include」を使うだけである。

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import java.lang.*?>
<?import javafx.scene.layout.*?>

<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
   <children>
      <GridPane hgap="3.0" vgap="3.0">
        <columnConstraints>
          <ColumnConstraints hgrow="SOMETIMES" percentWidth="0.0" />
          <ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
            <ColumnConstraints hgrow="SOMETIMES" percentWidth="0.0" />
        </columnConstraints>
        <rowConstraints>
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
          <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
        </rowConstraints>
         <children>
            <Label text="%directory" GridPane.halignment="RIGHT">
               <GridPane.margin>
                  <Insets right="5.0" />
               </GridPane.margin>
            </Label>
            <Label text="%namePattern" GridPane.halignment="RIGHT" GridPane.rowIndex="1">
               <GridPane.margin>
                  <Insets right="5.0" />
               </GridPane.margin>
            </Label>
            <fx:include fx:id="dirTextField" source="DirTextField.fxml" GridPane.columnIndex="1" />
            <TextField fx:id="txtNamePattern" text="*.*" GridPane.columnIndex="1" GridPane.rowIndex="1" />
            <CheckBox fx:id="chkSubdir" mnemonicParsing="false" text="%includeSubDirs" GridPane.columnIndex="1" GridPane.rowIndex="2" />
         </children>
      </GridPane>
      <FlowPane alignment="TOP_RIGHT" hgap="5.0" vgap="5.0">
         <children>
            <Button fx:id="btnOK" defaultButton="true" mnemonicParsing="false" onAction="#onOK" text="%ok" />
            <Button cancelButton="true" mnemonicParsing="false" onAction="#onCancel" text="%cancel" />
         </children>
         <padding>
            <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
         </padding>
      </FlowPane>
   </children>
   <padding>
      <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
   </padding>
</VBox>

この中の「」が、ネストしたコントローラである。

カスタムコントロールとほとんど同じであるが、SceneBuilder2で開くと、インポートなどの事前処理をせずとも普通にFXMLを読み込めるので、それがBorderPaneであることがわかるようになっている。


(なお、現状ではSceneBuilder2からはfx:includeを扱うのはめんどくさいので、FXMLを手作業で直したほうが簡単である。)

親コントローラ

親コントローラでは、子コントローラを受け取るために以下のような記述ができる。

    /**
     * 入れ子になったFXMLのルートがパイントされる.
     * フィールド名は子FXMLのidと同じでなければならない.
     */
    @FXML
    private BorderPane dirTextField;

    /**
     * 入れ子になったFXMLでインスタンス化されたコントローラがバインドされる
     * フィールド名は"子FXMLのid + Controller"でなければならない.
     */
    @FXML
    private DirTextFieldController dirTextFieldController;

"ネストしたコントローラのid"と同じフィールド名によって、そのコンポーネントを得ることができ、
"ネストしたコントローラのid + Controller"というフィールド名によって、その子コントローラを得ることができる。


ここでは親FXMLのロード方法は変更する必要はない。

実行結果

実行結果としては、カスタムコンポーネントもネストしたコントローラも同じである。


なので、画面キャプチャは省略する。


カスタムコントロールとネストしたコントローラの使い分けとしては、

ということではないか、と考えられる。

CSSの適用

FXMLはUIコンポーネントの構造を表現しており、スタイルはcssで表現することができるようになっている。

http://docs.oracle.com/javafx/2/api/javafx/scene/doc-files/cssref.html


CSSといっても、FXML自体にレイアウトペイン各種が存在しているため、CSSでレイアウトを決めるということはない。

CSSではフォントや前景色、背景色などの、こまかな調整を行うために使うことができる。


同様なことはFXML上のインラインスタイルでも指定可能であるが、パフォーマンス的にはcssのほうが良いようである。*6



CSSではあるが、記述ルールがCSSというだけであり、HTMLなどで使われるウェブのCSSとは何の関係もない。


すべての属性は「-fx-」のベンダプレフィックスにより始まっており、使われているプロパティ名も、基本的にはJavaFXコンポーネントのプロパティ名を元にしている、とのことである。*7

たとえば、ボタンテキストの前景色(いわゆるフォントの色)を指定するのには「-fx-text-fill」という属性になる。

これはHTMLとは何の関係もなく、textFillプロパティ側からの命名である。


また、FXMLのCSSは、使えるクラス名としては、

  • .root → ルート(すべてに適用)
  • .クラス名を小文字化・ハイフン区切りにしたもの → そのクラスのコンポーネントに適用
    • Buttonクラスならば、.button
    • ToggleButtonクラスならば、.toggle-button
  • FXML側でUI要素ごとに任意のクラス名を追加指定することも可能
  • ボタンなどはhoverなどの擬似セレクタも使用可能

などの既定のルールがある。


使用例

以下のCSSをFXMLと同じディレクトリ上の「SimpleApp5.css」という名前で保存しておく。


また、同じディレクトリ上に背景画像用の「background-image.jpg」を置いておく。*8

.root {
    -fx-background-color: #ffff80;
    -fx-font-size: 12pt;
    -fx-background-image: url("background-image.jpg");
    -fx-background-repeat: stretch;
    -fx-background-size: 800 400;
    -fx-background-position: right bottom;
}

.button {
    -fx-background-color: #8888ff;
    -fx-text-fill: #ffff88;
    -fx-background-insets: 2;
    -fx-background-radius: 10;
    -fx-padding: 3 20 3 20;
}

.text-field {
    -fx-background-insets: 3;
    -fx-background-color: #eeeeee;
}

.label {
    -fx-text-fill: black;
}

.button:hover {
    -fx-background-color: #80f0ff;
}


FXML側では"@"による相対位置指定で、このCSSリソースを相対参照するように設定する。

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import java.lang.*?>
<?import javafx.scene.layout.*?>

<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity"
	xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1"
	stylesheets="@SimpleApp5.css">
...(中略)...
</VBox>

内部でfx:includeされているネストされたコントローラは暗黙で親のCSSを引き継ぐので、同じCSSが適用される。


CSSはプログラム的に切り替えることも可能である。

実行結果

このようにCSSによってレイアウトや見た目の細かな調整ができるようになっている。


これらについてはコードは不要であり、デザインとロジックが分離されているため、コードで書くよりも何をしているのか、あとから見てもわかりやすいといえる。


※ 問題は、cssで使える属性が何か?を把握することであるが、Java Day Tokyo 2015で聞いてきたテクニックとして、
Scene BuilderのViewメニューから「Show CSS Analyzer」を選択すると、適用可能な属性一覧と、現在選択している要素の属性のソースがどこからきているか、などをチェックできるので、これを使うと便利とのことである。
(ただし、Scene Builder側からはcssを編集することはできない。確認には、そこそこ便利に使える。)


Antによる実行可能jarの作成と、JavaFXのランタイムを含む実行イメージの作成方法

Java8には"ant-javafx.jar"というant用のプラグインが同梱されており、
JavaFXをjar化するためのタスクを使えるようになっている。

実行可能jarの作成

JavaFX8は通常の実行可能jarとしてビルドできるが、せっかくなので、ant-javafxプラグインを使ってビルドしてみる。


以下、build.xmlを示す。

<?xml version="1.0" encoding="UTF-8"?>
<project name="project" default="default" basedir="."
    xmlns:fx="javafx:com.sun.javafx.tools.ant">

    <!-- ビルド情報 -->
    <description>SimpleApp5のビルド</description>
    <property name="appName" value="SimpleApp5"/>
    <property name="mainClass" value="jp.seraphyware.simpleapp5.SimpleApp5"/>
    <property name="vendorName" value="seraphyware.jp"/>

    <!-- Jdk8のjavapackagerのantタスクを定義する -->
    <taskdef resource="com/sun/javafx/tools/ant/antlib.xml"
        uri="javafx:com.sun.javafx.tools.ant"
        classpath=".:${java.home}/../lib/ant-javafx.jar" />

    <!-- アプリケーション情報 -->
    <fx:application
        id="appid"
        name="${appName}"
        mainClass="${mainClass}"/>

    <!-- 実行可能jarとしてビルドする -->
    <target name="default" description="ビルドと実行可能jarの作成">
        <delete dir="work"/>
        <mkdir dir="work"/>
        <javac srcdir="src" destdir="work"
            includeantruntime="false">
            <classpath>
                <pathelement path="work"/>
            </classpath>
        </javac>

        <fx:jar destfile="${appName}">
            <fx:application refid="appid"/>

            <fileset dir="work"/>
            <fileset dir="src" excludes="**/*.java"/>

            <manifest>
                <attribute name="Implementation-Vendor" value="${vendorName}"/>
                <attribute name="Implementation-Version" value="1.0"/>
            </manifest>
        </fx:jar>
        <delete dir="work"/>
    </target>
</project>

このant-javafx.jarは、JDK8の中に含まれているが、Antから暗黙では参照できないので「${java.home}/../lib/ant-javafx.jar」のように指定している。

JREを含む実行可能イメージの作成

このant-javafxプラグインには他にも様々な機能があり、JREを含む"自己完結型アプリケーションのパッケージ化"が可能になっている。

たとえば、fx:deployタスクを使い、nativeBundler="all"と設定すると、

が作成される。


※ それぞれの環境でビルドする必要がある。(dmg,exe,debが欲しいならば3つのosでビルドしなければならない。)


以下はJREを含む実行可能イメージの作成用スクリプトである。

    <!-- バンドルの作成 -->
    <target name="makeBundles" depends="default"
        description="自己完結型アプリケーションのパッケージ化">
        <delete dir="dist"/>
        <mkdir dir="dist"/>

        <!-- JREを含む実行イメージの作成.(imageタイプ)

         nativeBundles="all"にすると、現在のビルド時の環境で作成できる、
         すべてのタイプのインストーラを含む配布パッケージを作成する.

         * WindowsならInnoSetupがあればexe形式、WiXがあればmsi形式を作成する.
         * Macの場合はdmgとpkgを作成する.
         * Linuxの場合はdebとrpmを作成する.
         -->
        <fx:deploy
            width="800"
            height="600"
            nativeBundles="image"
            outdir="dist"
            outfile="${appName}">

            <fx:application refid="appid"/>

            <fx:resources>
                <fx:fileset dir="." includes="*.jar"/>
            </fx:resources>

            <fx:info
                title="${appName}"
                vendor="${vendorName}"/>
        </fx:deploy>
    </target>

これをWindowsで実行すると、以下のようなフォルダが得られる。

dist
└─bundles
    └─SimpleApp5
        │  msvcp100.dll
        │  msvcr100.dll
        │  packager.dll
        │  SimpleApp5.exe
        │  SimpleApp5.ico
        │  
        ├─app
        │      SimpleApp5.cfg
        │      SimpleApp5.jar
        │      
        └─runtime
            │  COPYRIGHT
            │  LICENSE
            │  README.txt
            │  THIRDPARTYLICENSEREADME-JAVAFX.txt
            │  THIRDPARTYLICENSEREADME.txt
            │  Welcome.html
            │  
            ├─bin
            │  │  attach.dll
            │  │  awt.dll
            │  │  bci.dll
            │  │  dcpr.dll
            │  │  decora_sse.dll
            │  │  deploy.dll
            │  │  dt_shmem.dll
            │  │  dt_socket.dll
            │  │  fontmanager.dll
            │  │  fxplugins.dll
            │  │  glass.dll
            │  │  glib-lite.dll
            │  │  gstreamer-lite.dll
            │  │  hprof.dll
            │  │  instrument.dll
            │  │  j2pcsc.dll
            │  │  j2pkcs11.dll
            │  │  jaas_nt.dll
            │  │  java.dll
            │  │  JavaAccessBridge-64.dll
            │  │  javafx_font.dll
            │  │  javafx_font_t2k.dll
            │  │  javafx_iio.dll
            │  │  java_crw_demo.dll
            │  │  jawt.dll
            │  │  JAWTAccessBridge-64.dll
            │  │  jdwp.dll
            │  │  jfr.dll
            │  │  jfxmedia.dll
            │  │  jfxwebkit.dll
            │  │  jli.dll
            │  │  jpeg.dll
            │  │  jsdt.dll
            │  │  jsound.dll
            │  │  jsoundds.dll
            │  │  kcms.dll
            │  │  lcms.dll
            │  │  management.dll
            │  │  mlib_image.dll
            │  │  msvcr100.dll
            │  │  net.dll
            │  │  nio.dll
            │  │  npt.dll
            │  │  prism_common.dll
            │  │  prism_d3d.dll
            │  │  prism_es2.dll
            │  │  prism_sw.dll
            │  │  resource.dll
            │  │  sawindbg.dll
            │  │  splashscreen.dll
            │  │  sunec.dll
            │  │  sunmscapi.dll
            │  │  t2k.dll
            │  │  unpack.dll
            │  │  verify.dll
            │  │  w2k_lsa_auth.dll
            │  │  WindowsAccessBridge-64.dll
            │  │  zip.dll
            │  │  
            │  ├─plugin2
            │  │      msvcr100.dll
            │  │      npjp2.dll
            │  │      
            │  └─server
            │          classes.jsa
            │          jvm.dll
            │          Xusage.txt
            │          
            └─lib
                │  accessibility.properties
                │  calendars.properties
                │  charsets.jar
                │  classlist
                │  content-types.properties
                │  currency.data
                │  flavormap.properties
                │  fontconfig.bfc
                │  fontconfig.properties.src
                │  hijrah-config-umalqura.properties
                │  javafx.properties
                │  javaws.jar
                │  jce.jar
                │  jfr.jar
                │  jfxswt.jar
                │  jsse.jar
                │  jvm.hprof.txt
                │  logging.properties
                │  management-agent.jar
                │  meta-index
                │  net.properties
                │  plugin.jar
                │  psfont.properties.ja
                │  psfontj2d.properties
                │  resources.jar
                │  rt.jar
                │  sound.properties
                │  tzdb.dat
                │  tzmappings
                │  
                ├─amd64
                │      jvm.cfg
                │      
                ├─cmm
                │      CIEXYZ.pf
                │      GRAY.pf
                │      LINEAR_RGB.pf
                │      PYCC.pf
                │      sRGB.pf
                │      
                ├─ext
                │      access-bridge-64.jar
                │      cldrdata.jar
                │      dnsns.jar
                │      jaccess.jar
                │      jfxrt.jar
                │      localedata.jar
                │      meta-index
                │      nashorn.jar
                │      sunec.jar
                │      sunjce_provider.jar
                │      sunmscapi.jar
                │      sunpkcs11.jar
                │      zipfs.jar
                │      
                ├─fonts
                │      LucidaBrightDemiBold.ttf
                │      LucidaBrightDemiItalic.ttf
                │      LucidaBrightItalic.ttf
                │      LucidaBrightRegular.ttf
                │      LucidaSansDemiBold.ttf
                │      LucidaSansRegular.ttf
                │      LucidaTypewriterBold.ttf
                │      LucidaTypewriterRegular.ttf
                │      
                ├─images
                │  └─cursors
                │          cursors.properties
                │          invalid32x32.gif
                │          win32_CopyDrop32x32.gif
                │          win32_CopyNoDrop32x32.gif
                │          win32_LinkDrop32x32.gif
                │          win32_LinkNoDrop32x32.gif
                │          win32_MoveDrop32x32.gif
                │          win32_MoveNoDrop32x32.gif
                │          
                ├─jfr
                │      default.jfc
                │      profile.jfc
                │      
                ├─management
                │      jmxremote.access
                │      jmxremote.password.template
                │      management.properties
                │      snmp.acl.template
                │      
                └─security
                        blacklist
                        blacklisted.certs
                        cacerts
                        java.policy
                        java.security
                        javaws.policy
                        local_policy.jar
                        trusted.libraries
                        US_export_policy.jar

SimpleApp5.exeという、本物の実行可能ファイルが作成されており、
従来であれば、launch4jなどのサードパーティのツールを使ってexe化し配布用パッケージを作成していたものが、標準の方法だけでexe化することができる。

また、これは配下のruntime下にコピーされているJREのサブセットを利用して実行されるように設定されている。


したがって、この「dist\bundles\SimapleApp5」以下のフォルダごと圧縮して他のマシンに持って行けば、マシンに既にJava8u40がインストールされているか問わず、そのまま利用可能となる。


※ なお、これはJREをまるごとコピーしたものではなく、ファイルをみると実行に必要のないツール類(*.exe)は含まれていない。

※ なお、JNLPを使ったウェブ配備用のファイルも作成されるが、今回は使う予定はないので無視している*9


これはWindowsの例であるが、同様にMacで実行すると、やはりMacで実行するためのアプリケーションバンドルが作成される。

とくにMacの場合はアプリケーションバンドルにすることでスクリーンメニュー上のアプリケーション名も、きちんと設定されるようになる、という重要なメリットがある。

結論

使ってみたところ、とくには嵌る点もなく、案外使いやすい。

複雑なものについては、まだ試していないので分からないのだが、簡単に使う分には、気軽に使える程度のものであることは分かった。


Scene Builderによるグラフィカルな編集ができるのは、やはり大きなメリットだと思う。


また、Scene Builderを使わずとも、FXML自体もわかりやすいので、パッと見て、どんな画面になるのか想像できる。

Swingのようにコードを読みながら画面がどう構成されるのかを考える難しさは軽減されており、レイアウトまわりの見通しが大変良いため、画面改修時の保守性が高いことは間違いないと思われる。


なので、とりあえず、今後はJavaFXの習得を兼ねて、簡単なところからJavaFXを使ってみたいと思う。


今回のコードは、GitHubに置いてあります。

今までに判明している注意点
  1. JavaFX8に対応していない環境だと画面が激しく乱れる。(サポートされていないグラボがあるらしい?)
    • MacBook2007LateのLion上に入れたJdk8で発生。Sceneがぐちゃぐちゃになり何も見えない。
  2. 動きがおかしなところがあったりする。たとえば、とても長いテーブルのホイールスクロールとか、かなり怪しい。(使い方が悪いのかも?)
  3. JavaFX標準のチャートのカスタマイズは大変難しい。(cssを使わざるを得ず、且つ、それが大変めんどくさい。)
  4. JogAmpや、ImageIOといった類がSwingやBufferedImageを想定しており、JavaFXでは直接使えないので、いろいろ苦労しそう。
  5. バージョンによって動きが変わることがある。バグが結構残存している感があり、updateのたびに改善されているようだ。

*1:http://www.oracle.com/technetwork/jp/java/javafx/overview/faq-1446554-ja.html#6

*2:JDT2015の櫻庭さんのセッションによると http://gluonhq.com/gluon-supports-scene-builder/ からビルド済みのものが取得できるらしい?

*3:JDT2015の櫻庭さんのセッションによると、Eclipse NetBeans等があればSceneBuilderはビルドできるらしい。

*4:初期化のためにをオーバーライドできる。

*5:自分でソースからビルドしたScene Builderの場合は連携がうまくゆかない場合もあるらしい。

*6:JDT2015で聞いてきたところによると

*7:JDT2015で聞いてきたところによると

*8:画像は、こちらのフリー素材からいただいたものをサイズ加工しています。 http://flowerillust.com/html/orange/orange1001.html

*9:いらないので作成したくないのだが、どうやっても必ず作成されるようだ。