😊

Goでファイルの文字コードがUTF-8かを判定する際に気をつけること

2023/06/19に公開

はじめに

アプリやシステムの開発をしていて、入力値を特定の文字コードだけに対応させたいケースは多々あるかと思います。
特に国内のものであれば、Shift-JISやUTF-8辺りが有名かと思います。
その中でも今回はファイル処理とUTF-8にフォーカスを当てて、
判定処理をどのように実装したら良いか、テストコードと併せて共有したいと思います。

目次

  • 結論
  • 実装内容
  • 今後の課題
  • 所感

結論

  • 標準ライブラリのunicode/utf8を使う
  • io.TeeReaderかio.ReadSeekerかは基本的に自由に選択するのが良い

実装内容

実装内容は以下のようになります。

package main

import (
	"bytes"
	"errors"
	"io"
	"log"
	"os"
	"unicode/utf8"
)

var errNonExistentFile = errors.New("file doesn't exist")

// isEncodedInUtf8 io.Readerとして受け取ったファイルの文字コードがutf-8かどうか判定する
func isEncodedInUtf8(file io.Reader) (isUtf8 bool, err error) {
	// io.Readerを[]byteに変換
	buf := bytes.NewBuffer(nil)
	io.Copy(buf, file)
	b := buf.Bytes()
	if len(b) == 0 {
		// ファイルが存在しない場合
		return false, errNonExistentFile
	}
	// 文字コードがutf-8かどうか判定
	return utf8.Valid(b), nil
}

func main() {
	file, err := os.Open("./testdata/utf8_sample.csv")
	if err != nil {
		log.Fatal(err)
	}

	// ファイルの文字コードがUTF-8どうかを判定
	isUtf8, err := isEncodedInUtf8(file)
	if err != nil {
		log.Fatal(err)
	}
	if !isUtf8 {
		log.Fatal(errors.New("invalid character encoding"))
	}
	log.Print("Done")
}

  • ./testdataフォルダ内にutf8_sample.csvファイル(文字コードはutf-8)が存在する前提です。
    • 今回はcsvを例に挙げていますが、txt等の別の拡張子でも問題ないです。

シンプルですね。os.Openで取得したos.FileのオブジェクトをisEncodedInUtf8の引数のio.Reader型にキャストして渡しています。isEncodedInUtf8内では、io.Readerのオブジェクトをbytes.Buffer型のオブジェクトとしてコピーをしていますが、io.ReadAllを使っていない理由はこちらの記事が参考になると思います。

最後にバイト列に変換した上で、utf8.Valid(b)において、該当のファイルデータがutf-8でエンコードされたものかを判定した結果を返しています。

かなりシンプルなものだと思います。

ですが、このコードには一つ問題があります。

実際プログラムとして運用する場合、UTF-8かどうかを判定した上で正常な文字コードであればそのファイルオブジェクトを使って何かしらの処理を後続で行いたいことが多いと思います。ですので、その処理内容をmain関数に書いてみます。

func main() {
	file, err := os.Open("./testdata/utf8_sample.csv")
	if err != nil {
		log.Fatal(err)
	}

	// ファイルの文字コードがUTF-8どうかを判定
	isUtf8, err := isEncodedInUtf8(file)
	if err != nil {
		log.Fatal(err)
	}
	if !isUtf8 {
		log.Fatal(errors.New("invalid character encoding"))
	}

	// fileオブジェクトの中身を読み取って出力をしてみる
	buf := bytes.NewBuffer(nil)
	io.Copy(buf, file)
	b := buf.Bytes()
	if len(b) == 0 {
		log.Fatal(errNonExistentFile)
	}
	log.Println("file content: ", string(b))
	log.Println("Done")
}

出力結果は以下の通りです。

> go run main.go
2023/03/31 09:31:57 file doesn't exist
exit status 1

ファイルが存在しないエラーが出てしまいますね。なぜでしょうか?

理由に関してはこちらの記事が参考になると思います。

つまり、簡単に言うとio.Readerのストリームは使い捨てなので、再利用ができないということですね。ですので、解消法としては次の2択があると思います。

  • io.TeeReaderでio.Readerを複製する
  • io.ReadSeekerにキャストして、読み込む度にSeekをファイルの始めにセットし直す

パフォーマンス的には両者に若干の差異は出るものの、それほど大きく影響の出るものではないと思うので、好みで選んで良いと思います。今回は前者を選んでみます。

