seraphyの日記

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

オンメモリのResultSetとしてJDBCのCachedRowSetを使う方法と、そのリファレンス実装の注意点

CachedRowSetとは?

CachedRowSetとは、データベースから取得したResultSetをメモリ上に保持する形にしてオフラインでも扱えるようにできるJDBC標準の仕組みである。


Windowsでいうところの旧ADOのRecordsetオブジェクト、あるいはADO.NETのDataTableのようなものと考えてよい。


Javaには多数のデータベースアクセス手段が用意されているが、
ResultSetの結果をメモリに保持しておきたい」という簡単な使い方ならCachedRowSetが適役である。


以下、このCachedRowSetと、その派生であるWebRowSetについての使い方をまとめる。

CahcedRowSetの使い方

CachedRowSetのインスタンスの取得方法(Java7以降)

RowSetファミリを作成するにはRowSetFactoryインタフェースを使う。

このRowSetFactoryインタフェースは、RowSetProviderクラスnewFactoryメソッドによって取得する。


ファクトリクラスが得られたら、以下のようにして、空のCachedRowSetを生成することができる。

    RowSetFactory rowSetFactory = RowSetProvider.newFactory();
    CachedRowSet rowSet = rowSetFactory.createCachedRowSet();


なお、RowSetFactoryは、システムプロパティもしくはサービスローダーの仕組みによって任意のRowSetFactoryの実装を切り替えることができるようになっている。

特に設定しなければ、Java7の標準では、"com.sun.rowset.RowSetFactoryImpl"が使われるようになっている。

この既定のRowSetFactoryによって作成されたCachedRowSetの実体クラスは、Java6以前から使われていたリファレンス実装である「com.sun.rowset.CachedRowSetImpl」と同じクラスが用いられるようになっている。

リフレクションによるリファレンス実装のインスタンス

CachedRowSetそのものは、JDK1.4時代より存在していたが、当時定義されていたのはインタフェースのみであった。

ただし、リファレンス実装としてcom.sun.rowset.*のパッケージはJREに同梱されており、CachedRowSetのJavadocでも、明示的にリファレンス実装のクラスを指定してインスタンス化することで使用できることが説明されている。

よって、Java6以前であれば、以下のようにリフレクションを通じてインスタンス化することができる。

    CachedRowSet rowSet;
    try {
        rowSet = (CachedRowSet) Class.forName("com.sun.rowset.CachedRowSetImpl").newInstance();
    } catch (Exception ex) {
        throw new RuntimeException(ex);
    }

CachedRowSetへのレコードデータの設定

CachedRowSetには、おもに2つの使い方がある。

  1. コネクションを指定して、それ自身にデータベースの読み書きをさせる方法
  2. ResultSetを受け取ってデータをコピーする方法

オフライン/オンメモリのResultSetとして使用する場合には後者の方法が適している。

データベースへ直接の読み込みする方法

CachedRowSetは「コネクション等を指定してCachedRowSetのexecuteメソッドでデータベースから直接に行セットを取得し、オフラインでRowSetを編集した後に、acceptChangedメソッドでデータベースに反映させる」という一連の動きをサポートしている。


また、このとき、データベースごとに読み書きの方法を調整するための「SyncProvider」というプラグイン的に設定できる仕組みが用意されている。


一見便利そうに見えるが、複雑な処理を1命令でこなすという性質の上に、データベースごとに異なるであろう差異についての説明がJavadocのドキュメントも十分ではないため、実際にどのような動きになっているのか内部処理を理解するのは難しく、実際にエラーメッセージを見ても不可解なものとなって原因追究が難しく、この便利機能をプログラマが十分にコントロールするのは難しく思える。

よって、私は、これを断じて使うべきではないと思う。

(データベースベンダーが固有のRowSetの実装を提供し、動作が明確にされていれば便利だとは思う。)


オフライン/オンメモリのResultSetとして使う分には、これらは使う必要はなく、従って、これらの便利機能を使おうと思わないければ、これに付随する複雑な知識も、まったく必要ない。

ResultSetをpopulateする方法

こちらがお勧めの方法である。

CachedRowSetを単純にオフラインのResultSetとしてのみ使うことを前提とするならば、CachedRowSet#populate(ResultSet rs)メソッドを使用すればよい。


