为了在日志中打印响应长度,代码先执行 await response.text(),然后重新创建 Response。原本能够边接收边发送的数据,现在必须全部到齐才交给客户端。流式 API 没有消失,但调用方式已经把它变成全量缓冲。
本文以 Worker 转发公开大文件为例。需要全文解析、签名或清洗的业务可能必须缓冲一部分内容,但应明确最大大小与超时,而不是默认所有请求都可放进内存。
区分传输流、应用消息与界面更新
一个上游每隔一秒输出一段文本,代理立即转发,浏览器却最后才显示。这可能是客户端一次性读取,不代表代理没有流式传输。反过来,代码使用 ReadableStream,也可能先把完整文件读取到内存再分块写出。要分别观察上游输出、代理转发、浏览器读取和页面更新,不能仅凭 API 名称判断端到端效果。
应用消息通常具有自己的边界。事件流使用明确分隔规则,逐行日志以换行结束,JSON 文档则可能需要完整语法。网络 chunk 可以把一个字符拆开,也可以包含多个消息。转换器必须维护有限状态和剩余字节,不能把每个 chunk 独立解析成一个对象。若一条消息无界增长,即使总体使用流,解析缓冲也可能失控,因此需要消息长度上限。
对于文件代理,最简单的正确实现通常是不解析正文,直接保留流并筛选头。若业务确实需要检查文件内容,可能必须暂存或验证后再提供下载,这与最低首字节延迟存在取舍。不要为了“必须流式”的目标在验证完成前发送不可信内容。安全与完整性需求先决定是否允许提前输出,然后才优化缓冲方式。
const upstream = await fetch(sourceURL);
const headers = new Headers(upstream.headers);
headers.set('X-Proxy-Version', 'v1');
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
这样不要求应用先读完正文。是否有实际端到端流式效果,还取决于上游是否及时输出、平台是否缓冲以及客户端如何消费。服务器分段发送,不代表用户界面会分段更新;客户端调用 text() 仍然可能等到结束。
修改正文时不能原样保留旧 Content-Length 或不再对应的实体标签。压缩相关头也需要与实际表示一致。头的复制应该理解语义,不能为了便利把所有上游头无条件传递,尤其是 Cookie 与内部诊断信息。
背压表达下游暂时接不动
ReadableStream 与 WritableStream 可以形成管道,消费者速度影响生产者继续写入。正确背压避免无界队列。手动循环读取后向数组不断追加,再异步发送,并不会自动获得这个保护。
const transformed = upstream.body.pipeThrough(new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk);
},
}));
示例只演示管道,不做业务转换。真实转换应避免把单个 chunk 当成完整文本行或 JSON 对象,网络分块边界与应用消息边界不同。UTF-8 字符也可能跨块,文本解码需要使用支持流模式的解码器。
假设上游每秒发送十兆字节,客户端只能每秒接收一兆。如果应用无界读取并排队,差额会持续积累;流管道的背压可以让读取速度与消费能力协调,但某些上游本身仍然会有缓冲。应观察实际运行时的内存与队列,而不承诺标准接口一定使资源恒定。手动实现需要等待写入准备状态,不能在循环中无限 enqueue。
正文 tee 后产生两个消费者。快速客户端与慢缓存分支可能导致较慢一侧的数据缓冲,日志分支如果读全文也会改变内存特征。不要给所有响应增加“为了分析而复制”的分支。只记录字节计数和时间可以避免保存内容,但计数 TransformStream 也应遵循管道流控制。大文件缓存需要独立的体积限制与失败处理。
缓冲上限包括应用消息、转换输出和测试日志。把一个文件按块加入数组,结束后计算哈希再返回,不是低内存流式实现。可增量哈希时使用适合运行时的成熟实现;若平台算法 API要求完整输入,则明确文件上限或在其他位置处理。技术选择应由接口能力证明,不因为算法概念可以增量就假设当前 API 也支持。
文本转换需要跨块状态与结束检查
UTF-8 解码器的流模式会保留跨块未完成字符,最后还需正确结束解码。按字节直接转字符串可能产生替代字符,技术日志中的路径或错误信息因此被破坏。逐行处理还要保留上个块的尾部,只有遇到分隔符才提交完整行;结束时是否接受无换行最后一行,属于协议约定。
转换器若改写内容,原来的长度、签名和实体标签需要重新计算或移除。压缩正文不能直接按明文处理,必须明确解压与重新压缩边界,同时注意平台自动处理行为。未知编码可以拒绝转换并正常转发,而不是尝试猜测。日志中声明使用 UTF-8,不会让实际字节自动符合编码。
错误消息不要混进二进制或普通文件流。一个下载开始后发生上游错误,正确行为可能是中断并让客户端识别不完整,而非追加“服务器错误”文本。对于有应用协议的事件流,可以发送受控错误事件并结束,但客户端必须按协议处理。不同内容类型不能共用一个把异常转成字符串的 transform。
取消是资源生命周期的一部分
用户关闭页面后,上游是否还继续下载?客户端取消应该尽量传播到下游读取与上游任务,避免把没人接收的工作做到结束。平台对请求中断的具体行为需要实际测试,不能仅凭标准接口推断运行时限制。
如果使用 tee() 同时给缓存与客户端分支,较慢分支可能影响缓冲与资源使用。日志分支不能为了记录全文无限读取;缓存分支也应限制适用内容大小。流式处理不是免费复制数据的工具。
客户端断开、上游超时、总任务期限和用户主动停止都可能导致读取结束。记录原因类别,避免把所有取消计作服务器故障。请求取消应尽量传递给上游 fetch 和管道,清理定时器与锁。某些运行时的连接中断传播有平台差异,需要在部署环境验证;本地模拟器成功不能证明真实网络释放同样及时。
超时也需要区分首字节等待与总完成时间。大型合法文件可能长时间传输,但不应长时间完全没有进展。采用有限空闲超时或明确总期限,依据业务决定,不给所有大小的响应固定几秒。重试已开始的流可能产生重复内容,只有具备偏移或消息标识的协议才能安全恢复,不能直接再次请求并接到旧输出后面。
取消时后台缓存是否继续完成需要有意决定。公开静态文件可能值得完成写入,但也可能浪费大量带宽;私人内容则不应被转成公共后台任务。waitUntil 不是无限运行许可,平台生命周期与限制仍然适用。把取消后的行为写明,才能在用户大量断开时解释资源消耗。
客户端也要遵守有限消费规则
浏览器读取事件流后把每条消息永远加入数组,服务端再节省内存也不能防止客户端长期增长。界面可以保留有限窗口,归档交给适当存储,并明确用户是否能获取历史。日志观察器只显示最近若干行,与文件下载保存完整内容是不同任务,客户端状态策略不能共用。
渲染频率也要控制。每个小块都触发一次页面更新,可能造成大量布局与长任务;按应用消息或合理批次更新更适合真实体验。批处理不能吞掉终止和错误事件,界面应区分连接中、接收中、完成和失败。收到最后一条数据并不必然等于正常结束,协议需要明确成功条件。
重新连接时使用消息标识或偏移避免重复,若协议没有这些能力,就提示重新开始,不偷偷把重复文本追加到旧结果。用户主动停止后保留的内容应标为部分结果,不能导出为“完整报告”。技术正确性不仅是字节到达,也包含界面是否如实表达完整性。
生产观察关注活跃流数量、平均持续时间、取消原因与资源占用。长连接自然持续很久,不能按普通短请求阈值全部报警;异常无进展或取消后持续工作才值得分析。日志避免记录每块正文,以免流量本身制造巨大存储。使用受控采样与聚合足以观察背压和故障特征。
最后审查所有辅助中间件。压缩、日志、缓存、内容扫描或错误包装都可能先读取全文,使核心 handler 看起来流式却实际缓冲。端到端测试从真实部署入口进行,才能覆盖这些层。一个组件保持流不等于整条链保持流,性能承诺应以最终观察为依据。
为逐行协议明确有限缓冲的算法
假设每条日志以换行结束。解码器接收字节块并保留跨块字符,应用把新文本追加到上一次未完成行,然后只提交已经完整结束的行,保留最后一段。这里的缓冲大小由最长未完成消息决定,不是由网络块大小决定。恶意或失效上游一直不发送换行,即使每个块很小,剩余段仍会无界增长,因此需要明确最大消息长度。
达到上限时按协议失败并清理管道,不偷偷截断后继续当作合法行。截断可能改变错误码、路径甚至安全判断。结束解码以后,最后一行没有分隔符是否可以提交应由协议规定;网络正常结束也不能证明应用消息完整。事件流若要求结束标识,缺少标识应显示部分结果,而不是仅依赖 reader 返回 done。
测试同一文本的多种分块方式:一个字符拆成多块、分隔符跨块、多个消息同块与空块。输出消息集合应相同,再加入超长消息、非法编码和半条消息结束。检查失败路径释放锁与定时器,不只断言错误被抛出。有限状态解析的正确性来自这些边界,而不是一次服务端逐秒输出的视觉演示。
慢分支怎样改变 tee 的资源模型
客户端消费很快,缓存写入很慢,tee 可以让快速分支继续读取,但慢分支所需的数据可能被缓冲。不能因此把 tee 当作两个彼此免费且恒定内存的管道。clone 响应也有类似的多消费者考虑。对小型 HTML 可接受的资源行为,不必然适用于大型文件。测试应观察慢分支、取消分支与总字节增长的实际运行时表现。
如果缓存分支失败,决定是否取消它,同时保持客户端正常传输。若客户端先取消而缓存继续,设定明确大小与总期限;它是一个有意保留的后台任务,不是默认无限工作。每个分支结束时释放资源并记录非敏感原因,避免日志任务又读取全文保存内容。记录字节与阶段时间通常足够诊断,不需要建立第三个正文副本。
要降低这类风险,可以只缓存批准大小的资源,或者让不可变文件由更适合的平台缓存机制承担。判断对象过大时不要先完整读取它再决定不缓存,这已经支付缓冲成本。可靠的长度头可以提供线索,流中实际累计字节仍需要预算约束;上游声明不是可信的无限内存许可。
取消传播必须以实际停止为证据
发出取消信号不等于上游已经停止工作。应用取消 reader、关联 fetch 信号和管道只是传播意图,具体连接与平台任务何时结束需要观察。测试上游可以记录发送进度与停止事件,客户端受控读取几段以后断开,再检查后续工作是否在有限时间内结束。不要只检查 AbortController 的状态就宣布资源已释放。
总期限与空闲期限分别处理。空闲计时器在有效进展后更新,总期限限制整体任务寿命。清理发生在正常结束、取消、转换失败与上游异常所有路径,避免定时器在任务完成后仍触发日志。错误类别区分用户停止与服务故障,监控不能把正常关闭长连接作为大量服务器错误。
若业务要求取消后继续上游操作,例如生成可再次下载的报告,应把它建模为独立后台任务,有任务 ID、权限和生命周期。不能仅依赖请求上下文无限存活。浏览器页面关闭也不撤销已经完成的业务写入,界面重连时需要查询真实状态。传输取消与业务取消是两个契约,不应共用一个停止按钮的模糊承诺。
中途失败如何保持文件与事件的真实性
二进制下载发送一半以后发生错误,不能追加 JSON 或 HTML 说明,因为那会成为文件字节。应中断传输并依靠客户端错误与完整性判断显示失败。若响应包含可靠长度或摘要,客户端可以核对;摘要如何获取也有协议要求,不能假设所有环境支持相同尾部元数据。未知能力如实保留,不手写一个不会被消费者检查的字段称为完整性保证。
事件协议可以定义错误消息,但必须与普通数据消息类型区分,并且终止状态可识别。客户端显示已收到部分内容,同时说明任务失败,不导出为完整结果。重连如果使用偏移或消息 ID,服务端需定义保留窗口与重复处理;没有恢复能力时重新开始更诚实,不把第二次流直接拼接到第一段后面。
性能预算覆盖并发而非单个文件
单个流在内存上稳定,不代表一千个并发流仍然安全。需要观察活跃连接数、每流缓冲、上游并发与平台期限,设置任务级准入预算。队列等待也有超时和取消,不能让大量请求在开始传输前无限排队。合法长文件会长时间占用槽位,公平策略应基于实际业务,而不是把所有长连接当作异常。
对照实验固定上游与消费者条件,分别测首字节、完成时间、内存峰值和取消后的工作。流式可能降低首字节与内存,却不改变总传输时间;全量缓冲的小文件实现可能更简单。明确每项结果的测量环境与限制,技术结论才不会滑成“用了 Stream 就一定更快”。
已发送头后,错误表达会改变
响应一旦开始发送,就不能再简单改成 500。中途失败可能表现为流中断,客户端需要区分完整结束与不完整数据。对于事件流,可以设计明确错误事件;对于文件下载,应由完整性校验或传输错误提示处理。
不要在流中追加看似正常的 HTML 错误页,这会污染下载内容,客户端可能把损坏文件当成成功。协议层与应用层需要约定结束标识、错误行为和重试策略。
验收时使用慢上游、慢消费者、超大响应与主动断开连接。记录首字节时间、总完成时间和资源占用,但不虚构固定提升比例。观察应用是否提前输出、取消是否释放任务,以及异常是否被误报为成功,才是流式实现的基本正确性。
应用心跳与有效进展也要分开。一条长任务每秒发送“仍在连接”,可能保持传输活跃,却没有产生任何新结果。若总期限只随心跳续期,失效任务会永久占用连接。协议可以分别记录传输活跃时间与业务进度,界面不把心跳计为完成比例;需要等待很久的合法任务,应有明确任务寿命,而不是依靠心跳获得无限延期。
测试上游持续发送合法心跳但不发送结果,再发送有限结果后只发心跳。检查传输层是否仍连通、应用进度是否停滞,以及任务何时按批准规则结束。这个实验不预设所有长连接都必须短期关闭;实时订阅可以长期存在,但资源预算和停止权限仍要明确。它检验的是“有字节”与“任务推进”的区别,避免吞吐指标掩盖业务停滞。