io.TeeReaderを取り入れて修正した内容は以下の通りになります。

func main() {
	file, err := os.Open("./testdata/utf8_sample.csv")
	if err != nil {
		log.Fatal(err)
	}
	// io.TeeReaderを用いることで同じストリームを使い回せるようにする
	bufFile := bytes.NewBuffer(nil)
	teeFile := io.TeeReader(file, bufFile)

	// ファイルの文字コードがUTF-8どうかを判定
	isUtf8, err := isEncodedInUtf8(teeFile)
	if err != nil {
		log.Fatal(err)
	}
	if !isUtf8 {
		log.Fatal(errors.New("invalid character encoding"))
	}

	buf := bytes.NewBuffer(nil)
	io.Copy(buf, bufFile)
	b := buf.Bytes()
	if len(b) == 0 {
		log.Fatal(errNonExistentFile)
	}

	log.Println("file content: ", string(b))

	log.Println("Done")
}

出力結果は以下の通りです。

> go run main.go
2023/03/31 09:38:03 file content:  hoge,fuga,piyo
foo,bar,baz
qux,quux,foobar

2023/03/31 09:38:03 Done

正常にファイルの中身が出力されていますね。

ちなみに、後者のio.ReadSeekerを使った場合は、isEncodedInUtf8関数の中身を以下の通りに修正すれば良いです。

func isEncodedInUtf8(file io.ReadSeeker) (isUtf8 bool, err error) {
	// 検証処理の中で、 Read() や io.ReadAll() によって読み込み位置が変わってしまうので、
	// 検証が終わったら読み込み位置を元に戻す
	defer file.Seek(0, io.SeekStart)

	// io.Readerを[]byteに変換
	buf := bytes.NewBuffer(nil)
	io.Copy(buf, file)
	b := buf.Bytes()
	if len(b) == 0 {
		// ファイルが存在しない場合
		return false, errNonExistentFile
	}
	// 文字コードがutf-8かどうか判定
	return utf8.Valid(b), nil
}

また、isEncodedInUtf8のテストコードは以下の通りとなります。

func TestIsEncodedInUtf8(t *testing.T) {
	type args struct {
		file io.Reader
	}
	tests := []struct {
		name       string
		fileName   string
		args       args
		wantIsUtf8 bool
		wantErr    error
	}{
		{
			name:     "SUCCESS(when encoded in utf-8)",
			fileName: "./testdata/utf8_sample.csv",
			args: args{
				file: nil,
			},
			wantIsUtf8: true,
			wantErr:    nil,
		},
		{
			name:     "SUCCESS(when encoded in non-utf-8)",
			fileName: "./testdata/sjis_sample.csv",
			args: args{
				file: nil,
			},
			wantIsUtf8: false,
			wantErr:    nil,
		},
		{
			name:     "FAIL(when file doesn't exist)",
			fileName: "./testdata/non_existent.csv",
			args: args{
				file: nil,
			},
			wantIsUtf8: false,
			wantErr:    errNonExistentFile,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			dir, _ := os.Getwd()
			filePath := path.Join(dir, tt.fileName)
			fp, _ := os.Open(filePath)
			defer fp.Close()

			tt.args.file = io.Reader(fp)

			gotIsUtf8, err := isEncodedInUtf8(tt.args.file)
			if err != tt.wantErr {
				t.Errorf("IsEncodedInUtf8() error = %v, wantErr %v", err, tt.wantErr)
			}
			if diff := cmp.Diff(gotIsUtf8, tt.wantIsUtf8); diff != "" {
				t.Errorf("gotIsUtf8 value is mismatch (-IsEncodedInUtf8() +want):\\n%s", diff)
			}
		})
	}
}

  • ./testdataフォルダ内にutf8_sample.csv(文字コードはutf-8)とsjis_sample.csv(文字コードはshift-jis)の2ファイルが存在する前提です。

まとめ

utf-8かどうかのハンドリングをする場合は上記の実装で問題ないですが、渡されたファイルの中身がどういった文字コードでエンコードされているか(例えばshift-jis)といったハンドリングをするとなった場合は、ファイルのバイナリデータを読み取ってルールベースで文字コードを判定する等の機構の導入が必要となるので注意です。
もしそういった実装に関わる機会があればまた記事として執筆したいと思います。

Discussion