【変則CSVパース】ダブルクォーテーション内改行・カンマを含む複雑なCSVデータを高精度にパースする自作アルゴリズム
レガシーシステムの深部、あるいは他社製基幹システムとのインターフェースにおいて、CSV(Comma-Separated Values)という名の「呪物」に直面したことのないエンジニアはいない。
「たかがテキストの分割だ。`Split(line, “,”)` で事足りる」
もし、あなたが設計レビューでそう口にしたとしたら、チーフアーキテクトである私は即座にそのコードを差し戻す。現実のデータは美しい仕様書通りには動かない。フィールド内にカンマが含まれ、さらにはダブルクォーテーションで囲まれた改行が混入する変則CSVの前に、単純な文字列分割は無力な紙屑と化す。
今回は、WSH(Windows Script Host)環境、とりわけVBScriptの極限まで枯れたランタイム上で、外部コンポーネント(RegExpの複雑な先読み・後読みや、ADO.Streamの過剰な依存)に頼らず、1文字ずつの純粋なステートマシン走査によって高精度かつ高速にパースする自作アルゴリズムを解説する。
—
1. なぜ「単純なSplit」や「正規表現」では破綻するのか
まず、敵の構造を正確に把握する。RFC 4180に準拠した、あるいは現場の泥臭い要件が詰め込まれた複雑なCSVには、以下のトラップが存在する。
1. フィールド内のカンマ: `”Smith, John”,Tokyo,Japan` (カンマで分割すると氏名が分断される)
2. フィールド内の改行: `”Address line 1\nAddress line 2″,100` (レコードの区切りとしての改行と、データとしての改行が混在する)
3. エスケープされたダブルクォーテーション: `”He said “”Hello””.”` (二重引用符のハンドリング)
正規表現(`RegExp`オブジェクト)でこれらを処理しようとすると、VBScriptの貧弱な正規表現エンジン(JScriptのNFAベース)では、長大な文字列を投入した瞬間にバックトラッキングによるスタックオーバーフロー、あるいはCPU使用率100%のフリーズを引き起こす。
したがって、我々が選択すべきアプローチは「文字単位の逐次ステートマシン(State Machine)」である。メモリ消費を最小限に抑え、$O(N)$ の計算量で確実にパースを完了させる。
—
2. アーキテクチャ設計:ステートマシンの構築
パース処理の本質は「現在、自分がダブルクォーテーションの中にいるか(InQuote)」という状態の追跡である。
- 通常状態 (Normal): カンマに出会えばフィールド区切り、改行に出会えばレコード区切り。
- クォート内状態 (InQuote): ダブルクォーテーションに遭遇するまで、中のカンマも改行も「ただの文字」としてバッファに蓄積する。
このロジックをVBScriptのメモリ効率を考慮しながら実装する。特にVBScriptの文字列結合(`&` 演算子)は、ループ内で多用するとメモリの再割り当てが発生しパフォーマンスが劣化する。そのため、動的配列(`ReDim Preserve`)あるいは `Scripting.Dictionary` や `ADODB.Stream` を駆使したバッファリング戦略が必要となる。
—
3. 実装コード:高精度変則CSVパーサーモジュール
以下のコードは、ファイル全体をメモリ上に一括読み込みし、バイナリ・文字単位で走査して二次元配列(またはジャグ配列を模した構造)へと昇華させる実用モジュールである。
‘ ==============================================================================
‘ Module: AdvancedCSVParser.vbs
‘ Description: ダブルクォーテーション内の改行・カンマに対応した高精度CSVパーサー
‘ Author: Chief Architect
‘ ==============================================================================
Option Explicit
Const ForReading = 1
Const TristateUseDefault = -2 ‘ システムデフォルトの文字コード
‘ テスト実行用のメインプロシージャ
Sub Main()
Dim targetPath
targetPath = WScript.ScriptFullName & “\..\complex_sample.csv”
‘ サンプル用ファイルの動的生成(実運用では既存ファイルを読む)
Call CreateSampleFile(targetPath)
Dim parsedData
parsedData = ParseCSV(targetPath, vbUtf8)
If IsArray(parsedData) Then
Dim r, c, rowStr
WScript.Echo “— パース結果 (総行数: ” & (UBound(parsedData, 1) + 1) &) —”
For r = 0 To UBound(parsedData, 1)
rowStr = “Row ” & (r + 1) & “: [”
For c = 0 To UBound(parsedData, 2)
rowStr = rowStr & “‘” & parsedData(r, c) & “‘”
If c < UBound(parsedData, 2) Then rowStr = rowStr & ", "
Next
rowStr = rowStr & "]"
WScript.Echo rowStr
Next
End If
End Sub
' ------------------------------------------------------------------------------
' CSVファイルをパースし、二次元配列(Row, Col)を返すコア関数
' ------------------------------------------------------------------------------
Function ParseCSV(filePath, charset)
Dim objStream, fileContent
' 1. ADODB.Streamを使用し、文字コードを明示して安全にファイルを読み込む
' (FileSystemObjectのOpenTextFileはBOMなしUTF-8等で文字化けするため使用しない)
Set objStream = CreateObject("ADODB.Stream")
objStream.Type = 2 ' text
objStream.Charset = "UTF-8" ' 必要に応じて "Shift_JIS" 等に変更
objStream.Open
objStream.LoadFromFile filePath
fileContent = objStream.ReadText
objStream.Close
Set objStream = Nothing
Dim i, length, char
Dim inQuote, currentField, currentRow, records
inQuote = False
currentField = ""
' 可変長レコードを保持するための動的配列の初期化
' VBScriptでは二次元配列の列数を途中で変えられないため、
' 一度「行ごとの配列(Dictionary/Array)」に格納してから二次元化する
Dim recordList
Set recordList = CreateObject("Scripting.Dictionary")
Dim fieldList
Set fieldList = CreateObject("Scripting.Dictionary")
Dim rowCount : rowCount = 0
Dim fieldCount : fieldCount = 0
Dim maxCols : maxCols = 0
length = Len(fileContent)
' 2. 1文字ずつのステートマシン走査($O(N)$ パース)
For i = 1 To length
char = Mid(fileContent, i, 1)
Select Case char
Case """"
' ダブルクォーテーションの処理
If inQuote And i < length Then
' エスケープされたダブルクォーテーション ("") の判定
If Mid(fileContent, i + 1, 1) = """" Then
currentField = currentField & """"
i = i + 1 ' 次の文字をスキップ
Else
inQuote = False
End If
ElseIf Not inQuote And currentField = "" Then
inQuote = True
Else
' クォート外での出現(不正値またはデータの一部)
currentField = currentField & char
End If
Case ","
If inQuote Then
' クォート内のカンマは単なる文字
currentField = currentField & char
Else
' フィールド確定
fieldList.Add fieldCount, currentField
fieldCount = fieldCount + 1
currentField = ""
End If
Case vbCr
' キャリッジリターンは無視(LFとセットで処理、あるいは単独LFを改行とみなす)
If i < length Then
If Mid(fileContent, i + 1, 1) = vbLf Then
' CRLFの場合はLF側で処理するためここでは何もしない
Else
' CR単独の改行
If inQuote Then
currentField = currentField & char
Else
' レコード確定
fieldList.Add fieldCount, currentField
If fieldCount > maxCols Then maxCols = fieldCount
recordList.Add rowCount, fieldList.Items
Set fieldList = CreateObject(“Scripting.Dictionary”)
rowCount = rowCount + 1
fieldCount = 0
currentField = “”
End If
End If
End If
Case vbLf
If inQuote Then
‘ クォート内の改行(【最重要要件】)
currentField = currentField & char
Else
‘ レコード確定
fieldList.Add fieldCount, currentField
If fieldCount > maxCols Then maxCols = fieldCount
recordList.Add rowCount, fieldList.Items
Set fieldList = CreateObject(“Scripting.Dictionary”)
rowCount = rowCount + 1
fieldCount = 0
currentField = “”
End If
Case Else
currentField = currentField & char
End Select
Next
‘ 最終行の残余データ処理(ファイルの末尾に改行がない場合への対策)
If currentField <> “” Or fieldCount > 0 Then
fieldList.Add fieldCount, currentField
If fieldCount > maxCols Then maxCols = fieldCount
recordList.Add rowCount, fieldList.Items
rowCount = rowCount + 1
End If
‘ 3. 取得したDictionary構造を、扱いやすいVBScriptの二次元配列へトランスフォーム
If rowCount = 0 Then
ParseCSV = Empty
Exit Function
End If
Dim finalResult()
ReDim finalResult(rowCount – 1, maxCols)
Dim rKey, fArr, fIdx
For r = 0 To rowCount – 1
fArr = recordList.Item(r)
For c = 0 To UBound(fArr)
finalResult(r, c) = fArr(c)
Next
‘ 不足している列があれば空文字で埋める
For c = UBound(fArr) + 1 To maxCols
finalResult(r, c) = “”
Next
Next
ParseCSV = finalResult
End Function
‘ テスト用CSVファイルを生成するヘルパー
Sub CreateSampleFile(path)
Dim fso, ts
Set fso = CreateObject(“Scripting.FileSystemObject”)
Set ts = fso.CreateTextFile(path, True, True) ‘ Unicode
ts.WriteLine “ID,Name,Description,Price”
ts.WriteLine “1,Apple,””A very juicy,” & vbCrLf & “red apple.””,100″
ts.WriteLine “2,Banana,””Standard “”sweet”” banana””,80″
ts.WriteLine “3,Cherry,””Line1” & vbLf & “Line2” & vbLf & “Line3″”,200″
ts.Close
Set ts = Nothing
Set fso = Nothing
End Sub
‘ 実行トリガー
Call Main()
—
4. チーフアーキテクトの視点:パフォーマンスとリソース管理の極意
このコードには、長年の現場経験から導き出された「レガシー環境を生き抜くための実践知」が凝縮されている。
A. FileSystemObject(FSO)の `OpenTextFile` を使わない理由
VBScriptでテキストを読む際、多くのプログラマは `FSO.OpenTextFile` を使う。しかし、このメソッドはBOMなしUTF-8の自動判別に失敗したり、Shift_JIS以外のマルチバイト文字混入時に文字化けを起こす。
本コードでは `ADODB.Stream` オブジェクトを採用している。これにより、文字コードの明示的な指定と、メモリ上への高速なストリーム展開が可能となる。
B. オブジェクトの明示的解放(Memory Leak防衛)
VBScriptのガベージコレクション(参照カウント方式)は、循環参照や巨大なCOMオブジェクトの解放遅延において無力である。
スクリプトが短命(実行して即終了)であっても、タスクスケューラー経由で24時間365日稼働する無人バッチサーバーの一部品である場合、メモリリークは数日でシステムを死に至らしめる。
使用した `ADODB.Stream`、`Scripting.Dictionary` などのCOMオブジェクトは、スコープを適切に分割し、不要になった時点で `Set obj = Nothing` を明示する習慣を徹底すべきだ。
C. 可変長列(Jagged Array)から二次元配列への変換マジック
CSVは行によって列数が微妙に異なる(データ欠損など)ケースがある。VBScriptの通常の二次元配列は固定長であるため、パース中に列数が拡張されると致命的なエラーになる。
そのため、一度柔軟性の高い `Scripting.Dictionary` をハッシュの入れ子(ジャグ配列構造)として利用してデータを安全に溜め込み、パースの最終段階で最大の列数を算出して綺麗に `ReDim` した二次元配列へコンバートしている。この二段構えのアーキテクチャこそが、堅牢性を担保する。
—
5. 結び:枯れた技術の極限利用
「いまどきVBScriptか」と嘲笑する者には、企業の基幹系ネットワークの奥深くで、Windowsの標準機能だけで動作し続けるこの言語の泥臭い強靭さを理解できていない。
API仕様の変更に怯えることなく、OSのバージョンアップをまたいで何十年もノーメンテナンスで動き続けるコード。それを作り上げるのは、最新のフレームワーク知識ではなく、「データの1バイト、1文字をどう解釈するか」というプリミティブな執念である。
複雑なCSVに頭を悩ませているなら、今すぐその安易な `Split` を捨て、このステートマシンを導入せよ。あなたのシステムは、どんなに歪んだデータが入力されようとも、決して音を上げなくなるはずだ。