これは引数に指定されたResultSetの中身を一括してCachedRowSet内に転送するものである。

転送されたあとは、もととなったResultSetやコネクションをクローズした状態で、オフライン化し使うことができるようになる。


CachedRowSet自身はResultSetインタフェースを実装しているため、いままでResultSetを扱ってきた場所なら、そのままCachedRowSetに差し替えてもインタフェースは適合し、そのまま動作すると期待できる。

また、オンメモリにあるためカーソルは何度でも繰り返し前後に移動しても良く、クローンを作成するも自在である。

        // CachedRowSetのRIを取得する.
        RowSetFactory rowSetFactory = RowSetProvider.newFactory();
        CachedRowSet rowSet = rowSetFactory.createCachedRowSet();

        // データベースのクエリ結果のResultSetをCachedRowSetに設定する.
        try (Connection conn = ds.getConnection()) {
            String selSql = "select id, val from testtbl order by id";
            try (PreparedStatement stm = conn.prepareStatement(selSql);
                 ResultSet rs = stm.executeQuery()) {
                rowSet.populate(rs);
            }
        }

        // CachedRowSetは前後にカーソルが移動可能なオフラインResultSetとして使える.
        rowSet.beforeFirst();
        while (rowSet.next()) {
            int id = rowSet.getInt(1);
            String val = rowSet.getString(2);
            System.out.println(id + "=" + val);
        }

ただし、Blob/Clobは注意が必要である。


データベースが返すResultSet中のBlob/Clobは、オブジェクトの有効期間がコネクションまたは結果セットが開いているかぎりの制限がある場合もあり、CachedRowSetに転送後、切断または結果セットをクローズするとBlob/Clobが読み込めなくなる可能性もある。


結論的には、CachedRowSetでは、Blob/Clobのオフライン化は使用しないほうが良い。

※ そもそもBlob/Clobは本質的に大規模データなわけで、オンメモリで保持しておくようなものでもないはずなので、CachedRowSetの想定外だとしても仕方ない気はする。

CachedRowSetのデータの読み書き

CachedRowSetはResultSetのスーパーセットであり、オンメモリ上にあるカーソルを前後に自由に移動可能なResultSetとして扱うことができる。

そのため読み込みに関しては、通常のResultSetと同じように扱うことができる。


CachedRowSet#getMetaData()を使って、カラムの定義にアクセスすることも、元のResultSet同様に可能である。

CachedRowSetの更新

書き込みに関しては、CachedRowSet独自の以下のメソッドをもちいる。

  • updateXXX系メソッド 変更または追加時に現在のレコードのカラムの値を設定する
  • updateRow() 行のカラムを編集後に更新としてマークする.
  • deleteRow() 現在のカーソル位置にある行に削除マークする
  • moveToInsertRow() 現在のカーソル位置を新規行に移動する.
  • insertRow() 新規行を挿入する.
  • moveToCurrentRow() 現在のカーソル行を参照行に戻す
        // CachedRowSetはレコードの更新・追加・削除も可能である.
        rowSet.setShowDeleted(true); // 削除した行も表示する.
        int numOfRows = rowSet.size();
        for (int rowNum = 1; rowNum <= numOfRows; rowNum++) {
            rowSet.absolute(rowNum); // CachedRowSetは行番号を指定して移動できる
            if (rowNum % 2 == 1) {
                String val = rowSet.getString("VAL");
                val = val + "@更新";

                // レコードセットの更新はupdateXXXX系メソッドで行う.
                rowSet.updateString("VAL", val);
                rowSet.updateRow(); // 行の更新

            } else {
                // カーソルを合わせてから削除する.
                rowSet.deleteRow();
            }
        }
        // 挿入する場合は、挿入用の位置にカーソルを移動してupdateを行う.
        for (int idx = 0; idx < 2; idx++) {
            rowSet.moveToInsertRow();
            rowSet.updateInt("ID", -(numOfRows + idx));
            rowSet.updateString("VAL", "挿入" + (numOfRows + idx));
            rowSet.insertRow(); // 挿入の実施
        }
        rowSet.moveToCurrentRow(); // カーソルを参照用に戻す.

