CSV連携を壊さない設計|UTF-8・BOM・改行・引用符・型変換

「CSV連携を壊さない設計|UTF-8・BOM・改行・引用符・型変換」の内容を表す技術イラスト

CSVはExcel、Python、データベース、CAD、部品表の間で使いやすい交換形式です。しかし、拡張子が.csvで同じでも、文字コード、区切り文字、改行、引用符、NULLの表現が異なれば別の形式として扱う必要があります。

「表計算ソフトで開けた」ことと、「同じデータとして再処理できる」ことは同義ではありません。この記事では、CSVを単なる文字列の集合ではなく、dialectとschemaを持つデータ契約として設計します。

目次

CSVで決めなければならない2種類の契約

CSV連携では、ファイルの読み方と、各列の意味を分けます。

契約決める内容例
dialect文字コード、区切り文字、改行、引用符、headerUTF-8、カンマ、CRLF、"、headerあり
schema列名、型、必須条件、NULL、桁数、単位asset_idは文字列で必須、length_mmは十進数

RFC 4180は一般的なCSV形式とtext/csv media typeを文書化していますが、Informational RFCであり、CSVのすべての実装を統一する規格ではありません。実際の連携では、RFCを基礎にしつつ自分たちの交換仕様を狭く固定します。

最小のCSV例

設計部品を交換するCSVを考えます。

asset_id,name,length_mm,active,note
000042,"Bracket, left",125.50,true,"line 1
line 2"
000043,Shaft,,false,"He said ""OK"""

この例にはCSVで壊れやすい要素が含まれます。

  • 000042は数値ではなく、先頭ゼロを持つID
  • Bracket, leftはカンマを含むため引用符が必要
  • noteは改行を含むため引用符が必要
  • 文字列内の"は""として表現
  • length_mmの空cellは、schemaでNULLと定義
  • trueとfalseは文字列として格納され、取込時にbooleanへ変換

CSV自体には数値、boolean、NULLという型がありません。readerが返すcellは基本的に文字列であり、schemaに従って型変換して初めて業務データになります。

区切り文字・引用符・改行を固定する

RFC 4180では、recordを改行で区切り、fieldをカンマで区切ります。カンマ、改行、二重引用符を含むfieldは二重引用符で囲み、field内の二重引用符は2個重ねます。

ただし、現実にはセミコロン区切り、タブ区切り、LF改行なども使われます。W3CのTabular Data Modelは、delimiter、encoding、quote character、line terminator、header数などをdialectとして記述できるモデルを定めています。

機械連携用CSVでは、少なくとも次を固定します。

media_type: text/csv
encoding: utf-8
bom:
  input: optional
  output: absent
delimiter: ","
quote_character: '"'
double_quote_escape: true
line_terminator: CRLF
header: required
trim_whitespace: false

空白を自動で削除するかも契約です。" A "と"A"を同一視すると、コードや名称を意図せず変更する場合があります。正規化する列だけをschemaで指定します。

UTF-8とBOMを分けて扱う

機械間交換ではUTF-8へ統一すると扱いやすくなります。ただし、UTF-8の先頭にBOMと呼ばれる3byteが付いたファイルもあります。

Python公式資料では、utf-8-sig codecは入力先頭のUTF-8 BOMがあれば読み飛ばし、出力時にはBOMを書きます。通常のUTF-8にはbyte orderがないためBOMは必須ではありません。

実務では、次のように入出力を分けられます。

  • 受信:通常のUTF-8とUTF-8 BOM付きの両方をutf-8-sigで受ける
  • システム向け出力:UTF-8 BOMなしへ統一する
  • 表計算ソフト向け出力:利用環境が要求する場合だけBOM付きprofileを別にする

文字コードを自動推測して処理を続けると、誤判定しても成功扱いになる危険があります。許可するencodingを限定し、decodeできないfileは隔離します。Shift_JISなどを受ける必要がある場合も、接続先ごとの明示的なprofileにします。

headerと列順をschemaで検証する

headerを見ただけで柔軟に取り込む設計は便利ですが、列名の誤字、重複、未知列を見逃しやすくなります。

columns:
  - name: asset_id
    type: string
    required: true
    pattern: "^[0-9]{6}$"
  - name: name
    type: string
    required: true
    min_length: 1
  - name: length_mm
    type: decimal
    required: false
    null_values: [""]
    minimum: 0
  - name: active
    type: boolean
    required: true
    true_values: ["true"]
    false_values: ["false"]
  - name: note
    type: string
    required: false

W3C CSVWでは、列ごとにdatatype、format、required、nullなどのメタデータを定義できます。独自YAMLやJSON Schema風のデータ辞書でも構いませんが、少なくとも次を決めます。

  • header名と列順を固定するか
  • 未知列を拒否するか無視するか
  • 空cell、空文字、NULL記号の意味
  • 数値の小数点、桁数、範囲、単位
  • 日時形式とtimezone
  • booleanとして許可する文字列
  • IDを数値へ変換しないこと

NA、NULL、-を一律に欠損扱いすると、実データと衝突する場合があります。NULL表現は列単位で決め、空cellと文字列"NULL"を無条件に同一視しません。

Pythonでdialectとschemaを固定して読む

Pythonのcsv moduleを使う場合、fileはnewline=""で開きます。公式ドキュメントでは、これを省略すると引用符内の改行が正しく扱われない場合や、書込時に余分な改行文字が入る場合があると説明されています。

次の例はdialectを推測せず、header、field数、型を明示的に検証します。

import csv
from decimal import Decimal, InvalidOperation
from pathlib import Path


EXPECTED_HEADER = [
    "asset_id", "name", "length_mm", "active", "note"
]


