HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

オブジェクトに対するarray_key_exists関数呼び出しの代替策について

array_key_exists関数の第二パラメータにオブジェクトを渡すとエラーになるようになってから久しいが、PHP 5.4.16で動くシステムを最低でもPHP 7.3以降に上げなければならなくなったためにぶつかった壁の調査。

以下のようなコードをPHP 5.6.40/7.4.29/8.1.6で実行してみる。

<?php

printf("%s\n", PHP_VERSION);

ini_set('display_errors', '1');
ini_set('error_reporting', E_ALL);

class A {
}

$obj = new A();
$ary = array();

$k = 'noExistence';
printf("%-12s: %d %d\n",
    $k,
    array_key_exists($k, $ary),
    array_key_exists($k, $obj));
$ php56 Main.php
5.6.40
noExistence : 0 0
$ 
$ php74 Main.php
7.4.29
PHP Deprecated:  array_key_exists(): Using array_key_exists() on objects is deprecated. Use isset() or property_exists() instead in /home/hhelibex/blog/2022-0518-01/Main.php on line 18

Deprecated: array_key_exists(): Using array_key_exists() on objects is deprecated. Use isset() or property_exists() instead in /home/hhelibex/blog/2022-0518-01/Main.php on line 18
noExistence : 0 0
$ 
$ php81 Main.php
8.1.6
PHP Fatal error:  Uncaught TypeError: array_key_exists(): Argument #2 ($array) must be of type array, A given in /home/hhelibex/blog/2022-0518-01/Main.php:18
Stack trace:
#0 {main}
  thrown in /home/hhelibex/blog/2022-0518-01/Main.php on line 18

Fatal error: Uncaught TypeError: array_key_exists(): Argument #2 ($array) must be of type array, A given in /home/hhelibex/blog/2022-0518-01/Main.php:18
Stack trace:
#0 {main}
  thrown in /home/hhelibex/blog/2022-0518-01/Main.php on line 18
$

確かにエラーになる。 さて、PHP 7.4.29での実行結果に「Use isset() or property_exists() instead」とあるので、その辺を検証してみる。

<?php

printf("%s\n", PHP_VERSION);

ini_set('display_errors', '1');
ini_set('error_reporting', E_ALL);

class A {
}

$base = array(
    'isNull' => null,
    'isZero' => 0,
    'isOne' => 1,
    'isStr0' => '0',
    'isEmptyStr' => '',
    'isStrHoge' => 'hoge',
);

$obj = new A();
foreach ($base as $k => $v) {
    $obj->$k = $v;
}
$ary = $base;

var_dump($obj, $ary);

foreach ($base as $k => $v) {
    printf("%-12s: %d %d %d %d\n",
        $k,
        array_key_exists($k, $ary),
        property_exists($obj, $k),
        isset($ary[$k]),
        isset($obj->$k));
}
$k = 'noExistence';
printf("%-12s: %d %d %d %d\n",
    $k,
    array_key_exists($k, $ary),
    property_exists($obj, $k),
    isset($ary[$k]),
    isset($obj->$k));

これを同様に実行してみると、以下のようになる。

$ php56 Test.php
5.6.40
object(A)#1 (6) {
  ["isNull"]=>
  NULL
  ["isZero"]=>
  int(0)
  ["isOne"]=>
  int(1)
  ["isStr0"]=>
  string(1) "0"
  ["isEmptyStr"]=>
  string(0) ""
  ["isStrHoge"]=>
  string(4) "hoge"
}
array(6) {
  ["isNull"]=>
  NULL
  ["isZero"]=>
  int(0)
  ["isOne"]=>
  int(1)
  ["isStr0"]=>
  string(1) "0"
  ["isEmptyStr"]=>
  string(0) ""
  ["isStrHoge"]=>
  string(4) "hoge"
}
isNull      : 1 1 0 0
isZero      : 1 1 1 1
isOne       : 1 1 1 1
isStr0      : 1 1 1 1
isEmptyStr  : 1 1 1 1
isStrHoge   : 1 1 1 1
noExistence : 0 0 0 0
$ 
$ php74 Test.php
7.4.29
object(A)#1 (6) {
  ["isNull"]=>
  NULL
  ["isZero"]=>
  int(0)
  ["isOne"]=>
  int(1)
  ["isStr0"]=>
  string(1) "0"
  ["isEmptyStr"]=>
  string(0) ""
  ["isStrHoge"]=>
  string(4) "hoge"
}
array(6) {
  ["isNull"]=>
  NULL
  ["isZero"]=>
  int(0)
  ["isOne"]=>
  int(1)
  ["isStr0"]=>
  string(1) "0"
  ["isEmptyStr"]=>
  string(0) ""
  ["isStrHoge"]=>
  string(4) "hoge"
}
isNull      : 1 1 0 0
isZero      : 1 1 1 1
isOne       : 1 1 1 1
isStr0      : 1 1 1 1
isEmptyStr  : 1 1 1 1
isStrHoge   : 1 1 1 1
noExistence : 0 0 0 0
$ 
$ php81 Test.php
8.1.6
object(A)#1 (6) {
  ["isNull"]=>
  NULL
  ["isZero"]=>
  int(0)
  ["isOne"]=>
  int(1)
  ["isStr0"]=>
  string(1) "0"
  ["isEmptyStr"]=>
  string(0) ""
  ["isStrHoge"]=>
  string(4) "hoge"
}
array(6) {
  ["isNull"]=>
  NULL
  ["isZero"]=>
  int(0)
  ["isOne"]=>
  int(1)
  ["isStr0"]=>
  string(1) "0"
  ["isEmptyStr"]=>
  string(0) ""
  ["isStrHoge"]=>
  string(4) "hoge"
}
isNull      : 1 1 0 0
isZero      : 1 1 1 1
isOne       : 1 1 1 1
isStr0      : 1 1 1 1
isEmptyStr  : 1 1 1 1
isStrHoge   : 1 1 1 1
noExistence : 0 0 0 0
$ 

いずれも「nullが代入されたpropertyが存在する」時にisset()とproperty_exists()の挙動が異なる。

すなわち、厳密には、オブジェクトに対するarray_key_exists関数の呼び出しはproperty_exists関数でしか代用できない。

令和4年3月21日

ということなので、遊んでみる。

import java.util.Calendar;
import java.util.Locale;
import java.text.DateFormat;
import java.text.SimpleDateFormat;

public class Main {
    public static void main(String[] args) {
        Locale locale = new Locale("ja", "JP", "JP");
        Calendar cal = Calendar.getInstance(locale);
        DateFormat df = new SimpleDateFormat("GGGGy年M月d日", locale);
        System.out.println(df.format(cal.getTime()));
    }
}
$ java Main
令和4年3月21日
$ 

わーい。

CSVファイルの扱いに関する挙動の比較(2)

前回、以下の記事を書いた。

hhelibex.hatenablog.jp

その中で、「自身が書き出したCSVファイルを読み込むとエラーになる」可能性が出たので、実際に検証してみた。 なお、今回は、PHPについては7.4.0以降を対象とする。

事前準備

以下のデータを共通のデータとして使用する。

  • CsvTestData.php
<?php

class CsvTestData {
    private static $data = array(
        array(
            array('なんてことない文字列', 'abc!#$%&\'()-=^~@`[]{};+:*,.<>/?_123'),
        ),
        array(
            array('空文字列', ''),
        ),
        array(
            array('カンマ', ','),
        ),
        array(
            array('ダブルクォーテーション', '"'),
        ),
        array(
            array('バックスラッシュ', '\\'),
        ),
        array(
            array('空白文字', ' bbb '),
        ),
        array(
            array('改行', "a\nb\r\nc"),
        ),
        array(
            array('テスト1', 'a"b c,d'),
        ),
        array(
            array('テスト2', 'a"b"c d,e,f'),
        ),
        array(
            array('テスト3', 'a\"b\ c\,d'),
        ),
        array(
            array('テスト4', 'a\"b\"c\ d,e,f'),
        ),
    );
    /**
     * @return int
     */
    public static function size() {
        return count(self::$data);
    }
    /**
     * @param int $idx
     * @return
     */
    public static function get($idx) {
        return self::$data[$idx];
    }
}
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class CsvTestData {
    private static String[][][] data = {
        {
            { "なんてことない文字列", "abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123" },
        },
        {
            { "空文字列", "" },
        },
        {
            { "カンマ", "," },
        },
        {
            { "ダブルクォーテーション", "\"" },
        },
        {
            { "バックスラッシュ", "\\" },
        },
        {
            { "空白文字", " bbb " },
        },
        {
            { "改行", "a\nb\r\nc" },
        },
        {
            { "テスト1", "a\"b c,d" },
        },
        {
            { "テスト2", "a\"b\"c d,e,f" },
        },
        {
            { "テスト3", "a\\\"b\\ c\\,d" },
        },
        {
            { "テスト4", "a\\\"b\\\"c\\ d,e,f" },
        },
    };
    public static int size() {
        return data.length;
    }
    public static List<List<String>> get(int idx) {
        List<List<String>> res = new ArrayList<>();
        for (int i = 0; i < data[idx].length; ++i) {
            res.add(new ArrayList<>(Arrays.asList(data[idx][i])));
        }
        return res;
    }
}

また、Java用に、PHPのfile_get_contentsに相当するライブラリを用意する。

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.IOException;

public class Utils {
    public static String getFileContents(String filename, String fileEncoding) throws IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(filename), fileEncoding));
        char[] buf = new char[1024];
        StringBuilder sb = new StringBuilder();
        int len;
        while ((len = reader.read(buf)) > 0) {
            sb.append(buf, 0, len);
        }
        return sb.toString();
    }
}

fgetcsv/fputcsv (PHP (7.4.0以降))

なぜ7.4.0以降かというと、fputcsvのescapeに空文字列を指定したいため。7.4.0より前だと、escapeには1文字を指定しないとダメという警告が出てしまう。

<?php

mb_internal_encoding('UTF-8');

require_once('CsvTestData.php');

for ($i = 0; $i < CsvTestData::size(); ++$i) {
    $filename = "test${i}.csv";

    $rows = CsvTestData::get($i);

    $original = $rows;

    // 書き込み
    $fp = fopen($filename, "w");
    foreach ($rows as $row) {
        foreach ($row as $key => $val) {
            $row[$key] = mb_convert_encoding($val, 'SJIS-win', 'UTF-8');
        }
        fputcsv($fp, $row, ',', '"', '');
    }
    fflush($fp);
    fclose($fp);

    // ファイルの内容
    printf("[%d]\n", $i);
    printf("file     = %s\n", var_export(mb_convert_encoding(file_get_contents($filename), 'UTF-8', 'SJIS-win'), true));

    // 読み込み
    $fp = fopen($filename, "r");
    for ($j = 0; ($row = fgetcsv($fp, 0, ',', '"', '')); ++$j) {
        foreach ($row as $key => $val) {
            $row[$key] = mb_convert_encoding($val, 'UTF-8', 'SJIS-win');
        }
        printf("original = %s\n",
            var_export($original[$j], true));
        printf("row      = %s\n",
            var_export($row, true));
        printf("result   = %s\n",
            ($row === $original[$j] ? "O" : "X"));
    }
    fclose($fp);
}

実行結果。

