#navi_header|技術| DbUnitでテーブルデータをCSVでExport/Importするやり方と、varcharなどに改行コードを始めとする制御コードが混ざってきた場合の注意点について簡単にまとめます。 参考: - DbUnit 公式サイト -- http://www.dbunit.org/ - DbUnitとデータ量 - 技術開発日記 -- http://kechanzahorumon.hatenadiary.com/entry/2012/09/11/122847 動作確認: DbUnit 2.4.9 * CSVでExport/Importするには サンプル : - https://github.com/msakamoto-sf/javasnack/commit/f9f48bbcd3d26b0bfe36b9b24fa26df693898f38 ** CSVでExportする CSVでExportするには、 org.dbunit.dataset.csv.CsvDataSetWriter のwrite()メソッドが利用できます。 サンプルは色々混ざってるので、CsvDataSetWriterに関する部分だけ抜き出します。 #code|java|> File tmpDir; // ... tmpDir = FileDirHelper.createTmpDir(); //... // export only t1, t2 table to CSV QueryDataSet exportDataSet = new QueryDataSet(dbunit_conn); exportDataSet.addTable("t1"); exportDataSet.addTable("t2"); CsvDataSetWriter.write(exportDataSet, tmpDir); ||< write()の第一引数にはIDataSetの実装クラスのインスタンスを渡します。上の例では、対象テーブルを指定するため QueryDataSet を使っていますが、全体をダンプしたい場合は IDatabaseConnection.createDataSet() でも問題ないと思います。 第二引数は、出力先のディレクトリを指定したFileオブジェクトを指定します。出力に成功すると、各テーブル名 + ".csv"というファイルと、"table-orderning.txt"というテキストファイルが生成されます。"table-orderning.txt"は、後でCSVをimportする場合に、テーブルのインポート順序を制御出来ます。 ** CSVにExportする時、カラムの「型」はどういうロジックで文字列に変換されるか? CsvDataSetWriterクラスのrow()メソッドの中で、各行の各カラムの値ごとに以下のコードで""囲みの文字列を生成しています。 String stringValue = DataType.asString(value); final String quoted = quote(stringValue); org.dbunit.dataset.datatype.DataType.asString() により文字列に変換しています。DataTypeは抽象クラスになっていますが、asString() メソッド自体は実装済みで、中身は以下の1行です。 return (String)DataType.VARCHAR.typeCast(value); DataType.VARCHARは org.dbunit.dataset.datatype.StringDataType のインスタンスで、そのtypeCast()メソッドが、受け取ったObjectの実際の型に応じて文字列に変換しています。 結論からいうと、byte[]とBlobはbase64エンコードされ、それ以外は基本的に toString() の結果が採用されます。 ** CSVからImportする CsvDataSetWriterで書きだしたCSVファイルセットは、CsvDataSetでそのままIDataSetのインスタンスとしてロード出来ます。CsvDataSetのコンストラクタの第一引数に、CsvDataSetWriterで書きだしたディレクトリのFileオブジェクトを渡せば、後はDatabaseOperationなどにそのまま渡せます。 #code|java|> IDataSet csvDataSet = new CsvDataSet(tmpDir); DatabaseOperation.CLEAN_INSERT.execute(dbunit_conn, csvDataSet); ||< なおCsvDataSetの実体は、CachedDataSetを継承し、以下のようにCsvProducerクラスをCachedDataSetのコンストラクタに渡しているだけです。そのため、CSVデータのインポート処理の実体はCsvProducerの方でコーディングされています。 #code|java|> public class CsvDataSet extends CachedDataSet { //... public CsvDataSet(File dir) throws DataSetException { super(new CsvProducer(dir)); } } ||< ** CSVからImportする時、CSVの文字列はどう処理されるのか? CsvDataSetWriterが出力するCSVは、一行目がカラム名のCSVレコードで、2行目から実際のデータのCSVレコードが始まります。型情報はCSVには出力されません。 CSVからImportする時、文字列として読み取った各カラムの値をどうやって各型に変換しているのか、ちょっとソース追って見ましたがすぐにはわかりませんでした。 org.dbunit.dataset.csv.CsvProducer.produceFromFile(File theDataFile) 中の以下で、parseしたCSVの各カラムごとに処理しているところまでは確認できたのですが、実際の処理は"_consumer"インスタンス、つまりIDataSetConsumerのインスタンスで、これは IDataSetProducer.setConsumer() により恐らくDatabaseOperationなどからセットされるため、ちょっと辿りきれませんでした・・・。 #code|java|> _consumer.startTable(metaData); for (int i = 1 ; i < readData.size(); i++) { List rowList = (List)readData.get(i); Object[] row = rowList.toArray(); for(int col = 0; col < row.length; col++) { row[col] = row[col].equals(CsvDataSetWriter.NULL) ? null : row[col]; } _consumer.row(row); } _consumer.endTable(); ||< ただし、一応、export -> import した後のDB内容を確認してみたところ、標準的なカラムに当たり障りの無い値を入れてみた範囲では問題なくINSERTできていたので、多分「よしなに」処理してくれているのではないかなと・・・。 * varcharに制御コードなどCSVでの処理が怪しくなりそうな文字が入ってくるシステムの場合 varcharなどの文字列フィールドに、制御コードなどCSVでの処理が怪しくなりそうな文字が入ってくるシステムの場合、ご想像の通り、まずCSVのexportで「文字化け」します。byte[], blobならexportまでなら少なくともbase64エンコードしてくれるのですが、varcharの場合は普通にStringとして処理され、CSV出力する際に1文字ずつエスケープ処理のフィルタを通過します。その際に、制御コードなどがおかしくなります。CsvDataSetWriter.escape(String) のコードを読んでみてください。 ではどうするか、ですが、色々考えてみたのですが、DataSetのexport/importの既存の仕組みを壊さずに安全に扱おうとしたら、CsvDataSetWriterで escape() するのではなく、もう一律にbase64エンコードしたのを""囲みにしてしまい、CsvProducerの方でbase64をデコードして文字列に直してから使う、というのがどうにか許容出来る内容になりました。 - String/varchar型だけbase64出来ないか・・・と考えたのですが: -- CsvDataSetWriterの方はある程度その判別処理を入れられても、CSV側にBase64した/してないの状態を書き込まないと、CsvProducer側で戻す/戻さないを判別できなくなります。 -- base64エンコードした/してない、の結果フラグをどこかに残そうとすると、カスタマイズ量が増えそうです。 - 基本、escape() に渡ってくる段階では、最終的に出力する文字列になってますので、ちょっと乱暴ですが数値や日付型など本来はbase64にする必要が無いデータについても、そのまま文字列としてbase64エンコードしてしまうようにして問題は無さそうです。 - blob/byte[]型については、escape()に来る段階では既にbase64エンコードされていますが、いちいち判別するのは面倒くさく、そもそもテストコードで扱う性質なのでパフォーマンスは考えなくて良いので、もう一度一括でbase64エンコードしてしまうことにします。 ではCsvDataSetWriter/CsvProducerを継承したクラスを用意して、ポイントとなるメソッドだけoverrideすれば良いのか・・・となりますが、別の問題が潜んでいました。CsvDataSetWriter/CsvProducerでは、重要なメンバの幾つかというか殆どがprivate宣言されていて、継承したクラスから参照できない状況です。メソッドのoverrideに支障が出る状況でした。 この辺りでいい加減ブチ切れて、今回は力技で、CsvDataSetWriter/CsvDataSet/CsvProducerのソースコードをまるっとコピーして、base64エンコード/デコードが必要な箇所のみを書き換えてしまうという暴挙に走らせてもらいました。(だからprivateは嫌いだ・・・) それが以下のコードになります。 - https://github.com/msakamoto-sf/javasnack/commit/ad4d9903cd1b9d26b161b6eb162d05e6b6304f6a まずCsvBase64BinarySafeDataSetWriterですが、quote()メソッドで以下のように強制的にbase64エンコードしたのを""囲みにするようにしました。 #code|java|> private String quote(String stringValue) throws UnsupportedEncodingException { logger.debug("quote(stringValue={}) - start", stringValue); // encode to base64 string byte[] rawbytes = stringValue.getBytes(CharsetTool.BINARY); String base64encoded = Base64.encodeBytes(rawbytes); // quote surround return new StringBuffer(QUOTE).append(base64encoded).append(QUOTE).toString(); } ||< これで数値型も日付型も文字列型も、さらに制御コードが混入した文字列も、文字コードがおかしくなる前に、バイナリデータとしてbase64エンコードされます。blob/byte[]型は、既にbase64エンコードされた文字列が入ってきますが、それをもう一度base64エンコードすることになります。 続いて CsvBase64BinarySafeProducer では produceFromFile() のカラム列の処理を以下のように修正しました。NULL以外、全部base64デコードして使うようにしています。 #code|java|> _consumer.startTable(metaData); for (int i = 1 ; i < readData.size(); i++) { List rowList = (List)readData.get(i); Object[] row = rowList.toArray(); for(int col = 0; col < row.length; col++) { if (row[col].equals(CsvDataSetWriter.NULL)) { row[col] = null; } else { byte[] rawbytes = Base64.decode(row[col].toString()); row[col] = new String(rawbytes, CharsetTool.BINARY); } } _consumer.row(row); } _consumer.endTable(); ||< 以上のようにカスタマイズしたクラスを使って、実際に0x00 - 0xFFまでの文字列やバイナリデータをexport/importして最終的に戻せたことを確認しているのが、DbUnitCsvUsageTest.javaの customExportAndImportForControlCodeStrings() メソッドになります。 #code|java|> @Test public void customExportAndImportForControlCodeStrings() throws IOException, DatabaseUnitException, SQLException { IDatabaseConnection dbunit_conn = new DatabaseConnection(conn); // cache(save) expected data snap-shot. IDataSet expectedDataSet = new CachedDataSet( dbunit_conn.createDataSet()); // H2DB can store and load binary string to varchar column safely. Set a = new T4().findAll(conn); for (T4 a2 : a) { assertEquals(a2.stringField, UnsignedByte.create0x00to0xFFString()); } // export t4 using base64 binary safely csv dataset writer. QueryDataSet exportDataSet = new QueryDataSet(dbunit_conn); exportDataSet.addTable("t4"); CsvBase64BinarySafeDataSetWriter.write(exportDataSet, tmpDir); // clear & insert from exported CSV using base64 binary safely csv data producer. IDataSet csvDataSet = new CsvBase64BinarySafeDataSet(tmpDir); DatabaseOperation.CLEAN_INSERT.execute(dbunit_conn, csvDataSet); IDataSet actualDataSet = dbunit_conn.createDataSet(); Assertion.assertEquals(expectedDataSet.getTable("t4"), actualDataSet.getTable("t4")); } ||< 0x00-0xFFが混入していることを意識させない、他のDataSet処理と同じ仕組を使って違和感のないコードに仕上がっています。 #navi_footer|技術|