注意すべき点は、削除した行はsetShowDeletedでtrueに設定しないかぎり、存在しなかったことにされる。

そのため、next, previous, absolute, relativeといったカーソルを移動する命令では削除行はスキップして動くことになる。

(ただし、sizeメソッドは削除した行を含む全行を示す)

CachedRowSetのレコードごとの更新状態の確認

編集したRowSetの状態は、各行にカーソルをあてた状態で、

  • rowInserted 追加行であるか?
  • rowUpdated 更新されているか?
  • rowDeleted 削除されているか? (setShowDeletedで表示可能にしてあること)
  • setShowDeleted(true) 削除している行も表示可能とする
  • setOriginalRow 更新済みにする. (setShowDeletedで表示可能にしてあること)

で判断、操作することができる。


前述のとおり、deleteRowされた削除行は、通常の走査ではスキップされレコードが存在しないように扱われるため、setShowDeleted(true) を設定して削除済み行もアクセスできるようにしておく。

        // 更新・追加・削除行を判定する.
        rowSet.setShowDeleted(true); // 削除した行も表示する.
        rowSet.beforeFirst();
        while (rowSet.next()) {
            String typ = null;
            if (rowSet.rowInserted()) {
                typ = "inserted";
            } else if (rowSet.rowUpdated()) {
                typ = "updated";
            } else if (rowSet.rowDeleted()) {
                typ = "deleted";
            }
            if (typ != null) {
                int id = rowSet.getInt(1);
                pw.println(id + ": " + typ);

                // .......................................
                // ... 変更行に対する何らかの処理を、ここで行う ...

                // 更新済みにマークしなおす.
                // 削除済みのレコードは除去される.
                // (必ず削除行の表示をsetShowDeletedで有効にしておくこと)
                rowSet.setOriginalRow();
            }
        }
        rowSet.setShowDeleted(false);

このようにCachedRowSetでは行の編集状態を把握しており、レコードセットの編集用バッファとして、そのまま活用することができる。


この編集結果をデータベースへ反映するには、行ごとに変更フラグを逐一確認して、ResultSetMetaDataと合わせてプログラマSQL文を組み立てることが、結局は、安全且つ現実的な制御ができるものと思われる。(たとえばプライマリキーの自動発番を受け取って反映するなど。)



※ CachedRowSetにはacceptChangesというメソッドがあり、これは上記変更点を一括でデータベースに反映させる仕組みである。しかし、前述したとおり、これは1命令でこなすには複雑な仕組みであり、理解するのも制御するのも難しいため、これは使用するべきではない。


データベースへの反映が終わった場合に、CachedRowSet上での変更点を受け入れ、オリジナルとして認識させるには、setOriginalRow()を呼び出す。

CachedRowSetのシリアライズ

CachedRowSetはオフラインで扱えるだけでなく、シリアライズ可能でもあるため、ResultSetの内容をファイルやHttpSessionに入れたり、あるいはリモートで転送することも可能となる。

        // CachedRowSetをシリアライズしてバイト列データにする.
        byte[] serialized;
        try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
             ObjectOutputStream oos = new ObjectOutputStream(bos)) {
            oos.writeObject(rowSet);
            serialized = bos.toByteArray();
        }

        // バイト列データからCachedRowSetをデシリアライズして復元する.
        CachedRowSet rowSet2;
        try (ByteArrayInputStream bis = new ByteArrayInputStream(serialized);
             ObjectInputStream ois = new ObjectInputStream(bis)) {
            try {
                rowSet2 = (CachedRowSet) ois.readObject();
            } catch (ClassNotFoundException e) {
                throw new RuntimeException(e);
            }
        }

ただし、すくなくともリファレンス実装のCachedRowSetと、Apache Derby 10.10の組み合わせで試した限りでは、Blob/Clobを含む場合はシリアライズすることができなかった。


プリミティブ型や値型、Date/Time/Timestamp型といった通常の型以外のカラムはCachedRowSetで扱うのは困難であるかもしれない。


Apache DerbyのResultSetが返すBlobがシリアライズ不可な形式であることが問題なのかと思って、ResultSetのDynamicProxyを作ってBlobをSerialBlobで置き換える実装を噛ませてみたが効果なく改善できなかった。