[0]
file     = 'なんてことない文字列,"abc!#$%&\'()-=^~@`[]{};+:*,.<>/?_123"
'
original = array (
  0 => 'なんてことない文字列',
  1 => 'abc!#$%&\'()-=^~@`[]{};+:*,.<>/?_123',
)
row      = array (
  0 => 'なんてことない文字列',
  1 => 'abc!#$%&\'()-=^~@`[]{};+:*,.<>/?_123',
)
result   = O
[1]
file     = '空文字列,
'
original = array (
  0 => '空文字列',
  1 => '',
)
row      = array (
  0 => '空文字列',
  1 => '',
)
result   = O
[2]
file     = 'カンマ,","
'
original = array (
  0 => 'カンマ',
  1 => ',',
)
row      = array (
  0 => 'カンマ',
  1 => ',',
)
result   = O
[3]
file     = 'ダブルクォーテーション,""""
'
original = array (
  0 => 'ダブルクォーテーション',
  1 => '"',
)
row      = array (
  0 => 'ダブルクォーテーション',
  1 => '"',
)
result   = O
[4]
file     = 'バックスラッシュ,\\
'
original = array (
  0 => 'バックスラッシュ',
  1 => '\\',
)
row      = array (
  0 => 'バックスラッシュ',
  1 => '\\',
)
result   = O
[5]
file     = '空白文字," bbb "
'
original = array (
  0 => '空白文字',
  1 => ' bbb ',
)
row      = array (
  0 => '空白文字',
  1 => ' bbb ',
)
result   = O
[6]
file     = '改行,"a
b
c"
'
original = array (
  0 => '改行',
  1 => 'a
b
c',
)
row      = array (
  0 => '改行',
  1 => 'a
b
c',
)
result   = O
[7]
file     = 'テスト1,"a""b c,d"
'
original = array (
  0 => 'テスト1',
  1 => 'a"b c,d',
)
row      = array (
  0 => 'テスト1',
  1 => 'a"b c,d',
)
result   = O
[8]
file     = 'テスト2,"a""b""c d,e,f"
'
original = array (
  0 => 'テスト2',
  1 => 'a"b"c d,e,f',
)
row      = array (
  0 => 'テスト2',
  1 => 'a"b"c d,e,f',
)
result   = O
[9]
file     = 'テスト3,"a\\""b\\ c\\,d"
'
original = array (
  0 => 'テスト3',
  1 => 'a\\"b\\ c\\,d',
)
row      = array (
  0 => 'テスト3',
  1 => 'a\\"b\\ c\\,d',
)
result   = O
[10]
file     = 'テスト4,"a\\""b\\""c\\ d,e,f"
'
original = array (
  0 => 'テスト4',
  1 => 'a\\"b\\"c\\ d,e,f',
)
row      = array (
  0 => 'テスト4',
  1 => 'a\\"b\\"c\\ d,e,f',
)
result   = O

全てのテストにパスしている。

Super CSV (Java)

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.List;

import org.supercsv.io.CsvListReader;
import org.supercsv.io.CsvListWriter;
import org.supercsv.io.ICsvListReader;
import org.supercsv.io.ICsvListWriter;
import org.supercsv.prefs.CsvPreference;

public class Main {
    private static final String FILE_ENCODING = "Windows-31J";

    public static void main(String[] args) throws IOException {
        for (int i = 0; i < CsvTestData.size(); ++i) {
            String filename = "test" + i + ".csv";

            List<List<String>> original = CsvTestData.get(i);

            // 書き込み
            writeCsv(original, filename);

            // ファイルの内容
            String fileContents = Utils.getFileContents(filename, FILE_ENCODING);

            // 読み込み
            List<List<String>> rows = new ArrayList<>();
            try {
                rows = readCsv(filename);
            } catch (Exception e) {
                e.printStackTrace();
            }

            System.out.printf("[%d]%n", i);
            System.out.printf("file     = '%s'%n", fileContents);
            if (original.size() == rows.size()) {
                for (int j = 0; j < rows.size(); ++j) {
                    System.out.printf("original = %s%n", original.get(j));
                    System.out.printf("row      = %s%n", rows.get(j));
                    System.out.printf("result   = %s%n", (rows.get(j).equals(original.get(j)) ? "O" : "X"));
                }
            } else {
                System.out.printf("original = %s%n", original);
                System.out.printf("row      = %s%n", rows);
                System.out.printf("result   = %s%n", "X");
            }
        }
    }
    private static void writeCsv(List<List<String>> rows, String filename) throws IOException {
        CsvPreference csvPref = new CsvPreference.Builder(
            CsvPreference.STANDARD_PREFERENCE).surroundingSpacesNeedQuotes(true).build();

        ICsvListWriter writer = new CsvListWriter(
            new OutputStreamWriter(new FileOutputStream(filename), FILE_ENCODING),
            csvPref);
        for (List<String> row : rows) {
            writer.write(row);
        }
        writer.flush();
        writer.close();
    }
    private static List<List<String>> readCsv(String filename) throws IOException {
        CsvPreference csvPref = new CsvPreference.Builder(
            CsvPreference.STANDARD_PREFERENCE).surroundingSpacesNeedQuotes(true).build();
        ICsvListReader reader = new CsvListReader(
            new BufferedReader(new InputStreamReader(new FileInputStream(filename), FILE_ENCODING)), csvPref);

        List<List<String>> res = new ArrayList<>();
        List<String> row;
        while ((row = reader.read()) != null) {
            res.add(row);
        }
        reader.close();

        return res;
    }
}

実行結果。

