#transport —— 传输层契约

    本模块唯一依赖 moonbitlang/async 的包。它把「真正把字节发出去」抽象成一个 Transport trait,上层只认 PreparedRequest / RawResponse 两个契约——换实现不破坏上层 API,也让整条管线能在无网络下测试。

    import { "q2316367743/moonhttp/transport", }

    #契约

    类型内容
    Transport(trait)只有一个方法:async fn send(Self, PreparedRequest) -> RawResponse raise TransportError
    PreparedRequest方法、完整 URL、拍平后的头、body 字节、超时、代理端点、上传进度回调、取消句柄
    RawResponse状态码、状态短语、响应头、响应体流 ResponseBody
    ProxyEndpoint{ url, authorization? }:隧道地址与 CONNECT 的凭据
    TransportErrorTimeout / Network(String) / Unsupported(String) / Cancelled;分类在根包翻译成 HttpError

    #ResponseBody 的读法

    方法说明
    from_bytes(bytes)手里已有完整响应体时包一层(Mock 与测试用)
    read_all(on_progress?)读到 EOF;传了回调就逐块报下载进度
    read_all_partial(on_progress?)同上但不抛错:返回 (已读到的字节, 失败原因?),「读到一半失败」时保住现场
    read_some(max_len?)读一块,None 表示 EOF
    read_until(delim)读到分隔符(含分隔符)为止
    close()释放连接

    超时在这里分两种口径:read_all / read_all_partial 是「整段读完」一个时限,read_some / read_until 是「每次等待」一个时限。

    #两个实现

    • AsyncHttpTransport:真实实现,基于 moonbitlang/async。每次请求新建连接(不做连接复用),代理用 CONNECT 隧道、http 与 https 目标都一样;取消作用域也在本包(取消信号能打断挂起中的连接动作)。
    • MockTransport:测试用,不碰网络。记录收到的每一份请求(received() / request_count() / last_request()),返回预置响应(new(response) 或 from_responses([...]),取完之后一直复用最后一个),也可以固定失败(failing(error) / with_failure(error))。

    #用法

    // 在测试里替换掉真实网络

    ///|
    let mock = @transport.MockTransport::new(response)

    ///|
    let transport : &@transport.Transport = mock

    ///|
    let client = @moonhttp.Client::new(transport~)

    自定义传输只要实现一个方法,底层类型完全被挡在上层之外:

    ///|
    pub impl Transport for MyTransport with fn send(self, request) {
    // request : PreparedRequest(方法、完整地址、已拍平的头、body、超时)
    // 返回 : RawResponse(状态码、状态短语、响应头、响应体流)
    ...
    }

    字段含义、超时语义与自定义传输的注意事项见 docs/05-transport.md。

    CancelToken

    这两个类型出现在 PreparedRequest / RawResponse 的公开签名里, 因此随本包一起再导出,使用者不必为了构造一份请求再 import 两个包。 CancelToken 同理:它由 PreparedRequest 携带到这一层。

    Headers

    大小写不敏感的 HTTP 头集合,对应 axios 的 AxiosHeaders。

    内部以小写化后的头名作为 Map 的 key,值里再记住写入时的原始拼写, 于是 get("Content-Type") 与 get("content-type") 命中同一条记录, 而遍历/打印时仍然保留调用方书写时的形状(与 axios「首次出现的拼写胜出」一致)。

    与 axios 相比的两处有意简化(README 里有完整清单):
    • 不支持 axios 用 false 表示「这条头禁止被默认值覆盖」的哨兵值;
    • 同一个头名只能有一个字符串值,不展开多值头(axios 允许数组)。

    所有变更方法都返回新实例而不是就地修改:Config 是值语义的, 合并配置时同一份 Headers 可能被多个 Config 引用, 返回新值可以避免「改一个实例的头,另一个实例跟着变」这类共享可变状态 bug。

    Method

    这两个类型出现在 PreparedRequest / RawResponse 的公开签名里, 因此随本包一起再导出,使用者不必为了构造一份请求再 import 两个包。 CancelToken 同理:它由 PreparedRequest 携带到这一层。

    ProgressCallback

    进度回调。

    noraise:回调是在请求发送 / 响应读取的中途被调用的,它抛出的错误没有 合理的归属方(既不属于这次请求的传输错误,也不该让整条请求失败), 所以类型上就要求它不抛。想在回调里记日志、推 UI 状态都没问题。

    回调是同步执行的:它占用请求本身的时间预算(上传回调落在 timeout 覆盖的发送阶段里),所以别在里面做耗时的事。

    Transport

    pub(open) trait Transport {
    async fn send(Self, PreparedRequest) -> RawResponse raise TransportError
    }

    传输层抽象:把一份准备好的请求发出去,拿回原始响应。

    用 trait 而不是直接调用异步 HTTP 库有两个目的:
    1. 把整个 async 运行时依赖关在实现里,上层(配置合并、URL 拼接、 错误映射)都能用普通同步测试覆盖;
    2. 使用方和测试可以注入自己的实现,不必真的发网络请求。

    TransportError

    pub(all) suberror TransportError {
    Timeout
    Network(String)
    Unsupported(String)
    Cancelled
    }

    传输层错误:把「网络世界里可能出什么事」收敛成四种情况, 上层再映射成对外的 ErrorCode。

    有了这层收敛,上层不需要 import 任何 async 相关的包, 自定义传输实现也不需要知道底层用的是哪套 HTTP 库。

    AsyncHttpTransport

    pub struct AsyncHttpTransport {
    }

    真实传输层:把请求交给 moonbitlang/async/http,真的走网络。

    这是整个模块唯一碰网络的地方。上层拿到的 RawResponse 已经与底层类型解耦, 所以底层库升级或替换不会波及配置合并、URL 拼接等逻辑。

    当前实现每次都新建连接(用底层的 @http.Client 手动走 「connect → request → write → end_request」四步),不做连接复用; TLS 校验开关暂未暴露,见 README 的「暂不支持」清单。 手动四步而非 @http.request 便捷函数,是为了不在传输层就把响应体读光。

    代理(PreparedRequest::proxy)由底层的 CONNECT 隧道实现:给它一个干净的 代理客户端,它就负责发 CONNECT host:port、验收并进入隧道,https 目标 再在隧道上叠一层 TLS。逐请求各建一条隧道(与「不复用连接」一致), 契约见 docs/09-proxy.md。

    AsyncHttpTransport::new

    创建真实传输层。

    AsyncHttpTransport::send

    MockTransport

    pub struct MockTransport {
    // private fields
    }

    测试用传输层:记录收到的每一份请求,并返回预置的响应。

    它本身就是「传输层可替换」这个设计的示范——不碰网络就能把 合并 → 拼接 → 拍平 → 校验的完整流程跑一遍。 使用方也可以用它在自己的测试里替换掉真实网络。

    MockTransport::failing

    构造一个总是失败的 Mock,用来验证错误分类与传播。

    MockTransport::from_responses

    fn MockTransport::from_responses(responses : Array[RawResponse]) -> MockTransport

    构造一个按队列依次返回响应的 Mock;队列用完后一直复用最后一个响应。

    MockTransport::last_request

    fn MockTransport::last_request(self : MockTransport) -> PreparedRequest?

    最近一次收到的请求;一次都没发过时为 None。

    MockTransport::new

    fn MockTransport::new(response : RawResponse) -> MockTransport

    构造一个始终返回同一响应的 Mock。

    MockTransport::received

    收到过的请求,按顺序。

    MockTransport::request_count

    fn MockTransport::request_count(self : MockTransport) -> Int

    断言用的便捷入口:接收一个 &Transport 也能读出请求记录。

    MockTransport::send

    async fn MockTransport::send(self : MockTransport, request : PreparedRequest) -> RawResponse raise TransportError

    MockTransport::with_failure

    fn MockTransport::with_failure(self : MockTransport, error : TransportError) -> MockTransport

    设置失败错误,返回新的 Mock(failure 是可变字段,这里刻意返回新实例, 让「构造—配置—使用」的写法保持一致)。

    PreparedRequest

    pub(all) struct PreparedRequest {
    http_method :
    Method

    url : String
    headers :
    Headers

    body : Bytes?
    timeout : Int?
    proxy : ProxyEndpoint?
    on_upload_progress : (
    ProgressEvent
    ) -> Unit?
    cancel_token :
    CancelToken
    ?
    }

    交给传输层去发送的一份请求。

    到达这一步时,配置合并、base_url 拼接、query 序列化、头拍平都已经完成, 所以它只包含「把字节发出去」真正需要的信息,不含任何配置语义。 这样任何实现——真实的 HTTP、测试用的 Mock、将来可能的连接池—— 都只需要关心这一层,替换传输实现不会影响上层语义。

    PreparedRequest::to_repr

    ProxyEndpoint

    pub(all) struct ProxyEndpoint {
    url : String
    authorization : String?
    } derive(
    Debug
    )

    走代理需要的两样东西:连到哪儿、以及 CONNECT 请求带什么凭据。

    已经是解析完的最小形态:协议名与端口默认值在拼请求时就写进了 url, 凭据也已经编码成可直接落头的字符串。这样传输层不必认识 ProxyProtocol 这类配置枚举,也不必知道 Basic 认证怎么编码——与 PreparedRequest 「不含任何配置语义」的口径一致。

    RawResponse

    pub(all) struct RawResponse {
    status : Int
    status_text : String
    headers :
    Headers

    body : ResponseBody
    } derive(
    Debug
    )

    传输层拿到的原始响应。

    刻意不叫 Response:上层对外的 Response 还要承担 JSON 解析、 状态码校验等语义,那些不属于传输层的职责。

    body 是流而不是字节:send 返回时只保证状态行与响应头已到手, 响应体按需读取(ResponseBody)。底层原语只保留最弱的能力, 一次性读全是上层的组合结果——这样 chunked / SSE 这类「边到边读」 的协议才能表达出来。

    ResponseBody

    pub struct ResponseBody {
    // private fields
    }

    响应体的可读流。

    Transport::send 只保证「状态行与响应头已经到手,响应体按需读」, 一次性读全是上层的组合结果(Client::request 就是 read_all() 之后 走原来的解析流程)。这样 chunked / SSE 这类「边到边读」的协议才有落点: 响应头先用于判断状态码与内容类型,数据到了再一段段取。

    内部有两种来源:真实的 HTTP 连接,以及内存字节(Mock 与测试用)。 两者的读语义刻意保持一致(对齐 @io.Reader),Mock 才能真实代表网络侧:
    • read_some 到 EOF 返回 None;
    • read_until 消费掉分隔符,且不把分隔符放进返回值;
    • 读到 EOF 会自动关闭底层连接,之后继续读仍然返回 None。

    拿到流之后必须读到 EOF 或调用 close()。本项目不复用连接,也没有析构器, 忘记关闭就会漏掉一条 TCP 连接。

    本文件只放读这一半(读语义、超时、取消作用域);构造与释放 (from_bytes / open / rewind / close)在 stream_lifecycle.mbt, 读全量那三件套在 stream_all.mbt——都是 RL-04 的 300 行上限逼出来的拆分。

    ResponseBody::close

    fn ResponseBody::close(self : ResponseBody) -> Unit

    关闭流并释放底层连接。幂等:重复调用只生效一次。

    只读了半截就停止(例如 SSE 收到想结束就断开)时必须显式调用它, 否则连接会一直挂着。

    ResponseBody::from_bytes

    fn ResponseBody::from_bytes(data : Bytes) -> ResponseBody

    用内存字节构造响应体。Mock 传输层与自定义实现用它造出 「不是网络来的」响应体,读语义与真实连接一致。

    ResponseBody::read_all

    async fn ResponseBody::read_all(self : ResponseBody, on_progress? : (
    ProgressEvent
    ) -> Unit) -> Bytes raise TransportError

    读到 EOF,返回剩下的全部字节,并关闭流。

    这是「非流式」用法的入口:Client::request 就是先读完再走原有的 JSON 解析 与状态码校验。中途失败时抛 TransportError——已经读到的部分在这一层丢掉, 需要它请用 read_all_partial(本函数就是它的「失败即抛」包装)。

    on_progress 原样转交给 read_all_partial。

    ResponseBody::read_all_partial

    async fn ResponseBody::read_all_partial(self : ResponseBody, on_progress? : (
    ProgressEvent
    ) -> Unit) -> (Bytes, TransportError?) noraise

    读到 EOF,返回剩下的全部字节;失败时不抛错,把已读到的部分与错误一起返回。

    为什么要它:网络在读到一半时断掉(超时、连接被重置)是常态,此时已经到手的 字节往往正是现场——服务端错误响应的正文、JSON 的开头、下载进度。上层要把它们 挂到错误上(HttpError::response),所以不能像 read_all 那样在中途失败时 把字节丢掉。

    返回 (全部字节, None) 表示正常读完,(已读到的部分, Some(错误)) 表示中途失败。 两种情况流都已关闭,与 read_all 一致。超时口径也与 read_all 一致: 整段读取受一次 timeout 约束,而不是每次分块各算一次。

    on_progress 有值时逐块报告下载进度。它是这次读取的进度而不是整个 响应体的:loaded 从 0 起算,先按块读过一段再调用本函数时不会接着累加。 回调在读取过程中同步执行,占用的也是这次读取的时间预算(timeout)。

    ResponseBody::read_some

    async fn ResponseBody::read_some(self : ResponseBody, max_len? : Int) -> Bytes? raise TransportError

    读取一段响应体;到 EOF 返回 None,此时连接已经关闭。

    max_len 限制单次返回的字节数,缺省时能取多少取多少—— 与 @io.Reader::read_some 一样,返回的块可能小于 max_len, 需要按长度区分的协议请自己缓冲。

    ResponseBody::read_until

    async fn ResponseBody::read_until(self : ResponseBody, sep : String) -> String? raise TransportError

    读到分隔符 sep 为止,返回 sep 之前的内容;sep 被消费掉但不返回。

    到 EOF 时把剩余内容当作最后一段返回,再读一次才返回 None—— 与 @io.Reader::read_until 一致。SSE 这类「按空行切事件」的协议 可以直接 read_until("\n\n") 取一个事件,不必自己处理跨块的边界。

    ResponseBody::to_repr