WebRowSetによるXMLとしての保存と復元

CachedRowSetはシリアライズをサポートしているが、より汎用的なXML形式でのエクスポートを行う場合にはCachedRowSetを継承したWebRowSetを用いる。

WebRowSetは、XMLによるデータの保存と復元機能を追加したCachedRowSetである。

XMLとして保存

XMLとして保存するのは簡単で、単に「writeXmlメソッド」を呼び出せば良い。

        // WebRowSetの構築
        WebRowSet webRowSet = rowSetFactory.createWebRowSet();

        // WebRowSetにCachedRowSetの内容を転記する.
        rowSet.beforeFirst(); // カーソル位置を開始前に移動しておくこと.
        webRowSet.populate(rowSet);

        // 出力されたXML形式を見てみる
        StringWriter sw = new StringWriter();
        webRowSet.writeXml(sw);
        System.out.println(sw.toString());

なお、Blob/ClobはJavaDocでは使えるかのように記載があるが、少なくともJava7までのリファレンス実装ではサポートされていない。

かわりに「適切なタイプではありません」という警告メッセージがStdoutに頻発する。


(RIのソースを追うと、BLOB, CLOB, Array, Refは未実装で、エラーメッセージをコンソールに出すだけということが分かる。)

XMLの形式

出力される形式は以下のようなものとなる。

<?xml version="1.0"?>
<webRowSet xmlns="http://java.sun.com/xml/ns/jdbc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/jdbc http://java.sun.com/xml/ns/jdbc/webrowset.xsd">
  <properties>
    <command><null/></command>
    <concurrency>1008</concurrency>
    <datasource><null/></datasource>
    <escape-processing>true</escape-processing>
    <fetch-direction>1000</fetch-direction>
    <fetch-size>0</fetch-size>
    <isolation-level>2</isolation-level>
    <key-columns>
    </key-columns>
    <map>
    </map>
    <max-field-size>0</max-field-size>
    <max-rows>0</max-rows>
    <query-timeout>0</query-timeout>
    <read-only>true</read-only>
    <rowset-type>ResultSet.TYPE_SCROLL_INSENSITIVE</rowset-type>
    <show-deleted>false</show-deleted>
    <table-name><null/></table-name>
    <url><null/></url>
    <sync-provider>
      <sync-provider-name>com.sun.rowset.providers.RIOptimisticProvider</sync-provider-name>
      <sync-provider-vendor>Oracle Corporation</sync-provider-vendor>
      <sync-provider-version>1.0</sync-provider-version>
      <sync-provider-grade>2</sync-provider-grade>
      <data-source-lock>1</data-source-lock>
    </sync-provider>
  </properties>
  <metadata>
    <column-count>2</column-count>
    <column-definition>
      <column-index>1</column-index>
      <auto-increment>true</auto-increment>
      <case-sensitive>false</case-sensitive>
      <currency>false</currency>
      <nullable>0</nullable>
      <signed>true</signed>
      <searchable>true</searchable>
      <column-display-size>11</column-display-size>
      <column-label>ID</column-label>
      <column-name>ID</column-name>
      <schema-name>APP</schema-name>
      <column-precision>10</column-precision>
      <column-scale>0</column-scale>
      <table-name>TESTTBL</table-name>
      <catalog-name></catalog-name>
      <column-type>4</column-type>
      <column-type-name>INTEGER</column-type-name>
    </column-definition>
    <column-definition>
      <column-index>2</column-index>
      <auto-increment>false</auto-increment>
      <case-sensitive>true</case-sensitive>
      <currency>false</currency>
      <nullable>1</nullable>
      <signed>false</signed>
      <searchable>true</searchable>
      <column-display-size>32</column-display-size>
      <column-label>VAL</column-label>
      <column-name>VAL</column-name>
      <schema-name>APP</schema-name>
      <column-precision>32</column-precision>
      <column-scale>0</column-scale>
      <table-name>TESTTBL</table-name>
      <catalog-name></catalog-name>
      <column-type>12</column-type>
      <column-type-name>VARCHAR</column-type-name>
    </column-definition>
  </metadata>
  <data>
    <currentRow>
      <columnValue>0</columnValue>
      <columnValue>番号0</columnValue>
    </currentRow>
    <currentRow>
      <columnValue>1</columnValue>
      <columnValue>番号1</columnValue>
    </currentRow>
    <currentRow>
      <columnValue>2</columnValue>
      <columnValue>番号2</columnValue>
    </currentRow>
  </data>
