ControlRookie
返回文章

第17篇_Client 06|chunked、关闭和 keep-alive 边界

Client 可能遇到固定长度、chunked 或连接关闭三种响应收口方式。连接复用只能在上一条响应边界清楚后继续。

Client 可能遇到固定长度、chunked 或连接关闭三种响应收口方式。连接复用只能在上一条响应边界清楚后继续。

适合谁收藏

  • 正在让 PLC 主动访问 HTTP 服务的工程师。
  • 需要处理请求构造、响应边界、超时与连接复用的人。
  • 希望把 Client 故障定位到确定状态和错误出口的读者。

客户端篇,第 6/7 篇;主系列第 17/28 篇。

现场问题

同一个 API,用测试服务返回 Content-Length 时正常,换成代理服务器后 Client 一直等待。抓包会发现代理改成了 Transfer-Encoding: chunked。

另一个隐患是连接复用。上一条响应如果没有完全消费,下一条请求就可能把残留字节当成新状态行。

先给结论

Client 必须先按 Header 选择响应边界策略。只有当前响应完整、缓冲区干净且状态复位后,受控 keep-alive 才能发送下一条请求;当前实现不支持 pipeline。

Client 06|chunked、关闭和 keep-alive 边界
Client 06|chunked、关闭和 keep-alive 边界

读图重点

这张图只压缩本篇的判断路径。读图时先找“Content-Length”对应的输入边界,再沿着“顺序复用同一连接”检查状态怎样推进,最后用“禁止并发 pipeline”确认输出是否已经形成验收证据。

把对象和边界分开

对象或阶段工程职责现场观察点
Content-Length读取固定字节数最容易诊断
chunked逐块解码到 0 块支持真实代理响应
Connection: close对端关闭提供结束信号需要区分正常关闭和错误
keep-alive顺序复用同一连接禁止并发 pipeline

从协议约束到代码职责

协议约束

Client 必须先按 Header 选择响应边界策略。只有当前响应完整、缓冲区干净且状态复位后,受控 keep-alive 才能发送下一条请求;当前实现不支持 pipeline。 这条结论先限定消息什么时候成立,再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务,半包、超时、重复执行和连接残留就会进入应用层。

工程抽象

Parser 将 bTransferChunked、bHasContentLength 和 bConnectionClose 分开保存。状态机依据这些字段决定继续读、解码、完成或等待关闭。

首版的目标是可诊断,不是追求最大吞吐。顺序 keep-alive 已经能减少频繁建连,但每次只允许一个在途事务,避免跨请求边界污染。

  • Content-Length:工程职责是“读取固定字节数”。它不能只停留在命名层面,运行时必须能通过“最容易诊断”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • chunked:工程职责是“逐块解码到 0 块”。它不能只停留在命名层面,运行时必须能通过“支持真实代理响应”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • Connection: close:工程职责是“对端关闭提供结束信号”。它不能只停留在命名层面,运行时必须能通过“需要区分正常关闭和错误”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • keep-alive:工程职责是“顺序复用同一连接”。它不能只停留在命名层面,运行时必须能通过“禁止并发 pipeline”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。

程序单元

本篇主证据来自 FB_HttpClient.st 中以 bTransferChunked 为定位点的连续源码。这里不是为了展示语法,而是把协议约束落到确定程序单元:输入先进入结构体或缓冲区,状态机只在本周期处理可确认的部分,长度和结束条件决定能否前进,错误码与指标负责把失败原因带出对象边界。这样一来,“响应边界策略由 Header 决定。”可以在代码、在线变量和外部报文之间逐项对照,而不是依赖经验猜测。

本篇核心源码片段

下面两段代码来自同一个真实文件 FB_HttpClient.st,以 bTransferChunked 为中心连续截取,没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件,第二段用于确认状态、边界和输出。核对时重点看“读取固定字节数”怎样进入对象,以及“禁止并发 pipeline”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“首版不支持 pipeline,要明确写出。”,就不能把局部代码截图当成实现证据。

片段一:入口、声明与前置条件

iecst
{attribute 'hide_all_locals'}
METHOD PUBLIC M_Reset
// === IMPLEMENTATION ===
// 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。
// 边界说明:执行前后保持输出和错误码可被在线诊断追踪。
M_ResetTransaction();
hConnection := 0;
bTcpConnected := FALSE;
xActive := FALSE;
xConnected := FALSE;
xBusy := FALSE;
xDone := FALSE;
xError := FALSE;
bBusy := FALSE;
bDone := FALSE;
bError := FALSE;
diErrorID := 0;
sDiagMsg := '';
eLastError := E_HttpError.iNoError;
eState := E_HttpClientState.iDisabled;
stResponse.sVersion := 'HTTP/1.1';
stResponse.uiStatusCode := 0;
stResponse.sReason := '';
stResponse.sContentType := GVL_Http.cnDefaultContentType;
stResponse.sAdditionalHeader := '';
stResponse.sRawHeaders := '';
stResponse.sBody := '';
stResponse.bHasContentLength := FALSE;
stResponse.bTransferChunked := FALSE;
stResponse.bConnectionClose := FALSE;
stResponse.udiContentLength := 0;
uiStatusCode := 0;
sTxMessage := '';
sRxMessage := '';
sResponseBody := '';
sResponseHeader := '';

