LoginSignup
2
1

More than 3 years have passed since last update.

フィールドに改行文字を含むCSVのパース

Last updated at Posted at 2020-04-14

手段のために目的を探す。

ある時、図書館で出会った「ふつうのコンパイラを作ってみよう」という本の影響で、javaCCを使ったパーサを作ってみたいなぁ、と思っていたところ、

「jp1baseのイベントDBをダンプしたCSVファイルって、ダブルクォートで囲まれたフィールドに改行やカンマが含まれているので、Excelに貼ったりするのが大変だよなぁ。」

という事で、早速パーサを作ってみることにしました。


やること

フィールドデータとしてカンマや改行を含むCSVデータをExcelで読み込み易い形に変換して標準出力する。

やり方

  1. 大まかな流れ

    1. CSVデータをパースして配列データにする。
    2. 1行分のデータを読み込んだら整形して出力する。
    3. 上記をjavaで実現する。
  2. CSVデータの定義

    1. カンマ区切りのデータ
    2. ダブルクォーテーション(")で囲まれたフィールドにはカンマや改行を含む。
    3. また、ダブルクォーテーションで囲まれたフィールド内でダブルクォーテーションそのものを表す場合は、バックスラッシュ(¥)でエスケープする。
  3. 出力形式

    1. 各フィールドをカンマ区切りで出力
    2. 各フィールドはダブルクォーテーションでくくって出力
    3. 各フィールドデータ内のダブルクォーテーションと改行文字はスペースに置き換える。

環境

  1. OS JDKが対応しているOSならどこでも大丈夫です。(自宅のMacで作ったものが、職場のRHEL6サーバとWindow10でそのまま動きました。)
  2. JDK

    この記事はJDK1.6の環境で動作確認しています。
    なお、2020年4月現在、公式サイトによれば100%pureなJavaなのでRuntimeへの依存はありませんとのこと。

  3. JavaCC
    6.0を利用しました。
    セットアップ方法はこちらの記事を参考にしていただければと思います。

    参考記事

スキャナの定義

まず始めにスキャナを定義します。
スキャナとは、字句解析を担う部分で、文字列の羅列から意味のある字句(TOKEN)を生成するものです。
スキャナ定義は".jj"という拡張子のテキストファイルに記述します。
以降、今回私が作成したスキャナ定義です。

CSVParser.jj
// 空白文字を無視するためSKIPの定義です。
// スキップした文字からはTOKENを生成しません。
// ここではスペースとタブを無視してもらいます。
SKIP : {
    " "
  | "\t"
}

// ダブルクォーテーションで囲まれた文字列をうまく読むための定義
// MOREディレクティブを使って、
// ダブルクォーテーションを見つけたら、"IN_DOUBLE_QUOTE"というモードに移行せい!
// と、指示します。
MORE : {
    "\"" : IN_DOUBLE_QUOTE
}

// ダブルクォーテーション読み込み中のルールです。
// IN_DOUBLE_QUOTEモードの場合
// 1. バックスラッシュ以外の文字がきたら続けて次の文字を読みなさい。
// 2. バックスラッシュの次に何かしら文字がきたら、さらに次の文字を読みなさい。
// と指示しています。(2のルールにより、バックスラッシュに続いたダブルクォーテーションをただの文字として読み込んでくれます。
<IN_DOUBLE_QUOTE> MORE: {
    < ~["\\"] >
  | < "\\" ~[] >
}

// 次はダブルコーテーションから抜けるための定義
// IN_DOUBLE_QUOTEモード内で
// 単独のダブルクォーテーションが現れたら、DQFIELDというトークンを生成し、
// デフォルトモード(DEFAULT)に戻れ!と指示しています。
// "DQFIELD"というのは勝手に決めた名前です。
// ついでにIN_DOUBLE_QUOTEというのも勝手に決めた名前です。
<IN_DOUBLE_QUOTE> TOKEN: {
    <DQFIELD : "\""> : DEFAULT
}

// デフォルトモードでは
// カンマ,ダブルクォーテーション, 改行文字を含まない文字の羅列をSTDFIELDトークンとして定義しました。
// また、カンマを<SEPARATOR>トークンとします。
// さらに、行の終端を表す文字として"\n"または"\r"の連続を<EOL>トークンとしてまとめて扱うようにします。
TOKEN : {
    <STDFIELD : (~["\"", ",", "\r", "\n"])+ >
  | <SEPARATOR : "," >
  | <EOL : (["\r", "\n"])+ >
}

パーサの定義

パーサとは、スキャナが生成したTOKENの羅列を解析して、必要なお仕事を行うものです。
ここでは、1行分のCSVデータを配列にして返すことを目指します。
まずは、CSVデータの定義をしていきます。

CSVデータの定義

CSVデータとは各データ(フィールドと呼んでます)がカンマ区切りで並んだ行(レコード)がズラーっと繰り返されているものですね。

CSVデータイメージ
  フィールド1-1,フィールド1-2,フィールド1-3,・・・・
  フィールド2-1,フィールド2-2,フィールド2-3,・・・・
 :

まずは、この構造をパーサで表現していきます。
最初にフィールドの定義。
ちなみに、パーサ定義もスキャナ定義と同じファイルに記述しました。
(CSV程度ならそんなに大きくならないので。)

CSVParser.jj(フィールドの定義)
String field() : {
} {
  (
    <DQFIELD> | <STDFIELD>
  )
}
///// 解説 //////
// 戻り値: データを文字列データとして生成したいので、戻り値の型はStringにしてます。
// 名前: あとで分かりやすいように"filed"という名前にしました。
// 内容: ダブルクォーテーションで囲まれた文字列(DQFIELD)または、普通の文字列(STDFIELD)である。
// と、定義しています。

続いて1行分のデータ(ここではレコードと呼ぶことにしました)の定義です。

jp1EventParser.jj(レコードの定義)
// SEPARATORで区切られたフィールドの0個以上連続しているものとして定義しました。 
// フィールドの配列ってことで、List<String>として定義しています。
List<String> record() : {
} {
  // まずフィールドがあって
  field()
  // 後ろにSEPARATOR区切りでフィールドが0個以上続く(=無い場合もある。)
  // なお、いきなりカンマが来る場合には対応していません。
  (
    <SEPARATOR>
    (field())?
  )*
}

最後にCSVファイル全体の定義です。

CSVParser.jj(CSVファイルの定義)
// csvContents()(CSVの中身ってことで)は
// レコードが複数行並んだものだとおっしゃっています。
// 一行ごとに標準出力してしまい、何も返すつもりは無いのでvoidにしました。
void csvContents() : {
} {
  (
    record()
    <EOL>
  )+
  <EOF> 
}

そしてパーサに肉付け。

CSVファイルの構造を定義したところで、次はその構造を読み込んだら何をさせるか?
という具体的な処理を書いて行きます。
実際に書いたコードがこちら。

CSVParser.jj(パーサ定義に実処理を書き加えたもの。)
// 恐ろしく見た目が変わって見えますが、基本的には()の中に処理を書き足しているだけです。
// フィールドの定義
String field() : {
  String data = "";  // 読み込んだ文字列を格納するための変数(空文字列で初期化しておく。)
  Token fieldToken;  // 読み込んだトークンを格納する変数
} {
  (
    // DQFIELDを読んだら、そのイメージ(実際の文字列)を変数dataに格納。
    fieldToken = <DQFIELD> {
      data = fieldToken.image;
    }
    // または、STDIELDを読んだら、そのイメージ(実際の文字列)を、やっぱり変数dataに格納。
    | fieldToken = <STDFIELD> {
      data = fieldToken.image;
    }
  ) {
    // DQFILEDもしくはSTDFIELDを一つ読み込んだら、変数dataの値を返す。
    return data;
  }
}

// レコードの定義
List<String> record() : {
  List<String> fieldList = new ArrayList<String>();
  String fieldData;
} {
  // まずフィールドがあって
  fieldData = field(){
    // 1個目のフィールドを配列に追加
    fieldList.add(fieldData);
  }
  // 後ろにSEPARATOR区切りでフィールドが0個以上続く(=無い場合もある。)
  // なお、いきなりカンマが来る場合には対応していません。
  (
    <SEPARATOR>
    (fieldData = field(){
      // SEPARATOR区切りで後ろのデータを見つけたら、配列にさらに追加していく。
      fieldList.add(fieldData);
    })?
  )*
  {
    // 一行分読んだらそこまでできた配列を返します。
    return fieldList;
  }
}

// CSVファイル全体の定義
void csvContents() : {
  List<String> csvRecord; // 一行分のデータを格納する変数
} {
  (
    csvRecord = record(){
      // 一行読んだら標準出力に出力します。
      // ここのwriterってのは自作のクラスです。(あとで出てきます。)
      CSVWriter.writeLine(csvRecord);
    }
    <EOL>
  )+
  <EOF> 
}

CSVファイルの中身を変換して出力

パーサの書き方としてはここまでですが、せっかくなので実際に動かせるところまで持っていきます。
まず、さっき出てきた文字列配列をカンマ区切りでダブルクォーテーションでくくって出力してくれるCSVWriterクラスを定義。

CSVWriter.java
import java.util.List;
public class CSVWriter {
  public static void writeLine(List<String> record) {
    String line = "";
    String comma = "";

    for ( String field : record ) {
      // 各フィールドの文字列をカンマ区切りで連結。
      line = line + comma + "\"" + sanitizeString(field) + "\"";
      // 昔からカンマ区切りのレコード作るときはこんな風に最初だけ空文字列が連結されるようにしてるんですが、他にいい方法あったら知りたい。
      comma = ",";
    }

    System.out.println(line);

  }

  private static sanitaizeString(String input){
    // 適当ですみません。
    // 改行文字とダブルクォーテーションを消すのです。
    return input.replace("\n", " ").replace("\r", " ").replace("\"", "");

  }
}

次にCSVParser.jjを完成形に。

CSVParser.jj
// このへんおまじないです。
options {
//  DEBUG_PARSER=true;
  UNICODE_INPUT=true;
}

// パーサクラスの定義(javaのコード)はこのPARSER_BEGIN〜PARSER_ENDの間に記述します。
PARSER_BEGIN(CSVParser)

import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.io.InputStream;
import java.io.FileInputStream;

public class CSVParser {
  public void parseCSV() {
    try {
      csvContents();
    } catch(Exception ex) {
      System.out.println("ParseError occured: " + ex.toString());
    }
  }
}

PARSER_END(CSVParser)

// ここからスキャナの定義(コメントは割愛。)
SKIP : {
    " "
  | "\t"
}

MORE : {
    "\"" : IN_DOUBLE_QUOTE
  | "'" : IN_SINGLE_QUOTE
}

<IN_DOUBLE_QUOTE> MORE: {
    < ~["\"", "\\"] >
  | < "\\" ~[] >
  | < "\"" "\"" >
}

<IN_SINGLE_QUOTE> MORE: {
    < ~["'", "\\"] >
  | < "\\" ~[] >
  | < "'" "'" >
}

<IN_DOUBLE_QUOTE> TOKEN: {
  <DQSTR : "\""> : DEFAULT
}

TOKEN : {
    <STDFIELD : (~["\"", ",", "\r", "\n" ])+ >
  | <SEPERATOR : "," >
  | <EOL : (["\r", "\n"])+ >
}


// ここからパーサの定義(コメントは割愛。)
void csvContents() : {
  List<String> csvRecord;
} {
  (
    csvRecord = record() {
      CSVWriter.writeLine(csvRecord);
    }
    <EOL>
  )+
  <EOF> 
}

List<String> record() : {
  List<String> fieldList = new ArrayList<String>();
  String fieldData;
} {
  fieldData = field() {
    fieldList.add(fieldData);
  }
  (
    <SEPERATOR>
    (fieldData = field(){
       fieldList.add(fieldData);
    })?
  )*
  {
    return fieldList;
  }
}

String field() : {
  String data = "";
  Token fieldToken;
} {
  (
    fieldToken = <DQSTR> {
      data = fieldToken.image;
    }
  | 
    fieldToken = <STDFIELD> {
      data = fieldToken.image;
    }
  ) {
    return data;
  }

}

最後、mainエントリポイントが必要ですよね。
すっごい適当ですが。。。一応呼び出しのサンプルになるので。

CSVConv.java
import java.io.InputStream;
import java.io.FileInputStream;

public class CSVConv {
  public static void main(String[] args) {
    if ( args.length != 1 ) {
      return;
    }

    // 引数をそのまま渡すのは悪い方法だと、後輩に散々教えながら。これ。。
    try(InputStream csvReader = new FileInputStream(args[0])) {
      // こんなInputStreamをもらうコンストラクタを定義した覚えは無いですね。
      // javaCCが勝手に作ってくれるのでご心配なく。
      CSVParser parser = new CSVParser(csvReader, "utf8");

      // 実際にファイルをパースさせます。
      // 読んだ行を勝手に標準出力させてるのでこんな書き方です。
      parser.parseCSV();

    } catch(Exception ex) {
      System.out.println("Error occured: " + ex.toString());
    }
  }
}

コンパイルの仕方など

一応コンパイルの手順を書いておきます。

shell
# javaCCを実行 => CSVParser.jjを読んでCSVParser.javaを作ってくれます。
javacc CSVParser.jj
# そしたら、これでまとめてコンパイル。
javac CSVConv.java

で、お試しCSVファイル作成(こういう改行が含まれた意地悪なデータありますよね。)

hoge.csv
abc,"def
ghi",jkl,"mno,pqr"
stu,vwx,yz

それでは、実行!!

shell
java CSVConv hoge.csv

# こんな出力が得られるはず。
# "abc","def ghi","jkl", "mno,pqr"
# "stu","vwx","yz"

長々と書いてしまいましたが、「こういうデータ、テキストで渡されると困るよね?」を解決する手段の一つとして使えるかな?と思い、記事にしてみました。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1