ControlRookie
返回文章

第09篇_Server 05|固定长度与 chunked 的消息边界

普通 Body 按 Content-Length 收齐,chunked Body 按块长度和终止块解码。两套边界不能混用,也不能依赖字符串结束符猜测。

普通 Body 按 Content-Length 收齐,chunked Body 按块长度和终止块解码。两套边界不能混用,也不能依赖字符串结束符猜测。

适合谁收藏

  • 正在实现 PLC 端 HTTP Server 的工程师。
  • 正在排查监听、多连接、请求边界或响应发送问题的人。
  • 需要建立 Server 真机验收清单的读者。

服务器篇,第 5/7 篇;主系列第 09/28 篇。

现场问题

当 Content-Length 比实际 Body 大,Server 会继续等;比实际 Body 小,后续字节可能污染下一次事务。chunked 更容易误判,因为长度不是写在 Header 里,而是分散在每个块前面。

PLC 侧没有无限缓冲区。边界判断除了符合协议,还必须在最大 Header、最大 Body 和最大消息容量内失败得足够明确。

先给结论

普通消息用声明长度收口,chunked 消息用十六进制块长度、CRLF 和 0 终止块收口。任何非法长度、缺失 CRLF、超限或 TE/CL 冲突都必须停止解析。

Server 05|固定长度与 chunked 的消息边界
Server 05|固定长度与 chunked 的消息边界

读图重点

这张图只压缩本篇的判断路径。读图时先找“Content-Length”对应的输入边界,再沿着“解码后不超过 1023 字节”检查状态怎样推进,最后用“超限返回 BodyTooLarge”确认输出是否已经形成验收证据。

把对象和边界分开

对象或阶段工程职责现场观察点
Content-Length十进制 Body 字节数收齐前返回 NeedMoreData
chunk-size十六进制块长度允许分号扩展但不解释语义
0 块chunked 正文结束后续只验证 trailer 结束
容量解码后不超过 1023 字节超限返回 BodyTooLarge

从协议约束到代码职责

协议约束

普通消息用声明长度收口,chunked 消息用十六进制块长度、CRLF 和 0 终止块收口。任何非法长度、缺失 CRLF、超限或 TE/CL 冲突都必须停止解析。 这条结论先限定消息什么时候成立,再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务,半包、超时、重复执行和连接残留就会进入应用层。

工程抽象

当前解码器先扫描 chunk-size 行,再调用 F_HttpParseHexSize。只有长度、数据和尾部 CRLF 都验证通过后才复制 Body,避免输出保留半截非法数据。

首版不生成 chunked 出站报文,统一用 Content-Length 构造请求和响应。这是工程取舍:接收端必须兼容真实 HTTP 对端,发送端则选择更容易诊断的确定长度。

  • Content-Length:工程职责是“十进制 Body 字节数”。它不能只停留在命名层面,运行时必须能通过“收齐前返回 NeedMoreData”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • chunk-size:工程职责是“十六进制块长度”。它不能只停留在命名层面,运行时必须能通过“允许分号扩展但不解释语义”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • 0 块:工程职责是“chunked 正文结束”。它不能只停留在命名层面,运行时必须能通过“后续只验证 trailer 结束”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • 容量:工程职责是“解码后不超过 1023 字节”。它不能只停留在命名层面,运行时必须能通过“超限返回 BodyTooLarge”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。

程序单元

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

本篇核心源码片段

下面两段代码来自同一个真实文件 FB_HttpChunkedDecoder.st,以 IF udiChunkSize = 0 THEN 为中心连续截取,没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件,第二段用于确认状态、边界和输出。核对时重点看“十进制 Body 字节数”怎样进入对象,以及“超限返回 BodyTooLarge”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“校验通过之前不要修改最终 Body。”,就不能把局部代码截图当成实现证据。

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

