Server 响应需要状态行、Content-Type、Content-Length、附加 Header、Body 和连接策略保持一致。业务结果必须先进入结构体,再由 Builder 统一生成报文。
适合谁收藏
- 正在实现 PLC 端 HTTP Server 的工程师。
- 正在排查监听、多连接、请求边界或响应发送问题的人。
- 需要建立 Server 真机验收清单的读者。
服务器篇,第 6/7 篇;主系列第 10/28 篇。
现场问题
最初验证 Server 时,直接返回 OK 看起来最快。换成 curl 或浏览器后,对端可能继续等待、显示空白,或者把下一次响应接到上一次后面。
对端并不知道 PLC 的字符串什么时候结束。它只理解 HTTP 状态行、Header、长度和连接关闭。
先给结论
业务层只决定状态码、Content-Type、Body 和可选 Header;Builder 负责生成完整响应,并在发送前检查容量。Server 不应在多个路由分支里分别拼报文。

读图重点
这张图只压缩本篇的判断路径。读图时先找“/api/ping”对应的输入边界,再沿着“404 + Not Found”检查状态怎样推进,最后用“路由边界验证”确认输出是否已经形成验收证据。
把对象和边界分开
| 对象或阶段 | 工程职责 | 现场观察点 |
|---|---|---|
| /api/ping | 200 + pong | 连通性验证 |
| /api/echo | 200 + 请求 Body | Body 回环验证 |
| /api/status | 200 + 状态信息 | 运行状态读取 |
| 其他路径 | 404 + Not Found | 路由边界验证 |
从协议约束到代码职责
协议约束
业务层只决定状态码、Content-Type、Body 和可选 Header;Builder 负责生成完整响应,并在发送前检查容量。Server 不应在多个路由分支里分别拼报文。 这条结论先限定消息什么时候成立,再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务,半包、超时、重复执行和连接残留就会进入应用层。
工程抽象
Builder 对状态码 0、Host 缺失和消息超限直接报错。响应的原因短语可以显式提供,也可以由 F_HttpStatusReason 根据状态码补齐。
xCloseConnection 为 TRUE 时使用 Connection: close;为 FALSE 时只允许顺序复用,不支持 pipeline。这个边界必须写进文章,避免读者把受控 keep-alive 理解为并发请求能力。
- /api/ping:工程职责是“200 + pong”。它不能只停留在命名层面,运行时必须能通过“连通性验证”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
- /api/echo:工程职责是“200 + 请求 Body”。它不能只停留在命名层面,运行时必须能通过“Body 回环验证”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
- /api/status:工程职责是“200 + 状态信息”。它不能只停留在命名层面,运行时必须能通过“运行状态读取”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
- 其他路径:工程职责是“404 + Not Found”。它不能只停留在命名层面,运行时必须能通过“路由边界验证”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
程序单元
本篇主证据来自 FB_HttpMessageBuilder.st 中以 METHOD PUBLIC M_BuildResponse 为定位点的连续源码。这里不是为了展示语法,而是把协议约束落到确定程序单元:输入先进入结构体或缓冲区,状态机只在本周期处理可确认的部分,长度和结束条件决定能否前进,错误码与指标负责把失败原因带出对象边界。这样一来,“响应是结构化消息,不是一段返回文字。”可以在代码、在线变量和外部报文之间逐项对照,而不是依赖经验猜测。
本篇核心源码片段
下面两段代码来自同一个真实文件 FB_HttpMessageBuilder.st,以 METHOD PUBLIC M_BuildResponse 为中心连续截取,没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件,第二段用于确认状态、边界和输出。核对时重点看“200 + pong”怎样进入对象,以及“路由边界验证”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“路由和报文构造应分层。”,就不能把局部代码截图当成实现证据。
片段一:入口、声明与前置条件
METHOD PUBLIC M_BuildResponse : BOOL
VAR_IN_OUT
stResponse : ST_HttpResponse; // 结构化协议或测试数据。
END_VAR
VAR_OUTPUT
sMessage : STRING(GVL_Http.cnMaxMessageSize); // 诊断或协议文本字段。
END_VAR
VAR
sReason : STRING(64); // 诊断或协议文本字段。
sStatus : STRING(16); // 诊断或协议文本字段。
sContentLen : STRING(16); // 诊断或协议文本字段。
sContentType : STRING(96); // 诊断或协议文本字段。
uiBodyLen : UINT; // 计数、长度或状态数值。
uiCandidate : UINT; // 计数、长度或状态数值。
END_VAR
// === IMPLEMENTATION ===
// 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。
// 边界说明:执行前后保持输出和错误码可被在线诊断追踪。
// 原因:响应构造始终显式 Content-Length,使外部 client 可以用真实字节数验证 Server 行为。
// 风险:状态码为 0 或 body 超过上限时必须拒绝构造,否则 Server 会发送不可诊断的坏报文。
M_Reset();
M_BuildResponse := FALSE;
sMessage := '';
IF stResponse.uiStatusCode = 0 THEN
M_SetError(
eNewError := E_HttpError.iInvalidArgument,
sMessage := 'response status code is zero'
);
RETURN;
END_IF
IF LEN(stResponse.sReason) > 0 THEN
sReason := stResponse.sReason;
ELSE
sReason := F_HttpStatusReason(
uiStatusCode := stResponse.uiStatusCode
);
END_IF
IF LEN(stResponse.sContentType) > 0 THEN
sContentType := stResponse.sContentType;
ELSE
sContentType := GVL_Http.cnDefaultContentType;
END_IF
uiBodyLen := TO_UINT(LEN(stResponse.sBody));
sStatus := UINT_TO_STRING(stResponse.uiStatusCode);
sContentLen := UINT_TO_STRING(uiBodyLen);
sMessage := CONCAT('HTTP/1.1 ', sStatus);
sMessage := CONCAT(sMessage, ' ');
sMessage := CONCAT(sMessage, sReason);
sMessage := CONCAT(sMessage, '$R$NConnection: ');
IF stResponse.bConnectionClose THEN
sMessage := CONCAT(sMessage, 'close$R$NContent-Type: ');
ELSE
sMessage := CONCAT(sMessage, 'keep-alive$R$NContent-Type: ');这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件,不能只看某个布尔量是否变成 TRUE。
片段二:状态推进、边界与输出
END_IF
sMessage := CONCAT(sMessage, sContentType);
sMessage := CONCAT(sMessage, '$R$NContent-Length: ');
sMessage := CONCAT(sMessage, sContentLen);
sMessage := CONCAT(sMessage, '$R$N');
IF LEN(stResponse.sAdditionalHeader) > 0 THEN
sMessage := CONCAT(sMessage, stResponse.sAdditionalHeader);
sMessage := CONCAT(sMessage, '$R$N');
END_IF
sMessage := CONCAT(sMessage, '$R$N');
IF uiBodyLen > 0 THEN
sMessage := CONCAT(sMessage, stResponse.sBody);
END_IF
uiCandidate := TO_UINT(LEN(sMessage));
IF uiCandidate >= GVL_Http.cnMaxMessageSize THEN
M_SetError(
eNewError := E_HttpError.iBufferTooSmall,
sMessage := 'response message exceeds buffer'
);
RETURN;
END_IF
udiMessageLength := TO_UDINT(uiCandidate);
bDone := TRUE;
M_BuildResponse := TRUE;
// === METHOD M_Reset ===
/// =======================================================================
/// 名称 : M_Reset
/// 功能 : 清空构造器诊断状态。
/// 说明 : 每个公开构造方法开头调用,保证输出语义一致。
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD PRIVATE M_Reset
// === IMPLEMENTATION ===
// 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。
bDone := FALSE;
bError := FALSE;
eError := E_HttpError.iNoError;
sDiagMsg := '';
udiMessageLength := 0;
// === METHOD M_SetError ===
/// =======================================================================
/// 名称 : M_SetError
/// 功能 : 锁存构造错误诊断。
/// 说明 : 内部错误出口统一调用,避免 bError/eError/sDiagMsg 不一致。
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD PRIVATE M_SetError
VAR_INPUT
eNewError : E_HttpError := E_HttpError.iNoError; // 枚举状态或错误码。
sMessage : STRING(255) := ''; // 诊断或协议文本字段。
END_VAR
// === IMPLEMENTATION ===
// 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。
bDone := FALSE;第二段继续展示同一连续源码范围。把它与第一段合起来,才能判断输入怎样被锁存、状态何时推进、边界何时满足,以及错误出口是否保留了足够诊断信息。
验证路径
| 场景 | 操作 | 通过口径 |
|---|---|---|
| 状态码 | 200、400、404、413 等路径 | 原因短语和 Body 对应 |
| 长度 | Content-Length 等于实际 Body | 对端不继续等待 |
| 附加 Header | 按行插入且不破坏空行 | Header 结构合法 |
| 连接策略 | close 与 keep-alive 分别测试 | 句柄生命周期可解释 |
场景 1:状态码
分别触发正常资源、非法请求、不存在路径和 Body 过大四类分支。响应的状态行、原因短语、Body 文本和内部错误诊断要能互相解释,不能出现 404 搭配 OK 文本、400 却继续返回业务成功 Body 这种错位。状态码不是装饰字段,它是对端判断事务结果的主证据。
场景 2:长度
用抓包或通信工具读取响应,核对 Content-Length 与实际 Body 字节数完全一致。长度偏小会截断正文,长度偏大会让对端继续等待,PLC 侧如果只看 xDone 很容易漏掉这个问题。这个场景必须以对端解析结果为准。
场景 3:附加 Header
配置一条附加 Header,再检查响应报文里 Header 区和 Body 之间是否仍然只有一个空行边界。附加字段必须按完整行插入,不能吞掉 CRLF CRLF,也不能把 Body 的第一行误接到 Header 后面。这个验证决定后续扩展认证、缓存或自定义字段时是否可靠。
场景 4:连接策略
分别设置 Connection: close 和受控顺序 keep-alive。前者应在响应发送完成后释放句柄,后者只能在上一笔事务完全收口后接下一笔请求,不能支持 pipeline。验证时同时看外部连接状态和 PLC 槽位状态,确认连接策略不是靠对端超时碰巧结束。
常见误判
- 路由分支各自拼响应,最终不同路径的 Header 和关闭策略不一致。
- 只返回
OK却没有状态行和 Content-Length,迫使对端靠超时判断结束。 - 把受控顺序 keep-alive 宣称成支持 pipeline 或并发在途请求。
这些误判的共同点,是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标,并用相同输入完成回归。
这一篇你最该记住
- 响应是结构化消息,不是一段返回文字。
- 状态码、长度和关闭策略必须一致。
- 路由和报文构造应分层。
系列导航
- 系列:CodeSys HTTP 系列教程,第 10/28 篇。
- 阶段:服务器篇,职责线位置 6/7。
- 上一篇:第09篇
- 下一篇:第11篇
- 发布顺序:基础认知 -> Server -> Client -> 完整源码加更 -> 综合收束。
评论区预留
这里先保留评论和回复结构,不接入第三方服务。后续统一决定登录、匿名、审核、反垃圾和静态站兼容策略。