【JavaScript】 CSV パーサーを自作する
JavaScript の勉強を兼ねて CSV パーサーを自作してみました。仕様として、カンマが「"」でくくられていた場合は、ただの文字として扱います。また、「"」でくくられた中で「"」を使いたい場合は「"」自身でエスケープします。
なお、JavaScript 初心者が書いたプログラムであるため、コードが汚かったり、アンチパターンが含まれていたり、バグが潜んでいたりするかもしれませんがご了承ください。
CSV パーサーの仕様
CSV パーサーの仕様は次のとおりとします。一般的な CSV パーサーとは異なるところがあるかもしれません。
- 入力された文字列を、文字列の終端が現れるまでパースする
- 文字列の終端は CRLF または LF とする
- 行の末尾が「,」だった場合は、返却するレコードの末尾に空のフィールド ('') を追加する
- 「,」が連続して現れた場合は、返却するレコードの当該位置に空のフィールド ('') を挿入する
- フィールドは「"」でくくられていてもよい
- フィールドが「"」でくくられていた場合、くくっている「"」は除去する
- 同 その中に現れる「,」は普通の文字として扱う
- 同 「"」が連続して現れた場合は「"」のエスケープとして扱う
- 解析結果は文字列の配列 (string[]) で返す
API の仕様は次のとおりとします。
/**
* CSV 文字列をレコードまたはレコードの配列にして返す
* @param {string} text - 解析したい文字列
* @returns {string[]|string[][]} - 解析結果
*/
parse(text)
text
が 1 行の場合 (改行コードを含まない場合):text
をそのまま CSV パーサーに渡す- CSV パーサーから受け取った結果 (
string[]
) をそのまま返す
text
が複数行の場合 (改行コードを含んでいる場合):text
を改行コードで分割し、それぞれ CSV パーサーに渡す- CSV パーサーから受け取った各行の結果 (
string[]
) を配列 (string[][]
) にして返す
- 空行と「#」で始まる行は無視する
以上をふまえて、だいたい次のようなテストケースをパスできれば良しとします。
入力 | 期待値 |
---|---|
hello | [ 'hello' ] |
hello, world | [ 'hello', 'world' ] |
hello, "world" | [ 'hello', 'world' ] |
hello, world, | [ 'hello', 'world', '' ] |
hello,, world | [ 'hello', '', 'world' ] |
hell,o, "w,orld" | [ 'hell', 'o', 'w,orld' ] |
"say ""hello, world!"" to the world." | [ 'say "hello, world!" to the world.' ] |
上の各ケースをすべて "\n" で連結 | 上の各期待値を要素とする配列 |
CSV パーサーの作成方針
機械的にsplit(",")
で区切った場合、「hello, world!
」のような単純なケースなら問題なく動作しますが、「say, "hello, world!", to, the, world
」のようなケースでは意に反して「hello」と「world!」の間が裂かれてしまいます。正規表現を使って実現できないかな?と頑張ってはみたものの、私には無理でした。そこでここでは、1 文字ずつ地道に解析していく方法をとることにします。
CSV パーサーの設計
「"」が現れたときの処理がややこしいことにならないように、「イベント」と「状態」で問題を考えてみます。「イベント」というのは状態を遷移させるためのトリガのことで、今考えている問題の場合は、入力される 1 文字 1 文字がイベントです。「状態」は何があるかというと、「開始状態」と「終了状態」、あとは要求仕様にもよりますが、今回の場合は
- 状態 1: 普通の状態 (普通にカンマ区切り処理を行っている状態)
- 状態 2: 「"」でくくられた中にいる状態
- 状態 3: 「"」がエスケープされるか判断している状態
の 3 つでしょうか。
例えば上のほうで出てきた「say, "hello, world!", to, the, world
」の例だと、「s」「a」「y」「,」「 」のところまでは「状態 1」です。その次は「"」が現れるので「状態 2」に遷移し、そのまま「!」のところまで処理します (このときは途中で「,」が現れても普通の文字として扱います)。するとまた「"」が現れますが、今度は「状態 3」に遷移してこの「"」がエスケープのための「"」なのか、くくられた状態を閉じるための「"」なのかを見極めようとします。ここで次に来る文字が「"」ではなく「,」なので、くくられた状態を抜けて「状態 1」に戻ります。文章で書くとなんのこっちゃですので、状態遷移図にしてみました:
漏れのないように状態遷移表にすると、次のようになります:
状態\イベント | カンマ | dq | eos | その他の文字 |
---|---|---|---|---|
normal | 自分自身 | indq | fin | 自分自身 |
indq | 自分自身 | escape? | fin | 自分自身 |
escape? | normal | indq | fin | normal |
CSV パーサーの実装
それでは、上に書いたような CSV パーサーを JavaScript で実装していきます。ソースコードの全文は次の場所にあるので、必要に応じて参照してください。
まず、パーサー全体をクラスにし、プライベートなメンバー変数として#state
と#fields
を持たせます (メンバー名やメソッド名の頭に「#
」を付けると、クラス外からのアクセスを禁止することができます)。#state
には現在の状態を格納します。#fields
は文字列の配列であり、ここに解析結果を格納します。つまり解析中にカンマで切り出した文字列 (フィールドと呼びます) をこの配列につっ込んでいきます。
class CsvParser {
#state = undefined; // 現在の状態を保持する
#fields = undefined; // フィールドを格納する配列
}
イベントは、カンマを表す「comma」、ダブルクォーテーションを表す「dq」、文字列の終端を表す「eos」、それ以外の文字を表す「others」の 4 つを定義します。プロパティの値をなぜ string 型にしているのかについては後述します (状態遷移表のところで)。
#ev = Object.freeze({comma: "comma", dq: "dq", eos: "eos", others: "others"});
次に状態ですが、これはオブジェクトとして定義します。状態オブジェクトは次のプロパティを持ちます。
- key: 状態の名前をそのまま文字列にしたもの。どう使うかは後述 (状態遷移表のところで)
- entry: その状態に遷移したとき、最初に 1 回だけ実行する処理
- do: その状態にいる間、実行する処理
- exit: その状態を抜けるときに 1 回だけ実行する処理
例えば「普通の状態」を疑似コードで書くと次のようになります。
normal: {
key: "normal",
entry: (ch) => { フィールドを "" で初期化 },
do: (ch) => { ch がカンマならフィールドを確定、そうでなければフィールドに ch を連結 },
exit: (ch) => { なにもしない },
}
JavaScript になじみがないと「() => {}
」という記述は面食らうかもしれません。詳しくは次のリンク先などを参照してください。
- JavaScript Primer 迷わないための入門書 - Arrow Functionhttps://jsprimer.net/basic/function-declaration/#arrow-function
次に状態遷移表です。前出の状態遷移表をほぼそのまま JavaScript のオブジェクトで記述しています。ただし、今回は終了状態で行いたい処理が特になかったので、fin という状態は設けていません。単にundefined
を返して状態遷移を終了させるようにしました。
#table = {
normal: {
comma: this.#states.normal,
dq: this.#states.indq,
eos: undefined,
others: this.#states.normal,
},
indq: {
comma: this.#states.indq,
dq: this.#states.escape,
eos: undefined,
others: this.#states.indq,
},
escape: {
comma: this.#states.normal,
dq: this.#states.indq,
eos: undefined,
others: this.#states.normal,
},
};
このテーブルの使い方ですが、例えば現在の状態が indq で、入力された文字がダブルクォーテーションだった場合の遷移先を#table["indq"]["dq"]
でゲットすることができます。これは「ブラケット記法」によるプロパティへのアクセスです。上のほうでイベントのデータ型を文字列にしたり、状態オブジェクトにわざわざ key プロパティを持たせたりしたのは、これがやりたかったからです。
最後に、入力された文字によって状態を遷移させる処理を作ります。疑似コードで書くと次のような感じです。
#input(ch) {
const next = #table[#state.key][#event(ch)]; // 遷移先の状態を取得
if (遷移先の状態 ≠ 現在の状態) {
#state.exit(ch);
if (遷移先の状態が undefined) {
#fin();
return;
}
#state = next; // 現在の状態を遷移先の状態に書き換える
#state.entry(ch);
} else {
#state.do(ch);
}
}
感想
たかだか 250 行くらいのプログラムを作るのにずいぶん苦労したものです。でも未知の世界だった JavaScript の理解がだいぶん進んだようにも思います。第一印象が最悪だった「アロー関数」とも打ち解けて、今ではすっかりマブダチです 笑
なお JavaScript の勉強にあたっては、下記のサイトが大変参考になりました。
- JavaScript Primer 迷わないための入門書https://jsprimer.net
広告