iecst
        FOR uiLineIndex := 0 TO uiLineLen - 1 DO
            pLine^ := pInput[uiIndex + uiLineIndex];
            pLine := pLine + 1;
        END_FOR
    END_IF
    pLine^ := 0;

    IF NOT F_HttpParseHexSize(
        sValue   := sLine,
        udiValue => udiChunkSize
        ) THEN
        bError := TRUE;
        eError := E_HttpError.iChunkedDecodeFailed;
        sDiagMsg := 'chunk size parse failed';
        RETURN;
    END_IF

    uiIndex := uiScan + 2;
    IF udiChunkSize = 0 THEN
        // 零长度块后若直接出现 CRLF,则判定为空 trailer 并立即完成。
        IF ((uiIndex + 1) < uiInputLen)
            AND (pInput[uiIndex] = 16#0D)
            AND (pInput[uiIndex + 1] = 16#0A) THEN
            bDone := TRUE;
            M_Decode := TRUE;
            RETURN;
        END_IF

        // 真实 HTTP/1.1 对端可能发送 trailer 字段;首版不消费字段语义,只验证终止空行并忽略其内容。
        uiScan := uiIndex;
        WHILE ((uiScan + 3) < uiInputLen)
            AND NOT ((pInput[uiScan] = 16#0D)
                AND (pInput[uiScan + 1] = 16#0A)
                AND (pInput[uiScan + 2] = 16#0D)
                AND (pInput[uiScan + 3] = 16#0A)) DO
            uiScan := uiScan + 1;
        END_WHILE

        IF ((uiScan + 3) < uiInputLen)
            AND (pInput[uiScan] = 16#0D)
            AND (pInput[uiScan + 1] = 16#0A)
            AND (pInput[uiScan + 2] = 16#0D)
            AND (pInput[uiScan + 3] = 16#0A) THEN
            bDone := TRUE;
            M_Decode := TRUE;
            RETURN;

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

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

iecst
        END_IF
        bError := TRUE;
        eError := E_HttpError.iNeedMoreData;
        sDiagMsg := 'chunk trailer is incomplete';
        RETURN;
    END_IF

    IF (udiDecodedLength + udiChunkSize) > TO_UDINT(GVL_Http.cnMaxBodySize) THEN
        bError := TRUE;
        eError := E_HttpError.iBodyTooLarge;
        sDiagMsg := 'decoded body exceeds limit';
        RETURN;
    END_IF

    uiChunkSize := TO_UINT(udiChunkSize);
    IF ((TO_UDINT(uiIndex) + udiChunkSize + 1) > TO_UDINT(uiInputLen)) THEN
        bError := TRUE;
        eError := E_HttpError.iNeedMoreData;
        sDiagMsg := 'chunk data is incomplete';
        RETURN;
    END_IF

    IF NOT ((pInput[uiIndex + uiChunkSize] = 16#0D)
        AND (pInput[uiIndex + uiChunkSize + 1] = 16#0A)) THEN
        bError := TRUE;
        eError := E_HttpError.iChunkedDecodeFailed;
        sDiagMsg := 'chunk data CRLF is invalid';
        RETURN;
    END_IF

    // 只有通过长度和 CRLF 校验后才复制数据,保证输出不会保留半截非法 body。
    IF uiChunkSize > 0 THEN
        FOR uiCopyIndex := 0 TO uiChunkSize - 1 DO
            pWrite^ := pInput[uiIndex + uiCopyIndex];
            pWrite := pWrite + 1;
            udiDecodedLength := udiDecodedLength + 1;
        END_FOR
        pWrite^ := 0;
    END_IF

    uiIndex := uiIndex + uiChunkSize + 2;
END_WHILE

bError := TRUE;
eError := E_HttpError.iNeedMoreData;
sDiagMsg := 'terminal chunk is missing';

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

验证路径

场景操作通过口径
合法分块多个块加 0 终止块解码 Body 顺序正确
分号扩展块长度后带扩展长度计算不受扩展影响
坏十六进制chunk-size 含非法字符ChunkedDecodeFailed
缺少 CRLF块数据边界错误拒绝复制不完整 Body

场景 1:合法分块

发送 4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n 这一类标准分块输入。解码结果必须按块顺序拼成连续 Body,完成标志只能在 0 块和 trailer 结束以后出现。这里要同时核对写指针、累计长度和输出字符串,防止只在外部看起来返回了正确文本。

场景 2:分号扩展

把 chunk-size 写成 5;name=value,确认分号后的扩展不会进入长度换算。PLC 端只按前面的十六进制数取数据,扩展字段不应改变 Body,也不应被误认为非法字符。这个场景用于证明解析器没有把整行当成纯数字硬转。

场景 3:坏十六进制

发送 G\r\n... 或带非十六进制字符的块长度。期望错误应落在 chunk-size 解析阶段,而不是等到 Body 拷贝阶段才失败。错误发生后,输出 Body 必须保持原状态或清空,不能留下已经复制的半截数据。

场景 4:缺少 CRLF

构造块数据后缺少 CRLF 的输入。解码器必须先验证长度和边界,再复制数据;边界不完整时应返回需要更多数据或解码失败,而不是先把看见的字节写入输出。这个顺序决定了异常报文会不会污染下一次请求。

常见误判

  • 用字符串结束符判断 Body,而不是按 Content-Length 或 chunk-size 计算字节边界。
  • chunk-size 解析失败后仍复制已经收到的部分数据,留下半截业务 Body。
  • 把 chunked 入站支持误写成当前实现也会生成 chunked 出站报文。

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

这一篇你最该记住

  • 消息边界必须由协议字段决定。
  • chunked 的结束标志是 0 块,不是连接关闭。
  • 校验通过之前不要修改最终 Body。

系列导航

  • 系列:CodeSys HTTP 系列教程,第 09/28 篇。
  • 阶段:服务器篇,职责线位置 5/7。
  • 上一篇:第08篇
  • 下一篇:第10篇
  • 发布顺序:基础认知 -> Server -> Client -> 完整源码加更 -> 综合收束。
评论和回复区

评论区预留

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

↑ ↓