// === METHOD M_SetClientError ===
/// =======================================================================
/// 名称      : M_SetClientError
/// 功能      : 统一锁存 Client 错误。
/// 说明      : 所有故障路径必须写入诊断文本和统计错误码。

这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件,不能只看某个布尔量是否变成 TRUE。

片段二:状态推进、边界与输出

iecst
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD PRIVATE M_SetClientError : BOOL
VAR_INPUT
    eError   : E_HttpError := E_HttpError.iNoError;    // 枚举状态或错误码。
    sMessage : STRING(255) := '';                      // 诊断或协议文本字段。
END_VAR
// === IMPLEMENTATION ===
// 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。
eLastError := eError;
stMetrics.eLastError := eError;
bError := TRUE;
diErrorID := TO_DINT(eError);
sDiagMsg := sMessage;
M_SetClientError := TRUE;

// === METHOD M_UpdateClientOutputs ===
/// =======================================================================
/// 名称      : M_UpdateClientOutputs
/// 功能      : 同步 Client 在线输出。
/// 说明      : 输出统一在主状态机末尾刷新,减少分支遗漏。
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD PRIVATE M_UpdateClientOutputs
// === IMPLEMENTATION ===
// 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。
bTcpConnected := fbTcpClient.xActive
    AND (hConnection <> 0)
    AND (eState <> E_HttpClientState.iDisabled)
    AND (eState <> E_HttpClientState.iFault);
bBusy := (eState = E_HttpClientState.iTcpConnect)
    OR (eState = E_HttpClientState.iSend)
    OR (eState = E_HttpClientState.iReceive);
xActive := bTcpConnected;
xConnected := bTcpConnected;
xBusy := bBusy;
xDone := bDone;
xError := bError;
uiStatusCode := stResponse.uiStatusCode;
sResponseBody := stResponse.sBody;
sResponseHeader := stResponse.sRawHeaders;
diErrorID := TO_DINT(eLastError);

第二段继续展示同一连续源码范围。把它与第一段合起来,才能判断输入怎样被锁存、状态何时推进、边界何时满足,以及错误出口是否保留了足够诊断信息。

验证路径

场景操作通过口径
固定长度连续两次顺序请求第二次无残留字节
chunked多块响应解码结果和长度正确
服务端关闭响应后主动断开Client 正常收口并可重连
坏 chunk长度或 CRLF 非法协议错误而非静默完成

场景 1:固定长度

在同一条 keep-alive 连接上连续请求两个资源。第一条响应完成后,记录接收缓冲区长度必须回到零、Parser 状态必须复位;再写入第二条请求并检查新状态行从缓冲区首字节开始。只要第二条解析前仍有残留数据,就停止复用连接并回到上一笔响应的边界诊断。

场景 2:chunked

用代理服务返回三块 chunked 数据,例如 4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n。逐块确认十六进制长度、块内容和末尾 0 块:前两块到达时不得 Done,0 块及终止 CRLF 到达后才输出拼接后的 Body。该测试的重点是解码状态,而不是单纯比较最终字符串。

场景 3:服务端关闭

让服务端在未声明长度的响应 Body 后主动关闭连接。Client 只能在已确认 Header 允许以关闭作为边界时,把 EOF 解释为本次响应结束;随后释放套接字并允许下一轮重新连接。若在 Header 尚未完整时先收到关闭,必须保留为协议或传输错误,不能伪装成正常完成。

场景 4:坏 chunk

分别注入非法 chunk 长度和缺失 CRLF 的响应。Parser 应给出明确协议错误,清理本次接收状态,并禁止把不完整数据交给业务层;下一次新连接仍需可以正常处理一条合法响应。这样才能确认错误出口没有把坏报文遗留到后续事务。

常见误判

  • 没有 Content-Length 就判定响应无 Body,漏掉代理返回的 chunked 数据。
  • 上一条响应未消费干净就复用连接,残留字节被当成下一条状态行。
  • 把 Connection: close 一律当故障,忽略它可能正是本次消息的结束策略。

这些误判的共同点,是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标,并用相同输入完成回归。

这一篇你最该记住

  • 响应边界策略由 Header 决定。
  • keep-alive 必须建立在缓冲区干净之上。
  • 首版不支持 pipeline,要明确写出。

系列导航

  • 系列:CodeSys HTTP 系列教程,第 17/28 篇。
  • 阶段:客户端篇,职责线位置 6/7。
  • 上一篇:第16篇
  • 下一篇:第18篇
  • 发布顺序:基础认知 -> Server -> Client -> 完整源码加更 -> 综合收束。
评论和回复区

评论区预留

这里先保留评论和回复结构,不接入第三方服务。后续统一决定登录、匿名、审核、反垃圾和静态站兼容策略。

↑ ↓