当前位置: 首页 > news >正文

Cloudflare HTML 解析器的十年演化史(一)

本文基于 Cloudflare 工程博客系列文章第一篇,梳理了 Cloudflare 从 2010 年起构建 HTML 流式解析器的完整历程。这不是一篇"又一个 HTML 解析器"的介绍,而是一个工程团队在极端性能约束下,反复与现实妥协、不断重建的真实故事。

原文链接:https://blog.cloudflare.com/html-parsing-1/


为什么 Cloudflare 需要自己的 HTML 解析器

很多人第一反应是:浏览器不是都有现成的 HTML 解析器吗?直接用不就好了?

问题在于,Cloudflare 的场景和浏览器完全不同。

浏览器解析 HTML 的目标是构建一棵 DOM 树,供渲染引擎使用。整个文档下载完之后,交给解析器,慢慢建树,内存够用就行。浏览器甚至可以边解析边让用户等,因为用户本来就在等页面加载。

Cloudflare 的场景是:数据流从源站流过来,流经 Cloudflare 的边缘节点,流向用户。边缘节点需要在数据经过时实时改写 HTML,改完之后继续往前传。整个过程中:

  • 不能把整个 HTML 文档缓冲下来再处理——太慢,用户会感觉到延迟;
  • 不能构建完整 DOM 树——内存不够,而且会引入大量延迟;
  • 处理速度必须极快——Cloudflare 每个 CPU 承载数百 MB 的流量,解析器的吞吐量需求比浏览器高一个数量级;
  • 内存占用必须极低——哪怕每个请求多用几十 KB,乘以海量并发之后都会成为灾难。

这就是**流式 HTML 改写器(Streaming HTML Rewriter)**的设计目标:数据流进来,边解析边改,改完边往外发,全程缓冲的数据尽可能少。


v0:从一个"简单"的需求开始

2010 年,Cloudflare 想给客户提供一个功能:自动混淆页面上的邮件地址,防止爬虫收集。思路很直接——找到页面里长得像邮件地址的字符串,把它编码,再注入一段 JavaScript 在浏览器端解码还原,对真实用户透明,但爬虫拿不到明文地址。

听起来很简单,实际上第一个坑就来了:数据是分包到达的

网络传输的数据按包分割,你无法控制一个邮件地址user@example.com会不会被切成两个包——前半个包里是user@exam,后半个包里才是ple.com。如果只处理当前包,就会漏掉跨包的邮件地址;如果把所有包缓冲起来再处理,延迟就不可接受了。

解决方案是把正则表达式转换成状态机(finite automata),用工具 Ragel 来生成高效的状态机代码。状态机的好处是可以在字节流上增量执行:处理完当前包之后,保存当前状态,等下一个包来了接着跑,不需要缓冲整个文档。

但邮件地址不是孤立存在的——它可能出现在 HTML 注释里,可能出现在<script>标签里,这些地方的邮件地址不应该被混淆。于是工程师在状态机里加了跳过注释和标签的逻辑。这是 Cloudflare HTML "解析"能力的起点,虽然那时还远远谈不上是一个解析器。

2011 年,Cloudflare 又想加 HTML 压缩(minification)功能。他们引入了一个叫 jitify 的外部库。问题出现了:jitify 有自己的 HTML 处理规则,和已有的邮件混淆模块的规则不兼容。两个并行运行的"解析器",各自有各自的状态,各自有各自的 bug,还会产生组合 bug。

这是临时方案堆叠的经典结局:每加一个新功能,系统就多一层脆弱性。


v1:从头来过,做一个合规的 HTML5 解析器

