Client 收到第一段响应后,应先解析状态行和 Header,再根据边界字段判断 Body。状态码 200 也不能替代完整性检查。
适合谁收藏
- 正在让 PLC 主动访问 HTTP 服务的工程师。
- 需要处理请求构造、响应边界、超时与连接复用的人。
- 希望把 Client 故障定位到确定状态和错误出口的读者。
客户端篇,第 5/7 篇;主系列第 16/28 篇。
现场问题
在线变量已经出现 HTTP/1.1 200 OK,业务却拿不到完整 Body。原因通常是程序在状态行出现后就提前完成,忽略了后续字节。
响应和请求一样可能跨多个 TCP_Read。Client 必须保留接收缓冲区,在 Parser 明确 Done 之前继续累积。
先给结论
响应完成条件由 Parser 决定,不由状态码文本决定。状态行合法、Header 完整、Body 边界满足之后,Client 才能向应用输出状态码和 Body。

读图重点
这张图只压缩本篇的判断路径。读图时先找“状态行”对应的输入边界,再沿着“在无明确长度的受控场景中提供边界”检查状态怎样推进,最后用“不能与半包混淆”确认输出是否已经形成验收证据。
把对象和边界分开
| 对象或阶段 | 工程职责 | 现场观察点 |
|---|---|---|
| 状态行 | 版本、状态码、原因短语 | 非法时拒绝响应 |
| Header | Content-Type、长度、传输编码、连接 | 决定后续读取 |
| Body | 按长度或 chunked 解码 | 超限时失败 |
| 关闭 | 在无明确长度的受控场景中提供边界 | 不能与半包混淆 |
从协议约束到代码职责
协议约束
响应完成条件由 Parser 决定,不由状态码文本决定。状态行合法、Header 完整、Body 边界满足之后,Client 才能向应用输出状态码和 Body。 这条结论先限定消息什么时候成立,再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务,半包、超时、重复执行和连接残留就会进入应用层。
工程抽象
ST_HttpResponse 同时保存原始 Header 和结构化字段。原始文本方便复盘,结构化字段供业务和状态机判断,两者职责不同。
状态码表示服务端处理结果,不表示传输一定完整。即使看到 200,如果 Body 声明 100 字节而实际只收到 60 字节,Client 仍应等待或超时。
- 状态行:工程职责是“版本、状态码、原因短语”。它不能只停留在命名层面,运行时必须能通过“非法时拒绝响应”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
- Header:工程职责是“Content-Type、长度、传输编码、连接”。它不能只停留在命名层面,运行时必须能通过“决定后续读取”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
- Body:工程职责是“按长度或 chunked 解码”。它不能只停留在命名层面,运行时必须能通过“超限时失败”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
- 关闭:工程职责是“在无明确长度的受控场景中提供边界”。它不能只停留在命名层面,运行时必须能通过“不能与半包混淆”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
程序单元
本篇主证据来自 FB_HttpMessageParser.st 中以 METHOD PUBLIC M_ParseResponse 为定位点的连续源码。这里不是为了展示语法,而是把协议约束落到确定程序单元:输入先进入结构体或缓冲区,状态机只在本周期处理可确认的部分,长度和结束条件决定能否前进,错误码与指标负责把失败原因带出对象边界。这样一来,“看到 200 仍要检查消息完整性。”可以在代码、在线变量和外部报文之间逐项对照,而不是依赖经验猜测。
本篇核心源码片段
下面两段代码来自同一个真实文件 FB_HttpMessageParser.st,以 METHOD PUBLIC M_ParseResponse 为中心连续截取,没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件,第二段用于确认状态、边界和输出。核对时重点看“版本、状态码、原因短语”怎样进入对象,以及“不能与半包混淆”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“应用只消费 Parser 完成后的响应。”,就不能把局部代码截图当成实现证据。
片段一:入口、声明与前置条件
METHOD PUBLIC M_ParseResponse : BOOL
VAR_INPUT
sMessage : STRING(GVL_Http.cnMaxMessageSize) := ''; // 诊断或协议文本字段。
END_VAR
VAR_OUTPUT
stResponse : ST_HttpResponse; // 结构化协议或测试数据。
END_VAR
VAR
iFirstLineEnd : INT; // 计数、长度或状态数值。
iHeaderEnd : INT; // 计数、长度或状态数值。
iSpace1 : INT; // 计数、长度或状态数值。
iSpace2Relative : INT; // 计数、长度或状态数值。
iHeaderStart : INT; // 计数、长度或状态数值。
iHeaderLen : INT; // 计数、长度或状态数值。
iBodyStart : INT; // 计数、长度或状态数值。
iBodyLen : INT; // 计数、长度或状态数值。
sStartLine : STRING(255); // 诊断或协议文本字段。
sAfterVersion : STRING(255); // 诊断或协议文本字段。
sHeaderText : STRING(GVL_Http.cnMaxHeaderSize); // 诊断或协议文本字段。
sBodyText : STRING(GVL_Http.cnMaxBodySize); // 诊断或协议文本字段。
sHeaderValue : STRING(GVL_Http.cnMaxHeaderValueLen); // 诊断或协议文本字段。
udiContentLength : UDINT; // 计数、长度或状态数值。
uiHeaderCount : UINT; // 计数、长度或状态数值。
bHeaderFound : BOOL; // 布尔状态或命令标志。
bHeaderInvalid : BOOL; // 布尔状态或命令标志。
END_VAR
// === IMPLEMENTATION ===
// 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。
// 边界说明:执行前后保持输出和错误码可被在线诊断追踪。
// 原因:Client 响应解析必须同时覆盖 Content-Length、chunked 和 close-delimited 三种常见响应边界。
// 约束:TE+CL 响应按协议错误处理,避免 PLC Client 接受代理或服务器生成的歧义报文。
// 风险:超限 body 在拷贝到业务字符串前即失败,保护固定长度 STRING 不被截断后误判为成功。
// 诊断:状态码、Reason、body 和协议错误码全部写入 stResponse/eError,供真机矩阵逐项断言。
M_Reset();
M_ParseResponse := FALSE;
M_ResetResponse(
stResponse := stResponse
);
iFirstLineEnd := FIND(sMessage, '$R$N');
iHeaderEnd := FIND(sMessage, '$R$N$R$N');
IF (iFirstLineEnd <= 1) OR (iHeaderEnd <= iFirstLineEnd) THEN
M_SetError(
eNewError := E_HttpError.iNeedMoreData,
sMessage := 'response header is incomplete'
);
RETURN;
END_IF
sStartLine := LEFT(sMessage, iFirstLineEnd - 1);
iSpace1 := FIND(sStartLine, ' ');
IF (iSpace1 <= 1) OR (FIND(sStartLine, 'HTTP/') <> 1) THEN
M_SetError(
eNewError := E_HttpError.iInvalidStartLine,
sMessage := 'response start-line is invalid'
);
RETURN;
END_IF
sAfterVersion := MID(sStartLine, LEN(sStartLine) - iSpace1, iSpace1 + 1);
iSpace2Relative := FIND(sAfterVersion, ' ');
IF iSpace2Relative <= 1 THEN
M_SetError(
eNewError := E_HttpError.iInvalidStartLine,
sMessage := 'response start-line misses reason'
);
RETURN;
END_IF
stResponse.sVersion := LEFT(sStartLine, iSpace1 - 1);
stResponse.uiStatusCode := STRING_TO_UINT(LEFT(sAfterVersion, iSpace2Relative - 1));
stResponse.sReason := MID(sAfterVersion, LEN(sAfterVersion) - iSpace2Relative, iSpace2Relative + 1);
IF stResponse.uiStatusCode = 0 THEN
M_SetError(
eNewError := E_HttpError.iInvalidStartLine,这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件,不能只看某个布尔量是否变成 TRUE。
片段二:状态推进、边界与输出
sMessage := 'response status code is invalid'
);
RETURN;
END_IF
iHeaderStart := iFirstLineEnd + 2;
iHeaderLen := iHeaderEnd - iHeaderStart;
IF iHeaderLen > GVL_Http.cnMaxHeaderSize THEN
M_SetError(
eNewError := E_HttpError.iBufferTooSmall,
sMessage := 'response header exceeds limit'
);
RETURN;
END_IF
IF iHeaderLen > 0 THEN
sHeaderText := MID(sMessage, iHeaderLen, iHeaderStart);
ELSE
sHeaderText := '';
END_IF
stResponse.sRawHeaders := sHeaderText;
bHeaderFound := F_HttpFindHeader(
sHeaders := sHeaderText,
sHeaderName := 'Content-Type',
sValue => sHeaderValue,
uiCount => uiHeaderCount,
bInvalidGrammar => bHeaderInvalid
);
IF bHeaderInvalid THEN
M_SetError(
eNewError := E_HttpError.iInvalidHeader,
sMessage := 'response header grammar is invalid'
);
RETURN;
END_IF
IF bHeaderFound THEN
stResponse.sContentType := sHeaderValue;
END_IF
stResponse.bTransferChunked := FALSE;
bHeaderFound := F_HttpFindHeader(
sHeaders := sHeaderText,
sHeaderName := 'Transfer-Encoding',
sValue => sHeaderValue,
uiCount => uiHeaderCount,
bInvalidGrammar => bHeaderInvalid
);
IF uiHeaderCount > 1 THEN
M_SetError(
eNewError := E_HttpError.iUnsupportedTransferEncoding,
sMessage := 'duplicate response transfer-encoding'
);
RETURN;
END_IF
IF bHeaderFound THEN
IF F_HttpAsciiEqualsIgnoreCase(
sLeft := sHeaderValue,
sRight := 'chunked'
) THEN
stResponse.bTransferChunked := TRUE;
ELSE
M_SetError(
eNewError := E_HttpError.iUnsupportedTransferEncoding,
sMessage := 'unsupported response transfer-encoding'
);
RETURN;
END_IF
END_IF
stResponse.bHasContentLength := FALSE;
bHeaderFound := F_HttpFindHeader(
sHeaders := sHeaderText,
sHeaderName := 'Content-Length',
sValue => sHeaderValue,
uiCount => uiHeaderCount,第二段继续展示同一连续源码范围。把它与第一段合起来,才能判断输入怎样被锁存、状态何时推进、边界何时满足,以及错误出口是否保留了足够诊断信息。
验证路径
| 场景 | 操作 | 通过口径 |
|---|---|---|
| 分段状态行 | 第一行跨两次读取 | Parser 等待完整 CRLF |
| 200 + 半 Body | 长度未满足 | 不提前 Done |
| 204 | 无内容响应 | 状态码和空 Body 合法 |
| 错误状态码 | 404 或 500 | 传输完成但业务结果保留 |
场景 1:分段状态行
把状态行拆成两次 TCP_Read 送入 Parser。第一次只收到 HTTP/1.1 20 时,bDone 必须保持 FALSE,缓冲区保留未完成字节;第二次补齐 0 OK 和 CRLF 后,才允许进入 Header 解析。验收时同时看接收长度、Parser 状态和原始报文,避免把“读到部分状态行”误判为“已经收到响应”。
场景 2:200 + 半 Body
构造 Content-Length: 100、实际只到达 60 字节的响应。此时 Header 可以被接受,但 Body 计数必须停在 60,Parser 继续等待而不是发出完成脉冲。再补入剩余 40 字节,检查完整 Body 与声明长度相等后才转入 Done;这能直接区分“TCP 已有数据”和“HTTP 响应完整”。
场景 3:204
让服务端返回 204 No Content,并确认 Header 结束后没有 Body。这里的通过条件不是“Body 长度为零”这一项,而是状态码语义、Header 边界和 Parser 的结束路径彼此一致。随后立即发起下一笔请求,验证前一笔没有把空响应的残留状态带入新的事务。
场景 4:错误状态码
用带有 JSON 错误体的 404 或 500 响应验证两层结果:传输层应正常收完整条消息,业务层应保留状态码和错误正文供调用方判断。不要因为状态码不是 2xx 就把 Parser 直接置为通信失败,否则会丢掉服务端已经返回的诊断信息。
常见误判
- 读到
200 OK就提前完成,声明长度对应的 Body 仍留在 TCP 缓冲区。 - 只保存结构化状态码,不保留原始 Header,现场无法复盘对端真实响应。
- 把 404 或 500 当成通信失败;实际上事务可能完整,只是业务结果失败。
这些误判的共同点,是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标,并用相同输入完成回归。
这一篇你最该记住
- 看到 200 仍要检查消息完整性。
- 原始 Header 和结构化字段都要保留。
- 应用只消费 Parser 完成后的响应。
系列导航
- 系列:CodeSys HTTP 系列教程,第 16/28 篇。
- 阶段:客户端篇,职责线位置 5/7。
- 上一篇:第15篇
- 下一篇:第17篇
- 发布顺序:基础认知 -> Server -> Client -> 完整源码加更 -> 综合收束。
评论区预留
这里先保留评论和回复结构,不接入第三方服务。后续统一决定登录、匿名、审核、反垃圾和静态站兼容策略。