def parse_decimal_or_none(value: str) -> Decimal | None:
    if value == "":
        return None
    try:
        result = Decimal(value)
    except InvalidOperation as exc:
        raise ValueError("length_mmが十進数ではありません") from exc
    if not result.is_finite() or result < 0:
        raise ValueError("length_mmは有限の0以上が必要です")
    return result


def parse_boolean(value: str) -> bool:
    if value == "true":
        return True
    if value == "false":
        return False
    raise ValueError("activeはtrueまたはfalseが必要です")


def read_assets_csv(path: Path) -> list[dict]:
    records = []

    with path.open(
        "r",
        encoding="utf-8-sig",
        newline="",
    ) as file:
        reader = csv.reader(
            file,
            delimiter=",",
            quotechar='"',
            doublequote=True,
            strict=True,
        )

        try:
            header = next(reader)
        except StopIteration as exc:
            raise ValueError("CSVが空です") from exc

        if header != EXPECTED_HEADER:
            raise ValueError(f"headerが不正です: {header}")
        if len(set(header)) != len(header):
            raise ValueError("header名が重複しています")

        for row in reader:
            line_number = reader.line_num
            if len(row) != len(header):
                raise ValueError(
                    f"{line_number}行目のfield数が不正です"
                )

            raw = dict(zip(header, row, strict=True))
            asset_id = raw["asset_id"]
            if len(asset_id) != 6 or not asset_id.isascii() or not asset_id.isdigit():
                raise ValueError(
                    f"{line_number}行目のasset_idが不正です"
                )
            if not raw["name"]:
                raise ValueError(
                    f"{line_number}行目のnameが空です"
                )

            records.append({
                "asset_id": asset_id,
                "name": raw["name"],
                "length_mm": parse_decimal_or_none(raw["length_mm"]),
                "active": parse_boolean(raw["active"]),
                "note": raw["note"],
            })

    return records

csv.Snifferで形式を推測する機能もありますが、正式な連携では推測結果を契約の代わりにしません。送信元ごとのdialectを設定として保持し、想定外の形式は明示的に失敗させます。

正常系と異常系を分ける

入力判定
カンマを引用符で囲んだfield正常
引用符内の改行正常
UTF-8 BOM付きの先頭headerutf-8-sigで正常化
asset_idが000042文字列のまま保持
field数がheaderと不一致拒否
二重引用符のescapeが不正拒否
decodeできないbyte列拒否・隔離
数値列がNaNや負数schema違反として拒否
boolean列が1やTRUE契約外なら拒否
headerの誤字・重複拒否

エラー時にはfile名だけでなく、物理行番号、列名、受信値、期待規則を記録します。ただし、個人情報や機密値をそのままlogへ出さないようにします。

冪等性と再実行を設計する

CSV取込は、通信切断や処理失敗で同じfileが再投入されます。次の2段階で重複を防ぎます。

  • file単位:raw byte列のSHA-256と取込profile versionを記録する
  • record単位:asset_idなどの安定した業務keyでUPSERTする

file hashだけでは、内容を修正した再送は別fileになります。recordの同一性は別途IDで判断します。取込処理は、全行を検証してstaging tableへ保存し、問題がなければ本tableと取込履歴を同じtransactionで確定する方法が安全です。

変換規則を変更した場合に備え、schema_version、parser_version、元fileのhashを残します。再実行時は元fileから同じ規則で再生成できるようにします。

表計算ソフト向けCSVのセキュリティ

外部入力をCSVへ出力し、ExcelやLibreOfficeで開く場合はformula injectionへ注意します。OWASPは、=、+、-、@などで始まるcellが数式として解釈される可能性を説明しています。

引用符で囲むだけでは、表計算ソフトによる数式評価を防げません。対策は利用先に応じて決めます。

  • 信頼できない値を表計算ソフト向けexportへ含めるか確認する
  • 数式開始文字を検出し、拒否または専用のescapeを行う
  • escape後の値が原本と異なることを明示する
  • 機械連携用の正本CSVと、人が開く安全化済みCSVを分ける
  • CSVを開く利用者の権限を必要最小限にする

安全化のために先頭へ文字を追加するとデータそのものが変わります。原本を上書きせず、用途別の出力profileとして管理します。

CAD・DB・APIへ再利用する

CAD属性、部品表、検査値、計算条件をCSVで交換する場合も、同じ契約を利用できます。

  • 図面番号や部品番号は文字列として保持する
  • 寸法値と単位を別列にするか、列schemaで単位を固定する
  • revisionとrecord IDを分離する
  • 親子関係は親ID・子ID・数量の列で表す
  • 削除や無効化を空行で表さず、状態列や別の差分仕様を設ける
  • APIやDBへ渡す前に型変換と参照整合性を検証する

CSVのdialectとschemaをmachine-readableな設定として保存すれば、Python reader、DB staging table、API変換、テストデータ、データ辞書へ同じ定義を展開できます。

まとめ

CSV連携では、拡張子や見た目ではなく、byte列から業務データへ変換する規則を契約にします。

  • dialectと列schemaを分けて定義する
  • encoding、BOM、delimiter、改行、引用符、headerを固定する
  • カンマ・改行・引用符を含むfieldは正しくquoteする
  • CSVのcellを型付きデータだと思い込まない
  • IDの先頭ゼロを保持し、数値へ自動変換しない
  • NULL、空文字、boolean、日時、単位を列ごとに決める
  • Pythonではnewline=""と明示的なencodingを使う
  • 形式推測に依存せず、想定外のdialectを拒否する
  • file hashとrecord IDの両方で再実行へ備える
  • 表計算ソフト向けexportではformula injectionを考慮する

この契約があれば、Excelで見える表を、API・DB・Python・CADでも同じ意味を保つ再利用可能なデータ資産へ変えられます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次