Скачать часть файла в Go: ServeContent и Range
Клиент скачивает большой готовый отчёт и хочет продолжить загрузку после разрыва. Простой io.Copy в ResponseWriter каждый раз отправляет файл целиком. Ручной разбор Range быстро разрастается: начало, конец, выход за размер, несколько диапазонов, HEAD и условия кеширования. Для seekable-содержимого часть этой работы уже есть в net/http.
Дать обработчику seekable-содержимое
http.ServeContent принимает имя, время изменения и io.ReadSeeker. Он определяет размер, учитывает Range и формирует нужный ответ. Здесь данные уже подготовлены и не меняются во время отдачи. Если отчёт создаётся на лету без возможности seek, понадобится другой протокол возобновления или предварительное сохранение результата.
В примере содержимое состоит из десяти ASCII-байтов. Запрос bytes=2-5 должен получить cdef: границы включительные. Проверяется статус 206, Content-Range и точное тело. Затем HEAD получает ту же длину полного содержимого без тела, а диапазон за концом приводит к 416. Эти сценарии описывают контракт скачивания лучше, чем единственный успешный GET.
Для каждого запроса создаётся собственный strings.Reader. Seek меняет позицию потока, поэтому общий reader нельзя бездумно разделять между параллельными обработчиками. В настоящем файловом endpoint открывайте отдельный файл для запроса и закрывайте его после ServeContent. Сам неизменяемый файл может быть общим, позиции открытых дескрипторов должны оставаться независимыми.
Проверить Range, HEAD и 416
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
modified := time.Date(2026, 1, 2, 7, 0, 0, 0, time.UTC)
http.ServeContent(w, r, "report.txt", modified, strings.NewReader("abcdefghij"))
}
func main() {
request := httptest.NewRequest(http.MethodGet, "/report", nil)
request.Header.Set("Range", "bytes=2-5")
response := httptest.NewRecorder()
handler(response, request)
if response.Code != 206 || response.Body.String() != "cdef" || response.Header().Get("Content-Range") != "bytes 2-5/10" {
panic("incorrect range response")
}
head := httptest.NewRecorder()
handler(head, httptest.NewRequest(http.MethodHead, "/report", nil))
if head.Code != 200 || head.Body.Len() != 0 || head.Header().Get("Content-Length") != "10" {
panic("incorrect HEAD response")
}
invalid := httptest.NewRequest(http.MethodGet, "/report", nil)
invalid.Header.Set("Range", "bytes=20-30")
rejected := httptest.NewRecorder()
handler(rejected, invalid)
if rejected.Code != 416 {
panic("out-of-range accepted")
}
fmt.Println(response.Code, response.Header().Get("Content-Range"), response.Body.String())
fmt.Println("HEAD bytes:", head.Body.Len(), "invalid:", rejected.Code)
}
Range работает с байтами выбранного представления, а не с символами текста. Для UTF-8 диапазон может пройти внутри многобайтного символа. Поэтому клиент, собирающий части, должен восстанавливать байтовый файл, а не независимо декодировать каждую часть как законченный текст. Пример использует ASCII, чтобы номера байтов легко сравнивались с буквами.
Сохранять версию скачиваемого файла
Возобновление требует стабильной версии содержимого. Если под тем же URL уже другой отчёт, добавление старого начала к новому хвосту повредит файл. Используйте отдельный устойчивый URL версии или согласованный валидатор и условие If-Range. Время изменения в примере фиксировано для одного неизменного содержимого; не подставляйте time.Now при каждом чтении одного файла.
Авторизацию выполняйте до ServeContent. Поддержка диапазонов не должна позволять читать объект, на который у пользователя нет прав. Путь к файлу также выбирается по доверенной записи хранилища, а не склейкой произвольного пользовательского пути. Это отдельные решения доступа, предшествующие выдаче байтов.
Сжатие посредником может изменить представление и смысл диапазона для клиента. Проверьте фактический маршрут через прокси и настройки кодирования ответа. Если экспорт хранится как уже сжатый архив, отдавайте именно его байты и согласуйте Content-Type. Не добавляйте сжатие к каждому фрагменту без проверки полного восстановления.
Сохраните тест точного диапазона, HEAD и недоступной позиции. Для реального файла дополнительно проверьте повторное скачивание двух частей с восстановлением исходного хеша. Так проверяется пользовательский результат возобновления, а не только наличие статуса 206.
Продолжить практику
В курсе «Микросервисы на Go» есть HTTP-контракты и тесты обработчика с клиентом. Откройте программу и продолжите от диапазона одного файла к проверке отказов и совместимости: клиент должен понимать, какие байты он получил и к какой версии результата они относятся.
Продолжить на практике
- Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗
От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.