到 2016 年,工程师们意识到必须打破这个循环,彻底重写,基于 HTML5 规范从零构建一个真正的流式解析器。这个项目最终产出了 LazyHTML(已开源:https://github.com/cloudflare/lazyhtml )。

构建过程中踩了几个有意思的坑,每一个都值得展开讲。


坑一:HTML5 解析规范要求有 DOM,但我们不能建 DOM

HTML5 规范对 HTML 解析的定义方式,是一个词法分析器(tokenizer)+ 一个树构建器(tree builder)协同工作的状态机。关键在于:树构建器会反过来驱动词法分析器——词法分析器的当前状态,取决于已经构建好的 DOM 树的状态。

换句话说,你不构建 DOM,就没办法正确地切分 token。

这也是为什么大多数 HTML 解析器都选择把整个文档读完,然后一次性建树——流式解析和规范要求之间天然存在矛盾。

Cloudflare 的解法是引入一个**“树构建器反馈模拟器”(Parser Feedback Simulator)**。它不真正建树,但它跟踪足够多的上下文信息,让词法分析器相信它在和一个正常的树构建器交互。经过在大量真实页面上的测试,这个模拟器能正确处理互联网上绝大多数写得乱七八糟的页面。

这个思路来自对已有开源 HTML5 解析器 parse5 的研究,部分测试用例也回馈给了上游项目。


坑二:字符编码——绕开解码,一个聪明的洞察

流式改写 HTML 还有一个棘手问题:字符编码。HTML 文档可以用 UTF-8、GBK、Latin-1 等各种编码,而且编码信息可能藏在文档里面(比如<meta charset="...">),需要读了 1KB 内容之后才能确定。

如果要先确认编码再处理,就需要缓冲大量数据,违背了流式处理的初衷。如果解码之后再处理,还要面对不同编码的字节序问题,实现复杂度极高。

工程师发现了一个关键性质:HTML 规范允许的所有字符编码(除了 UTF-16 和 ISO-2022-JP),都是 ASCII 兼容的。也就是说,所有 ASCII 字符在这些编码中的字节表示与纯 ASCII 完全相同,非 ASCII 字符的字节值一定在 ASCII 范围之外。

而 HTML 语法中,所有有意义的边界字符<>"=等)全部是 ASCII 字符。这意味着:只要不修改非 ASCII 内容,完全可以在不知道文档编码的情况下安全地解析和改写 HTML。

做法是:对 UTF-16 文档做嗅探并直接跳过(这类文档在现实中不到 0.1%);对其他文档,原样处理字节流,只操作 ASCII 范围内的 token 边界。

这个洞察直接避免了解码和重编码的开销,既提升了性能,也规避了一大类潜在的编码相关安全漏洞。

规范中要求把 U+0000(NUL 字符)替换为 U+FFFD(替换字符)的条款,LazyHTML 选择了静默忽略。因为 U+FFFD 是非 ASCII 字符,在不同编码下字节表示不同,不知道编码就没法正确处理。而且使用"胖指针"(length + pointer)而非 C 风格 null 结尾字符串之后,NUL 字符本身也不会带来安全问题。


坑三:Content-Type 是谎言,25% 的流量声称是 HTML 但并不是

当一个 HTTP 响应的Content-Typetext/html时,你会认为里面是 HTML,对吧?

Cloudflare 的实测数据给出了一个令人绝望的答案:大约 25% 声称是text/html的响应,实际上根本不是 HTML

原因很简单:PHP 的默认Content-Type就是text/html。大量开发者写了返回 JSON 的接口、输出图片的脚本,却从来没有手动设置Content-Type,于是服务器就用默认值把这些响应都标注成了text/html

如果 Cloudflare 的 HTML 改写器对所有text/html响应都盲目处理,后果是:把 JSON 响应当 HTML 解析时,可能错误地把a<b识别成一个不完整的 HTML 标签,然后在末尾注入脚本代码,直接把 API 响应破坏掉。

工程师在代码里留了一段注释,坦诚记录了这个痛苦的现实(原文大意):

亲爱的后来者,我也不喜欢这个 hack,但写这段代码的时候互联网是个糟糕的地方。大量网站用 PHP 默认的Content-Type: text/html来返回 JSON API 响应、私钥、二进制图片……我们只能在正式解析之前,先确认第一个非空白字符是不是<,来提高我们猜对的概率。

最终形成的内容嗅探逻辑要检测:二进制数据、JSON、AMP 页面、XML(许多 XML 文档也错误地标注为text/html)……整个检测流程复杂到可以画成一张相当大的流程图。

这是规范与现实之间鸿沟的典型写照。


坑四:标签名比较——一个用 5 位哈希换来的单指令比较

HTML 解析的一个高频操作是比较标签名:当前遇到的标签是<a>还是<div>还是<script>

朴素实现是逐字节比较。对于短标签名(大多数 HTML 标签名都很短)来说,这没什么问题,但这是一个在每个 token 上都要执行的操作,在 Cloudflare 的流量规模下,积少成多。

LazyHTML 用了一个精妙的哈希方案:

所有标准 HTML 标签名只包含小写 ASCII 字母和数字 1-6(用于<h1><h6>)。这意味着只需要 32 个不同的字符值就能覆盖所有可能。用 5 位来编码每个字符,一个 64 位整数可以容纳64 ÷ 5 = 12个字符——而所有标准 HTML 标签名都不超过 12 个字符(最长的是blockquote,9 个字符)。

这样,任意一个标准标签名都能被编码成一个唯一的 64 位整数,标签名比较就变成了一次整数相等比较

这个哈希甚至不需要额外的遍历——可以在解析标签名的同时,逐字节更新哈希值,完全零额外开销。

有一个小陷阱:32 种字符里必须用到00000这个 5 位值,如果字母 ‘a’ 被编码为00000,那么abaab的哈希值就会相同(前导零不影响整数值)。解决方法也简单:把数字 1-6 编码为00000-00101,字母从00110开始编码。由于 HTML 规范规定标签名的第一个字符必须是字母,不可能是数字,前导零的冲突就被规避掉了。


LazyHTML 的最终成绩

经过以上所有设计权衡,LazyHTML 问世。它通过了 HTML5 规范官方测试套件,团队还把几处规范描述中的歧义和可简化之处回馈给了规范本身。

在 2016 年 9 月的基准测试中,以改写 HTML5 规范文档本身(7.9 MB 的 HTML 文件)为测试用例,对比了同时期流行的 HTML 解析器(仅 tokenization 模式,不构建 AST),LazyHTML 的速度比其他解析器快了约一个数量级

在对 HTTP Archive 中 238 万份真实 HTML 文档的测试中,LazyHTML 没有在任何一份文档上崩溃。约 0.2% 的文档超出了缓冲区限制——这些文档实际上是 JavaScript、RSS 或其他内容,只是被错误标注成了text/html(排除两个已知问题广告网络的文档后,这个比例降至 0.03%)。


一条演化主线背后的工程教训

回顾这整段历程,有几个模式一再出现:

特殊场景催生专用方案,专用方案积累成负担。邮件混淆、HTML 压缩,每个功能都带着自己的解析逻辑和边界处理,最终形成了一堆互相干扰、难以维护的代码。这种债务没有办法靠局部修修补补偿还,只能重写。

规范描述的是理想,现实是混乱的。Content-Type: text/html有 25% 在说谎,大量页面的 HTML 不符合规范但浏览器能正常渲染,规范要求的 NUL 字符替换在不知道编码的情况下无法安全实现……每个"按规范来"的方案,都需要一层针对现实世界的适配。

约束是创造力的来源。不能用 DOM,催生了 Parser Feedback Simulator;不能做字符编码解码,促使工程师发现了 ASCII 兼容性这个性质;内存极度受限,逼出了 5 位哈希标签名比较。如果没有这些约束,LazyHTML 就只是又一个普通的 HTML 解析器。


第二篇博客将描述 LazyHTML 的下一代继承者:LOL HTML——一个用 Rust 写成、支持 CSS 选择器 API 的流式改写器,也就是今天 Cloudflare WorkersHTMLRewriterAPI 的底层实现。


http://www.jsqmd.com/news/706542/

相关文章:

  • Geo-Bootstrap开发者深度指南:源码结构与扩展开发
  • 源码-Eureka
  • 正则表达式终极指南:10个文本处理匹配技巧
  • 【MCP 2026漏洞猎人内部手册】:3类隐蔽型RCE触发路径+2套自动化检测脚本(限免领取至2026.04.30)
  • langsmith-fetch技能:调试LangChain和LangGraph代理的必备工具
  • nw.js调试工具:10个高级调试技巧解决复杂开发问题
  • ADB Idea多设备支持完全指南:智能设备选择与记忆功能
  • AndroidTagGroup布局优化指南:掌握15个自定义属性提升UI体验
  • 开源代码生成工具MassGen:模板驱动,解放重复编码生产力
  • 智能体技能开发实战:从工具调用到系统架构的完整指南
  • Cloudflare HTML 解析器的十年演化史(二)
  • 如何快速掌握Preact:从零开始的现代前端框架完整指南
  • NW.js质量保证终极指南:从代码审查到自动化测试的完整流程
  • ARM NEON与VFP指令集:高性能嵌入式开发实战
  • DevDocs知识管理系统:团队经验的积累与分享终极指南
  • 第二十二篇技术笔记:郭大侠学DoIP - OBD口的“隐藏技能”
  • 2026年3月有名的避雷塔代加工加工厂,钢管塔避雷塔/箱变基础平台/三项变压器/角钢塔避雷针,避雷塔加工联系方式 - 品牌推荐师
  • 掌握Noto Emoji:构建跨平台表情符号的终极视觉工具箱
  • 10个高效Docker部署策略:容器化应用最佳实践指南
  • owl4ce/dotfiles桌面环境核心组件深度解析
  • 强化学习智能体记忆系统设计:从经验回放到语义检索的架构演进
  • 9Router:本地AI模型路由代理,智能调度Claude/Codex/免费模型实现低成本不间断编程
  • 如何掌握Yew Future:Rust Web应用的异步操作与并发处理终极指南
  • owl4ce/dotfiles双主题切换:从机械风到艺术风的完美转换
  • PHPCI配置文件详解:phpci.yml编写技巧与最佳实践
  • Homarr开发者工具链详解:Turbo、TypeScript与Monorepo架构
  • 终极PHP导航菜单指南:从KnpMenu到Spatie Menu的完整实现方案
  • 2026年可靠卫生检测报告收费指南及行业标杆名录:卫生检测公司、卫生检测公司、卫生检测报告在哪里办、卫生检测报告在哪里办选择指南 - 优质品牌商家
  • 如何快速掌握Vim:零基础到熟练的完整指南
  • 乐山临江鳝丝店排行:临江鳝丝店哪家靠谱/临江鳝丝店排名前十/乐山临江鳝丝店哪个专业/乐山临江鳝丝店哪个值得选/乐山临江鳝丝店哪些更专业/选择指南 - 优质品牌商家