GoでBOM付きのUTF-8ファイルを扱う必要があったので、その時に知ったテクニックを共有したいと思います。
具体的に言えば、Excelでファイル形式を「CSV UTF-8 (コンマ区切り) (.csv)」
として保存した際のCSVファイルを扱った時の話です。
(Excelや少し前のメモ帳などでUTF-8としてファイルを保存すると、BOM付きのUTF-8ファイルになります。1)
BOM(Byte Order Mark)とは
BOM(Byte Order Mark)というのは、Unicode系の符号化方式(UTF-8やUTF-16など)の場合に、どの符号化方式であるかが分かるように先頭に付ける数バイトのデータのことです。
より詳しい説明についてはWikipediaのバイト順マークのページなどに譲ります。
このBOMというのはテキストデータの一部ではないので、プログラムで処理する時には、BOMということを認識した上で適切に読み飛ばす必要があります。
また厄介なことに、BOMがついていないケース(特にUTF-8)というのもあるため、一般的にはBOMがあってもなくてもうまく読み込めるような実装を求められることも多いでしょう。
BOMを考慮してファイルを読み込む
さて、GoでBOMを考慮してファイルを読み込むにはどうしたらよいでしょうか。
以下のようにtransform.NewReaderとunicode.BOMOverrideを利用すると、BOMを考慮したio.Reader
(Goにおける入力ストリームのインターフェース)を簡単に得ることができます。2
// fallback := transform.Nop
fallback := unicode.UTF8.NewDecoder()
r := transform.NewReader(input, unicode.BOMOverride(fallback))
このio.Reader
を使うと、次のような動作になります。
- BOMがない場合:
fallback
で指定したDecoder
(この場合はUTF-8)で読み込む-
transform.Nop
を指定した場合は、何も変換されずにそのまま読み込む
-
- BOMがある場合: (BOMを解釈して)UTF-8, UTF-16BE, UTF-16LEを自動認識して読み込む
- 読み込まれた文字列はUTF-8に変換される
BOMがあってもなくても気にせずに読み込める上に、UTF-16にも対応できます。
この方式の良いところは、bufio
などを使って難しいコードを書かなくてよく、fallback
の部分も引数に直接書いてしまえば、実質一行で対応できるところです。
補足: fallbackについて
fallback
の部分には今回はunicode.UTF8.NewDecoder()
を指定しましたが、何も変換処理をしないTransformer
であるtransform.Nop
を使ってもよいと思います。
その場合は、何も変換されずにそのままのバイト列として読み込まれるので、BOMなしのUTF-8であれば、そのままUTF-8として扱えます。
しかし、Shift_JISなどUTF-8以外のものを読み込んだ場合には、そのままShift_JISなどのバイト列として読み込まれてしまいます。
一方で、unicode.UTF8.NewDecoder()
を指定した場合は、UTF-8以外のデータを読み込ませると、UTF-8に変換できない値については、文字化け時によく見かける「�」(U+FFFD
)に変換されて、UTF-8として妥当な文字列として読み込まれます。
どちらも想定外のエンコーディングという点では同じですが、前者が得体の知れないバイト列として読み込まれるのに対して、後者は見た目上は文字化けしているとはいえUTF-8のデータとしては正しいバイト列として読み込まれる点が異なります。
このあとの処理で、読み込んだデータが(謎のバイナリ値ではなく)妥当な文字列であると想定してコードを書くなら、後者のほうが安全かなと個人的には思いました(どういう処理をするか、要件にもよると思います)。
完全なサンプルコード
上記のコードを利用したサンプルコードです。
処理内容に意味はありませんが、得られたio.Reader
をそのまま気にせず扱えることがわかるかと思います。
package main
import (
"encoding/csv"
"fmt"
"io"
"os"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
func main() {
// 標準入力か引数で指定されたファイルを入力とする
input := os.Stdin
if len(os.Args) > 1 {
f, err := os.Open(os.Args[1])
if err != nil {
panic(err)
}
defer f.Close()
input = f
}
// fallback := transform.Nop
fallback := unicode.UTF8.NewDecoder()
r := transform.NewReader(input, unicode.BOMOverride(fallback))
csvr := csv.NewReader(r)
for {
record, err := csvr.Read()
if err == io.EOF {
break
}
if err != nil {
panic(err)
}
fmt.Println(record)
}
}
さいごに
巷には以下のような面倒な処理を自作するサンプルコードやライブラリがたくさんあるのですが、今回色々調べてみて簡単に書けることがわかったので、この記事を書きました。
巷の手法
-
bufio
を使って先頭3バイトをPeek
で覗く - BOMに一致したら、そのエンコーディングの
Reader
を作って返す - 一致しなかったら、そのまま
bufio.Reader
を返す
-
最近のメモ帳はBOMなしUTF-8が標準になったようです。 ↩
-
共にgolang.org/x/textモジュールのものです。 ↩