ControlRookie
返回文章

第10篇_Server 06|路由、状态码和响应关闭不能靠拼字符串

Server 响应需要状态行、Content-Type、Content-Length、附加 Header、Body 和连接策略保持一致。业务结果必须先进入结构体,再由 Builder 统一生成报文。

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 不应在多个路由分支里分别拼报文。

Server 06|路由、状态码和响应关闭不能靠拼字符串
Server 06|路由、状态码和响应关闭不能靠拼字符串

读图重点

这张图只压缩本篇的判断路径。读图时先找“/api/ping”对应的输入边界,再沿着“404 + Not Found”检查状态怎样推进,最后用“路由边界验证”确认输出是否已经形成验收证据。

把对象和边界分开

对象或阶段工程职责现场观察点
/api/ping200 + pong连通性验证
/api/echo200 + 请求 BodyBody 回环验证
/api/status200 + 状态信息运行状态读取
其他路径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”怎样进入对象,以及“路由边界验证”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“路由和报文构造应分层。”,就不能把局部代码截图当成实现证据。

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

iecst
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。

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

iecst
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 -> 完整源码加更 -> 综合收束。
评论和回复区

评论区预留

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

↑ ↓