第23.4章:从 echo 到 HTTP

English

1. 边界:字节与结构

echo 服务器移动字节。HTTP 服务器读结构、写结构,中间夹着字节 body。这改变了应该使用的操作:

协议部分 操作 为什么
请求行和头 async_scan 它们是格式化 token:方法、目标、版本、头名和头值。当 token 跨越待处理窗口时,scan 层会 补充输入缓冲区。
状态行和响应头 async_print 它们是格式化输出。print 层格式化进输出缓冲区并提交写。
已知长度的 body async_transmit_all_bytes 它是字节范围,不是格式化值。把 Content-Length 作为上界,让 transmit 引擎分块移动它。
结构化格式的 body async_scan / async_print JSON、表单字段和行协议由格式化层解析和产生。对传输层来说,body 只是字节。

上一页的规则仍然成立:输入用 scan,输出用 print,字节移动用 transmit。 HTTP 不是例外;它是在一个会话里同时出现三者的最清晰例子。

2. 客户端:print、scan、transmit

这是 examples/0017.network/async/http.cc 的形状,只保留这里重要的部分:

static fi::io_async_task<> fetch(
    fi::io_async_observer sched, fi::u8iobuf_socket_file sock,
    fi::u8native_file out, char const *host) throws
{
    co_await fi::async_print(
        sched, {}, sock,
        u8"GET / HTTP/1.1\r\n"
        u8"Host: ", fi::mnp::os_c_str(host), u8"\r\n"
        u8"User-Agent: fast_io\r\n"
        u8"Accept: */*\r\n"
        u8"Connection: close\r\n\r\n");

    fi::u8http_header_buffer hdr{};
    co_await fi::async_scan(sched, {}, sock, hdr);

    for (auto [key, value] : fi::line_generator(hdr))
    {
        if (::std::u8string_view(key) == u8"Content-Length")
        {
            auto length{fi::u8to<::std::uint_least64_t>(value)};
            co_await fi::operations::async_transmit_all_bytes(
                sched, {}, out, {}, sock, {}, fi::size_t_opt{length});
            co_return;
        }
    }
}

三个操作,三个层次:

  1. async_print 写请求头。socket 是带缓冲的 u8iobuf_socket_file,所以调用返回时字节可能还在输出缓冲区里。
  2. async_scan 读响应头。输入侧的 tie 处理会在第一次读之前把待处理输出刷出去, 所以请求在 scan 等待回答之前已经上线。这和同步层有相同的顺序保证。
  3. async_transmit_all_bytes 从 socket 精确复制 Content-Length 个字节到输出文件。 上界来自头部;即使对端保持连接,transmit 引擎也会在上界处停下。

host 字符串通过 mnp::os_c_str 格式化,而不是用 std::string 拼进请求。格式化层处理编码和写;没有临时字符串,也没有手工缓冲区管理。真实例子还使用 mnp::string_filters::host,在写出一个字节之前拒绝恶意的 Host 值。

3. 服务器:每连接一个会话

服务器保持 echo 架构:一个 accept 循环,每个连接一个 detached 会话。只有会话体改变。

static fi::io_async_task<> http_session(
    fi::io_async_observer sched, fi::u8iobuf_socket_file sock) throws
{
    fi::u8http_header_buffer hdr{};
    co_await fi::async_scan(sched, {}, sock, hdr);

    auto const target{hdr.code()};
    fi::u8string_view path{target.data(), target.size()};

    if (path == fi::u8string_view{u8"/health"})
    {
        co_await fi::async_print(
            sched, {}, sock,
            u8"HTTP/1.1 200 OK\r\n"
            u8"Content-Type: text/plain\r\n"
            u8"Content-Length: 3\r\n"
            u8"Connection: close\r\n\r\n"
            u8"ok\n");
        co_await fi::operations::async_output_stream_flush(sched, {}, sock);
        co_return;
    }

    co_await fi::async_print(
        sched, {}, sock,
        u8"HTTP/1.1 404 Not Found\r\n"
        u8"Content-Type: text/plain\r\n"
        u8"Content-Length: 10\r\n"
        u8"Connection: close\r\n\r\n"
        u8"not found\n");
    co_await fi::operations::async_output_stream_flush(sched, {}, sock);
}

会话仍然是拥有自己 socket 的协程。accept 循环不变:接受、包装、按值传递、detach。唯一的区别是 会话现在理解请求,而不是回显它。

async_scan(sched, {}, sock, hdr) 扫描完整的 HTTP 头。 u8http_header_buffer 拥有解析后的头存储,所以 hdr.code() 和 line_generator(hdr) 返回的字符串指向那个缓冲区。 它们在下一次 scan 或缓冲区销毁之前有效。不要把字符串存进比会话活得更久的 detached 任务, 也不要返回给调用者。

