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 のようにエンコードする形式で、テキストデータには適しているがバイナリデータの送信には向かない。

application/x-www-form-urlencoded

テキストデータのみ。バイナリは Base64 エンコードが必要で、サイズが約 33% 増加する。

multipart/form-data

テキストとバイナリを混在して送信できる。各パートが境界文字列で区切られ、ファイルはそのままのバイナリで送られる。

multipart/form-data では、リクエストボディが「パート」と呼ばれる単位に分割される。各パートにはヘッダーと本体があり、ファイル名やコンテンツタイプなどのメタ情報を個別に持てる。これにより、1 回のリクエストで複数のファイルとテキストフィールドを同時に送信できる。

ParseMultipartForm でリクエストを解析する

Go でマルチパートリクエストを扱う最もシンプルな方法は、http.RequestParseMultipartForm メソッドを使うことだ。

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.Fileio.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 を呼んでおく方がよい。

ParseMultipartForm を明示的に呼ぶ

メモリ上限を自分で設定できる。大きなファイルを扱うときや、逆にメモリ使用量を厳しく制限したいときに有用。

FormFile を直接呼ぶ

コードが簡潔になる。デフォルトの 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 だ。同じフィールド名で送られた複数のファイルがスライスに格納される。各 FileHeaderOpen メソッドでファイルの中身を読み取れる。

ループ内で 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 でファイルを取り出し、MaxBytesReaderDetectContentType でバリデーションを行う——これらを組み合わせれば、外部ライブラリなしで堅牢なファイルアップロード機能を構築できる。