【golang】io.Readerを使いまわしてContentType判定、S3アップロードしたらハマった話
前書き:同じハマりを繰り返す
Single Page Application(SPA)をAmazon S3にアップロードする機能を持つspareコマンドを開発しているとき、io.Readerの使い方を間違えて少しハマってしまいました。ハマりの原因はio.Readerで読み出すデータが欠損していたことであり、欠損の原因はio.Readerを使いまわしたことです。
このハマり方は2回目なので、備忘録として記事にします。
ことの発端(時系列)
- spareコマンドにファイルをS3へアップロードする機能を追加し、S3にSPAをアップロード
- CloudFront経由でS3に格納されているindex.htmlをチェックしたら、画面には何も表示されず、ダウンロード処理が開始された
- 上記2.の挙動となった理由は、ファイルをS3へアップロードする時にContentTypeを指定していなかったため
- httpパッケージのDetectContentTypeメソッドでファイルのコンテンツタイプを判定し、その情報をS3へのアップロード時に付与する実装に変更
- SPAをS3へアップロードし直し
- 再度CloudFront経由でindex.htmlをチェックしたら、ダウンロード処理が開始されなくなったが、ブラウザには何も表示されなかった
どこが駄目だったか
以下のUploadFileメソッドは、引数input.Dataを持っており、この引数がio.Readerです。
input.Data は、detectContentType() と s3manager.UploadInput() に渡されています。
detectContentType()は、io.Readerから512バイト読み込み、読み込んだデータからコンテンツタイプを判定します。
s3manager.UploadInput()は、渡されたio.Readerからデータを読み込み、S3へアップロードします
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
func (s *S3Uploader) UploadFile(_ context.Context, input *service.FileUploaderInput) (*service.FileUploaderOutput, error) { contentType, err := detectContentType(input.Data) if err != nil { return nil, errfmt.Wrap(service.ErrFileUpload, err.Error()) } uploadInput := &s3manager.UploadInput{ Bucket: aws.String(input.BucketName.String()), Body: aws.ReadSeekCloser(input.Data), Key: aws.String(input.Key), ContentType: aws.String(contentType), } if _, err := s.Upload(uploadInput); err != nil { return nil, err } return &service.FileUploaderOutput{ DetectedMIMEType: contentType, }, nil } func detectContentType(reader io.Reader) (string, error) { buffer := make([]byte, 512) _, err := reader.Read(buffer) if err != nil && err != io.EOF { return "", errfmt.Wrap(service.ErrNotDetectContentType, err.Error()) } return http.DetectContentType(buffer), nil } |
このinput.Dataの使い回しが原因で、ブラウザでindex.htmlを確認する時に何も表示されませんでした。
何が悪いか:io.Readerは読込毎に読込開始位置が進む
私は古い人間なので、「io.Readerは、C言語のファイルポインタと一緒か」とすぐ思いました。
io.Readerはデータの読み込み処理を実行すると、ファイルの読み込み開始位置が進みます。そのため、先程の例では「コンテンツタイプを読み込むために512Byte分、読み込み開始位置が移動」「S3へのアップロード時は、ファイルの先頭から512Byte進んだ地点のデータからファイル終端までをアップロード」という挙動をしていたことになります。
解決策1:io.ReadSeekerなどのio.Seekerインターフェースを利用
先程の例のinput.Data(io.Reader)がio.Seekerを満たす場合は、input.Dataの型をio.ReadSeekerに変更するのが簡単です。ファイルの読み出し位置を変えるには、以下の例のようにSeek()メソッドを用います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
package main import ( "fmt" "io" "os" ) func main() { // ファイルをオープンして読み込み可能なio.ReadSeekerを作成します file, err := os.Open("example.txt") if err != nil { fmt.Println("ファイルを開けませんでした:", err) return } defer file.Close() // ファイルの内容を読み取ります data := make([]byte, 20) n, err := file.Read(data) if err != nil { fmt.Println("読み込みエラー:", err) return } fmt.Printf("最初の %d バイトを読み取りました: %s\n", n, data[:n]) // ファイルの開始位置を戻します _, err = file.Seek(0, io.SeekStart) if err != nil { fmt.Println("Seekエラー:", err) return } // ファイルの内容をもう一度読み取ります n, err = file.Read(data) if err != nil { fmt.Println("読み込みエラー:", err) return } fmt.Printf("再度最初の %d バイトを読み取りました: %s\n", n, data[:n]) } |
解決策2:io.Readerを複製
io.Readerがio.Seekerインターフェースを満たしていない場合はどうでしょうか。この場合は、io.Readerを複製する方法が考えられます。
例えば、io.ReadAll()でデータをバイトスライスとして読み込み、バイトスライスから2つのio.Readerを作ることができます。ただし、この方法はio.Readerで読み込むデータのサイズが大きい場合、データ読み込みに時間がかかり処理が遅くなる点に注意してください。
1 2 3 4 5 6 7 |
func duplicateReader(r io.Reader) (io.Reader, io.Reader, error) { data, err := io.ReadAll(r) if err != nil { return nil, nil, err } return bytes.NewReader(data), bytes.NewReader(data), nil } |
実行時間を気にする場合は、io.TeeReader()を使う案が考えられます。io.TeeReader()は、データを読み取りながら、同時にそのデータを別の場所にコピーできます。そのため、io.Readerからデータを読み取りつつ、同じデータをバッファに書き込むことができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
package main import ( "fmt" "io" "strings" ) func main() { input := "Hello, World!" reader := strings.NewReader(input) // データを複製するためのバッファを作成します var buffer1, buffer2 strings.Builder // TeeReaderを作成し、データを複製します teeReader := io.TeeReader(reader, &buffer1) // データを読み取りながら標準出力にコピーします _, err := io.Copy(&buffer2, teeReader) if err != nil { fmt.Println("コピー中にエラーが発生しました:", err) return } fmt.Println("元のデータ:", input) fmt.Println("バッファ1にコピーされたデータ:", buffer1.String()) fmt.Println("バッファ2にコピーされたデータ:", buffer2.String()) } |
最後に:CSSとJSのコンテンツ判定方法が分からない
本記事で登場したhttp.DetectContentType()は、コンテンツがCSSかJavaScriptかを判定しません。そのため、CloudFrontからindex.htmlを確認した時に、表示が崩れる問題が発生しました。表示崩れの原因は、コンテンツタイプを正しく指定せずにS3へファイルをアップロードしたからです。
より細かくコンテンツタイプを判定できるgabriel-vasile/mimetypeを利用しましたが、こちらもCSSとJavaScriptを正しく判定できないようでした。そのため、以下の実装のように拡張子で判定する処理を暫定的に入れてます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
func detectContentType(reader io.Reader, filename string) (string, error) { var extensionToContentType = map[string]string{ ".css": "text/css", ".js": "application/javascript", } contentType, found := extensionToContentType[filepath.Ext(filename)] if found { return contentType, nil } mtype, err := mimetype.DetectReader(reader) if err != nil { return "", errfmt.Wrap(service.ErrNotDetectContentType, err.Error()) } return mtype.String(), nil } |
この方法は好ましくないので、上手な判定方法を見つけないとなと思ってます(が、他に優先する事項があるので、放置しています)
ロシア人と国際結婚した地方エンジニア。
小学〜大学院、就職の全てが新潟。
大学の専攻は福祉工学だったのに、エンジニアとして就職。新卒入社した会社ではOS開発や半導体露光装置ソフトを開発。現在はサーバーサイドエンジニアとして修行中。HR/HM(メタル)とロシア妻が好き。サイトに関するお問い合わせやTwitterフォローは、お気軽にどうぞ。