Глава 15 / 51
Том 3 · Разработка протоколов

Построение ответов: заголовки, chunked и keep-alive

Ответ — поток байтов, который вы пишете сами. Глава разбирает и собирает заново ответные помощники демо — тот самый набор, на котором routebench крутил десять тысяч маршрутов; это прототип вашего modules/http.h.

Обзор

Позиционирование: после главы вы пишете с нуля текстовые / JSON / HTML / chunked ответы и понимаете, почему HEAD и keep-alive — ваша ответственность, а не фреймворка.

15.1 Введение

«Нет объекта Response» означает: три дела ваши — генерация байтов заголовка (xrtHttp1ResponseWrite), запись в транспорт (помощник ConnSend) и поддержание семантики протокола (Content-Length точен, HEAD не шлёт тело). Выглядит рутиной, но покупает ноль скрытого поведения — а написанный однажды код становится вашим modules/http.h, переиспользуемым всем приложением.

15.2 Полный ответ: паттерн ReplyRaw

Ядро modules/http.h — три вещипроверено на реальной машинеисточник: demo-single/modules/http.h (фрагмент)
/* 1. транспортный помощник: отправка через транспорт, который использует запрос */
static bool ConnSend(XS_HttpReq* pReq, const void* pData, size_t iSize)
{
    size_t iWritten = 0;
    if ( pReq->tls != NULL ) {
        /* TLS допускает успешную короткую запись: готово только при полном приёме */
        return xrtTlsStreamSend(pReq->tls, pData, iSize,
            &iWritten) == XTLS_OK && iWritten == iSize;
    }
    return pReq->tcp != NULL &&
        xrtNetStreamSend(pReq->tcp, pData, iSize) == XNET_RESULT_OK;
}

/* 2. полный ответ: заголовок + тело пишутся разом */
static bool ReplyRaw(XS_HttpReq* pReq, uint16 iStatus,
    const char* sContentType, const void* pBody, size_t iBodySize)
{
    char arrHead[1024], arrLength[32];
    xhttpfield arrField[12];
    size_t iFieldCount = 0, iHeadSize = 0;

    snprintf(arrLength, sizeof(arrLength), "%llu",
        (unsigned long long)iBodySize);
    arrField[iFieldCount].Name  = XRT_STR_LITERAL("Content-Length");
    arrField[iFieldCount].Value = xrtStrView(arrLength);
    iFieldCount++;
    /* ... необязательные Content-Type и доп. поля ... */

    if ( !xrtHttp1ResponseWrite(XHTTP_VERSION_1_1, iStatus,
        xrtHttpStatusText(iStatus), arrField, iFieldCount,
        arrHead, sizeof(arrHead), &iHeadSize) ) return false;
    if ( !ConnSend(pReq, arrHead, iHeadSize) ) return false;
    return pReq->head->MethodCode == XHTTP_METHOD_HEAD ||
        ConnSend(pReq, pBody, iBodySize);
}

/* 3. семантические обёртки */
static bool ReplyText(XS_HttpReq* pReq, uint16 iStatus, const char* sText)
{
    return ReplyRaw(pReq, iStatus, "text/plain; charset=utf-8",
        sText, strlen(sText));
}

Что только что произошло — четыре ключевых момента:

  • xhttpfield — пара представлений {Name, Value}; представления обязаны жить до возврата ResponseWrite, поэтому строка длины лежит в стековом массиве и только потом xrtStrView
  • xrtHttp1ResponseWrite только генерирует байты заголовка (в ваш буфер), не отправляет — генерация и транспорт разделены
  • Content-Length вычисляете и строките вы — это якорь корректности keep-alive
  • Ветка HEAD: заголовок сохраняется (с Content-Length), отправка тела пропускается

15.3 HEAD и keep-alive

  • keep-alive: соединения переиспользуются по умолчанию (семантика HTTP/1.1); при Connection: close от клиента закрытие после этого ответа. После XS_OK драйвер вычитывает остаток тела и читает следующий запрос — при условии, что ваш Content-Length точен; лишнее или недостающее смещает разбор следующего запроса
  • HEAD: семантика — «метаданные GET»: заголовки должны совпадать с GET (включая Content-Length), тело не пишется. В ReplyRaw это одна строка
  • Ограниченный пайплайнинг: следующий запрос не разбирается, пока не допишется статический файл — ответы приложения следуют той же дисциплине «дописал — верни соединение»

