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 という概念があるらしい。
書き込みで開いた場合、os.O_TRUNCを指定しないと、以前の内容をリフレッシュせずに上書きするので、書き込み内容が短い場合は以前の内容が残ってしまう。
[Go言語]Golangの上書き処理が少し慣れない[memo] | 鈍色スイッチ
O_TRUNCを追加しておくと、書き込み先ファイルが存在したときにその内容を消し、そのあと出力する形になる。つまり、中身を消した上で上書きするのである。
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 する時にいったん削除?するのか、ふーんとテキトーに読み流していた
- もっと深掘りしようぜ……