Содержание урока · 12 разделов
- gin загрузка файлов: multipart/form-data изнутри
- Анатомия FormFile: ParseMultipartForm и FileHeader
- SaveUploadedFile: MkdirAll, os.Create и чужие пути
- MultipartForm: пачка файлов и текст рядом
- MaxMultipartMemory: порог памяти, а не лимит размера
- Валидация: имя, размер и магические байты
- curl, HTML-форма и жизнь после сохранения
- Справочник: инструменты загрузки
- Семь правил загрузки файлов
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
gin загрузка файлов: multipart/form-data изнутри#
До сих пор наш трекер питался JSON: TodoInput из урока про привязку, конверты Envelope из урока про ответы. Но JSON таскает только текст: аватар, вложение письма, импорт CSV в него не кладёшь — байты пришлось бы кодировать base64 (треть лишнего трафика) и держать всё тело в памяти до конца декодирования. Для файлов есть стандарт — multipart/form-data: тело режется на самодостаточные части (parts), каждая со своими заголовками Content-Disposition и Content-Type, а между частями стоит boundary — случайная строка из заголовка запроса. HTML-форма должна просить этот формат явно: <form method="post" enctype="multipart/form-data">, — забудете enctype, и форма уедет как application/x-www-form-urlencoded: сервер получит только имя файла, а не содержимое. curl собирает multipart за вас: curl -F "avatar=@cat.jpg" localhost:8080/upload — флаг -F читает файл, ставит Content-Type по расширению и режет boundary сам. На стороне gin gin загрузка файлов — это одна пара вызовов: c.FormFile достаёт файл из разобранной формы, c.SaveUploadedFile пишет его на диск. Всё остальное в уроке — гигиена вокруг этой пары: куда уходят большие файлы, почему имя и тип от клиента нельзя верить, как принять пачку за один POST и где стоит настоящий лимит размера. Хендлер повесим на POST: загрузка создаёт ресурс, и 201 Created здесь уместнее 200, как и для POST /todos из четвёртого урока.
Минимальный хендлер: FormFile и SaveUploadedFile
GOpackage main
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
)
func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.POST("/upload", func(c *gin.Context) {
fh, err := c.FormFile("avatar")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "BAD_REQUEST",
"message": "файл не найден в поле avatar",
})
return
}
dst := filepath.Join("./uploads", filepath.Base(fh.Filename))
if err := c.SaveUploadedFile(fh, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
return
}
c.JSON(http.StatusCreated, gin.H{
"saved": dst,
"name": fh.Filename,
"size": fh.Size,
})
})
if err := r.Run(":8080"); err != nil {
panic(err)
}
}c.FormFile("avatar") возвращает *multipart.FileHeader и error; SaveUploadedFile(fh, dst) пишет содержимое в ./uploads. filepath.Base — страховка от клиентских путей в имени, ниже разберём, почему она обязательна. Ответ — привычный JSON из c.JSON: имя сохранённого файла, оригинальное имя и размер — ровно те поля, которые фронтенд покажет сразу после загрузки.
Анатомия FormFile: ParseMultipartForm и FileHeader#
Заглянем в context.go — там пять строк, и все про net/http. c.FormFile первым делом проверяет c.Request.MultipartForm и, если форма ещё не разобрана, зовёт c.Request.ParseMultipartForm(c.engine.MaxMultipartMemory): поле движка уходит в стандартную библиотеку как maxMemory. ParseMultipartForm читает всё тело запроса и раскладывает части: текстовые поля — в Value, файловые — в буфер объёмом maxMemory, а что в буфер не влезло, переливается во временные файлы в os.TempDir. Второй вызов разбора не случится: MultipartForm уже не nil, поэтому и c.FormFile, и c.PostForm, и c.MultipartForm на одном запросе спокойно делят одну разобранную форму. Возврат — Request.FormFile(name): первый *multipart.FileHeader поля. FileHeader — это снимок части: Filename дословно как назвал файл клиент, Size — фактический размер части в байтах, посчитанный при разборе, Header — MIME-заголовки самой части, включая Content-Type, присланный клиентом. fh.Open() отдаёт multipart.File — интерфейс io.Reader, io.ReaderAt, io.Seeker и io.Closer: для мелких файлов это окно в память, для крупных — чтение секции временного файла. Пара FormFile и SaveUploadedFile — это весь formfile gin: получить хедер, открыть, скопировать. Разница между «заработало» и «не утекло в прод» — в деталях вокруг: имени файла, типе содержимого, лимитах и поведении при пачке. Их и разберём по порядку, начиная с того, куда именно пишет SaveUploadedFile.
Проверка: файл не приложили
POST /upload содержит только текстовое поле caption, поле avatar отсутствует вовсе. Что вернёт c.FormFile("avatar")?
SaveUploadedFile: MkdirAll, os.Create и чужие пути#
SaveUploadedFile — обёртка из четырёх строк над net/http-миром: fh.Open(), os.MkdirAll(filepath.Dir(dst), 0o750), os.Create(dst) и io.Copy. Каталог назначения она создаёт сама — ./uploads не нужно готовить руками; файл получает права 0666 до umask, а в свежих версиях gin есть и SaveUploadedFileWithPermissions с явным perm. io.Copy работает потоково с буфером 32 КиБ: память хендлера не зависит от размера файла — хоть из буфера, хоть из временного файла на диске. Главная дыра здесь — fh.Filename: он приходит с клиента дословно. Браузер шлёт только базовое имя, но curl позволяет filename=..., а руками собранный multipart — вообще что угодно, хоть ../../.bashrc. filepath.Join фильтром не служит: он склеивает путь и вызывает Clean, а Clean честно разрешает ../.. — и путь выезжает за пределы каталога. Минимальная страховка — filepath.Base(fh.Filename), отрезающая каталоги; пустое имя (filename=) после Base превращается в «.», и os.Create на каталоге честно упадёт ошибкой — обрабатывайте её как 400. Норма для боевого кода — вообще не использовать клиентское имя для сохранения: метка времени плюс случайный суффикс, а оригинал хранить в базе и показывать в ответе. Это убивает разом traversal, коллизии двух пользователей с одинаковым cat.jpg и проблемы с юникодом в файловых системах.
Клиентское имя без Base: куда уйдёт файл
Прочитайте код и выберите, что он выведет:
r.POST("/upload", func(c *gin.Context) {
fh, err := c.FormFile("avatar")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST"})
return
}
dst := filepath.Join("./uploads", fh.Filename) // filepath.Base забыли
if err := c.SaveUploadedFile(fh, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
return
}
c.JSON(http.StatusCreated, gin.H{"saved": dst})
})
// curl -F "avatar=@cat.jpg;filename=../../evil.txt" localhost:8080/uploadMultipartForm: пачка файлов и текст рядом#
c.FormFile всегда возвращает только первый файл поля. Для пачки — галереи, набора вложений — есть c.MultipartForm(): она доводит ParseMultipartForm и возвращает multipart.Form с двумя картами: Value map[string][]string для текстовых полей и File map[string][]multipart.FileHeader для файлов. Ключи повторяются ровно как в форме: три раза -F "photos=..." дают File["photos"] из трёх хедеров — тот же механизм, что у повторяющихся query-ключей из третьего урока. Текст рядом с файлами достаётся c.PostForm("caption") — метод сам разберёт multipart, если это ещё не случилось, и вернёт первое значение поля. Есть и путь через binding: multipart/form-data у ShouldBind — отдельный биндер, и структура с полями multipart.FileHeader и []multipart.FileHeader по тегу form заполнится файлами так же, как TodoInput заполнялся JSON-ом; binding:"required" на поле-файле проверит, что файл приложили, а validate прогонит остальные теги — механика четвёртого урока без изменений. Ручной путь с c.MultipartForm оставляет больше контроля: пофайловые логи, частичный успех, свои сообщения об ошибках — для загрузок это обычно и нужно. Общий принцип прежний: binder — для простых случаев с готовой валидацией, ручные вызовы — когда обработка файла сама по себе логика.
Пачка файлов: form.File["photos"]
GOr.POST("/gallery", func(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST", "message": err.Error()})
return
}
caption := c.PostForm("caption")
saved := make([]string, 0, len(form.File["photos"]))
for _, fh := range form.File["photos"] {
name := fmt.Sprintf("%d-%s", time.Now().UnixNano(), filepath.Base(fh.Filename))
dst := filepath.Join("./uploads", name)
if err := c.SaveUploadedFile(fh, dst); err != nil {
continue // одна битая часть не отменяет остальные
}
saved = append(saved, dst)
}
c.JSON(http.StatusCreated, gin.H{
"caption": caption,
"saved": saved,
"total": len(form.File["photos"]),
})
})form.File — карта поле-слайс: len и обход дают пачку целиком. caption через c.PostForm — форма уже разобрана, обращений к телу больше нет. UnixNano в имени спасает от коллизий при пачке одноимённых файлов; continue оставляет частичный успех — в ответе saved и total, по разнице видно, сколько не доехало.
Проверка: все файлы одного поля
Клиент отправил три файла в одном поле photos. Как достать все три?
MaxMultipartMemory: порог памяти, а не лимит размера#
Самое коварное место темы. r.MaxMultipartMemory = 8 << 20 — поле движка, по умолчанию 32 МиБ; gin передаёт его в ParseMultipartForm как maxMemory. Семантика у параметра одна: сколько байтов файловой части жить в памяти. Читатель multipart сначала заполняет буфер, а дальше переливает остаток части во временный файл в os.TempDir — загрузка в 2 ГиБ при maxMemory 8 МиБ завершится успешно: место на диске, а не в RAM. После возврата хендлера net/http удаляет временные файлы формы сам — чистить нечего, но и подхватить их не выйдет: копируйте содержимое в своём хендлере, как мы и делаем через SaveUploadedFile. Жёсткий же лимит тела в gin не встроен — его ставит http.MaxBytesReader: обёртка c.Request.Body в звене из шестого урока, c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit). При превышении ParseMultipartForm получает ошибку «http: request body too large», и хендлер отвечает 413 Request Entity Too Large. Дешёвый фильтр до разбора — проверка c.Request.Content-Length: клиент её может соврать, поэтому это только первый рубеж, авторитетный — MaxBytesReader, читающий по факту. Для очень больших файлов, где даже временные файлы не нужны, есть c.Request.MultipartReader() — потоковый разбор частей на лету без ReadForm-буферизации: NextPart, ручной разбор Content-Disposition и io.Copy сразу в хранилище. Gin-удобств там нет, но для гигабайтов и ограниченного диска это единственный путь; в 95% случаев хватает FormFile с нормальными лимитами.
Звено лимита: MaxBytesReader и 413
GOconst maxUpload = 10 << 20 // 10 МиБ на всё тело запроса
func limitBody() gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUpload)
c.Next()
}
}
func buildUpload(r *gin.Engine) {
r.MaxMultipartMemory = 8 << 20 // память на буфер, не лимит размера
up := r.Group("/upload", limitBody())
up.POST("/avatar", func(c *gin.Context) {
fh, err := c.FormFile("avatar")
if err != nil {
if errors.Is(err, http.ErrMissingFile) {
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST", "message": "файл не приложен"})
return
}
// сюда же прилетает «http: request body too large»
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "TOO_LARGE"})
return
}
dst := filepath.Join("./uploads", fmt.Sprintf("avatar-%d", time.Now().UnixNano()))
if err := c.SaveUploadedFile(fh, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
return
}
c.JSON(http.StatusCreated, gin.H{"saved": dst, "size": fh.Size})
})
}limitBody оборачивает Body на группе — звено встанет в цепочку маршрутов, как мы собирали звенья в седьмом уроке. ErrMissingFile и превышение лимита приходят в один err: разделяем их errors.Is — первый это 400, второй честный 413. MaxMultipartMemory оставляем 8 МиБ, чтобы RAM не прыгал с трафиком, а потолок задаёт звено.
Сто мегабайт при буфере восемь
Прочитайте код и выберите, что он выведет:
r := gin.Default()
r.MaxMultipartMemory = 8 << 20 // 8 МиБ, MaxBytesReader не подключён
r.POST("/upload", func(c *gin.Context) {
fh, err := c.FormFile("video") // клиент шлёт файл на 100 МиБ
if err != nil {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "TOO_LARGE"})
return
}
if err := c.SaveUploadedFile(fh, "./uploads/video.mp4"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
return
}
c.JSON(http.StatusCreated, gin.H{"size": fh.Size})
})
// curl -F "video=@big.mp4" localhost:8080/uploadВалидация: имя, размер и магические байты#
В загруженном файле доверять можно только фактам. Filename — слова клиента: годится для показа («вы загрузили cat.jpg») и ни для чего больше. Header.Get("Content-Type") — Content-Type части — тоже слова клиента: curl -F "avatar=@evil.exe;type=image/jpeg" подделывает его одним флагом. fh.Size — вычислен net/http по факту чтения части, ему верить можно; проверка верхней границы всё равно должна случиться заранее — звеном с MaxBytesReader, а не после сохранения двух гигабайт на диск. Настоящий тип говорит содержимое: http.DetectContentType по первым 512 байтам — тот самый sniffing из net/http с таблицей магических байтов: JPEG начинается с \xFF\xD8\xFF, PNG — с \x89PNG, PDF — с %PDF-. На неизвестных байтах вернёт application/octet-stream — «непонятно, скачивается, не исполняется». Механика: fh.Open(), io.ReadFull в буфер на 512 байтов, DetectContentType по прочитанному куску, а перед дальнейшим копированием — Seek(0, io.SeekStart): multipart.File поддерживает Seek, но голова уже съедена. Whitelist расширений (.jpg, .png, .gif) оставьте второй линией: совпадение расширения и сниффнутого типа — признак здравого файла, расхождение — повод для 415 Unsupported Media Type. И помните из девятого урока: validator валидирует структуру и строки, байты — ваша работа. Последний штрих — раскладка по каталогам: тысячи файлов в одной папке тормозят файловую систему, раскладывайте по дате или префиксу имени.
saveImage: тип, размер и своё имя
GOvar imageTypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
}
func saveImage(fh *multipart.FileHeader) (string, error) {
if fh.Size > 5<<20 { // 5 МиБ на один файл
return "", errors.New("файл больше 5 МиБ")
}
f, err := fh.Open()
if err != nil {
return "", err
}
defer f.Close()
head := make([]byte, 512)
n, _ := io.ReadFull(f, head)
ext, ok := imageTypes[http.DetectContentType(head[:n])]
if !ok {
return "", fmt.Errorf("тип %q не разрешён", http.DetectContentType(head[:n]))
}
name := fmt.Sprintf("%d-%x%s", time.Now().UnixNano(), randSuffix(4), ext)
dst := filepath.Join("./uploads", time.Now().Format("2006/01/02"), name)
f.Seek(0, io.SeekStart) // голова уже прочитана — перематываем
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return "", err
}
out, err := os.Create(dst)
if err != nil {
return "", err
}
defer out.Close()
_, err = io.Copy(out, f)
return dst, err
}
func randSuffix(n int) []byte {
b := make([]byte, n)
_, _ = rand.Read(b) // crypto/rand
return b
}DetectContentType по первым 512 байтам решает, картинка ли это на самом деле; whitelist imageTypes отсекает остальное. Имя своё: метка времени, случайный суффикс из crypto/rand и сниффнутое расширение — клиентское имя осталось только для ответа. Seek(0) перед копированием обязателен: ReadFull уже съел голову. Раскладка 2006/01/02 держит каталоги небольшими.
Проверка: настоящий тип файла
Клиент прислал файл с именем photo.jpg и Content-Type части image/jpeg. Как надёжно понять, что внутри?
curl, HTML-форма и жизнь после сохранения#
Отправлять multipart удобно curl-ом: -F "avatar=@cat.jpg" читает файл и ставит Content-Type по расширению; -F "avatar=@cat.jpg;type=image/jpeg" задаёт тип части явно; текстовые поля — просто ещё один -F; повторяйте имя поля — получите пачку, как в form.File["photos"]. HTML-форма симметрична: method="post", enctype="multipart/form-data", input type="file" с name="avatar", атрибут multiple для пачки и accept="image/*" как фильтр диалога — но помните, что accept фильтрует только окно выбора, серверная проверка всё равно ваша. Забытый enctype — самый частый дебют: сервер получит urlencoded и упадёт с ErrNotMultipart на разборе. Обрезанный curl (Ctrl+C посреди передачи) даст ошибку unexpected EOF от multipart-читателя — отвечайте 400 с «файл не долетел». Дальше файл живёт своей жизнью: сохранить сохранили, а отдать — штатными средствами прошлого урока: r.Static("/uploads", "./uploads") раздаёт сохранённое по URL, c.FileAttachment отдаёт с Content-Disposition для скачивания под исходным именем. Для тестов тело собирает mime/multipart.NewWriter — разберём его в уроке 21-testirovanie вместе с httptest. И последняя карта — прод: локальный диск удобен на одном узле, а под горизонтальное масштабирование файлы уезжают в объектное хранилище, и тогда вместо SaveUploadedFile io.Copy пишет в клиент S3 — механика разбора формы не меняется ни на байт.
Справочник: инструменты загрузки#
| Инструмент | Что делает | Возвращает | Когда брать |
|---|---|---|---|
| c.FormFile("avatar") | разбирает форму при первом вызове и берёт первый файл поля | *multipart.FileHeader, error | одно поле с одним файлом |
| c.MultipartForm() | разбирает всё тело целиком | *multipart.Form: Value и File | пачка файлов и текстовые поля |
| fh.Open() | открывает содержимое файла | multipart.File: Reader + Seeker | свой копир, sniffing типа |
| c.SaveUploadedFile(fh, dst) | MkdirAll + os.Create + io.Copy | error | быстро сохранить на диск |
| r.MaxMultipartMemory = 8 << 20 | порог памяти ParseMultipartForm | по умолчанию 32 МиБ | ограничить RAM, не размер |
| http.MaxBytesReader в звене | жёсткий лимит тела запроса | ошибка too large, ответ 413 | боевой лимит загрузки |
| c.FileAttachment(path, name) | отдаёт файл с Content-Disposition | — | скачивание сохранённого, урок 11 |
Семь правил загрузки файлов#
- Файлы едут только multipart/form-data: без enctype в форме и с JSON-телом разбор упадёт ErrNotMultipart — проверяйте Content-Type до c.FormFile.
- «Файла нет» — это http.ErrMissingFile, а не пустой хедер и не паника: для необязательного файла это нормальный исход, различайте его через errors.Is.
- Filename и Content-Type части — слова клиента: filepath.Base для имени, http.DetectContentType для типа; расширение — вторая линия, не первая.
- MaxMultipartMemory — про RAM: порог, излишек уходит во временные файлы и удаляется после хендлера; жёсткий лимит размера — http.MaxBytesReader в звене с ответом 413.
- Сохраняйте под своим именем — метка времени плюс случайный суффикс; клиентское имя остаётся только для показа, traversal и коллизии исчезают вместе.
- Один битый файл пачки не откатывает остальные: продолжайте цикл и верните отчёт saved/failed — partial success честнее 500 на всём запросе.
- Сохранённое раздавайте Static из прошлого урока, скачивайте c.FileAttachment, а в проде уезжайте на объектное хранилище — разбор формы при этом не меняется.
Соберите хендлер загрузки аватара
Допишите загрузку: достать файл, отличить «не приложен» от лимита, сохранить под чистым именем и вернуть размер.
Соберите путь multipart-запроса
POST /upload/avatar при MaxMultipartMemory 8 МиБ и звене limitBody: разложите события от curl до 201 в логе.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: загрузка файлов
Соберите buildUpload и проверьте весь маршрут: curl -F "avatar=@cat.jpg" localhost:8080/upload/avatar — 201 с saved и size; curl -F "avatar=@cat.jpg;filename=../../evil.txt" ... — с filepath.Base путь не вылезает за uploads, без него — уезжает в ../; файл больше лимита ловит 413 от звена; JSON вместо multipart — 400 с ErrNotMultipart; Ctrl+C посреди передачи — ошибка разбора и аккуратный BAD_REQUEST. Сохранённое отдайте r.Static("/uploads", "./uploads") из прошлого урока и откройте в браузере. В браузерной песочнице сайта сетевой код не исполняется, поэтому runner-ов в курсе нет — интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь курс gin. Дальше — 14-cookie-sessii: cookie и сессии, SetCookie и sessions middleware — сервер уже принимает файлы, пора узнавать пользователя между запросами.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Загрузка — это multipart/form-data: c.FormFile возвращает первый *multipart.FileHeader поля; отсутствие поля — http.ErrMissingFile, различаемый через errors.Is, а не пустой хедер.
- c.SaveUploadedFile(fh, dst) — MkdirAll, os.Create, io.Copy; имя целиком ваше: filepath.Base от клиентского Filename — минимум, метка времени со случайным суффиксом — норма.
- c.MultipartForm() даёт Value и File целиком: пачка одного поля — File["photos"], текст рядом — c.PostForm; ShouldBind заполняет *multipart.FileHeader по тегу form.
- MaxMultipartMemory — порог памяти (по умолчанию 32 МиБ): излишек уходит во временные файлы и удаляется после хендлера; это не лимит размера загрузки.
- Жёсткий лимит — http.MaxBytesReader в звене: превышение ловится ошибкой разбора и отвечает 413; проверка Content-Length — лишь первый рубеж.
- Настоящий тип файла — http.DetectContentType по первым 512 байтам; расширение и Content-Type части — слова клиента; сохранённое отдаётся Static и c.FileAttachment из урока 11.
Домашнее задание
Соберите загрузочный этаж трекера: POST /upload/avatar с c.FormFile и SaveUploadedFile в ./uploads под именем из метки времени, звено limitBody с http.MaxBytesReader на 10 МиБ и ответом 413, различение ErrMissingFile и превышения лимита через errors.Is. Добавьте POST /gallery с c.MultipartForm: пачка photos, текстовый caption через c.PostForm, отчёт saved и total. Проверьте тип через http.DetectContentType и отбейте не-картинки 415 Unsupported Media Type. Прогоните curl-ом четыре сценария: обычная загрузка, filename=../../evil.txt, файл больше лимита, JSON вместо multipart. Сохранённое отдайте через r.Static("/uploads", "./uploads") и убедитесь, что файл открывается в браузере.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему c.FormFile вернул ErrNotMultipart?
Content-Type не multipart/form-data: в HTML-форме забыли enctype="multipart/form-data", или клиент прислал JSON. Файлы едут только multipart-ом; JSON-тело разбирают ShouldBindJSON и компания, FileHeader там не существует.
MaxMultipartMemory ограничивает размер загрузки?
Нет: это порог памяти ParseMultipartForm. Всё, что не влезло, net/http кладёт во временные файлы и удаляет после хендлера, поэтому и гигабайтный файл сохранится. Жёсткий лимит ставит http.MaxBytesReader в звене — с ответом 413.
Как принимать очень большие файлы без дискового буфера?
c.Request.MultipartReader() — потоковый разбор: NextPart в цикле, ручной разбор Content-Disposition и io.Copy сразу в хранилище. Gin-удобств FileHeader и SaveUploadedFile там нет, но диска и памяти уходит минимум — путь для файлов в гигабайты.
Где хранить загруженные файлы в проде?
Не на локальном диске пода, если узлов несколько: объектное хранилище (S3-совместимое) или общая файловая система. Локальный диск остаётся для разработки и одиночных узлов; раздача — через StaticFS или прямые ссылки хранилища.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию