Go の HTTP ハンドラでファイルアップロードを受け取る - multipart/form-data の処理
Web アプリケーションでファイルアップロードを実装する場面は多い。プロフィール画像の変更、CSV データのインポート、ドキュメントの添付——いずれも HTTP の multipart/form-data を通じてサーバーにファイルを送信する仕組みだ。Go の標準ライブラリには、このマルチパートデータを扱うための機能が net/http パッケージに組み込まれている。
multipart/form-data とは何か
通常のフォーム送信では application/x-www-form-urlencoded が使われる。これはキーと値のペアを key=value&key2=value2 のようにエンコードする形式で、テキストデータには適しているがバイナリデータの送信には向かない。
テキストデータのみ。バイナリは Base64 エンコードが必要で、サイズが約 33% 増加する。
テキストとバイナリを混在して送信できる。各パートが境界文字列で区切られ、ファイルはそのままのバイナリで送られる。
multipart/form-data では、リクエストボディが「パート」と呼ばれる単位に分割される。各パートにはヘッダーと本体があり、ファイル名やコンテンツタイプなどのメタ情報を個別に持てる。これにより、1 回のリクエストで複数のファイルとテキストフィールドを同時に送信できる。
ParseMultipartForm でリクエストを解析する
Go でマルチパートリクエストを扱う最もシンプルな方法は、http.Request の ParseMultipartForm メソッドを使うことだ。
func uploadHandler(w http.ResponseWriter, r *http.Request) {
// 最大 10MB をメモリに保持、超過分は一時ファイルへ
err := r.ParseMultipartForm(10 << 20)
if err != nil {
http.Error(w, "リクエストの解析に失敗", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("avatar")
if err != nil {
http.Error(w, "ファイルの取得に失敗", http.StatusBadRequest)
return
}
defer file.Close()
log.Printf("ファイル名: %s, サイズ: %d bytes", header.Filename, header.Size)
}ParseMultipartForm の引数 10 << 20 は、メモリ上に保持するデータの上限をバイト単位で指定する。この例では 10MB だ。リクエスト全体がこの上限を超えると、超過分はOS の一時ディレクトリに書き出される。
r.FormFile("avatar") は、フォームフィールド名が avatar であるファイルを取得する。戻り値は 3 つで、ファイルの中身を読み取る multipart.File、ファイル名やサイズなどのメタ情報を持つ *multipart.FileHeader、そしてエラーだ。
ファイルをディスクに保存する
受け取ったファイルをサーバーのディスクに書き出す処理は、io.Copy を使えば簡潔に書ける。
func saveUploadedFile(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(10 << 20)
if err != nil {
http.Error(w, "解析失敗", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("document")
if err != nil {
http.Error(w, "ファイル取得失敗", http.StatusBadRequest)
return
}
defer file.Close()
// 保存先のファイルを作成
dst, err := os.Create(filepath.Join("uploads", header.Filename))
if err != nil {
http.Error(w, "ファイル作成失敗", http.StatusInternalServerError)
return
}
defer dst.Close()
// コピー
written, err := io.Copy(dst, file)
if err != nil {
http.Error(w, "書き込み失敗", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "%d bytes を保存しました", written)
}io.Copy はソースからデスティネーションへバッファリングしながらデータを転送するので、巨大なファイルでもメモリを圧迫しない。multipart.File は io.Reader インターフェースを実装しているため、そのまま io.Copy に渡せる。
ただし、header.Filename をそのまま保存パスに使うのは危険だ。悪意のあるクライアントが ../../etc/passwd のようなファイル名を送ってくる可能性がある。本番環境では UUID やハッシュ値でファイル名を生成するべきだろう。
FormFile を直接呼ぶ方法
実は ParseMultipartForm を明示的に呼ばなくても、r.FormFile を呼ぶだけでマルチパートの解析は自動的に行われる。
func simpleUpload(w http.ResponseWriter, r *http.Request) {
// ParseMultipartForm は内部で自動的に呼ばれる
file, header, err := r.FormFile("photo")
if err != nil {
http.Error(w, "ファイル取得失敗", http.StatusBadRequest)
return
}
defer file.Close()
fmt.Fprintf(w, "受信: %s (%d bytes)", header.Filename, header.Size)
}この場合、メモリ上限はデフォルトの 32MB(http.defaultMaxMemory)が適用される。上限を明示的に制御したい場合は、先に ParseMultipartForm を呼んでおく方がよい。
メモリ上限を自分で設定できる。大きなファイルを扱うときや、逆にメモリ使用量を厳しく制限したいときに有用。
コードが簡潔になる。デフォルトの 32MB 上限で問題ないなら、こちらで十分。
複数ファイルを一度に受け取る
1 つのフォームフィールドに複数のファイルが紐づく場合(HTML の <input type="file" multiple>)、r.MultipartForm.File を使ってすべてのファイルにアクセスする。
func multiUpload(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(32 << 20)
if err != nil {
http.Error(w, "解析失敗", http.StatusBadRequest)
return
}
files := r.MultipartForm.File["photos"]
for _, fh := range files {
file, err := fh.Open()
if err != nil {
log.Printf("ファイルを開けません: %s: %v", fh.Filename, err)
continue
}
dst, err := os.Create(filepath.Join("uploads", fh.Filename))
if err != nil {
file.Close()
log.Printf("保存先を作成できません: %v", err)
continue
}
io.Copy(dst, file)
file.Close()
dst.Close()
log.Printf("保存完了: %s (%d bytes)", fh.Filename, fh.Size)
}
fmt.Fprintf(w, "%d 件のファイルを受信しました", len(files))
}r.MultipartForm.File はフィールド名をキー、[]*multipart.FileHeader のスライスを値とする map だ。同じフィールド名で送られた複数のファイルがスライスに格納される。各 FileHeader の Open メソッドでファイルの中身を読み取れる。
ループ内で defer を使うとすべてのファイルがループ終了まで閉じられないため、明示的に Close を呼んでいる点に注意してほしい。
テキストフィールドとファイルを同時に受け取る
実際のフォームでは、ファイルと一緒にテキストデータも送信されることが多い。ユーザー名と一緒にアバター画像を送る、タイトルと一緒に添付ファイルを送る、といった場面だ。
func profileUpdate(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(10 << 20)
if err != nil {
http.Error(w, "解析失敗", http.StatusBadRequest)
return
}
// テキストフィールドの取得
username := r.FormValue("username")
bio := r.FormValue("bio")
// ファイルの取得
file, header, err := r.FormFile("avatar")
if err != nil {
http.Error(w, "アバター画像が必要です", http.StatusBadRequest)
return
}
defer file.Close()
log.Printf("ユーザー: %s, 自己紹介: %s, 画像: %s",
username, bio, header.Filename)
fmt.Fprintf(w, "プロフィールを更新しました")
}r.FormValue はマルチパートリクエストのテキストフィールドにもアクセスできる。ParseMultipartForm を呼んだ後であれば、テキストフィールドもファイルフィールドも同じリクエストオブジェクトから取得できる。
ファイルサイズとコンテンツタイプのバリデーション
本番環境では、受け取ったファイルを無条件に保存するわけにはいかない。サイズの上限チェックとコンテンツタイプの検証は必須だ。
const maxFileSize = 5 << 20 // 5MB
func validateAndSave(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFileSize)
err := r.ParseMultipartForm(maxFileSize)
if err != nil {
http.Error(w, "ファイルが大きすぎます(上限 5MB)", http.StatusRequestEntityTooLarge)
return
}
file, header, err := r.FormFile("image")
if err != nil {
http.Error(w, "ファイル取得失敗", http.StatusBadRequest)
return
}
defer file.Close()
// 先頭 512 バイトを読んでコンテンツタイプを判定
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil {
http.Error(w, "ファイル読み取り失敗", http.StatusInternalServerError)
return
}
contentType := http.DetectContentType(buf[:n])
if contentType != "image/jpeg" && contentType != "image/png" {
http.Error(w, "JPEG または PNG のみ許可されています", http.StatusBadRequest)
return
}
// 読み取り位置を先頭に戻す
file.Seek(0, io.SeekStart)
dst, err := os.Create(filepath.Join("uploads", header.Filename))
if err != nil {
http.Error(w, "保存失敗", http.StatusInternalServerError)
return
}
defer dst.Close()
io.Copy(dst, file)
fmt.Fprintf(w, "画像を保存しました(%s)", contentType)
}http.MaxBytesReader はリクエストボディ全体のサイズを制限するラッパーだ。ParseMultipartForm のメモリ上限とは異なり、こちらはリクエスト自体を拒否する。上限を超えたリクエストを読もうとするとエラーが返る。
http.DetectContentType はファイルの先頭バイト列を調べて MIME タイプを判定する関数で、内部では MIME Sniffing アルゴリズム に基づいて判定を行う。
RFC 7231 や WHATWG の仕様に沿ったバイトパターンマッチングで、拡張子ではなく実際のデータ内容から型を判定するため、拡張子の偽装を防げる。
クライアントが送ってくる Content-Type ヘッダーは信用してはいけない。簡単に偽装できるからだ。http.DetectContentType を使えば、ファイルの実際の中身からコンテンツタイプを判定できる。先頭 512 バイトを読み取った後に file.Seek(0, io.SeekStart) で読み取り位置を戻すのを忘れないようにしよう。
curl でテストする
実装したハンドラは curl の -F フラグで簡単にテストできる。
# 単一ファイルのアップロード
curl -X POST -F "avatar=@photo.jpg" http://localhost:8080/upload
# テキストフィールドとファイルを同時に送信
curl -X POST \
-F "username=alice" \
-F "bio=Hello!" \
-F "avatar=@photo.jpg" \
http://localhost:8080/profile
# 複数ファイルのアップロード
curl -X POST \
-F "photos=@img1.jpg" \
-F "photos=@img2.jpg" \
-F "photos=@img3.jpg" \
http://localhost:8080/multi-upload-F フラグを使うと、curl は自動的に Content-Type: multipart/form-data を設定してくれる。@ をファイルパスの前に付けることで、ファイルの内容がリクエストボディに含まれる。
Go の net/http はマルチパートの処理に必要な機能を標準ライブラリだけで十分に備えている。ParseMultipartForm でメモリ管理を制御し、FormFile でファイルを取り出し、MaxBytesReader と DetectContentType でバリデーションを行う——これらを組み合わせれば、外部ライブラリなしで堅牢なファイルアップロード機能を構築できる。