</webRowSet>

※ WebRowSetの出力するXML形式については、JavaDocに記載されているので、このフォーマットをもとにStAXやSAXで独自に解析するのも、それほど苦ではない。
※ 出力されるXML形式でDate/Time/Timestampは、Javaエポックタイムのミリ秒の数値として表現されている。
※ NULLと空文字は特別で、それぞれというタグが値として出力される。

XMLからの復元

このXMLデータを読み込んだWebRowSetを復元するには、「readXmlメソッド」を呼ぶ。

        // WebRowSetにXMLデータからレコードセットを復元する.
        WebRowSet webRowSet2 = rowSetFactory.createWebRowSet();
        webRowSet2.readXml(new StringReader(sw.toString()));

        // 復元した内容を出力してみる.
        webRowSet2.beforeFirst();
        while (webRowSet2.next()) {
            int id = webRowSet2.getInt(1);
            String val = webRowSet2.getString(2);
            System.out.println(id + "=" + val);
        }

ただし、XML形式の読み込みは、現在のJava7のリファレンス実装ではかなり時間がかかる。

※ この遅さが許容できるか、実用的なデータサイズで計測してみるべきである。

(データが数百件程度なら問題にはならないと思うけれど。)

その他の注意点

そのほか、RowSetファミリを使って気が付いた点。

プライマリキー、ユニークキーの扱い

CachedRowSetには、ユニークキーを示すことができる。
これはResultSetをpopulateしただけでは設定されず、明示的に設定する必要がある。

たとえば、以下のようにカラムのインデックスを明示する。

rowSet.setKeyColumns(new int[] {1});

引数が配列であることからわかるように、
複数のユニークキーを指定できるが、しかし「複合キー」を指定する方法がない。

つまり、

rowSet.setKeyColumns(new int[] {1, 2});

とした場合には、カラム1と、カラム2は、それぞれ独立したユニークキーである。

どちらか一方が重複した時点でエラーになるので複合キー的な使い方はできない。


※ 本当に、これで正しいのか非常に疑問だが、少なくともJava7におけるリファレンス実装は、そうゆう実装になっている。(ドキュメントを見ても、複数キー指定できることが複合キーを想定しているのかどうか、いまいち分からない。)
※ 重複がチェックされるのは、そのRowSet内だけである。(実データベースへの書き込み時にも安全であるかは、実際に更新するまで分からない。)


ResultSetのオフライン版として使用するかぎりではキーはなくても何の問題もないので、とりあえず設定しておかなくて良い

Blob/Clobまわりは実装が全滅している。

Blob/Clobは、少なくともJava7のリファレンス実装では、まったく想定されていない。
RowSetまわりではBlob/Clobは使えない、ということだけ覚えておけば良いと思う。

結論

  • CachedRowSet/WebRowSetは、データベースから取得したResultSetをオンメモリで保持し、編集するためのものとして利用する分には、手軽且つ汎用的に使える。
    • セッションに入れたりシリアライズして保存することも可能である。
    • 更新・削除、または追加した場合には、レコードごとに編集状態を判別することができる。
    • データモデルクラスのように、リスナを設定することにより変更通知を受け取ることもできる。
    • 自分で “List> records”みたいなリストにマップをつめてレコードセットを作るぐらいならばCachedRowSetを返してあげたほうが簡単且つ汎用的である。
    • ただし、Blob/Clobは扱えないので、クエリの段階で予めBlob/Clobカラムは除外しておく。
  • WebRowSetはXML形式で長期間結果セットを保存する場合には検討しても良い。
  • データベースから直接CachedRowSetと読み書きさせようと思ったり、レコードの変更点をacceptChangesで反映させようなどと考えてはいけない。

リファレンス実装のCachedRowSet/WebRowSetには注意すべき制約がいくつも見受けられるが、
目的を絞って使うことにより、データベース専用の有益なコレクションクラスとして活用できると思う。


以上、メモ終了。