Golang でファイルを書き込んだ時に古い内容が末尾に残る件

File に Write して、ちゃんと Flush もしてるのに、なぜかゴミが append されているような挙動になる。ゴミデータはどうも「前回そのファイルに存在していた内容」っぽい。つまり古いファイル内容が残ったままになっている?……という、よくわからない挙動に遭遇。

os.OpenFile やら bufio.NewWriter やら I/O まわりに何かしら問題があることは特定したが、その先がわからない。半日くらいハマって、ようやく解決できたのでまとめる。

結論

ファイル書き込み時(特に毎回指定内容で丸々新たに書き込むような使い方の時)は os.OpenFile ではなく os.Create を使う。

試行過程

解決に至るまでの過程をまとめておく。

1 問題だったコード

一行一要素の文字列配列をファイル化する list2file 関数を、以下のようにつくっていた。

func list2file(filepath string, lines []string) {
    fp, err := os.OpenFile(filepath, os.O_WRONLY|os.O_CREATE, 0666)
    if err != nil {
        abort(err.Error())
    }
    defer fp.Close()

    writer := bufio.NewWriter(fp)
    for _, line := range lines {
        writer.WriteString(line + "\n")
    }
    writer.Flush()
}

これを実行すると、filepath で示すファイルの末尾に何故か古い内容がひっついてくる。

たとえばファイル filepath の内容が

aaa
bbb
ccc

だとして、list2file 関数で ["aaa", "bbb"] を書き込んだとする。この時 filepath の内容は

aaa
bbb

こうなるはずだが、なぜか

aaa
bbb
cc

こんな感じで末尾に 古い内容の一部が残っている ような挙動になる。

2 原因は Truncate?

調べていると、どうも Truncate という概念があるらしい。

Golang ファイル入出力メモ - Qiita

書き込みで開いた場合、os.O_TRUNCを指定しないと、以前の内容をリフレッシュせずに上書きするので、書き込み内容が短い場合は以前の内容が残ってしまう。

[Go言語]Golangの上書き処理が少し慣れない[memo] | 鈍色スイッチ

O_TRUNCを追加しておくと、書き込み先ファイルが存在したときにその内容を消し、そのあと出力する形になる。つまり、中身を消した上で上書きするのである。

Brian Beardさんのツイート: "A Bug's Life #6378932: Replaced os.Create(file) with os.OpenFile(file, flags, perms) for more options like appending. ...

trying to figure out why saves were strange at the end when not appending but overwriting. File flag os.O_TRUNC was missing.

I/O 低レイヤーの事情はよくわからんが、どうも「既存ファイルに対して丸々新しい内容を書き込みたい場合」は、Truncate という「元々の内容をいったん全部消す」的な処理が必要、という事情?仕様?があるようだ。

で、 os.OpenFile 関数を使った場合は、この Truncate が行われない。つまりは「元々の内容をいったん全部消す」も行われないわけで、それゆえに末尾にゴミが残ってしまっていた……と。

3 os.OpenFile で Truncate するには?

じゃあ os.OpenFile で Truncate するようフラグを付ければいい……と考える。幸いにも os.TRUNC が用意されている

1 のコードを以下のように修正した。

func list2file(filepath string, lines []string) {
    // ★ os.O_TRUNC を付けただけ。あとは同じ
    fp, err := os.OpenFile(filepath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
    if err != nil {
        abort(err.Error())
    }
    defer fp.Close()

    writer := bufio.NewWriter(fp)
    for _, line := range lines {
        writer.WriteString(line + "\n")
    }
    writer.Flush()
}

これでも本現象は発生しなくなった。めでたし。

4 そもそも OpenFile の必要ある?という話

もう少し踏み込む。

そもそも OpenFile する必要があるのか という点に気付く。このツイートのおかげです。

のぼのぼ☂️さんのツイート: "#golang これ自前でos.OpenFileしてO_TRUNC指定忘れ?...

単にos.Create使えばいいです。os.OpenFileを使うのは追記や特殊な事情がある場合だけ。

そのとおりだ。公式ドキュメント os.OpenFile - The Go Programming Language にも

most users will use Open or Create instead.

と書いてある。OpenFile は、追記など特殊な事情用の関数なのだと読み取れる。

5 最終的なコード

私が今回つくった list2file 関数は、指定したファイル名で指定した文字列配列を書き込む関数。追記という事情は無い。毎回丸々上書きするだけだ。これは毎回 Create することに等しい。たしかに、Create を使えば済む。

というわけで、最終的なコードは以下のように。

func list2file(filepath string, lines []string) {
    // ★Create を使うようにした
    fp, err := os.Create(filepath)
    if err != nil {
        abort(err.Error())
    }
    defer fp.Close()

    writer := bufio.NewWriter(fp)
    for _, line := range lines {
        writer.WriteString(line + "\n")
    }
    writer.Flush()
}

所感やら反省やら

これで半日くらいハマった。Truncate という概念にたどり着くのに時間がかかった。

結局、golang の i/o についてあれこれググりまくってる最中に Golang ファイル入出力メモ - Qiita にヒットして、

os.O_TRUNCを指定しないと、以前の内容をリフレッシュせずに上書きする

という文言と出会ったおかげで解決できた。あとは Twitter で golang trunc - Twitter検索 を見てみて、ああこれだ間違いないと確信。無事解決できたのであった。

反省点としては、こんな感じだろうか。

  • Truncate について知らなかったのは仕方ない
  • でも公式ドキュメントに os.O_TRUNC という定数が存在している(のでここから TRUNC の意味や存在意義を知ることはできた)
  • 私は一度そこを読んだが、『// truncate regular writable file when opened.』 ← open する時にいったん削除?するのか、ふーんとテキトーに読み流していた
    • もっと深掘りしようぜ……