Ранний веб работал на обычном тексте. Вы вводили имя пользователя или комментарий в форму, нажимали «отправить», и короткая строка передавалась на сервер по протоколу HTTP. Этот простой цикл «запрос-ответ» определял всю инфраструктуру. Когда люди захотели делиться фотографиями, документами и видео, инженерам пришлось искать способ передачи необработанных бинарных данных через систему, построенную исключительно для читаемого текста.

Если попытаться поместить JPEG в JSON-объект, вы столкнетесь с фундаментальной проблемой. JSON — это текстовый протокол. Он ожидает валидные символы Unicode, кавычки, скобки и правильно экранированные строки. Бинарный файл — это просто длинная последовательность байтов, многие из которых не имеют печатного представления. Если попытаться «втиснуть» эти байты в JSON-строку, парсер сломается, последовательности экранирования повредят полезную нагрузку, и всё сообщение станет нечитаемым на другой стороне.

Кодирование Base64 стало очевидным обходным путем. Оно перекодирует бинарные данные в ограниченный набор из шестидесяти четырех печатных символов ASCII. Каждые три байта бинарных данных превращаются в четыре текстовых символа. Теперь полезная нагрузка является валидным JSON, а значит, она успешно пройдет через любой стандартный API. Но цена за это незамедлительна. Такое перекодирование увеличивает размер файла примерно на тридцать три процента. Изображение размером три мегабайта превращается в четыре мегабайта при передаче по сети. И клиент, и сервер тратят лишние циклы процессора на преобразование данных туда и обратно. Что еще важнее, многие серверные фреймворки для JSON считывают всё тело запроса в память перед парсингом. Несколько одновременных загрузок больших файлов могут перегрузить скромный сервер, так как каждый файл удерживается в оперативной памяти в виде избыточной текстовой строки еще до того, как он будет сохранен на диск. Base64 выручает в крайних случаях, но он никогда не предназначался для передачи тяжелых файлов в промышленных масштабах.

Лучшим решением является multipart/form-data. Этот формат рассматривает один HTTP-запрос как набор отдельных частей, каждая из которых разделена уникальной строкой-границей (boundary). Одна часть может содержать текстовое поле, а следующая — необработанное бинарное изображение с собственными заголовками Content-Type и Content-Disposition. Сервер читает входящий поток последовательно, отслеживая маркеры границ, и передает каждую секцию соответствующему обработчику, при этом ему не нужно рассматривать всю полезную нагрузку как единый блок текста.

В Node.js это различие особенно важно. Middleware express.json() умеет парсить JSON-тела, но не работает с потоками файлов. Для обработки multipart-загрузок вам понадобится потоковый парсер, такой как Multer или Busboy. Эти инструменты подключаются к необработанному потоку запроса и читают его по частям (chunks). Multer, например, позволяет выбрать: записывать ли входящие файлы во временную папку на диске или хранить небольшие файлы в памяти. Это решение по конфигурации имеет значение. Если вы будете хранить всё в памяти, а ваше приложение внезапно получит несколько больших файлов одновременно, процессу может не хватить места в куче (heap), и он аварийно завершится. Запись на диск — это обмен производительности ввода-вывода (I/O) на стабильность, но это порождает вопросы об очистке данных и безопасности путей.

Для небольшого приложения сохранение файлов в локальную папку вроде ./uploads кажется естественным и быстрым решением. Файл попадает на ту же машину, где выполняется ваш код, а его последующая отдача — это лишь вопрос указания правильного пути. Это работает до тех пор, пока не перестает работать.

Как только вы ставите балансировщик нагрузки перед вторым сервером приложений, локальное хранилище превращается в баг. Пользователь загружает фото профиля. Балансировщик направляет запрос на Сервер А, и файл записывается на диск Сервера А. Позже этот же пользователь хочет посмотреть изображение, но балансировщик отправляет запрос на Сервер Б. Сервер Б проверяет свою файловую систему и ничего не находит. Файл фактически отсутствует. Можно реализовать sticky sessions, чтобы привязать пользователя к одной и той же машине, но это хрупкое решение. Если Сервер А перезагрузится, будет переразвернут или заменен экземпляром автомасштабирования, данные исчезнут. В контейнеризированных средах локальные диски еще более эфемерны. Файловая система Docker-контейнера предназначена для того, чтобы быть временной. Использование её в качестве постоянного хранилища — верный способ потерять пользовательские данные.

Стандартное решение — отделить вычисления от хранения. Вы делаете свои серверы приложений stateless и отправляете загруженные файлы в специализированное объектное хранилище, такое как AWS S3 или Google Cloud Storage. Эти сервисы созданы для обеспечения долговечности, географического распределения и обработки огромного количества одновременных запросов. Сервер приложений обрабатывает запрос, проверяет метаданные, а затем передает байты инфраструктуре, специально предназначенной для их хранения.

Однако даже этот паттерн создает «узкое место», если реализовать его неосторожно. Многие команды начинают с того, что браузер загружает файл на бэкенд, а затем бэкенд пересылает каждый байт в объектное хранилище. Если пользователь загружает пятисотмегабайтный видеофайл, ваш сервер становится посредником. Он расходует пропускную способность на прием файла, а затем расходует еще больше на его отправку в S3. Соединение остается открытым на протяжении всего времени передачи. Медленная загрузка у пользователей с плохим сетевым соединением может занимать серверные соединения на несколько минут. Использование памяти остается высоким, если сервер буферизует поток, а если вы используете хостинг с оплатой по мере использования, вы платите дважды за одну и ту же передачу данных. Горизонтальное масштабирование не решает эту проблему, так как каждый добавленный сервер все равно будет занят пересылкой байтов, которые ему не нужно видеть.

Современные системы решают эту проблему, полностью исключая бэкенд из пути передачи данных. Вместо того чтобы принимать файл, бэкенд принимает только запрос на разрешение загрузки. Процесс выглядит следующим образом:

  • Браузер запрашивает у бэкенда инициацию загрузки, обычно отправляя только имя файла, его тип и предполагаемое назначение.
  • Бэкенд аутентифицирует пользователя, проверяет запрос на соответствие бизнес-правилам и использует SDK для генерации временного presigned URL от провайдера объектного хранилища.
  • Бэкенд возвращает этот URL браузеру. URL ограничен конкретным бакетом и ключом, действителен в течение короткого периода (например, пять или пятнадцать минут) и подписан токеном, который предоставляет только необходимые разрешения.
  • Браузер загружает файл напрямую в S3 или GCS, используя стандартные методы PUT или POST. Байты передаются напрямую с устройства пользователя в