async_print 把响应头和 body 格式化进 socket 的输出缓冲区。 async_output_stream_flush 在会话返回前排空缓冲区。没有 flush,缓冲的字节可能在 socket 关闭时还留在用户空间;对端会看到截断的响应。flush 是同步 flush(out) 的异步对应物。

4. 提供文件:Content-Length 与 transmit

静态文件响应是格式化头加字节 body。不要把文件读进 std::string。向文件系统询问大小, 写头,然后 transmit 文件:

static fi::io_async_task<> serve_file(
    fi::io_async_observer sched, fi::u8iobuf_socket_file sock,
    fi::u8native_file file, ::std::uint_least64_t size) throws
{
    co_await fi::async_print(
        sched, {}, sock,
        u8"HTTP/1.1 200 OK\r\n"
        u8"Content-Type: application/octet-stream\r\n"
        u8"Content-Length: ", size, u8"\r\n"
        u8"Connection: close\r\n\r\n");

    co_await fi::operations::async_transmit_all_bytes(
        sched, {}, sock, {}, file, {}, fi::size_t_opt{size});
    co_await fi::operations::async_output_stream_flush(sched, {}, sock);
}

输出是 socket,输入是文件。上界是精确的 Content-Length。transmit 引擎读一块,写一块, 重复到上界用完。如果客户端提前断开,写失败,错误逃出会话,detached 帧被销毁。没有人需要捕获它: 连接已经没了。

同样的模式反方向也成立:带 Content-Length 的请求 body 从 socket 传输到文件或内存流。 如果 body 是结构化的,用 scan;如果只是字节,用 transmit。不要在同一个 body 里混用两种模型。

5. Keep-Alive、流水线与循环

上面的例子回答一个请求然后关闭。HTTP/1.1 可以让连接保持打开,处理更多请求。会话就变成一个循环:

static fi::io_async_task<> http_session(
    fi::io_async_observer sched, fi::u8iobuf_socket_file sock) throws
{
    for (;;)
    {
        fi::u8http_header_buffer hdr{};
        co_await fi::async_scan(sched, {}, sock, hdr);

        bool const keep_alive{/* inspect Connection: and the version */};
        co_await fi::async_print(sched, {}, sock, /* response */);
        co_await fi::operations::async_output_stream_flush(sched, {}, sock);

        if (!keep_alive)
        {
            co_return;
        }
    }
}

只有 scan 精确消费一个请求头时,循环才安全。在上一请求的字符串不再需要之前,缓冲区不能复用。 在循环内声明 hdr 会让每次迭代有新的缓冲区;这是最简单正确的生命周期。

流水线更难:客户端可能在读第一个响应之前发送两个请求。scan 层从 socket 的输入缓冲区读, 所以第二个请求在第一个响应写出时已经待处理。只要协议状态是按请求而不是按连接,循环就能自然处理。 不要假设“一次读等于一个请求”;scan 层已经把两者解耦了。

6. 超时、限制与背压

公开服务器需要限制。异步层提供机制;策略由你决定。

背压是 transmit 操作比手写“全读再全写”循环更好的原因。transmit 引擎只有在前一块写完之后才读下一块。 慢客户端会减慢生产者,而不是让内存里的缓冲区增长。

7. 真正会发生的错误

错误 症状 修复
在异步会话里用同步 print / scan 整个事件循环停住;其他连接冻结。 用 async_print / async_scan。
忘记 async_output_stream_flush 响应被截断或根本不到达。 返回前或等待下一请求前 flush。
把头部缓冲区的 u8string_view 存过下一次 scan 路径乱码、响应损坏或崩溃。 复制值,或让缓冲区在整个使用期间存活。
把局部 socket 的引用传进 detached 会话 调用者返回后 use-after-free。 按值传 socket;帧拥有它。
不设上限地相信 Content-Length 一个请求耗尽内存或磁盘。 分配或传输前检查上限。
在会话里做 CPU 工作 其他所有连接等待。 卸载、分块,或用工作线程池。
丢弃一个暂停的任务 帧死亡,而完成事件仍指向它。 驱动到 done(),或者 detach() 它。

8. 接下来读什么

仓库里有这些模式的完整版本:

按这个顺序读。每个例子加一个想法:一个连接、一个请求、一个响应 body、一个协议状态机。 底下的异步机制保持不变。

下一章完全换主题:反射。它不再向操作系统要字节,而是向编译器要程序自身的形状。