[0]
file     = 'なんてことない文字列,"abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123"
'
original = [なんてことない文字列, abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123]
row      = [なんてことない文字列, abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123]
result   = O
[1]
file     = '空文字列,
'
original = [空文字列, ]
row      = [空文字列, null]
result   = X
[2]
file     = 'カンマ,","
'
original = [カンマ, ,]
row      = [カンマ, ,]
result   = O
[3]
file     = 'ダブルクォーテーション,""""
'
original = [ダブルクォーテーション, "]
row      = [ダブルクォーテーション, "]
result   = O
[4]
file     = 'バックスラッシュ,\
'
original = [バックスラッシュ, \]
row      = [バックスラッシュ, \]
result   = O
[5]
file     = '空白文字," bbb "
'
original = [空白文字,  bbb ]
row      = [空白文字,  bbb ]
result   = O
[6]
file     = '改行,"a
b
c"
'
original = [改行, a
b
c]
row      = [改行, a
b
c]
result   = X
[7]
file     = 'テスト1,"a""b c,d"
'
original = [テスト1, a"b c,d]
row      = [テスト1, a"b c,d]
result   = O
[8]
file     = 'テスト2,"a""b""c d,e,f"
'
original = [テスト2, a"b"c d,e,f]
row      = [テスト2, a"b"c d,e,f]
result   = O
[9]
file     = 'テスト3,"a\""b\ c\,d"
'
original = [テスト3, a\"b\ c\,d]
row      = [テスト3, a\"b\ c\,d]
result   = O
[10]
file     = 'テスト4,"a\""b\""c\ d,e,f"
'
original = [テスト4, a\"b\"c\ d,e,f]
row      = [テスト4, a\"b\"c\ d,e,f]
result   = O

全てのテストにパスしていると言いたいところだが、空文字列に対してnullで返す仕様なのが惜しい。

改行コードは、ファイルに書き込む際に変換されてしまうらしい。

$ od -cx test6.csv 
0000000 211 374 215   s   ,   "   a  \r  \n   b  \r  \n   c   "  \r  \n
           fc89    738d    222c    0d61    620a    0a0d    2263    0a0d
0000020

「"a\nb\r\nc"」が「"a\r\nb\r\nc"」になっている。更に読み込んだ際に、今回もCentOS 7環境で実行したため、「"a\nb\nc"」に変換されている。

Super CSV Annotation

import com.github.mygreen.supercsv.annotation.CsvBean;
import com.github.mygreen.supercsv.annotation.CsvColumn;

@CsvBean(header=false)
public class HogeBean {
    @CsvColumn(number=1)
    private String fieldA;
    @CsvColumn(number=2)
    private String fieldB;

    public HogeBean() {
    }

    public String getFieldA() {
        return fieldA;
    }
    public void setFieldA(String fieldA) {
        this.fieldA = fieldA;
    }

    public String getFieldB() {
        return fieldB;
    }
    public void setFieldB(String fieldB) {
        this.fieldB = fieldB;
    }

    public boolean equals(Object o) {
        if (o instanceof HogeBean) {
            HogeBean that = (HogeBean)o;
            return (this.fieldA == null && that.fieldA == null
                || this.fieldA != null && this.fieldA.equals(that.fieldA))
                && (this.fieldB == null && that.fieldB == null
                    || this.fieldB != null && this.fieldB.equals(that.fieldB));
        }
        return false;
    }
    public String toString() {
        return "[" + fieldA + "][" + fieldB + "]";
    }
}
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.List;

import com.github.mygreen.supercsv.io.CsvAnnotationBeanReader;
import com.github.mygreen.supercsv.io.CsvAnnotationBeanWriter;

import org.supercsv.prefs.CsvPreference;

public class Main {
    private static final String FILE_ENCODING = "Windows-31J";

    public static void main(String[] args) throws IOException {
        for (int i = 0; i < CsvTestData.size(); ++i) {
            String filename = "test" + i + ".csv";

            List<List<String>> tmpOriginal = CsvTestData.get(i);
            List<HogeBean> original = new ArrayList<>();
            for (List<String> list : tmpOriginal) {
                HogeBean hoge = new HogeBean();
                hoge.setFieldA(list.get(0));
                hoge.setFieldB(list.get(1));
                original.add(hoge);
            }

            // 書き込み
            writeCsv(original, filename);

            // ファイルの内容
            String fileContents = Utils.getFileContents(filename, FILE_ENCODING);

            // 読み込み
            List<HogeBean> rows = new ArrayList<>();
            try {
                rows = readCsv(filename);
            } catch (Exception e) {
                e.printStackTrace();
            }

            System.out.printf("[%d]%n", i);
            System.out.printf("file     = '%s'%n", fileContents);
            if (original.size() == rows.size()) {
                for (int j = 0; j < rows.size(); ++j) {
                    System.out.printf("original = %s%n", original.get(j));
                    System.out.printf("row      = %s%n", rows.get(j));
                    System.out.printf("result   = %s%n", (rows.get(j).equals(original.get(j)) ? "O" : "X"));
                }
            } else {
                System.out.printf("original = %s%n", original);
                System.out.printf("row      = %s%n", rows);
                System.out.printf("result   = %s%n", "X");
            }
        }
    }
    private static void writeCsv(List<HogeBean> rows, String filename) throws IOException {
        CsvPreference csvPref = new CsvPreference.Builder(
            CsvPreference.STANDARD_PREFERENCE).surroundingSpacesNeedQuotes(true).build();
        CsvAnnotationBeanWriter<HogeBean> writer = new CsvAnnotationBeanWriter<>(
            HogeBean.class,
            new OutputStreamWriter(new FileOutputStream(filename), FILE_ENCODING),
            csvPref);
        for (HogeBean hoge : rows) {
            writer.write(hoge);
        }
        writer.flush();
        writer.close();
    }
    private static List<HogeBean> readCsv(String filename) throws IOException {
        CsvPreference csvPref = new CsvPreference.Builder(
            CsvPreference.STANDARD_PREFERENCE).surroundingSpacesNeedQuotes(true).build();
        CsvAnnotationBeanReader<HogeBean> reader = new CsvAnnotationBeanReader<>(
            HogeBean.class,
            new BufferedReader(new InputStreamReader(new FileInputStream(filename), FILE_ENCODING)),
            csvPref);

        List<HogeBean> res = new ArrayList<>();
        HogeBean hoge;
        while ((hoge = reader.read()) != null) {
            res.add(hoge);
        }
        reader.close();

        return res;
    }
}

実行結果。

[0]
file     = 'なんてことない文字列,"abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123"
'
original = [なんてことない文字列][abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123]
row      = [なんてことない文字列][abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123]
result   = O
[1]
file     = '空文字列,
'
original = [空文字列][]
row      = [空文字列][null]
result   = X
[2]
file     = 'カンマ,","
'
original = [カンマ][,]
row      = [カンマ][,]
result   = O
[3]
file     = 'ダブルクォーテーション,""""
'
original = [ダブルクォーテーション]["]
row      = [ダブルクォーテーション]["]
result   = O
[4]
file     = 'バックスラッシュ,\
'
original = [バックスラッシュ][\]
row      = [バックスラッシュ][\]
result   = O
[5]
file     = '空白文字," bbb "
'
original = [空白文字][ bbb ]
row      = [空白文字][ bbb ]
result   = O
[6]
file     = '改行,"a
b
c"
'
original = [改行][a
b
c]
row      = [改行][a
b
c]
result   = X
[7]
file     = 'テスト1,"a""b c,d"
'
original = [テスト1][a"b c,d]
row      = [テスト1][a"b c,d]
result   = O
[8]
file     = 'テスト2,"a""b""c d,e,f"
'
original = [テスト2][a"b"c d,e,f]
row      = [テスト2][a"b"c d,e,f]
result   = O
[9]
file     = 'テスト3,"a\""b\ c\,d"
'
original = [テスト3][a\"b\ c\,d]
row      = [テスト3][a\"b\ c\,d]
result   = O
[10]
file     = 'テスト4,"a\""b\""c\ d,e,f"
'
original = [テスト4][a\"b\"c\ d,e,f]
row      = [テスト4][a\"b\"c\ d,e,f]
result   = O

これもSuper CSVと同じく、空文字列をnullにしてしまうところが惜しい。あとは改行コードも。

OrangeSignal CSV (Java)

import com.orangesignal.csv.annotation.CsvColumn;
import com.orangesignal.csv.annotation.CsvEntity;

@CsvEntity(header=false)
public class HogeBean {
    @CsvColumn(position=0)
    private String fieldA;
    @CsvColumn(position=1)
    private String fieldB;

    public HogeBean() {
    }

    public String getFieldA() {
        return fieldA;
    }
    public void setFieldA(String fieldA) {
        this.fieldA = fieldA;
    }

    public String getFieldB() {
        return fieldB;
    }
    public void setFieldB(String fieldB) {
        this.fieldB = fieldB;
    }

    public boolean equals(Object o) {
        if (o instanceof HogeBean) {
            HogeBean that = (HogeBean)o;
            return (this.fieldA == null && that.fieldA == null
                || this.fieldA != null && this.fieldA.equals(that.fieldA))
                && (this.fieldB == null && that.fieldB == null
                    || this.fieldB != null && this.fieldB.equals(that.fieldB));
        }
        return false;
    }
    public String toString() {
        return "[" + fieldA + "][" + fieldB + "]";
    }
}
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.List;

import com.orangesignal.csv.annotation.CsvColumnException;
import com.orangesignal.csv.CsvConfig;
import com.orangesignal.csv.CsvReader;
import com.orangesignal.csv.CsvWriter;
import com.orangesignal.csv.io.CsvEntityReader;
import com.orangesignal.csv.io.CsvEntityWriter;

public class Main {
    private static final String FILE_ENCODING = "Windows-31J";

    public static void main(String[] args) throws IOException {
        for (int i = 0; i < CsvTestData.size(); ++i) {
            String filename = "test" + i + ".csv";

            List<List<String>> tmpOriginal = CsvTestData.get(i);
            List<HogeBean> original = new ArrayList<>();
            for (List<String> list : tmpOriginal) {
                HogeBean hoge = new HogeBean();
                hoge.setFieldA(list.get(0));
                hoge.setFieldB(list.get(1));
                original.add(hoge);
            }

            // 書き込み
            writeCsv(original, filename);

            // ファイルの内容
            String fileContents = Utils.getFileContents(filename, FILE_ENCODING);

            // 読み込み
            List<HogeBean> rows = new ArrayList<>();
            try {
                rows = readCsv(filename);
            } catch (Exception e) {
                e.printStackTrace();
            }

            System.out.printf("[%d]%n", i);
            System.out.printf("file     = '%s'%n", fileContents);
            if (original.size() == rows.size()) {
                for (int j = 0; j < rows.size(); ++j) {
                    System.out.printf("original = %s%n", original.get(j));
                    System.out.printf("row      = %s%n", rows.get(j));
                    System.out.printf("result   = %s%n", (rows.get(j).equals(original.get(j)) ? "O" : "X"));
                }
            } else {
                System.out.printf("original = %s%n", original);
                System.out.printf("row      = %s%n", rows);
                System.out.printf("result   = %s%n", "X");
            }
        }
    }
    private static void writeCsv(List<HogeBean> rows, String filename) throws IOException {
        CsvConfig cfg = new CsvConfig(',', '"', '"');
        CsvEntityWriter<HogeBean> writer = CsvEntityWriter.newInstance(new CsvWriter(
            new OutputStreamWriter(new FileOutputStream(filename), FILE_ENCODING), cfg), HogeBean.class);

        for (HogeBean hoge : rows) {
            writer.write(hoge);
        }
        writer.flush();
        writer.close();
    }
    private static List<HogeBean> readCsv(String filename) throws IOException {
        CsvConfig cfg = new CsvConfig(',', '"', '"');
        CsvEntityReader<HogeBean> reader = CsvEntityReader.newInstance(new CsvReader(
            new BufferedReader(new InputStreamReader(new FileInputStream(filename), "Windows-31J")), cfg), HogeBean.class);

        List<HogeBean> res = new ArrayList<>();
        while (true) {
            HogeBean hoge;
            try {
                if ((hoge = reader.read()) == null) {
                    break;
                }
                res.add(hoge);
            } catch (CsvColumnException e) {
                e.printStackTrace();
                continue;
            } catch (RuntimeException e) {
                // 最大限の配慮
                e.printStackTrace();
                break;
            }
        }
        reader.close();

        return res;
    }
}

実行結果。

java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[0]
file     = '"なんてことない文字列","abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123"
'
original = [なんてことない文字列][abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123]
row      = [なんてことない文字列][abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[1]
file     = '"空文字列",""
'
original = [空文字列][]
row      = [空文字列][]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[2]
file     = '"カンマ",","
'
original = [カンマ][,]
row      = [カンマ][,]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[3]
file     = '"ダブルクォーテーション",""""
'
original = [ダブルクォーテーション]["]
row      = [ダブルクォーテーション]["]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[4]
file     = '"バックスラッシュ","\"
'
original = [バックスラッシュ][\]
row      = [バックスラッシュ][\]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[5]
file     = '"空白文字"," bbb "
'
original = [空白文字][ bbb ]
row      = [空白文字][ bbb ]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[6]
file     = '"改行","a
b
c"
'
original = [改行][a
b
c]
row      = [改行][a
b
c]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[7]
file     = '"テスト1","a""b c,d"
'
original = [テスト1][a"b c,d]
row      = [テスト1][a"b c,d]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[8]
file     = '"テスト2","a""b""c d,e,f"
'
original = [テスト2][a"b"c d,e,f]
row      = [テスト2][a"b"c d,e,f]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[9]
file     = '"テスト3","a\""b\ c\,d"
'
original = [テスト3][a\"b\ c\,d]
row      = [テスト3][a\"b\ c\,d]
result   = O
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
        at java.util.ArrayList.rangeCheck(ArrayList.java:659)
        at java.util.ArrayList.get(ArrayList.java:435)
        at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
        at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
        at Main.readCsv(Main.java:82)
        at Main.main(Main.java:42)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
[10]
file     = '"テスト4","a\""b\""c\ d,e,f"
'
original = [テスト4][a\"b\"c\ d,e,f]
row      = [テスト4][a\"b\"c\ d,e,f]
result   = O

結果だけ見ると全てOKに見えるけど、IndexOutOfBoundsExceptionが発生するのが最大の難点。 自身が吐き出したCSVファイルを読み込んでエラーになるってどんなだ。

opencsv (Java)

import com.opencsv.bean.CsvBindByPosition;

public class HogeBean {
    @CsvBindByPosition(position=0)
    private String fieldA;
    @CsvBindByPosition(position=1)
    private String fieldB;

    public HogeBean() {
    }

    public String getFieldA() {
        return fieldA;
    }
    public void setFieldA(String fieldA) {
        this.fieldA = fieldA;
    }

    public String getFieldB() {
        return fieldB;
    }
    public void setFieldB(String fieldB) {
        this.fieldB = fieldB;
    }

    public boolean equals(Object o) {
        if (o instanceof HogeBean) {
            HogeBean that = (HogeBean)o;
            return (this.fieldA == null && that.fieldA == null
                || this.fieldA != null && this.fieldA.equals(that.fieldA))
                && (this.fieldB == null && that.fieldB == null
                    || this.fieldB != null && this.fieldB.equals(that.fieldB));
        }
        return false;
    }
    public String toString() {
        return "[" + fieldA + "][" + fieldB + "]";
    }
}
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.util.ArrayList;
import java.util.List;

import com.opencsv.bean.CsvToBean;
import com.opencsv.bean.CsvToBeanBuilder;
import com.opencsv.bean.StatefulBeanToCsv;
import com.opencsv.bean.StatefulBeanToCsvBuilder;
import com.opencsv.exceptions.CsvDataTypeMismatchException;
import com.opencsv.exceptions.CsvRequiredFieldEmptyException;

public class Main {
    private static final String FILE_ENCODING = "Windows-31J";

    public static void main(String[] args) throws IOException {
        for (int i = 0; i < CsvTestData.size(); ++i) {
            String filename = "test" + i + ".csv";

            List<List<String>> tmpOriginal = CsvTestData.get(i);
            List<HogeBean> original = new ArrayList<>();
            for (List<String> list : tmpOriginal) {
                HogeBean hoge = new HogeBean();
                hoge.setFieldA(list.get(0));
                hoge.setFieldB(list.get(1));
                original.add(hoge);
            }

            // 書き込み
            writeCsv(original, filename);

            // ファイルの内容
            String fileContents = Utils.getFileContents(filename, FILE_ENCODING);

            // 読み込み
            List<HogeBean> rows = new ArrayList<>();
            try {
                rows = readCsv(filename);
            } catch (Exception e) {
                e.printStackTrace();
            }

            System.out.printf("[%d]%n", i);
            System.out.printf("file     = '%s'%n", fileContents);
            if (original.size() == rows.size()) {
                for (int j = 0; j < rows.size(); ++j) {
                    System.out.printf("original = %s%n", original.get(j));
                    System.out.printf("row      = %s%n", rows.get(j));
                    System.out.printf("result   = %s%n", (rows.get(j).equals(original.get(j)) ? "O" : "X"));
                }
            } else {
                System.out.printf("original = %s%n", original);
                System.out.printf("row      = %s%n", rows);
                System.out.printf("result   = %s%n", "X");
            }
        }
    }
    private static void writeCsv(List<HogeBean> rows, String filename) throws IOException {
        Writer w = new OutputStreamWriter(new FileOutputStream(filename), FILE_ENCODING); // flush()するために変数にセット。
        StatefulBeanToCsv<HogeBean> writer = new StatefulBeanToCsvBuilder<HogeBean>(
                w
            ).build();

        for (HogeBean hoge : rows) {
            try {
                writer.write(hoge);
            } catch (CsvDataTypeMismatchException|CsvRequiredFieldEmptyException e) {
                e.printStackTrace();
            }
        }
        
        w.flush();
        w.close();
    }
    private static List<HogeBean> readCsv(String filename) throws IOException {
        Reader r = new BufferedReader(new InputStreamReader(new FileInputStream(filename), FILE_ENCODING));
        CsvToBean<HogeBean> reader = new CsvToBeanBuilder<HogeBean>(
                r
            ).withType(HogeBean.class).build();

        List<HogeBean> res = new ArrayList<>();
        try {
            for (HogeBean hoge : reader) {
                res.add(hoge);
            }
        } catch (RuntimeException e) {
            e.printStackTrace();
        } finally {
            r.close();
        }

        return res;
    }
}

実行結果。

[0]
file     = '"なんてことない文字列","abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123"
'
original = [なんてことない文字列][abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123]
row      = [なんてことない文字列][abc!#$%&'()-=^~@`[]{};+:*,.<>/?_123]
result   = O
[1]
file     = '"空文字列",""
'
original = [空文字列][]
row      = [空文字列][]
result   = O
[2]
file     = '"カンマ",","
'
original = [カンマ][,]
row      = [カンマ][,]
result   = O
[3]
file     = '"ダブルクォーテーション",""""
'
original = [ダブルクォーテーション]["]
row      = [ダブルクォーテーション]["]
result   = O
java.lang.RuntimeException: Error capturing CSV header!
        at com.opencsv.bean.CsvToBean.prepareToReadInput(CsvToBean.java:304)
        at com.opencsv.bean.CsvToBean.iterator(CsvToBean.java:322)
        at Main.readCsv(Main.java:89)
        at Main.main(Main.java:44)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
Caused by: com.opencsv.exceptions.CsvMalformedLineException: Unterminated quoted field at end of CSV line. Beginning of lost text: ["
]
        at com.opencsv.CSVReader.primeNextRecord(CSVReader.java:245)
        at com.opencsv.CSVReader.flexibleRead(CSVReader.java:598)
        at com.opencsv.CSVReader.peek(CSVReader.java:574)
        at com.opencsv.bean.ColumnPositionMappingStrategy.captureHeader(ColumnPositionMappingStrategy.java:72)
        at com.opencsv.bean.CsvToBean.prepareToReadInput(CsvToBean.java:302)
        ... 9 more
[4]
file     = '"バックスラッシュ","\"
'
original = [[バックスラッシュ][\]]
row      = []
result   = X
[5]
file     = '"空白文字"," bbb "
'
original = [空白文字][ bbb ]
row      = [空白文字][ bbb ]
result   = O
[6]
file     = '"改行","a
b
c"
'
original = [改行][a
b
c]
row      = [改行][a
b
c]
result   = X
[7]
file     = '"テスト1","a""b c,d"
'
original = [テスト1][a"b c,d]
row      = [テスト1][a"b c,d]
result   = O
[8]
file     = '"テスト2","a""b""c d,e,f"
'
original = [テスト2][a"b"c d,e,f]
row      = [テスト2][a"b"c d,e,f]
result   = O
java.lang.RuntimeException: Error capturing CSV header!
        at com.opencsv.bean.CsvToBean.prepareToReadInput(CsvToBean.java:304)
        at com.opencsv.bean.CsvToBean.iterator(CsvToBean.java:322)
        at Main.readCsv(Main.java:89)
        at Main.main(Main.java:44)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
        at java.lang.Thread.run(Thread.java:748)
Caused by: com.opencsv.exceptions.CsvMalformedLineException: Unterminated quoted field at end of CSV line. Beginning of lost text: [a""b c,d
]
        at com.opencsv.CSVReader.primeNextRecord(CSVReader.java:245)
        at com.opencsv.CSVReader.flexibleRead(CSVReader.java:598)
        at com.opencsv.CSVReader.peek(CSVReader.java:574)
        at com.opencsv.bean.ColumnPositionMappingStrategy.captureHeader(ColumnPositionMappingStrategy.java:72)
        at com.opencsv.bean.CsvToBean.prepareToReadInput(CsvToBean.java:302)
        ... 9 more
[9]
file     = '"テスト3","a\""b\ c\,d"
'
original = [[テスト3][a\"b\ c\,d]]
row      = []
result   = X
[10]
file     = '"テスト4","a\""b\""c\ d,e,f"
'
original = [テスト4][a\"b\"c\ d,e,f]
row      = [テスト4][a""b""c d,e,f]
result   = X

こちらはバックスラッシュ(エスケープ文字)が全滅。

まとめ

  • PHP
    • 特に問題なし
  • Super CSV / Super CSV Annotation
    • 空文字列がnullで返されるのが惜しい
    • 改行コードは、書き込みの際に「\r\n」に統一され、読み込みの際に環境に応じたものに変換されてしまう
  • OrangeSignal CSV
    • とにかくIndexOutOfBoundsExceptionが発生するのが難点というに尽きる
  • opencsv
    • エスケープ文字(デフォルトではバックスラッシュ)の扱いが雑

CSVファイルの扱いに関する挙動の比較

CSVファイルの読み書きに関する挙動をまとめたメモ。

はじめに

まず、復習がてらRFC 4180を見直す。

BNF表記の部分を抜き出す。

The ABNF grammar [2] appears as follows:
file = [header CRLF] record *(CRLF record) [CRLF]
header = name *(COMMA name)
record = field *(COMMA field)
name = field
field = (escaped / non-escaped)
escaped = DQUOTE *(TEXTDATA / COMMA / CR / LF / 2DQUOTE) DQUOTE
non-escaped = *TEXTDATA
COMMA = %x2C
CR = %x0D ;as per section 6.1 of RFC 2234 [2]
DQUOTE = %x22 ;as per section 6.1 of RFC 2234 [2]
LF = %x0A ;as per section 6.1 of RFC 2234 [2]
CRLF = CR LF ;as per section 6.1 of RFC 2234 [2]
TEXTDATA = %x20-21 / %x23-2B / %x2D-7E

TEXTDATAの部分がASCII文字のみで構成されているのは時代だから仕方ないとして、ここでは空白文字(0x20)も対象に入っていることに着目したい。つまり、ダブルクォーテーションで括ろうが括るまいが、空白文字は値の一部として扱われるべきというのがRFC 4180の主張である。

検証に使うCSVファイル

基本的なCSV形式のデータを含むファイル。

a,b,c,d,e
A,B,C,D,E
"カンマ(comma)",",","","でぃ",""
"ダブルクォーテーション(double quotation)","""","","でぃ",""
"改行","
","","でぃ",""
"空白文字の扱い(white space) 1", B ,"","でぃ",""
"空白文字の扱い(white space) 2"," B ","","でぃ",""

空白文字の扱い(white space) 1/2は、ダブルクォーテーションで括るか括らないかで両端の空白文字の扱いに変化が出るかどうかを確認するためのデータ。

  • test-backslash.csv

バックスラッシュをエスケープ文字とする「fgetcsv($stream)」のような例があるので、あえて分けて検証する。

a,b,c,d,e
"バックスラッシュ(back slash)","\","","でぃ",""
A,B,C,D,E
  • invalid-test1.csv

不正ケースの1。

a,b,c,d,e
"不正な値(invalid value) 1","びぃ\"B","","でぃ",""
A,B,C,D,E

バックスラッシュでエスケープしているつもりで、本来は単にダブルクォーテーションが余分にあるというデータ。

  • invalid-test2.csv

不正ケースの2。

a,b,c,d,e
"不正な値(invalid value) 2","びぃ"B"びぃ","","でぃ",""
A,B,C,D,E

RFC 4180によると、

field = (escaped / non-escaped)
escaped = DQUOTE *(TEXTDATA / COMMA / CR / LF / 2DQUOTE) DQUOTE
non-escaped = *TEXTDATA

なので、上記の「"びぃ"B"びぃ"」の部分は「escaped non-escaped escaped」となっていて、本来なら構文エラーになるはずのデータ。

fgetcsv/fputcsv (PHP)

fgetcsvについては以下。

なお、str_getcsvは基本的にfgetcsvと同じなので、ここでは省略する。

<?php

mb_internal_encoding('UTF-8');

$header = array();

while (($row = fgetcsv(STDIN, 0, ',', '"', '"'))) {
    if (empty($header)) {
        $header = $row;
        fputcsv(STDOUT, $header);
    } else {
        $ss = $row;
        $a = array();
        for ($i = 0; $i < count($header); ++$i) {
            if (isset($ss[$i])) {
                $a[$header[$i]] = mb_convert_encoding($ss[$i], mb_internal_encoding(), 'SJIS-win');
            } else {
                $a[$header[$i]] = 'N/A';
            }
        }
        var_dump($a);
        fputcsv(STDOUT, $a);
    }
}

実行結果。

$ php Main.php < test.csv
a,b,c,d,e
array(5) {
  ["a"]=>
  string(1) "A"
  ["b"]=>
  string(1) "B"
  ["c"]=>
  string(1) "C"
  ["d"]=>
  string(1) "D"
  ["e"]=>
  string(1) "E"
}
A,B,C,D,E
array(5) {
  ["a"]=>
  string(16) "カンマ(comma)"
  ["b"]=>
  string(1) ","
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
カンマ(comma),",",,でぃ,
array(5) {
  ["a"]=>
  string(51) "ダブルクォーテーション(double quotation)"
  ["b"]=>
  string(1) """
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
"ダブルクォーテーション(double quotation)","""",,でぃ,
array(5) {
  ["a"]=>
  string(6) "改行"
  ["b"]=>
  string(2) "
"
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
改行,"
",,でぃ,
array(5) {
  ["a"]=>
  string(36) "空白文字の扱い(white space) 1"
  ["b"]=>
  string(3) " B "
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
"空白文字の扱い(white space) 1"," B ",,でぃ,
array(5) {
  ["a"]=>
  string(36) "空白文字の扱い(white space) 2"
  ["b"]=>
  string(3) " B "
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
"空白文字の扱い(white space) 2"," B ",,でぃ,
$ 
$ php Main.php < test-backslash.csv
a,b,c,d,e
array(5) {
  ["a"]=>
  string(36) "バックスラッシュ(back slash)"
  ["b"]=>
  string(1) "\"
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
"バックスラッシュ(back slash)","\",,でぃ,
array(5) {
  ["a"]=>
  string(1) "A"
  ["b"]=>
  string(1) "B"
  ["c"]=>
  string(1) "C"
  ["d"]=>
  string(1) "D"
  ["e"]=>
  string(1) "E"
}
A,B,C,D,E
$ 
$ php Main.php < invalid-test1.csv
a,b,c,d,e
array(5) {
  ["a"]=>
  string(29) "不正な値(invalid value) 1"
  ["b"]=>
  string(9) "びぃ\B""
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
"不正な値(invalid value) 1","びぃ\B""",,でぃ,
array(5) {
  ["a"]=>
  string(1) "A"
  ["b"]=>
  string(1) "B"
  ["c"]=>
  string(1) "C"
  ["d"]=>
  string(1) "D"
  ["e"]=>
  string(1) "E"
}
A,B,C,D,E
$ 
$ php Main.php < invalid-test2.csv
a,b,c,d,e
array(5) {
  ["a"]=>
  string(29) "不正な値(invalid value) 2"
  ["b"]=>
  string(15) "びぃB"びぃ""
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
"不正な値(invalid value) 2","びぃB""びぃ""",,でぃ,
array(5) {
  ["a"]=>
  string(1) "A"
  ["b"]=>
  string(1) "B"
  ["c"]=>
  string(1) "C"
  ["d"]=>
  string(1) "D"
  ["e"]=>
  string(1) "E"
}
A,B,C,D,E
$ 

test.csvに関しては、fgetcsvのescapeを指定することにより、特に問題なく処理されている。

test-backslash.csvについては、「fgetcsv(STDIN, ',', '"', '"')」とエスケープ文字を変えているので問題なし。

invalid-test1.csvは謎。なぜ「"びぃ\"B"」を読み込んだ結果が「びぃ\B"」となるのか。

invalid-test2.csvも謎。せめて「びぃBびぃ」となってほしいところが「びぃB"びぃ"」となっている。どういうロジックでこうなっているんだろうか。

Super CSV (Java)

Super CSV自体についての詳細は以下。

Super CSVで一番シンプルにCSVデータの読み書きをするにはorg.supercsv.io.CsvListReaderとorg.supercsv.io.CsvListWriterを使う。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.Arrays;
import java.util.List;

import org.supercsv.exception.SuperCsvException;
import org.supercsv.io.CsvListReader;
import org.supercsv.io.CsvListWriter;
import org.supercsv.io.ICsvListReader;
import org.supercsv.io.ICsvListWriter;
import org.supercsv.prefs.CsvPreference;

public class Main {
    public static void main(String[] args) throws IOException {
        CsvPreference csvPref = new CsvPreference.Builder(
            CsvPreference.STANDARD_PREFERENCE).surroundingSpacesNeedQuotes(true).build();
        ICsvListReader reader = new CsvListReader(
            new BufferedReader(new InputStreamReader(System.in, "Windows-31J")), csvPref);
        ICsvListWriter writer = new CsvListWriter(
            new OutputStreamWriter(System.out), csvPref);
        String[] header = reader.getHeader(true);
        System.out.println(Arrays.toString(header));
        while (true) {
            List<String> row;
            try {
                if ((row = reader.read()) == null) {
                    break;
                }
            } catch (SuperCsvException e) {
                e.printStackTrace();
                continue;
            }

            System.out.println();
            int i = 0;
            for (String val : row) {
                System.out.println((header.length >= i + 1 ? header[i++] : "N/A")
                    + " => " + (val != null ? "\"" + val + "\"" : val));
            }
            writer.write(row);
            writer.flush();
        }
    }
}

実行結果。

[super-csv]$ mvn package
[super-csv]$ 
[super-csv]$ mvn exec:java < ../test.csv
[a, b, c, d, e]

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
A,B,C,D,E

a => "カンマ(comma)"
b => ","
c => null
d => "でぃ"
e => null
カンマ(comma),",",,でぃ,

a => "ダブルクォーテーション(double quotation)"
b => """
c => null
d => "でぃ"
e => null
ダブルクォーテーション(double quotation),"""",,でぃ,

a => "改行"
b => "
"
c => null
d => "でぃ"
e => null
改行,"
",,でぃ,

a => "空白文字の扱い(white space) 1"
b => "B"
c => null
d => "でぃ"
e => null
空白文字の扱い(white space) 1,B,,でぃ,

a => "空白文字の扱い(white space) 2"
b => " B "
c => null
d => "でぃ"
e => null
空白文字の扱い(white space) 2," B ",,でぃ,
[super-csv]$ 
[super-csv]$ mvn exec:java < ../test-backslash.csv
[a, b, c, d, e]

a => "バックスラッシュ(back slash)"
b => "\"
c => null
d => "でぃ"
e => null
バックスラッシュ(back slash),\,,でぃ,

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
A,B,C,D,E
[super-csv]$ 
[super-csv]$ mvn exec:java < ../invalid-test1.csv
[a, b, c, d, e]
org.supercsv.exception.SuperCsvException: unexpected end of file while reading quoted column beginning on line 2 and ending on line 3
context=null
    at org.supercsv.io.Tokenizer.readColumns(Tokenizer.java:162)
    at org.supercsv.io.AbstractCsvReader.readRow(AbstractCsvReader.java:179)
    at org.supercsv.io.CsvListReader.read(CsvListReader.java:69)
    at Main.main(Main.java:28)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
    at java.lang.Thread.run(Thread.java:748)
[super-csv]$ 
[super-csv]$ mvn exec:java < ../invalid-test2.csv
[a, b, c, d, e]

a => "不正な値(invalid value) 2"
b => "びぃBびぃ"
c => null
d => "でぃ"
e => null
不正な値(invalid value) 2,びぃBびぃ,,でぃ,

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
A,B,C,D,E
[super-csv]$ 

test.csvでは、「空白文字の扱い(white space) 1」で前後の空白文字が無視されている。

test-backslash.csvは問題なし。

invalid-test1.csvでは、期待通り構文エラーだとしてorg.supercsv.exception.SuperCsvExceptionが投げられている。ただ、これはjava.lang.RuntimeExceptionを継承していることに注意しないといけない。

invalid-test2.csvは、RFC 4180違反ではあるけど、「びぃBびぃ」として扱っていて、まあまあ。

Super CSV Annotation (Java)

Super CSV Annotation自体についての詳細は以下。

Super CSV Annotationでは、CSVデータに合わせたBeanクラスを作る。それ以外の部分は基本的にSuper CSVをベースとしているので、まぁ結果は予想がつくが。

import com.github.mygreen.supercsv.annotation.CsvBean;
import com.github.mygreen.supercsv.annotation.CsvColumn;

@CsvBean(header=true)
public class HogeBean {
    @CsvColumn(number=1)
    private String fieldA;
    @CsvColumn(number=2, label="びぃ")
    private String fieldB;
    @CsvColumn(number=3)
    private String fieldC;
    @CsvColumn(number=4)
    private String fieldD;
    @CsvColumn(number=5)
    private String fieldE;

    public HogeBean() {
    }

    public String getFieldA() {
        return fieldA;
    }
    public void setFieldA(String fieldA) {
        this.fieldA = fieldA;
    }

    public String getFieldB() {
        return fieldB;
    }
    public void setFieldB(String fieldB) {
        this.fieldB = fieldB;
    }

    public String getFieldC() {
        return fieldC;
    }
    public void setFieldC(String fieldC) {
        this.fieldC = fieldC;
    }

    public String getFieldD() {
        return fieldD;
    }
    public void setFieldD(String fieldD) {
        this.fieldD = fieldD;
    }

    public String getFieldE() {
        return fieldE;
    }
    public void setFieldE(String fieldE) {
        this.fieldE = fieldE;
    }
}
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.Arrays;

import com.github.mygreen.supercsv.io.CsvAnnotationBeanReader;
import com.github.mygreen.supercsv.io.CsvAnnotationBeanWriter;

import org.supercsv.exception.SuperCsvException;
import org.supercsv.prefs.CsvPreference;

public class Main {
    public static void main(String[] args) throws IOException {
        CsvPreference csvPref = new CsvPreference.Builder(
            CsvPreference.STANDARD_PREFERENCE).surroundingSpacesNeedQuotes(true).build();
        CsvAnnotationBeanReader<HogeBean> reader = new CsvAnnotationBeanReader<>(
            HogeBean.class,
            new BufferedReader(new InputStreamReader(System.in, "Windows-31J")),
            csvPref);
        CsvAnnotationBeanWriter<HogeBean> writer = new CsvAnnotationBeanWriter<>(
            HogeBean.class,
            new OutputStreamWriter(System.out),
            csvPref);
        String[] header = reader.getHeader(true);
        System.out.println(Arrays.toString(header));
        while (true) {
            HogeBean hoge;
            try {
                if ((hoge = reader.read()) == null) {
                    break;
                }
            } catch (SuperCsvException e) {
                e.printStackTrace();
                continue;
            }

            System.out.println();
            System.out.println((header.length >= 1 ? header[0] : "N/A")
                + " => " + (hoge.getFieldA() != null ? "\"" + hoge.getFieldA() + "\"" : null));
            System.out.println((header.length >= 2 ? header[1] : "N/A")
                + " => " + (hoge.getFieldB() != null ? "\"" + hoge.getFieldB() + "\"" : null));
            System.out.println((header.length >= 3 ? header[2] : "N/A")
                + " => " + (hoge.getFieldC() != null ? "\"" + hoge.getFieldC() + "\"" : null));
            System.out.println((header.length >= 4 ? header[3] : "N/A")
                + " => " + (hoge.getFieldD() != null ? "\"" + hoge.getFieldD() + "\"" : null));
            System.out.println((header.length >= 5 ? header[4] : "N/A")
                + " => " + (hoge.getFieldE() != null ? "\"" + hoge.getFieldE() + "\"" : null));
            writer.write(hoge);
            writer.flush();
        }
    }
}

実行結果。

[super-csv-annotation]$ mvn package
[super-csv-annotation]$ 
[super-csv-annotation]$ mvn exec:java < ../test.csv
[a, b, c, d, e]

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
A,B,C,D,E

a => "カンマ(comma)"
b => ","
c => null
d => "でぃ"
e => null
カンマ(comma),",",,でぃ,

a => "ダブルクォーテーション(double quotation)"
b => """
c => null
d => "でぃ"
e => null
ダブルクォーテーション(double quotation),"""",,でぃ,

a => "改行"
b => "
"
c => null
d => "でぃ"
e => null
改行,"
",,でぃ,

a => "空白文字の扱い(white space) 1"
b => "B"
c => null
d => "でぃ"
e => null
空白文字の扱い(white space) 1,B,,でぃ,

a => "空白文字の扱い(white space) 2"
b => " B "
c => null
d => "でぃ"
e => null
空白文字の扱い(white space) 2," B ",,でぃ,
[super-csv-annotation]$ 
[super-csv-annotation]$ mvn exec:java < ../test-backslash.csv
[a, b, c, d, e]

a => "バックスラッシュ(back slash)"
b => "\"
c => null
d => "でぃ"
e => null
バックスラッシュ(back slash),\,,でぃ,

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
A,B,C,D,E
[super-csv-annotation]$ 
[super-csv-annotation]$ mvn exec:java < ../invalid-test1.csv
[a, b, c, d, e]
org.supercsv.exception.SuperCsvException: unexpected end of file while reading quoted column beginning on line 2 and ending on line 3
context=null
    at org.supercsv.io.Tokenizer.readColumns(Tokenizer.java:162)
    at org.supercsv.io.AbstractCsvReader.readRow(AbstractCsvReader.java:179)
    at com.github.mygreen.supercsv.io.AbstractCsvAnnotationBeanReader.read(AbstractCsvAnnotationBeanReader.java:90)
    at Main.main(Main.java:30)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
    at java.lang.Thread.run(Thread.java:748)
[super-csv-annotation]$ 
[super-csv-annotation]$ mvn exec:java < ../invalid-test2.csv
[a, b, c, d, e]

a => "不正な値(invalid value) 2"
b => "びぃBびぃ"
c => null
d => "でぃ"
e => null
不正な値(invalid value) 2,びぃBびぃ,,でぃ,

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
A,B,C,D,E
[super-csv-annotation]$ 

予想通り、ですよねー、という結果。

OrangeSignal CSV (Java)

OrangeSignal CSV自体についての詳細は以下。

クイックスタートを見ると、Super CSV Annotationと同様にBeanクラスを作るやり方のようだ。クイックスタートの通りにpom.xmlを書くとエラーを吐くので、以下のように書く。

<dependency>
  <groupId>com.orangesignal</groupId>
  <artifactId>orangesignal-csv</artifactId>
  <version>2.2.1</version>
</dependency>
import com.orangesignal.csv.annotation.CsvColumn;
import com.orangesignal.csv.annotation.CsvEntity;

@CsvEntity(header=true)
public class HogeBean {
    @CsvColumn(name="a")
    private String fieldA;
    @CsvColumn(name="b")
    private String fieldB;
    @CsvColumn(name="c")
    private String fieldC;
    @CsvColumn(name="d")
    private String fieldD;
    @CsvColumn(name="e")
    private String fieldE;

    public HogeBean() {
    }

    public String getFieldA() {
        return fieldA;
    }
    public void setFieldA(String fieldA) {
        this.fieldA = fieldA;
    }

    public String getFieldB() {
        return fieldB;
    }
    public void setFieldB(String fieldB) {
        this.fieldB = fieldB;
    }

    public String getFieldC() {
        return fieldC;
    }
    public void setFieldC(String fieldC) {
        this.fieldC = fieldC;
    }

    public String getFieldD() {
        return fieldD;
    }
    public void setFieldD(String fieldD) {
        this.fieldD = fieldD;
    }

    public String getFieldE() {
        return fieldE;
    }
    public void setFieldE(String fieldE) {
        this.fieldE = fieldE;
    }
}
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.List;

import com.orangesignal.csv.annotation.CsvColumnException;
import com.orangesignal.csv.CsvConfig;
import com.orangesignal.csv.CsvReader;
import com.orangesignal.csv.CsvWriter;
import com.orangesignal.csv.io.CsvEntityReader;
import com.orangesignal.csv.io.CsvEntityWriter;

public class Main {
    public static void main(String[] args) throws IOException {
        CsvConfig cfg = new CsvConfig(',', '"', '"');
        CsvEntityReader<HogeBean> reader = CsvEntityReader.newInstance(new CsvReader(
            new BufferedReader(new InputStreamReader(System.in, "Windows-31J")), cfg), HogeBean.class);
        CsvEntityWriter<HogeBean> writer = CsvEntityWriter.newInstance(new CsvWriter(
            new OutputStreamWriter(System.out), cfg), HogeBean.class);
        List<String> header = reader.getHeader();
        System.out.println(header);
        while (true) {
            HogeBean hoge;
            try {
                if ((hoge = reader.read()) == null) {
                    break;
                }
            } catch (CsvColumnException e) {
                e.printStackTrace();
                continue;
            }

            System.out.println();
            System.out.println((header.size() >= 1 ? header.get(0) : "N/A")
                + " => " + (hoge.getFieldA() != null ? "\"" + hoge.getFieldA() + "\"" : null));
            System.out.println((header.size() >= 2 ? header.get(1) : "N/A")
                + " => " + (hoge.getFieldB() != null ? "\"" + hoge.getFieldB() + "\"" : null));
            System.out.println((header.size() >= 3 ? header.get(2) : "N/A")
                + " => " + (hoge.getFieldC() != null ? "\"" + hoge.getFieldC() + "\"" : null));
            System.out.println((header.size() >= 4 ? header.get(3) : "N/A")
                + " => " + (hoge.getFieldD() != null ? "\"" + hoge.getFieldD() + "\"" : null));
            System.out.println((header.size() >= 5 ? header.get(4) : "N/A")
                + " => " + (hoge.getFieldE() != null ? "\"" + hoge.getFieldE() + "\"" : null));
            writer.write(hoge);
            writer.flush();
        }
    }
}

実行結果。

[orange-signal-csv]$ mvn package
[orange-signal-csv]$ 
[orange-signal-csv]$ mvn exec:java < ../test.csv
[a, b, c, d, e]

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
"a","b","c","d","e"
"A","B","C","D","E"

a => "カンマ(comma)"
b => ","
c => ""
d => "でぃ"
e => ""
"カンマ(comma)",",","","でぃ",""

a => "ダブルクォーテーション(double quotation)"
b => """
c => ""
d => "でぃ"
e => ""
"ダブルクォーテーション(double quotation)","""","","でぃ",""

a => "改行"
b => "
"
c => ""
d => "でぃ"
e => ""
"改行","
","","でぃ",""

a => "空白文字の扱い(white space) 1"
b => " B "
c => ""
d => "でぃ"
e => ""
"空白文字の扱い(white space) 1"," B ","","でぃ",""

a => "空白文字の扱い(white space) 2"
b => " B "
c => ""
d => "でぃ"
e => ""
"空白文字の扱い(white space) 2"," B ","","でぃ",""
[WARNING] 
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
    at java.util.ArrayList.rangeCheck(ArrayList.java:657)
    at java.util.ArrayList.get(ArrayList.java:433)
    at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
    at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
    at Main.main(Main.java:26)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
    at java.lang.Thread.run(Thread.java:748)
[orange-signal-csv]$ 
[orange-signal-csv]$ mvn exec:java < ../test-backslash.csv
[a, b, c, d, e]

a => "バックスラッシュ(back slash)"
b => "\"
c => ""
d => "でぃ"
e => ""
"a","b","c","d","e"
"バックスラッシュ(back slash)","\","","でぃ",""

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
"A","B","C","D","E"
[WARNING] 
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
    at java.util.ArrayList.rangeCheck(ArrayList.java:657)
    at java.util.ArrayList.get(ArrayList.java:433)
    at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
    at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
    at Main.main(Main.java:26)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
    at java.lang.Thread.run(Thread.java:748)
[orange-signal-csv]$ 
[orange-signal-csv]$ mvn exec:java < ../invalid-test1.csv
[a, b, c, d, e]

a => "不正な値(invalid value) 1"
b => "びぃ\"B"
c => ""
d => "でぃ"
e => ""
"a","b","c","d","e"
"不正な値(invalid value) 1","びぃ\""B","","でぃ",""

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
"A","B","C","D","E"
[WARNING] 
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
    at java.util.ArrayList.rangeCheck(ArrayList.java:657)
    at java.util.ArrayList.get(ArrayList.java:433)
    at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
    at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
    at Main.main(Main.java:26)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
    at java.lang.Thread.run(Thread.java:748)
[orange-signal-csv]$ 
[orange-signal-csv]$ mvn exec:java < ../invalid-test2.csv
[a, b, c, d, e]

a => "不正な値(invalid value) 2"
b => "びぃ"B"びぃ"
c => ""
d => "でぃ"
e => ""
"a","b","c","d","e"
"不正な値(invalid value) 2","びぃ""B""びぃ","","でぃ",""

a => "A"
b => "B"
c => "C"
d => "D"
e => "E"
"A","B","C","D","E"
[WARNING] 
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
    at java.util.ArrayList.rangeCheck(ArrayList.java:657)
    at java.util.ArrayList.get(ArrayList.java:433)
    at com.orangesignal.csv.io.CsvEntityReader.convert(CsvEntityReader.java:300)
    at com.orangesignal.csv.io.CsvEntityReader.read(CsvEntityReader.java:198)
    at Main.main(Main.java:26)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
    at java.lang.Thread.run(Thread.java:748)
[orange-signal-csv]$ 

いろいろと微妙だ。

まず、以下を生成して渡してやらないと、囲み文字(")すら有効にならないらしいのでそれは指定したが。

        CsvConfig cfg = new CsvConfig(',', '"', '"');

いずれのファイルに対しても、ファイル末尾の改行コードが邪魔をして、その後ろを空文字列から成る行として扱い、IndexOutOfBoundsExceptionを吐く。これ、OrangeSignal CSVで出力したCSVファイルを食わせたら例外を吐くのではないか?

test.csvとtest-backslash.csvについては特に問題はなさそう(CsvConfigでいろいろと挙動を変えられるらしいが)。

invalid-test1.csvは、なぜ「びぃ\"B」と解析できるのか謎。

invalid-test2.csvは、「びぃ"B"びぃ」となっていて、これも謎。

invalid-test1.csvもinvalid-test2.csvも、ダブルクォーテーションの次が区切り文字(カンマ)でなければ無視するという実装なんだろうか?

opencsv (Java)

opencsv自体についての詳細は以下。

こちらもBeanを作成する方法でやってみる。

import com.opencsv.bean.CsvBindByPosition;

public class HogeBean {
    @CsvBindByPosition(position=0)
    private String fieldA;
    @CsvBindByPosition(position=1)
    private String fieldB;
    @CsvBindByPosition(position=2)
    private String fieldC;
    @CsvBindByPosition(position=3)
    private String fieldD;
    @CsvBindByPosition(position=4)
    private String fieldE;

    public HogeBean() {
    }

    public String getFieldA() {
        return fieldA;
    }
    public void setFieldA(String fieldA) {
        this.fieldA = fieldA;
    }

    public String getFieldB() {
        return fieldB;
    }
    public void setFieldB(String fieldB) {
        this.fieldB = fieldB;
    }

    public String getFieldC() {
        return fieldC;
    }
    public void setFieldC(String fieldC) {
        this.fieldC = fieldC;
    }

    public String getFieldD() {
        return fieldD;
    }
    public void setFieldD(String fieldD) {
        this.fieldD = fieldD;
    }

    public String getFieldE() {
        return fieldE;
    }
    public void setFieldE(String fieldE) {
        this.fieldE = fieldE;
    }
}
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;

import com.opencsv.bean.CsvToBean;
import com.opencsv.bean.CsvToBeanBuilder;
import com.opencsv.bean.StatefulBeanToCsv;
import com.opencsv.bean.StatefulBeanToCsvBuilder;
import com.opencsv.exceptions.CsvDataTypeMismatchException;
import com.opencsv.exceptions.CsvRequiredFieldEmptyException;

public class Main {
    public static void main(String[] args) throws IOException {
        CsvToBean<HogeBean> reader = new CsvToBeanBuilder<HogeBean>(
                new BufferedReader(new InputStreamReader(System.in, "Windows-31J"))
            ).withType(HogeBean.class).build();
        Writer w = new OutputStreamWriter(System.out); // flush()するために変数にセット。
        StatefulBeanToCsv<HogeBean> writer = new StatefulBeanToCsvBuilder<HogeBean>(
                w
            ).build();
        for (HogeBean hoge : reader) {
            System.out.println();
            System.out.println("[0] => "
                + (hoge.getFieldA() != null ? "\"" + hoge.getFieldA() + "\"" : null));
            System.out.println("[1] => "
                + (hoge.getFieldB() != null ? "\"" + hoge.getFieldB() + "\"" : null));
            System.out.println("[2] => "
                + (hoge.getFieldC() != null ? "\"" + hoge.getFieldC() + "\"" : null));
            System.out.println("[3] => "
                + (hoge.getFieldD() != null ? "\"" + hoge.getFieldD() + "\"" : null));
            System.out.println("[4] => "
                + (hoge.getFieldE() != null ? "\"" + hoge.getFieldE() + "\"" : null));
            try {
                writer.write(hoge);
                w.flush();
            } catch (CsvDataTypeMismatchException|CsvRequiredFieldEmptyException e) {
                e.printStackTrace();
            }
        }
    }
}

実行結果。

[opencsv]$ mvn package
[opencsv]$ 
[opencsv]$ mvn exec:java < ../test.csv

[0] => "a"
[1] => "b"
[2] => "c"
[3] => "d"
[4] => "e"
"a","b","c","d","e"

[0] => "A"
[1] => "B"
[2] => "C"
[3] => "D"
[4] => "E"
"A","B","C","D","E"

[0] => "カンマ(comma)"
[1] => ","
[2] => ""
[3] => "でぃ"
[4] => ""
"カンマ(comma)",",","","でぃ",""

[0] => "ダブルクォーテーション(double quotation)"
[1] => """
[2] => ""
[3] => "でぃ"
[4] => ""
"ダブルクォーテーション(double quotation)","""","","でぃ",""

[0] => "改行"
[1] => "
"
[2] => ""
[3] => "でぃ"
[4] => ""
"改行","
","","でぃ",""

[0] => "空白文字の扱い(white space) 1"
[1] => " B "
[2] => ""
[3] => "でぃ"
[4] => ""
"空白文字の扱い(white space) 1"," B ","","でぃ",""

[0] => "空白文字の扱い(white space) 2"
[1] => " B "
[2] => ""
[3] => "でぃ"
[4] => ""
"空白文字の扱い(white space) 2"," B ","","でぃ",""
[opencsv]$ 
[opencsv]$ mvn exec:java < ../test-backslash.csv
[WARNING] 
java.lang.RuntimeException: Error parsing CSV.
    at com.opencsv.bean.CsvToBean$CsvToBeanIterator.readSingleLine(CsvToBean.java:394)
    at com.opencsv.bean.CsvToBean$CsvToBeanIterator.next(CsvToBean.java:410)
    at Main.main(Main.java:23)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.codehaus.mojo.exec.ExecJavaMojo$1.run(ExecJavaMojo.java:282)
    at java.lang.Thread.run(Thread.java:748)
Caused by: com.opencsv.exceptions.CsvMalformedLineException: Unterminated quoted field at end of CSV line. Beginning of lost text: [",",でぃ,"
A,B,C,D,E
]
    at com.opencsv.CSVReader.primeNextRecord(CSVReader.java:245)
    at com.opencsv.CSVReader.flexibleRead(CSVReader.java:598)
    at com.opencsv.CSVReader.readNext(CSVReader.java:204)
    at com.opencsv.bean.concurrent.SingleLineReader.readNextLine(SingleLineReader.java:49)
    at com.opencsv.bean.CsvToBean$CsvToBeanIterator.readLineWithPossibleError(CsvToBean.java:364)
    at com.opencsv.bean.CsvToBean$CsvToBeanIterator.readSingleLine(CsvToBean.java:391)
    ... 8 more
[opencsv]$ 
[opencsv]$ mvn exec:java < ../invalid-test1.csv

[0] => "a"
[1] => "b"
[2] => "c"
[3] => "d"
[4] => "e"
"a","b","c","d","e"

[0] => "不正な値(invalid value) 1"
[1] => "びぃ"B"
[2] => ""
[3] => "でぃ"
[4] => ""
"不正な値(invalid value) 1","びぃ""B","","でぃ",""

[0] => "A"
[1] => "B"
[2] => "C"
[3] => "D"
[4] => "E"
"A","B","C","D","E"
[opencsv]$ 
[opencsv]$ mvn exec:java < ../invalid-test2.csv

[0] => "a"
[1] => "b"
[2] => "c"
[3] => "d"
[4] => "e"
"a","b","c","d","e"

[0] => "不正な値(invalid value) 2"
[1] => "びぃ"B"びぃ"
[2] => ""
[3] => "でぃ"
[4] => ""
"不正な値(invalid value) 2","びぃ""B""びぃ","","でぃ",""

[0] => "A"
[1] => "B"
[2] => "C"
[3] => "D"
[4] => "E"
"A","B","C","D","E"
[opencsv]$ 

test.csvは特に問題なし。

test-backslash.csvは、バックスラッシュがエスケープ文字だと扱われて構文エラーになっている。 最初、

        CsvToBean<HogeBean> reader = new CsvToBeanBuilder<HogeBean>(
                new BufferedReader(new InputStreamReader(System.in, "Windows-31J"))
            )
            .withSeparator(',')
            .withQuoteChar('"')
            .withEscapeChar('"')
            .withType(HogeBean.class).build();

と書いて試してみたけど、 java.lang.UnsupportedOperationException: The separator, quote, and escape characters must be different! と怒られてしまう。

invalid-test1.csvは、なぜか「びぃ"B」となる。

invalid-test2.csvも、なぜか「びぃ"B"びぃ」となる。

まとめ

RFC 4180をもとにした各方法の挙動は以下のようになる。

  1. ダブルクォーテーションによるクォート文字列の扱い
  2. クォート文字列中のダブルクォーテーションの扱い
  3. クォート文字列中の改行コードの扱い
  4. バックスラッシュの扱い(エスケープ文字として扱わないかどうか)
  5. 前後の空白文字の扱い
  6. ダブルクォーテーションが閉じていないケースの扱い
  7. クォート文字列とそれ以外が混在したケースの扱い
関数/ライブラリ 1 2 3 4 5 6 7
fgetcsv/fputcsv (PHP) × ×
Super CSV (Java)
Super CSV Annotation (Java)
OrangeSignal CSV (Java) ○* ○* ○* ○* ○* ×* ×*
opencsv (Java) × × ×

(*)末尾の改行コードが邪魔をして例外を投げる。

参考

おまけ(Excelで開く)

(2021年11月10日追記)

f:id:hhelibex:20211110164101p:plain

  • test-backslash.csv

f:id:hhelibex:20211110164149p:plain

  • invalid-test1.csv

f:id:hhelibex:20211110164221p:plain

  • invalid-test2.csv

f:id:hhelibex:20211110164240p:plain

Excelにとって辛いところは、「一般ユーザーが間違いを修正するために」何が何でも開けなきゃいけないところなんだろうな、と。

超訳:Apache MavenでJavaアプリケーション開発

いまさら、人生初のApache Maven使用ということで。

詳細は以下のサイトに譲るとして。

このサイトの内容をベースにして、Javaアプリケーションの作成から実行までを超訳しておく。

Apache Mavenのセットアップ

環境がCentOS 7ということで、パッケージでインストールする。

# yum -y install maven

私の環境では、以下の依存ライブラリが合わせてインストールされた。トータル1+58個。

f:id:hhelibex:20211107222255p:plain

プロジェクトの作成・ビルド

まず、プロジェクトを作成する。

[~]$ mvn archetype:generate
[INFO] Scanning for projects...
[INFO]                                                                         
[INFO] ------------------------------------------------------------------------
[INFO] Building Maven Stub Project (No POM) 1
[INFO] ------------------------------------------------------------------------
[INFO] 
[INFO] >>> maven-archetype-plugin:3.2.0:generate (default-cli) @ standalone-pom >>>
[INFO] 
[INFO] <<< maven-archetype-plugin:3.2.0:generate (default-cli) @ standalone-pom <<<
[INFO] 
[INFO] --- maven-archetype-plugin:3.2.0:generate (default-cli) @ standalone-pom ---
[INFO] Generating project in Interactive mode
[INFO] No archetype defined. Using maven-archetype-quickstart (org.apache.maven.archetypes:maven-archetype-quickstart:1.0)
Choose archetype:
1: remote -> am.ik.archetype:elm-spring-boot-blank-archetype (Blank multi project for Spring Boot + Elm)
  (中略)
1832: remote -> org.apache.maven.archetypes:maven-archetype-profiles (-)
1833: remote -> org.apache.maven.archetypes:maven-archetype-quickstart (An archetype which contains a sample Maven project.)
1834: remote -> org.apache.maven.archetypes:maven-archetype-simple (An archetype which contains a simple Maven project.)
  (中略)
1838: remote -> org.apache.maven.archetypes:maven-archetype-webapp (An archetype which contains a sample Maven Webapp project.) 
  (中略)
3003: remote -> za.co.absa.hyperdrive:component-archetype_2.12 (-)
Choose a number or apply filter (format: [groupId:]artifactId, case sensitive contains): 1833: 
Choose org.apache.maven.archetypes:maven-archetype-quickstart version: 
1: 1.0-alpha-1
2: 1.0-alpha-2
3: 1.0-alpha-3
4: 1.0-alpha-4
5: 1.0
6: 1.1
7: 1.3
8: 1.4
Choose a number: 8: 
Define value for property 'groupId': hhelibex
Define value for property 'artifactId': sample
Define value for property 'version' 1.0-SNAPSHOT: : 
Define value for property 'package' hhelibex: : 
Confirm properties configuration:
groupId: hhelibex
artifactId: sample
version: 1.0-SNAPSHOT
package: hhelibex
 Y: : 
[INFO] ----------------------------------------------------------------------------
[INFO] Using following parameters for creating project from Archetype: maven-archetype-quickstart:1.4
[INFO] ----------------------------------------------------------------------------
[INFO] Parameter: groupId, Value: hhelibex
[INFO] Parameter: artifactId, Value: sample
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] Parameter: package, Value: hhelibex
[INFO] Parameter: packageInPathFormat, Value: hhelibex
[INFO] Parameter: package, Value: hhelibex
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] Parameter: groupId, Value: hhelibex
[INFO] Parameter: artifactId, Value: sample
[INFO] Project created from Archetype in dir: /home/hhelibex/sample
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 2:00.363s
[INFO] Finished at: Sun Nov 07 22:56:52 JST 2021
[INFO] Final Memory: 13M/55M
[INFO] ------------------------------------------------------------------------
[~]$ 

基本的に、入力するのは以下の2行のみで、あとは単に[Enter]を押せばよい。

Define value for property 'groupId': hhelibex
Define value for property 'artifactId': sample

そしてビルド。

[~]$ cd sample
[sample]$ mvn package
[INFO] Scanning for projects...
[INFO]                                                                         
[INFO] ------------------------------------------------------------------------
[INFO] Building sample 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO] 
[INFO] --- maven-resources-plugin:3.0.2:resources (default-resources) @ sample ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] skip non existing resourceDirectory /home/hhelibex/sample/src/main/resources
[INFO] 
[INFO] --- maven-compiler-plugin:3.8.0:compile (default-compile) @ sample ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 1 source file to /home/hhelibex/sample/target/classes
[INFO] 
[INFO] --- maven-resources-plugin:3.0.2:testResources (default-testResources) @ sample ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] skip non existing resourceDirectory /home/hhelibex/sample/src/test/resources
[INFO] 
[INFO] --- maven-compiler-plugin:3.8.0:testCompile (default-testCompile) @ sample ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 1 source file to /home/hhelibex/sample/target/test-classes
[INFO] 
[INFO] --- maven-surefire-plugin:2.22.1:test (default-test) @ sample ---
[INFO] 
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running hhelibex.AppTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.988 s - in hhelibex.AppTest
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] 
[INFO] --- maven-jar-plugin:3.0.2:jar (default-jar) @ sample ---
[INFO] Building jar: /home/hhelibex/sample/target/sample-1.0-SNAPSHOT.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1:28.550s
[INFO] Finished at: Sun Nov 07 23:05:15 JST 2021
[INFO] Final Memory: 15M/86M
[INFO] ------------------------------------------------------------------------
[sample]$ 

アプリケーションの実行

pom.xmlを編集する。pluginsタグの中に以下を追記する。

        <plugin>
          <groupId>org.codehaus.mojo</groupId>
          <artifactId>exec-maven-plugin</artifactId>
          <version>1.6.0</version>
          <configuration>
            <mainClass>hhelibex.App</mainClass>
          </configuration>
        </plugin>

そして実行。

[sample]$ mvn exec:java
[INFO] Scanning for projects...
[INFO]                                                                         
[INFO] ------------------------------------------------------------------------
[INFO] Building sample 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO] 
[INFO] --- exec-maven-plugin:1.6.0:java (default-cli) @ sample ---
Hello World!
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 23.067s
[INFO] Finished at: Sun Nov 07 23:12:56 JST 2021
[INFO] Final Memory: 9M/29M
[INFO] ------------------------------------------------------------------------
[sample]$ 

以上!

RFC4180に従わないfgetcsv/str_getcsvの独自仕様

「バックスラッシュを削除するとうまくいく」。そんな問い合わせを受けて調べてみたら、驚愕の事実が発覚。その調査メモ。

CSVファイル

さすがにオリジナルを載せるわけにはいかないので、簡略化したCSVファイルを示す。文字エンコーディングWindows-31J

a,b,c,d,e
A,B,C,D,E
"カンマ(comma)",",","","でぃ",""
"ダブルクォーテーション(double quotation)","""","","でぃ",""
"改行","
","","でぃ",""
"バックスラッシュ(back slash)","\","","でぃ",""

ついでに、CSVで気を付けるべき、カンマ、ダブルクォーテーション、改行についても合わせてテストしてみる。

str_getcsvでのテスト

そもそもの発端である問い合わせの対象システムがPHPのstr_getcsvを使ってCSVファイル解析しているので、それを模倣したテストプログラムを作ってみる。

<?php

mb_internal_encoding('UTF-8');

$header = array();

while (($str = fgets(STDIN))) {
    $str = mb_convert_encoding($str, mb_internal_encoding(), 'SJIS-win');
    if (empty($header)) {
        $header = str_getcsv($str);
    } else {
        $ss = str_getcsv($str);
        $a = array();
        for ($i = 0; $i < count($header); ++$i) {
            if (isset($ss[$i])) {
                $a[$header[$i]] = $ss[$i];
            } else {
                $a[$header[$i]] = 'N/A';
            }
        }
        var_dump($a);
    }
}

実行結果。

$ php Main.php < test.csv
array(5) {
  ["a"]=>
  string(1) "A"
  ["b"]=>
  string(1) "B"
  ["c"]=>
  string(1) "C"
  ["d"]=>
  string(1) "D"
  ["e"]=>
  string(1) "E"
}
array(5) {
  ["a"]=>
  string(16) "カンマ(comma)"
  ["b"]=>
  string(1) ","
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
array(5) {
  ["a"]=>
  string(51) "ダブルクォーテーション(double quotation)"
  ["b"]=>
  string(1) """
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
array(5) {
  ["a"]=>
  string(6) "改行"
  ["b"]=>
  string(3) "
"
  ["c"]=>
  string(3) "N/A"
  ["d"]=>
  string(3) "N/A"
  ["e"]=>
  string(3) "N/A"
}
array(5) {
  ["a"]=>
  string(10) ",",でぃ""
  ["b"]=>
  string(0) ""
  ["c"]=>
  string(3) "N/A"
  ["d"]=>
  string(3) "N/A"
  ["e"]=>
  string(3) "N/A"
}
array(5) {
  ["a"]=>
  string(36) "バックスラッシュ(back slash)"
  ["b"]=>
  string(12) "\",",でぃ""
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(3) "N/A"
  ["e"]=>
  string(3) "N/A"
}

まず、バックスラッシュの行だが、事前に予想した通り、バックスラッシュがエスケープ文字として機能している「ように見える」。 「ように見える」というのは、以下の2点の疑問をはらんでいるからである。

  1. エスケープ文字であるならば、値としての文字列にバックスラッシュが残っているのはなぜか?
  2. 元データが "バックスラッシュ(back slash)","\","","でぃ","" であるので、「でぃ」の直前が閉じダブルクォーテーションとなるはずで、その後ろの「でぃ」が連結されたのだと考えても、その後ろのダブルクォーテーションで値が終了しているのがおかしい(開きダブルクォーテーションとなるべきもののはず)。

更に、入力が「ヘッダ1行+データ5行」に対して、出力が「データ6行」になっている。これは、fgetsで1行ずつ読んでいるため、CSV形式の値に含まれる改行コードとして扱われていないことに起因する。ただ、それに対処しようとすると、自前でCSV形式の解析をすることになり、str_getcsvの存在意義がない。

fgetcsvでのテスト

普通に考えたら結果が違うはずはないが、一応確かめておく。

<?php

mb_internal_encoding('SJIS-win');

$header = array();

while (($row = fgetcsv(STDIN))) {
    if (empty($header)) {
        $header = $row;
    } else {
        $ss = $row;
        $a = array();
        for ($i = 0; $i < count($header); ++$i) {
            if (isset($ss[$i])) {
                $a[$header[$i]] = mb_convert_encoding($ss[$i], 'UTF-8', mb_internal_encoding());
            } else {
                $a[$header[$i]] = 'N/A';
            }
        }
        var_dump($a);
    }
}

実行結果。

$ php Main2.php < test.csv 
array(5) {
  ["a"]=>
  string(1) "A"
  ["b"]=>
  string(1) "B"
  ["c"]=>
  string(1) "C"
  ["d"]=>
  string(1) "D"
  ["e"]=>
  string(1) "E"
}
array(5) {
  ["a"]=>
  string(16) "カンマ(comma)"
  ["b"]=>
  string(1) ","
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
array(5) {
  ["a"]=>
  string(51) "ダブルクォーテーション(double quotation)"
  ["b"]=>
  string(1) """
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
array(5) {
  ["a"]=>
  string(6) "改行"
  ["b"]=>
  string(2) "
"
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(6) "でぃ"
  ["e"]=>
  string(0) ""
}
array(5) {
  ["a"]=>
  string(36) "バックスラッシュ(back slash)"
  ["b"]=>
  string(12) "\",",でぃ""
  ["c"]=>
  string(0) ""
  ["d"]=>
  string(3) "N/A"
  ["e"]=>
  string(3) "N/A"
}

str_getcsvの場合と同様に、バックスラッシュはエスケープ文字として働いているように見える。一方で、改行コードを値に含むケースは期待通りに処理されている。

JavaのSuper CSVではどうか?

身近で、JavaのSuper CSVhttps://super-csv.github.io/super-csv/)が使用されているので、比較のために同じCSVファイルを読ませてみた。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.List;

import org.supercsv.io.CsvListReader;
import org.supercsv.io.ICsvListReader;
import org.supercsv.prefs.CsvPreference;

public class Main {
    public static void main(String[] args) throws IOException {
        CsvPreference csvPref = new CsvPreference.Builder(
            CsvPreference.STANDARD_PREFERENCE).surroundingSpacesNeedQuotes(true).build();
        ICsvListReader reader = new CsvListReader(
            new BufferedReader(new InputStreamReader(System.in, "Windows-31J")), csvPref);
        String[] header = reader.getHeader(false);
        System.out.println(Arrays.toString(header));
        List<String> row;
        while ((row = reader.read()) != null) {
            int i = 0;
            for (String val : row) {
                System.out.println((header.length >= i + 1 ? header[i++] : "N/A")
                    + " => " + (val != null ? "\"" + val + "\"" : val));
            }
            System.out.println();
        }
    }
}

実行結果。

$ javac -cp lib/super-csv-2.4.0.jar Main.java
$ java -cp .:lib/super-csv-2.4.0.jar Main < test.csv
[a, b, c, d, e]
a => "A"
b => "B"
c => "C"
d => "D"
e => "E"

a => "カンマ(comma)"
b => ","
c => null
d => "でぃ"
e => null

a => "ダブルクォーテーション(double quotation)"
b => """
c => null
d => "でぃ"
e => null

a => "改行"
b => "
"
c => null
d => "でぃ"
e => null

a => "バックスラッシュ(back slash)"
b => "\"
c => null
d => "でぃ"
e => null

こちらは、値が空文字列の場合にnullを返すという厄介な仕様なのだが、バックスラッシュについての扱いが適切である。

Microsoft Excelで開いてみる

もともとが仕事の調査ということで、社用のMicrosoft 365を拝借して、同じCSVファイルを開いてみた。

f:id:hhelibex:20211104154645p:plain

ご覧の通り、バックスラッシュは単なる文字として扱われる。

CSV形式のファイルに関するRFC

以下の1つ目が対象のRFC(RFC4180)。その日本語訳も見付けたので2つ目に挙げている。

これによると、そもそも2005年10月というのもあるが、

TEXTDATA =  %x20-21 / %x23-2B / %x2D-7E

という辺りが時代を感じさせる。

それはともかく、重要なのは以下の部分である。

6.  Fields containing line breaks (CRLF), double quotes, and commas
       should be enclosed in double-quotes.  For example:

       "aaa","b CRLF
       bb","ccc" CRLF
       zzz,yyy,xxx

   7.  If double-quotes are used to enclose fields, then a double-quote
       appearing inside a field must be escaped by preceding it with
       another double quote.  For example:

       "aaa","b""bb","ccc"

(中略)

   field = (escaped / non-escaped)

   escaped = DQUOTE *(TEXTDATA / COMMA / CR / LF / 2DQUOTE) DQUOTE

   non-escaped = *TEXTDATA

まず、バックスラッシュでエスケープするという記述はどこにもない。更に、値は両端がダブルクォーテーションでくくられているかいないかの二択であり、先のテスト結果にあるような、「\",",でぃ"」という混合形式がそもそも有り得ない。

まぁ、fgetcsvが追加されたPHP 4がリリースされたのが2000年5月(https://ja.wikipedia.org/wiki/PHP_(%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E8%A8%80%E8%AA%9E)#PHP_4)であるので、仕方がないのだろうが。(ちなみにstr_getcsvはPHP 5.3.0から)

その挙動はPHP 8に至るまで変わっていない。

が、よく読むと、

escape
オプションの escape パラメータで、エスケープ文字 (シングルバイト文字 最大で1文字) を設定します。 空文字列("") を指定すると、(RFC 4180 に準拠していない) 独自仕様のエスケープ機構が無効になります。

と書いてあるので、一応意識はされているのか(PHP 7.4.0以降)。

iconvで「シフトJIS」を変換する際にハマった

シフトJIS」で保存された、バックスラッシュ(0x5c)を含むファイルをiconvで変換するときにしばらくハマった。

$ iconv --version
iconv (GNU libc) 2.17
Copyright (C) 2012 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Ulrich Drepper.
$ for enc in Shift-JIS Shift_JIS Shift_JISX0213 SJIS SJIS-win Windows-31J CP932 ; do
    echo -n '\' | iconv -f ${enc} -t UTF-8 | od -cx
done

結果。

0000000 302 245
           a5c2
0000002
0000000 302 245
           a5c2
0000002
0000000 302 245
           a5c2
0000002
0000000 302 245
           a5c2
0000002
0000000   \
           005c
0000001
0000000   \
           005c
0000001
0000000   \
           005c
0000001

ずっと"Shift_JIS"を指定していて、どうしても円記号(U+00A5)になるなぁ、逆斜線(U+005C)が欲しいのに、とハマっていた。 "SJIS-win"か"Windows-31J"か"CP932"を指定しないといけなかったという教訓。

参考