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? А логотип? Вони однакові для всіх, і запускати заради кожного з них корутину якось незручно. Про це наступний розділ, і він буде коротким.