Скачать часть файла в 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 — через код и наглядные разборы.