Saltar al contenido

Cómo implementar carga de archivos HTTP en Go | Soporte net/http, multipart y S3

Categoría: Go / Golang
Este artículo está disponible actualmente solo en japonés. Estamos trabajando en las traducciones.

Usando la biblioteca estándar de Go <code>net/http</code> y <code>mime/multipart</code>, puedes implementar cargas de archivos sin paquetes externos. Este artículo proporciona una explicación detallada de la implementación a nivel de producción, desde el análisis de cuerpos multiparte con <code>r.ParseMultipartForm()</code>, la recuperación de archivos con <code>r.FormFile()</code>, la aplicación de límites de tamaño de archivo, la validación de tipos MIME, hasta las cargas en S3 utilizando AWS SDK v2.

Secuencia de procesamiento multipart en Go Secuencia de subida en Go Client Handler Storage POST /upload (multipart/form-data) r.Body = http.MaxBytesReader(...) r.ParseMultipartForm(32 << 20) file, hdr, _ := r.FormFile("file") http.DetectContentType(buf[:512]) io.Copy(dst, file) 200 OK / JSON response
Fig 1: Secuencia multipart de net/http

Recibir solicitudes multipart con net/http

Para recibir una solicitud <code>multipart/form-data</code>, llame a <code>r.ParseMultipartForm(maxMemory)</code>. <code>maxMemory</code> es el máximo de bytes a mantener en memoria; el exceso se escribe en archivos temporales.

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"time"
)

func uploadHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
		return
	}

	// 最大10MBをメモリに保持し、超過分は /tmp に一時保存
	const maxMemory = 10 << 20 // 10MB
	if err := r.ParseMultipartForm(maxMemory); err != nil {
		http.Error(w, "フォームの解析に失敗しました: "+err.Error(), http.StatusBadRequest)
		return
	}

	// フォームの文字列フィールドを取得
	title := r.FormValue("title")
	fmt.Printf("title: %s\n", title)

	// ファイルを取得
	file, header, err := r.FormFile("file")
	if err != nil {
		http.Error(w, "ファイルの取得に失敗しました: "+err.Error(), http.StatusBadRequest)
		return
	}
	defer file.Close()

	fmt.Printf("ファイル名: %s\n", header.Filename)
	fmt.Printf("ファイルサイズ: %d bytes\n", header.Size)
	fmt.Printf("Content-Type: %s\n", header.Header.Get("Content-Type"))

	// 保存先のパスを生成
	timestamp := time.Now().UnixNano()
	safeFilename := filepath.Base(header.Filename) // ディレクトリトラバーサル対策
	destPath := fmt.Sprintf("./uploads/%d_%s", timestamp, safeFilename)

	// ファイルを保存
	dst, err := os.Create(destPath)
	if err != nil {
		http.Error(w, "ファイルの作成に失敗しました", http.StatusInternalServerError)
		return
	}
	defer dst.Close()

	if _, err := io.Copy(dst, file); err != nil {
		http.Error(w, "ファイルの書き込みに失敗しました", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	fmt.Fprintf(w, `{"success":true,"filename":%q}`, safeFilename)
}

func main() {
	os.MkdirAll("./uploads", 0755)
	http.HandleFunc("/upload", uploadHandler)
	log.Println("サーバー起動: :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Implementación de límites de tamaño de archivo

Usar <code>http.MaxBytesReader</code> le permite limitar la cantidad de cuerpo de solicitud leído. Como el argumento a <code>ParseMultipartForm</code> solo no puede limitar el tamaño general de la solicitud, combinarlo con <code>MaxBytesReader</code> es importante.

package handler

import (
	"errors"
	"net/http"
)

const (
	MaxUploadSize = 50 << 20 // 50MB
	MaxMemory     = 10 << 20 // 10MB(メモリに保持する最大量)
)

func uploadHandler(w http.ResponseWriter, r *http.Request) {
	// リクエストボディ全体のサイズを制限
	r.Body = http.MaxBytesReader(w, r.Body, MaxUploadSize)

	if err := r.ParseMultipartForm(MaxMemory); err != nil {
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			http.Error(w, fmt.Sprintf("ファイルサイズが上限(%dMB)を超えています。", MaxUploadSize>>20), http.StatusRequestEntityTooLarge)
		} else {
			http.Error(w, "不正なリクエストです。", http.StatusBadRequest)
		}
		return
	}

	file, header, err := r.FormFile("file")
	if err != nil {
		http.Error(w, "ファイルの取得に失敗しました。", http.StatusBadRequest)
		return
	}
	defer file.Close()

	// ヘッダーのサイズも確認(ParseMultipartForm 後でも確認可能)
	if header.Size > MaxUploadSize {
		http.Error(w, "ファイルが大きすぎます。", http.StatusRequestEntityTooLarge)
		return
	}

	// ... 以降の処理
}

Verificación de tipo MIME

El encabezado <code>Content-Type</code> declarado por el cliente puede ser falsificado. Es más seguro leer los bytes iniciales (número mágico) del contenido del archivo real y determinar el tipo mediante <code>http.DetectContentType</code>.

package handler

import (
	"fmt"
	"mime/multipart"
	"net/http"
)

var allowedMIMETypes = map[string]bool{
	"image/jpeg":      true,
	"image/png":       true,
	"image/gif":       true,
	"image/webp":      true,
	"application/pdf": true,
	"application/zip": true,
}

// detectMIMEType はファイルの先頭512バイトを読んでMIMEタイプを判定します
func detectMIMEType(file multipart.File) (string, error) {
	// 先頭512バイトを読む(http.DetectContentType は最大512バイトを使用)
	buf := make([]byte, 512)
	n, err := file.Read(buf)
	if err != nil && err.Error() != "EOF" {
		return "", fmt.Errorf("ファイルの読み込みに失敗しました: %w", err)
	}

	// ファイルポインタを先頭に戻す(後続の読み込みのため)
	if seeker, ok := file.(interface{ Seek(int64, int) (int64, error) }); ok {
		if _, err := seeker.Seek(0, 0); err != nil {
			return "", fmt.Errorf("シークに失敗しました: %w", err)
		}
	}

	mimeType := http.DetectContentType(buf[:n])
	return mimeType, nil
}

func validateFile(file multipart.File, header *multipart.FileHeader) error {
	// 実際のMIMEタイプを検出
	mimeType, err := detectMIMEType(file)
	if err != nil {
		return err
	}

	if !allowedMIMETypes[mimeType] {
		return fmt.Errorf("許可されていないファイル形式です(%s)", mimeType)
	}

	return nil
}

Carga de S3 con AWS SDK v2

El AWS SDK v2 de Go tiene mejoras significativas respecto a SDK v1, con características de soporte de contexto y organización modular. Las descargas multiparte se manejan automáticamente con <code>s3manager.Upload</code>.

go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/s3
go get github.com/aws/aws-sdk-go-v2/feature/s3/manager
package s3upload

import (
	"context"
	"fmt"
	"io"
	"mime/multipart"
	"os"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type S3Uploader struct {
	uploader *manager.Uploader
	bucket   string
}

func NewS3Uploader(ctx context.Context) (*S3Uploader, error) {
	// 環境変数(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION)から設定を読み込む
	cfg, err := config.LoadDefaultConfig(ctx,
		config.WithRegion(os.Getenv("AWS_REGION")),
	)
	if err != nil {
		return nil, fmt.Errorf("AWS 設定の読み込みに失敗しました: %w", err)
	}

	client := s3.NewFromConfig(cfg)
	uploader := manager.NewUploader(client, func(u *manager.Uploader) {
		u.PartSize = 10 * 1024 * 1024 // 10MB パートサイズ
		u.Concurrency = 3             // 並列アップロード数
	})

	return &S3Uploader{
		uploader: uploader,
		bucket:   os.Getenv("AWS_S3_BUCKET"),
	}, nil
}

func (u *S3Uploader) Upload(ctx context.Context, file io.Reader, header *multipart.FileHeader, mimeType string) (string, error) {
	// ユニークなキーを生成
	timestamp := time.Now().UnixNano()
	key := fmt.Sprintf("uploads/%d_%s", timestamp, header.Filename)

	result, err := u.uploader.Upload(ctx, &s3.PutObjectInput{
		Bucket:      aws.String(u.bucket),
		Key:         aws.String(key),
		Body:        file,
		ContentType: aws.String(mimeType),
		// ACL を使わずバケットポリシーで制御する場合は省略
	})
	if err != nil {
		return "", fmt.Errorf("S3 へのアップロードに失敗しました: %w", err)
	}

	return result.Location, nil
}

// Presigned URL の生成(プライベートファイルへの一時アクセス)
func GeneratePresignedURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) {
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		return "", err
	}

	client := s3.NewFromConfig(cfg)
	presigner := s3.NewPresignClient(client)

	req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
		Bucket: aws.String(bucket),
		Key:    aws.String(key),
	}, s3.WithPresignExpires(expiry))
	if err != nil {
		return "", fmt.Errorf("Presigned URL の生成に失敗しました: %w", err)
	}

	return req.URL, nil
}

Ejemplo de implementación con Echo Framework

El uso del marco web <code>Echo</code> permite configurar el enrutamiento y el middleware de manera simple. El manejo de carga de archivos funciona de la misma manera que <code>net/http</code> estándar.

package main

import (
	"net/http"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	e.POST("/upload", func(c echo.Context) error {
		// マルチパートフォームを解析(最大32MBまでメモリに保持)
		if err := c.Request().ParseMultipartForm(32 << 20); err != nil {
			return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
		}

		file, err := c.FormFile("file")
		if err != nil {
			return c.JSON(http.StatusBadRequest, map[string]string{"error": "ファイルが見つかりません"})
		}

		src, err := file.Open()
		if err != nil {
			return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
		}
		defer src.Close()

		// MIMEタイプの検証
		mimeType, err := detectMIMEType(src)
		if err != nil || !allowedMIMETypes[mimeType] {
			return c.JSON(http.StatusUnsupportedMediaType, map[string]string{
				"error": "許可されていないファイル形式です",
			})
		}

		return c.JSON(http.StatusOK, map[string]interface{}{
			"filename": file.Filename,
			"size":     file.Size,
			"mime":     mimeType,
		})
	})

	e.Logger.Fatal(e.Start(":8080"))
}

Archivos de prueba para este artículo (gratis)

  • → <a href="/ja/files/images/png/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">Imagen PNG de Prueba (1MB)</a> — Para verificación de tipo MIME y pruebas de <code>http.DetectContentType</code>
  • → <a href="/ja/files/pdf/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">Archivo de prueba PDF (1MB)</a> — Para verificar la carga a S3 y el procesamiento multiparte
  • → <a href="/ja/files/zip/1mb/" class="text-primary-600 dark:text-primary-400 hover:underline">Archivo de prueba ZIP (1MB)</a> — para pruebas de límites de tamaño de archivo

Artículos relacionados

  • → <a href="/ja/blog/s3-upload-limit/" class="text-primary-600 dark:text-primary-400 hover:underline">Resumen de Límites de Carga de Archivos para AWS S3 y CloudFront</a>
  • → <a href="/ja/blog/file-validation-checklist/" class="text-primary-600 dark:text-primary-400 hover:underline">Lista de Verificación de Implementación de Validación de Archivos en Formularios Web</a>
  • → <a href="/ja/blog/laravel-file-upload/" class="text-primary-600 dark:text-primary-400 hover:underline">Guía de Implementación de Carga de Archivos en Laravel | Validación, Storage y S3</a>