TrueAsync ServerПотоки байтов

Потоки байтов

До сих пор наши ответы были маленькими и цельными с помощью setBody(), json(). Для API это ровно то, что нужно.

Но у ProfileService появились задачи другого калибра. Бухгалтерия просит выгрузку за год, а это сотни мегабайт CSV. Пользователи загружают файлы импорта, и они тоже не маленькие. Прикинем в лоб: пятьдесят конкурентных запросов, каждый держит в памяти по сто мегабайт... нет, считать дальше не будем, и так понятно, чем кончится.

Нужно научиться работать с телами по частям. В обе стороны.

Ответ по частям: send()

php
$server->addHttpHandler(function (HttpRequest $req, HttpResponse $res) {
    $res->setStatusCode(200)
        ->setHeader('Content-Type', 'text/csv');

    foreach (exportRows() as $row) {
        $res->send(implode(',', $row) . "\n");
    }
});

Первый send() фиксирует статус и заголовки, дальше блоки уходят клиенту по мере готовности: chunked в HTTP/1.1, DATA-фреймы в HTTP/2. В памяти в каждый момент живёт одна строка. Образуется поток байт клиенту!

Теперь вопрос на сообразительность. Мы генерируем строки из базы со скоростью, скажем, гигабит в секунду. А клиент качает через мобильный интернет в поезде. Куда денутся лишние строки?

Если вы читали главу про каналы, ответ у вас уже есть: обратное давление. Когда буфер потока заполняется, send() приостанавливает корутину обработчика. Клиент разгрёб очередь, корутина проснулась, генерация продолжилась. Выгрузка сама подстраивается под скорость самого узкого места, и заметьте, мы ни строчки для этого не написали!

Ждать медленного клиента, впрочем, не всегда хочется:

php
if (!$res->sendable()) {
    // send() сейчас заблокируется. Скажем, что мы не будем ждать.
}

send() безопасен всегда. sendable() лишь предупреждает: следующий вызов уснёт. Дальше решайте сами.

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

Тело запроса по частям: readBody()

Обратная ситуация. Пользователь загружает CSV на гигабайт, и по умолчанию обработчик вызывается, когда всё тело уже прочитано. Слово «гигабайт» и слова «в память» в одном предложении — это как раз то, чего мы договорились избегать.

Включаем потоковый режим:

php
$config->setBodyStreamingEnabled(true);
php
$server->addHttpHandler(function (HttpRequest $req, HttpResponse $res) {
    $file = fopen('/var/imports/' . bin2hex(random_bytes(8)) . '.csv', 'wb');
    $total = 0;

    while (($chunk = $req->readBody()) !== null) {
        fwrite($file, $chunk);
        $total += strlen($chunk);
    }

    fclose($file);
    $res->json(['received' => $total]);
});

В сервере TrueAsync обработчик стартует сразу после заголовков достаточно рано. На самом деле работа корутины происходит конкретно относительно приёма данных из сокета. Поэтому работа с потоком данных здесь естественная история.

Если сделать пятьдесят конкурентных загрузок по 20 МиБ, пиковая память была бы 1170 МиБ, стала 197. Пропускная способность выросла почти втрое, потому что обработка начинается, не дожидаясь конца загрузки. Неплохо за одну строчку конфигурации.

Формы с файлами: UploadedFile

Классическая HTML-форма с файлом — случай попроще, и для него ничего включать не нужно. Multipart всегда разбирается потоково, а обработчику достаются готовые объекты:

php
$server->addHttpHandler(function (HttpRequest $req, HttpResponse $res) {
    $csv = $req->getFile('import');

    if ($csv === null || !$csv->isValid()) {
        throw new HttpException('File is required', 422);
    }

    $csv->moveTo('/var/imports/' . $csv->getClientFilename());
    $res->json(['size' => $csv->getSize()]);
});

Кто видел PSR-7, чувствует себя дома: moveTo(), getSize(), getClientFilename(). Несколько файлов в одном поле (photos[]) приходят массивом через getFiles().

Отдача файлов: sendFile()

Осталась выгрузка готовых файлов. Вот её собирать из fopen и send() руками не надо, и сейчас объясню почему:

php
use TrueAsync\SendFileOptions;
use TrueAsync\SendFileDisposition;

$server->addHttpHandler(function (HttpRequest $req, HttpResponse $res) {
    $res->sendFile('/var/reports/2026-06.csv', new SendFileOptions(
        disposition:  SendFileDisposition::ATTACHMENT,
        downloadName: 'report-june.csv',
        cacheControl: 'private, max-age=3600',
    ));
});

За этим одним вызовом прячется примерно тридцать лет истории HTTP. Content-Type по расширению. ETag и Last-Modified, чтобы повторный запрос отделался коротким 304. Докачка через Range, когда клиент оборвался на середине. Отдача заранее сжатого соседа (report.csv.gz), если клиент понимает gzip. И всё это асинхронным чтением с диска, не задерживая цикл событий. Писать такое руками — значит забыть минимум половину.

Одно правило: после sendFile() ответ запечатан. Дописать в него что-то ещё не выйдет, будет исключение.

Файл на гигабайт мы теперь и примем, и отдадим. Но заметьте, кому мы всё это отдаём через PHP-обработчик: отчёты по правам доступа, файлы по одноразовым ссылкам. А CSS? А логотип? Они одинаковы для всех, и запускать ради каждого из них корутину как-то неловко. Про это следующая глава, и она будет короткой.