Client 可能遇到固定长度、chunked 或连接关闭三种响应收口方式。连接复用只能在上一条响应边界清楚后继续。
适合谁收藏
- 正在让 PLC 主动访问 HTTP 服务的工程师。
- 需要处理请求构造、响应边界、超时与连接复用的人。
- 希望把 Client 故障定位到确定状态和错误出口的读者。
客户端篇,第 6/7 篇;主系列第 17/28 篇。
现场问题
同一个 API,用测试服务返回 Content-Length 时正常,换成代理服务器后 Client 一直等待。抓包会发现代理改成了 Transfer-Encoding: chunked。
另一个隐患是连接复用。上一条响应如果没有完全消费,下一条请求就可能把残留字节当成新状态行。
先给结论
Client 必须先按 Header 选择响应边界策略。只有当前响应完整、缓冲区干净且状态复位后,受控 keep-alive 才能发送下一条请求;当前实现不支持 pipeline。

读图重点
这张图只压缩本篇的判断路径。读图时先找“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,要明确写出。”,就不能把局部代码截图当成实现证据。
片段一:入口、声明与前置条件
{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。
片段二:状态推进、边界与输出
/// =======================================================================
{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 -> 完整源码加更 -> 综合收束。
评论区预留
这里先保留评论和回复结构,不接入第三方服务。后续统一决定登录、匿名、审核、反垃圾和静态站兼容策略。