はじめに (対象読者・この記事でわかること)
この記事は、GoでWeb APIやHTTP経由で画像を取得して、そのままimage.Decodeしようとして「image: unknown format」と怒られてしまった方、あるいはこれから実装しようとしている方向けです。
記事を読み終えると、以下のことがわかります。
- なぜダウンロードしたbytes.Bufferが1回目のデコードで空になってしまうのか
io.Readerを「複数回」使いたいときの正しいリセット/複製のテクニックio.TeeReaderとbytes.Bufferを使って、ログ用・画像処理用の2つの処理をシンプルに共存させる方法
コードレベルで実装を急ぎたい方でも、サンプルコードをコピペするだけで即解決できます。
前提知識
- Goの基礎文法(エラー処理は
if err != nilスタイル) net/httpパッケージを使った簡単なGETリクエストimage.Decodeの存在を知っていること(使ったことがなくても可)
事象の概要:ダウンロードした画像が"unknown format"になる理由
Goで画像を扱うとき、次のように書くのが定石です。
Goresp, _ := http.Get(url) img, _, err := image.Decode(resp.Body)
これで問題なく動く場合も多いのですが、以下のように「取得した画像を一度別の処理に回してからデコードしよう」とすると、突然image: unknown formatエラーが出ます。
Go// 例:ダウンロードした画像をロギングしてからデコード data, _ := io.ReadAll(resp.Body) log.Printf("size=%d", len(data)) // ← ここで読み切る img, _, err := image.Decode(bytes.NewReader(data)) // ← ここでエラー
resp.Bodyもbytes.NewReader(data)もio.Readerです。
Readerは「読み進めるとポインタが進む」ため、1回目で全部読み切ると2回目以降はもう「空っぽ」になってしまいます。
image.Decodeは先頭のマジックナンバーを見てフォーマット判定するため、空のReaderでは当然unknown formatと判断します。
具体的な手順と回避策:Readerを複数回使う3パターン
ステップ1:io.ReadAllでスライスを確保してリセットする最簡単パターン
小さい画像であれば、最初にまるごとメモリに載せて以降はbytes.NewReaderを何度でも作り直すのが手軽です。
Goresp, err := http.Get(url) if err != nil { log.Fatal(err) } defer resp.Body.Close() // 1. 一度に全部読む data, err := io.ReadAll(resp.Body) if err != nil { log.Fatal(err) } // 2. ログ出力 log.Printf("download size=%d", len(data)) // 3. デコード img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { log.Fatal(err) }
メモリに余裕がある場合はこれで十分。ただし数百MBの画像を扱うとdataの確保が辛くなります。
ステップ2:io.TeeReaderでストリームを分岐する大容量対応パターン
巨大な画像を扱う場合、メモリに載せたくないこともあります。
io.TeeReaderを使うと「読み流しながら」別のWriterにコピーできるため、ダウンロードしながらファイルに保存したり、ハッシュを計算したりできます。
Go// 保存先ファイル file, err := os.Create("downloaded.jpg") if err != nil { log.Fatal(err) } defer file.Close() // ダウンロード → ファイル保存 → decode まで一気通貫 resp, err := http.Get(url) if err != nil { log.Fatal(err) } defer resp.Body.Close() // MultiWriterで resp.Body をファイルとハッシュに分岐 mw := io.MultiWriter(file, sha256.New()) tr := io.TeeReader(resp.Body, mw) // ここで読み進められるが、tee先のmwにも同時に流れる img, _, err := image.Decode(tr) if err != nil { log.Fatal(err) }
TeeReaderは内部で一時バッファを持たず、即座にWriter側に流すため、メモリ効率が良いのが特徴です。
ステップ3:io.Seekerを実装するネットワーク再読み込みパターン(応用)
http.Response.Bodyがio.ReadCloserである一方で、ローカルファイルならos.FileがSeekメソッドを持っているように、一部のReaderは「先頭に戻って再読み込み」できます。
サーバがRangeリクエストに対応している場合、同じURLに再アクセスすれば先頭から読めるという手もあります(コードは省略)。
ハマりどころ:「空のReader」を疑う癖をつける
unknown formatやEOFが出たとき、真っ先に疑うべきは「Readerの中身が本当に残っているか?」です。
以下のデバッグ一行を入れるだけで、原因特定が劇的に早くなります。
Goif runtime.GOOS == "debug" { io.Copy(io.Discard, reader) } // 残量チェック
解決策:Readerの寿命を意識する設計
- 「読み終わった=もう使えない」と思って設計する
- 複数回使いたいときは
- 小さいデータ →
[]byteに落としてbytes.NewReaderで作り直す - 大きいデータ →io.TeeReaderで分岐、あるいはSeek可能なReaderを使う - エラーメッセージが抽象的でも「Readerが空」という視点を忘れない
まとめ
本記事では、GoでHTTP経由で画像を取得してimage.Decodeしようとした際にunknown formatエラーが出る仕組みと、それを回避する3つの実装パターンを紹介しました。
io.Readerは読み進めると消費される(読み返し不可)- 複数回処理するなら「メモリに載せて作り直す」「TeeReaderで分岐する」「Seekerを使う」のいずれかを選ぶ
- デバッグ時は
io.Copy(io.Discard, reader)で残量をチェックすると早期解決しやすい
この知識は画像に限らず、CSVやJSONなどあらゆるストリーム処理で同様の落とし穴が潜んでいます。
次回は「encoding/csvで巨大CSVを1億行処理する時のメモリ対策」について掘り下げていきます。
参考資料
- 公式パッケージドキュメント: image, bytes
- 「Goならわかるシステムプログラミング」第5章「IOとストリーム」
- ブログ記事: io.TeeReaderを使った高速ロギング