15.4 Потоковый chunked

Контент неизвестной длины или генерируемый кусками идёт chunked-кодированием — каждый кусок пакует xrtHttp1ChunkWrite, завершает терминальный кусок:

Скелет chunked-ответа
/* заголовок: Transfer-Encoding: chunked (без Content-Length) */
xhttpfield arrField[1] = {
    { XRT_STR_LITERAL("Transfer-Encoding"),
      XRT_STR_LITERAL("chunked") } };
xrtHttp1ResponseWrite(XHTTP_VERSION_1_1, 200,
    xrtHttpStatusText(200), arrField, 1, arrHead, sizeof(arrHead), &iHeadSize);
ConnSend(pReq, arrHead, iHeadSize);

/* кусок за куском */
while ( HasMore(&tGen) ) {
    char arrChunk[4096];
    size_t iChunkSize = NextChunk(&tGen, arrChunk, sizeof(arrChunk));
    char arrFrame[4096 + 32];
    size_t iFrameSize = 0;
    if ( xrtHttp1ChunkWrite(arrChunk, iChunkSize,
        arrFrame, sizeof(arrFrame), &iFrameSize) )
        ConnSend(pReq, arrFrame, iFrameSize);
}
/* терминальный кусок (нулевой длины) — тоже через ChunkWrite */

В chunked-ответе HEAD так же не пишет тело (заголовку даже не обязательно нести Transfer-Encoding — можно выродить в пустой заголовок в стиле GET; выберите политику и держите её consistently).

15.5 Контракт

  • Длина: Content-Length обязан точно равняться числу байтов тела — линия жизни keep-alive.
  • HEAD: заголовки как у GET, тело пропущено; обрабатывается в транспортном помощнике, а не в каждом обработчике.
  • Переиспользование: ответные помощники живут в modules/http.h с полными комментариями владения (ReplyJSON лишь заимствует pObject).

Частые ошибки

Content-Length не соответствует факту
Симптом
На keep-alive последующие запросы смещаются; клиент зависает или сообщает о битом ответе.
Причина
Лишний/недостающий подсчёт тела (strlen по бинарным данным с \0, изменение содержимого после подсчёта длины).
Игнорирование коротких записей TLS
Симптом
Под HTTPS случайные обрезанные ответы.
Причина
Успех определяется только по возврату xrtTlsStreamSend — TLS допускает успешную короткую запись; обязательно сверяйте written == size (как в ConnSend).
✗ Ошибка
xrtTlsStreamSend(pReq->tls, p, n,
    &iWritten);          /* iWritten не проверяется */
✓ Правильно
== XTLS_OK && iWritten == n

Упражнения

БазовыйЧетыре Reply

По паттерну главы реализуйте ReplyText / ReplyHTML / ReplyJSON / ReplyBinary (заголовок вложения Content-Disposition).

Приёмка: все четыре ответа curl разбирает корректно; бинарность сверяется md5.
ПродвинутыйПроверка канала HEAD

curl -I проверяет поведение маршрутов на HEAD; при 405 чинится масками методов из главы 17.

Приёмка: HEAD возвращает заголовки как GET (с Content-Length) и без тела.
ВызовБесконечный поток (разминка)

Реализуйте /counter на chunked: счёт от 1 до 100, кусок в секунду — фундамент для push в стиле SSE из главы 22.

Приёмка: curl получает 100 кусков постепенно; Ctrl+C посреди потока не роняет сервер (подсказка: очистка на пути неудачной отправки).

Итоги

ТезисВывод
ТриадаConnSend (транспорт) / ReplyRaw (заголовок+тело) / семантические обёртки
Генерация ≠ транспортResponseWrite лишь производит байты заголовка в ваш буфер
Content-Lengthсчитать точно — линия жизни keep-alive; сверять короткие записи TLS
HEADзаголовки как GET, тело пропущено, обработка в транспортном слое
chunkedChunkWrite пакует куски, терминальный кусок закрывает
Что дальше по xrt