はじめに
CSVを取り扱う開発の中で、意図しない文字列によるバリデーションエラーが発生する事例に遭遇しました。
原因はBOM(Byte Order Mark)を考慮していなかったことにあります。
本記事では、BOMとは何か、なぜ問題になるのか、そしてScalaでの具体的な対処法について解説します。
同様の課題に直面した方の参考になれば幸いです。
発生していた事象
データを大量に生成するユースケースに対応するため、ユーザーがテンプレートとなるCSVファイルをダウンロードし、加工してアップロードできる機能を開発していました。
しかし、検証時に一部のツールを使って編集したCSVファイルをアップロードすると、サーバー側でヘッダーのバリデーションエラーが必ず発生する事象が確認されました。
また、このエラーは使用する編集ツールによって発生したりしなかったりするため、ファイル編集時に何らかの影響が及んでいると考えられました。
原因
調査の結果、テキストファイルの先頭に付与される「Byte Order Mark(BOM)」と呼ばれる数バイトのデータが、ヘッダーの一部として認識されてしまい、エラーの原因となっていました。
具体的には、以下のような文字(Unicode: U+FEFF)がファイルの先頭に付与されており、ヘッダーのバリデーション時に本来のヘッダーとは異なるものとして扱われていました。
\uFEFF
例えば、ヘッダーが name,age,email であることを期待している場合でも、BOM付きのファイルでは実際には \uFEFFname,age,email として読み込まれるため、startsWith("name")のようなバリデーションが失敗します。
また、調査の過程で、Excelでは文字化け対策としてBOM付きUTF-8を認識する仕様となっていることが分かりました。現在のExcelの普及率を考慮すると、BOM付きファイルへの対応は必須と判断しました。
BOMとは
Unicodeの符号化形式で符号化したテキストの先頭につける数バイトのデータ
参考: バイト順マーク
Wikipediaによると、Unicodeが開発された当初は、ASCIIやShift_JISなど各国によって主流の文字コードが分かれており、使用する符号化方式がUnicodeのものであることの明示が必要だったようです。そのため先頭のデータにテキスト以外のデータを入れることが発案されたとのことです。
BOMのバイト列
| 符号化方式 | BOM(16進数) |
|---|---|
| UTF-8 | EF BB BF |
| UTF-16 (BE) | FE FF |
| UTF-16 (LE) | FF FE |
| UTF-32 (BE) | 00 00 FE FF |
| UTF-32 (LE) | FF FE 00 00 |
対応
読み込み側のサーバーではBOMを利用する必要がないため、ファイルを処理する前にBOMを除去すれば問題は解決します。
処理自体はシンプルですが、ScalaはJavaのライブラリ資産を活用できるため、既存のライブラリを調査したところ、Apache Commons IOのBOMInputStreamを利用するのが最も簡単で確実だと判断しました。
依存関係の追加
build.sbt に以下を追加します。
libraryDependencies += "commons-io" % "commons-io" % "2.15.1"
実装例
BOMInputStream を使うことで、CSVファイルのInputStreamをラップし、指定したBOMを自動的にスキップできます。
以下はUTF-8のBOMをスキップして処理する例です。
import org.apache.commons.io.input.BOMInputStream
import org.apache.commons.io.ByteOrderMark
import java.io.{FileInputStream, InputStreamReader, BufferedReader}
import java.nio.charset.StandardCharsets
// CSVファイルからInputStreamを取得
val fileInputStream = new FileInputStream("data.csv")
// BOMInputStreamでラップしてBOMをスキップ
val bomInputStream = BOMInputStream
.builder()
.setInputStream(fileInputStream)
.setByteOrderMarks(ByteOrderMark.UTF_8)
.get()
val reader = new BufferedReader(new InputStreamReader(bomInputStream, StandardCharsets.UTF_8))
// BOMが除去された状態で読み込まれる
val headerLine = reader.readLine()
複数の符号化方式に対応する場合は、setByteOrderMarksに複数のBOMを指定できます。
val bomInputStream = BOMInputStream
.builder()
.setInputStream(fileInputStream)
.setByteOrderMarks(ByteOrderMark.UTF_8, ByteOrderMark.UTF_16LE, ByteOrderMark.UTF_16BE)
.get()
BOMの検出
BOMが実際に存在したかどうかを確認したい場合は、getBOMメソッドを利用できます。
// ストリームを読み込んだ後にBOMを確認
val detectedBom = Option(bomInputStream.getBOM)
detectedBom match {
case Some(bom) => println(s"BOMが検出されました: ${bom.getCharsetName}")
case None => println("BOMは検出されませんでした")
}
さいごに
BOMは日常的な開発ではあまり意識されませんが、外部からファイルを受け取るシステムでは注意が必要なポイントです。
特に、Excelで編集されたCSVファイルを取り扱う場合、BOM付きファイルがアップロードされるケースを想定しておくことが必要となりそうです。
自前実装でも良いですが、今回紹介したApache Commons IOのBOMInputStreamを利用すれば、既存のコードを大きく変更することなく、BOMへの対応が可能となります。
同様の課題に直面した際は、ぜひ本記事の内容を参考にしてみて頂けると幸いです。
参考
- https://ja.wikipedia.org/wiki/%E3%83%90%E3%82%A4%E3%83%88%E9%A0%86%E3%83%9E%E3%83%BC%E3%82%AF
- https://note.shiftinc.jp/n/nb92ccb17132d
- https://tech.pepabo.com/2021/03/19/remove-the-bom-when-importing-csv/
- https://qiita.com/buttakyou/items/311015c943113d83f240
- https://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/input/BOMInputStream.html