☰
Nokogiri API 演进路线图:序列化、SAX、选择器与解析选项的改造蓝图
2026/10/8 2:00:16 网站建设 项目流程
  • 后端

【免费下载链接】nokogiri

Nokogiri (鋸) makes it easy and painless to work with XML and HTML from Ruby.

项目地址:https://gitcode.com/gh_mirrors/no/nokogiri
点击查看免费下载

本文基于 Nokogiri 官方仓库中的 ROADMAP.md 展开,逐条剖析项目对 XML/HTML 解析、序列化、搜索与解析选项等核心 API 的既有缺陷与改造设想,并结合仓库当前源码(如 lib/nokogiri/xml/parse_options.rb、lib/nokogiri/xml/node.rb)给出落地证据。读完本文,你将清楚 Nokogiri 各 API 的当前实现方式、被点名的问题所在,以及未来可能出现的接口形态,便于在升级版本时提前迁移。

1. 路线图概览:一份给 API 动手术的清单

ROADMAP.md是一份面向 Nokogiri API 的演进规划,而非版本发布排期。它列出的是项目维护者认为"现在这样不够好、需要改造"的九个方向:

方向核心痛点相关源码/测试
序列化与 pretty printing API格式化行为不可关闭、默认行为不直观lib/nokogiri/xml/node/save_options.rb
SAX 解析性能"慢得可怕"ext/nokogiri/xml_sax_parser.c
Node 的 Enumerable 混入与属性 API混入 Enumerable 带来意外副作用lib/nokogiri/xml/node.rb
CSS 查询解析伪类支持不足、选择器语义错误lib/nokogiri/css/parser.y
DocumentFragment上下文节点参与搜索时结果不一致lib/nokogiri/xml/document_fragment.rb
XPath 函数/查询语法自定义函数与参数提取的语法不统一lib/nokogiri/xml/searchable.rb
编码处理libxml2 编码探测能力弱lib/nokogiri/html4/encoding_reader.rb
Reader不安全对象引用可导致应用崩溃lib/nokogiri/xml/reader.rb
ParseOptions镜像 libxml2 选项、命名危险、难以组合lib/nokogiri/xml/parse_options.rb

以下各节按路线图顺序展开,并在每个方向后补充当前仓库中可验证的实现证据与演进脉络。

2. 序列化与 pretty printing API 的重构

路线图把"彻底改造序列化/pretty printing API"列在第一位,引出的两个具体问题都指向格式化的不可控性:

  • [#530]:XHTML 格式化无法关闭;
  • [#415]:XML 默认应该"无格式化"。

当前实现中,序列化由Nokogiri::XML::Node#serialize及其派生的to_xml/to_html/to_xhtml驱动,其格式行为由 lib/nokogiri/xml/node/save_options.rb 中的SaveOptions位掩码决定:

常量值含义
FORMAT1序列化时格式化输出(缩进)
NO_DECLARATION2不输出 XML 声明
NO_EMPTY_TAGS4不输出空标签(如<br/>)
NO_XHTML8不按 XHTML 序列化
AS_XHTML16按 XHTML 序列化
AS_XML32按 XML 序列化
AS_HTML64按 HTML 序列化

有趣的是,路线图指出的"默认行为"问题已经部分落地。在非 JRuby 平台上,save_options.rb 中DEFAULT_XML = FORMAT | AS_XML——即 XML 默认仍带格式化;而文件里直接引用了 issue #415 作为设计注记,说明该议题正是DEFAULT_XML取值讨论的一部分。反观 JRuby 分支,DEFAULT_XML = AS_XML(无FORMAT),恰恰实现了"XML 默认无格式化"的设想,两个平台默认值至今仍未统一。

SerializeOptions的位运算代码(@options |= constant的 setter 与@options & constant的查询方法)由constants.each的class_eval批量生成,与ParseOptions的模式一致。如果你想在升级前自主控制输出,可以用save_with显式组合:

node.serialize(encoding: "UTF-8", save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) node.serialize(encoding: "UTF-8") do |config| config.format # 启用格式化 end

底层serialize在 lib/nokogiri/xml/node.rb 中把选项合并进SaveOptions后交给原生层输出。可以推断,路线图设想的"API 改造"将聚焦于把save_with/FORMAT这套位掩码语义收敛为更直观、可显式关闭格式化的接口。

3. SAX 解析的性能与重构

路线图用一句"SAX parsing is wicked slow"(fairy wing throwdown 之论)点名了 SAX 解析的性能问题。SAX(Simple API for XML)本应是流式、低内存的解析方式,适合处理超大文档;性能瓶颈通常来自事件回调的 Ruby 边界穿越与原生层对象分配。

当前 SAX 体系的 Ruby 侧入口分散在多处:

  • XML:Nokogiri::XML::SAX::Parser/ParserContext/PushParser(lib/nokogiri/xml/sax/);
  • HTML4:Nokogiri::HTML4::SAX::Parser/ParserContext/PushParser(lib/nokogiri/html4/sax/);
  • 原生实现:CRuby 走 ext/nokogiri/xml_sax_parser.c 与 ext/nokogiri/xml_sax_push_parser.c,JRuby 走 ext/java/nokogiri/XmlSaxParser.java 等。

从代码结构看,"overhaul and optimize"至少包含两层含义:一是减少 Ruby↔C/Java 边界的调用次数与对象装箱,二是统一 XML 与 HTML4 两套 SAX 入口的接口。路线图未给出具体时间表,但后续版本中 SAX 的接口逐步收敛到Parser#parse(io)与PushParser#<<(chunk)两种范式,正是该议题持续推进的体现。

4. Node 不应是 Enumerable,并应拥有更好的属性 API

路线图明确指出:Nokogiri::XML::Node混入Enumerable带来了"非预期的副作用"(引述 issue #679),因此应该移除Enumerable混入,同时改进属性访问 API。

从当前源码看,该设想尚未落地:在 lib/nokogiri/xml/node.rb 第 92 行仍是:

include Enumerable

也就是说,node.map、node.each这类操作目前依然可用(遍历子节点)。路线图认为这种能力"免费"混入到每个节点上并不合适——例如Enumerable#first、#any?等方法的语义对单个 DOM 节点而言并不自然,且与NodeSet(真正的节点集合)职责重叠。改造方向是把节点"集合行为"收敛到NodeSet,让Node专注树结构操作。

至于"更好的属性 API",路线图链接了 #666(已关闭)与 #765 两个议题。当前属性访问已具备类 Hash 风格:

node = Nokogiri::XML::DocumentFragment.parse("<a href='#foo' id='link'>link</a>").at_css("a") node["href"] # => "#foo" node.keys # => ["href", "id"] node["class"] = "green" # 可写 node.attribute_nodes # 返回 Attr 节点集合

相关的 C 侧实现位于 ext/nokogiri/xml_node.c,Ruby 侧属性方法群在 lib/nokogiri/xml/node.rb 的Working with Node Attributes部分。可以推断,改进方向是提供显式的、不依赖 Hash 惯例的属性遍历/命名空间感知接口。

5. CSS 查询解析的改进清单

路线图在 CSS 查询解析下列出了大量待办,几乎全部是"选择器语义缺失或错误":

  • :not()不支持非平凡参数,如:not(div p.c)(#528);
  • 链式:not伪类(#451);
  • 更完整的 jQuery/CSS 伪类支持(#621、#342、#628、#652、#688);
  • nth-of-type等选择器计算结果错误(#394);
  • 查询被执行错误(#309);
  • :has行为不正确(#350)。

这些能力的实现位于 CSS 解析器 lib/nokogiri/css/parser.y(Bison 语法文件,由parser.y生成parser.rb)、词法分析器 lib/nokogiri/css/tokenizer.rex,以及将 CSS AST 翻译为 XPath 的 lib/nokogiri/css/xpath_visitor.rb。

关键机制是:Nokogiri 的 CSS 查询并不会直接作用于 DOM,而是先被翻译为 XPath,再交由底层 XPath 引擎求值。例如div.employee会被XPathVisitor转成等价的 XPath 表达式。因此":not()带复杂参数"、":has()"、"nth-of-type"等问题,本质上是CSS→XPath 翻译器对选择器语义覆盖不全。对应测试见 test/css/test_xpath_visitor.rb 与 test/css/test_css.rb。若你当前在使用这些伪类,需要在升级后回归验证结果。

另外,lib/nokogiri/xml/searchable.rb 中的search方法用正则LOOKS_LIKE_XPATH = %r{^(\./|/|\.\.|\.$)}启发式区分 CSS 与 XPath,文档已明确警告该启发式"将来可能变化",并建议明确调用css或xpath——这与路线图"改进查询解析"的意图一致。

6. DocumentFragment 的上下文搜索问题

路线图整理了四个 ticket(#213、#370、#454、#572),共同症状是:在 DocumentFragment 上执行搜索时,是否把上下文节点纳入搜索范围会导致结果不一致。路线图提出的修复设想是:让DocumentFragment成为NodeSet的子类。

当前实现中,DocumentFragment < Nokogiri::XML::Node(lib/nokogiri/xml/document_fragment.rb),它并没有继承NodeSet。其搜索行为直接透传给子节点:

def css(*args) if children.any? children.css(*args) # 'children' is a smell here else NodeSet.new(document) end end

源码注释里赫然写着# 'children' is a smell here,并且xpath干脆没有独立实现(注释# NOTE that we don't delegate #xpath to children ... another smell.)。search同样在内部按xpath/children.css两条路径分发。这些"smell"正是路线图所述"上下文节点参与搜索"行为不一致的代码级根源。

此外,DocumentFragment的构造支持context:参数——当传入上下文节点时,会调用context.parse让解析器"仿佛该节点是片段子树的父节点"并相对其解析命名空间(JRuby 下还会用namespace_declarations补齐根元素的命名空间声明,见注释中 issue #770 的修复)。若未来改为继承NodeSet,这类上下文语义需要重新设计。

7. XPath 与 NodeSet 的查询语法统一

7.1 自定义 XPath 函数处理器

路线图希望为自定义 XPath 函数提供"更好的语法"(PR #464)。当前机制要求用户自建处理器类,把实现挂在nokogiri命名空间下(lib/nokogiri/xml/searchable.rb):

handler = Class.new { def regex(node_set, regex) node_set.find_all { |node| node["some_attribute"] =~ /#{regex}/ } end }.new node.xpath('.//title[nokogiri:regex(., "\w+")]', handler) node.css('title:regex("\w+")', handler)

处理器类可以出现在参数列表的任意位置,由extract_params识别(params.find { |param| ![Hash, String, Symbol].include?(param.class) })。这套"鸭子类型"虽灵活,但不够显式——这正是路线图要改进的"语法"。

7.2 Node#xpath 与 NodeSet#xpath 的参数提取

路线图指出Node#xpath/NodeSet#xpath以及Node#{css,search}都用到Node#extract_params来解析位置参数,设想"统一成一份 Hash 选项"。

当前Searchable#extract_params的行为是:从参数尾部循环弹出 Hash/nil 作为命名空间绑定与变量绑定,且绑定顺序为ns, binds = hashes.reverse。例如:

node.xpath('.//address[@domestic=$value]', nil, { value: "Yes" })

命名空间与变量绑定都靠位置堆叠,调用约定较为隐晦。路线图的设想是改用关键字参数(如namespaces:、variables:、handler:),并顺带回答"NodeSet#xpath 到底该返回什么"(#656,当前返回NodeSet)。

JRuby 侧的变量绑定实现在 ext/java/nokogiri/NokogiriXPathVariableResolver.java,CRuby 侧在 ext/nokogiri/xml_xpath_context.c 的XPathContext中,Ruby 注册入口是 lib/nokogiri/xml/xpath_context.rb 的register_namespaces/register_variables。

8. 编码处理:EncodingReader 的现状与前景

路线图在"Encoding"一节直言:"我们对编码有一堆未解决的问题……需要一个懂编码的人来牵头",并设想把EncodingReader抽成一个"可注入的真实对象"。

其背景在 lib/nokogiri/html4/encoding_reader.rb 中写得很清楚:libxml2 的编码探测能力弱——既不识别 HTML5 风格的<meta charset>声明,即使探测到编码提示,也不会回头重新解码前面已乱码的部分。EncodingReader的目标就是在 libxml2 之外做更高级的编码探测,并在发现编码提示后模拟"回绕流",让 libxml2 从头重新解析。

其探测逻辑(EncodingReader.detect_encoding)覆盖两种提示:

  1. XML 声明:<?xml ... ?>中的 encoding,通过Nokogiri.XML小文档取.encoding;
  2. HTML<meta>标签:SAX 扫描<meta charset="...">或<meta http-equiv="Content-Type" content="...; charset=...">(SAXHandler类实现了对该两形态的提取;JRuby 下还直接对首块做正则匹配,或经JumpSAXHandler提前抛出:encoding_found信号)。

read方法配合原生层htmlReadIO工作:它预期首次read调用会拿到约 1KB 的首块数据用于探测;一旦发现编码,就抛出EncodingFound异常(并把首块缓存进@firstchunk),上层据此设置编码后重新从流开头解析。这个"先探测、后回放"的协作模式,正是路线图想把它变成可注入对象的动机——目前它被内嵌在Document#read_io的解析管线中,难以被用户替换或测试。

相关测试见 test/html4/test_document_encoding.rb,编码处理器的注册机制见 lib/nokogiri/encoding_handler.rb。

9. Reader 的固有问题

路线图对Nokogiri::XML::Reader的评价是"从根本上就坏了"——具体指:无法阻止用户以不安全的方式使用对象引用导致应用崩溃。Reader是面向流的只读遍历接口(见 lib/nokogiri/xml/reader.rb),它按节点推进并暴露当前节点的属性;由于底层直接持有原生解析状态,若在遍历过程中保存并跨迭代使用节点引用,原生对象可能已被释放,从而触发崩溃而非 Ruby 层的干净异常。

路线图并未给出修复方案细节,但从表述看,改造重点在于"让不安全的用法失败得安全"——例如在原生层增加生命周期保护、或禁止保存跨迭代引用。当前Reader的行为没有变化,使用时应遵循"仅在当前迭代内消费节点信息"的纪律。

10. 类方法需要 Document 的约定问题

路线图指出:Nokogiri::XML::Comment.new等类方法要求传入一个Document对象,这个约定"不直观、违背惯例",应改为在Document上提供实例方法(如new_comment)作为推荐写法。

当前仓库已部分落实这一设想。在 lib/nokogiri/xml/document.rb 中可以看到一组Document#create_*实例方法:

方法职责
create_element(name, *contents_or_attrs, &block)创建并返回一个元素节点
create_text_node(string, &block)创建文本节点
create_cdata(string, &block)创建 CDATA 节点
create_comment(string, &block)创建注释节点

这些方法由文档持有,天然满足"新节点必须归属于某文档"的不变式,比裸调Nokogiri::XML::Comment.new(document, ...)更安全、更符合直觉。路线图中的命名是new_comment,而当前仓库提供的是create_comment——可以推断,在后续演进中这类工厂方法会继续扩充(如create_processing_instruction等),并逐渐成为创建节点的推荐入口。

11.collect_namespaces的缺陷

路线图直指collect_namespaces"就是坏的":它返回一个 Hash,导致无法表达同前缀的多个命名空间(背景见 #885)。

当前实现位于 lib/nokogiri/xml/document.rb 的collect_namespaces,返回类型为Hash<String(Namespace#prefix) ⇒ String(Namespace#href)>。测试 test/xml/test_document.rb 中有test_collect_namespaces用例覆盖其基本行为。由于 Hash 键是前缀字符串,一旦文档中同名前缀绑定到不同 URI,后出现的条目就会覆盖先出现的——信息丢失不可避免。

路线图作者甚至调侃"这方法似乎没用,但反正我讨厌 XML,谁知道呢"。结论是:若你的代码依赖collect_namespaces处理复杂命名空间文档,应当改用Namespace节点遍历(如node.namespaces、namespace_scopes、namespace_definitions)来获得无信息丢失的完整视图。

12. ParseOptions 的彻底改造:从位掩码到语义化选项

这是路线图中讨论最深入的一项,值得展开。

12.1 现状:镜像 libxml2 的位掩码

当前ParseOptions(lib/nokogiri/xml/parse_options.rb)完整镜像了 libxml2 的解析选项位,再在 JRuby 上"追溯"映射到 Xerces-J/NekoHTML。核心位常量:

常量位值含义与安全提示
STRICT0严格解析(等价于"关闭 RECOVER")
RECOVER1<<0从输入错误中恢复
NOENT1<<1替换实体。⚠️ 名字与行为相反(是"启用"实体替换),解析不可信文档时设置它不安全
DTDLOAD1<<2加载外部子集,不可信文档不安全
DTDATTR1<<3默认 DTD 属性
DTDVALID1<<4用 DTD 校验
NOERROR1<<5抑制错误报告
NOWARNING1<<6抑制警告报告
PEDANTIC1<<7启用迂腐式错误报告
NOBLANKS1<<8移除空白节点
XINCLUDE1<<10执行 XInclude 替换
NONET1<<11禁止网络访问。解析不可信文档时关闭它不安全
NSCLEAN1<<13移除冗余命名空间声明
NOCDATA1<<14将 CDATA 合并为文本节点
NOXINCNODE1<<15不生成 XInclude START/END 节点
COMPACT1<<16压缩小文本节点;⚠️ 解析后禁止再修改 DOM
OLD101<<17按 XML 1.0 update 5 之前的版本解析
NOBASEFIX1<<18不修复 XInclude 的 xml:base URI
HUGE1<<19放宽解析器硬性限制。⚠️不可信文档不安全
BIG_LINES1<<22行号支持long int(默认short int)

另有四个"速记"组合常量:DEFAULT_XML(RECOVER|NONET|BIG_LINES)、DEFAULT_XSLT(额外含 NOENT/DTDLOAD/DTDATTR/NOCDATA,官方警告不要解析不可信 XSLT)、DEFAULT_HTML(含 NOERROR/NOWARNING)、DEFAULT_SCHEMA(仅 NONET|BIG_LINES)。

12.2 使用方式的三个痛点

路线图明确列举了现有 API 的"难用"之处,并全部能在源码中印证:

  1. 用块设置/取消选项很笨重。虽然支持块式写法(XML::parse(xml) { |o| o.strict }),但每次都要包一层 block。
  2. 拼位掩码常量很笨重。例如MY_PARSE_OPTIONS = ParseOptions::STRICT | ParseOptions::RECOVER | ...,长且易错。
  3. 命名危险。最典型的是NOENT——按名字理解是"不替换实体",实际行为是替换实体(libxml2的XML_PARSE_NOENT语义如此,官方在 parse_options.rb 的注释里特别标注"contrary to what the name implies"并加了盾牌警告)。这直接引用了 issue #1582 的讨论。

12.3 设想的改造方向

路线图提出的方向可归纳为三条:

  • 识别哪些选项在 libxml2 与 Xerces-J 两个解析器上都可用,避免"设了但没生效"的跨平台差异(官方文档也提示"并非所有解析选项在 JRuby 上受支持");
  • 让"可信/不可信文档"成为一组联动语义:解析不可信文档时,应把NONET、NOENT、DTDLOAD一起翻转——即"不可信文档"应同时关闭网络、实体替换与外子集加载;
  • 允许发明新的解析选项,例如 #1582 提出的"允许本地实体、但禁止外部实体"的安全中间态。

从当前ParseOptions的class_eval批量生成 setter/unsetter/查询方法(options.recover、options.norecover、options.recover?)可以看出,它的对象模型已经为语义化改造铺好了路——未来版本很可能会新增诸如"信任级别"这类高阶选项对象,把NOENT/DTDLOAD/NONET打包成可信/不可信两个预设,而保留noent、nonoent这类细粒度开关。

12.4 一个可复制的安全实践

在改造完成前,解析不可信文档建议显式收紧:

require "nokogiri" untrusted = File.read("untrusted.xml") doc = Nokogiri::XML::Document.parse(untrusted) do |options| options.nonet # 确保网络访问被禁止(默认已开启,显式重申) options.nonoent # 关闭实体替换(NOENT 默认关闭,显式重申) options.nodtdload # 关闭外部 DTD 加载 options.norecover # 可选:严格模式,让错误直接抛出 end

注意上述 setter 由ParseOptions的动态方法生成,每个都返回self便于链式调用。

13. 从路线图看 Nokogiri 的演进方法论

纵观整个 ROADMAP,可以提炼出 Nokogiri 在 API 演进上的几条方法论:

  1. 先立"反例"再动手:每项改造都绑定具体的 issue/PR(#530、#415、#679、#765、#528、#451、#656、#885、#1582……),保证改动有真实痛点支撑;
  2. 以文档安全为硬约束:NOENT、DTDLOAD、HUGE、NONET的安全警示贯穿 ParseOptions 设计,"可信/不可信文档"将成为未来选项分组的主轴;
  3. 跨平台对齐优先:反复强调 libxml2 与 Xerces-J(JRuby)的行为差异,选项、编码、命名空间处理都要在两个解析器间收敛;
  4. 惯例优于魔法:Document#create_*取代需要隐式传 Document 的类方法、Hash 选项取代位置参数提取、显式css/xpath取代启发式search,都指向"让接口更不容易用错"。

这些方向中,Document#create_*工厂方法已经落地(lib/nokogiri/xml/document.rb),ParseOptions的对象模型已具备语义化改造的骨架,其余各项则仍处于规划状态。若你在代码中大量依赖save_with位掩码、search启发式、collect_namespaces或Comment.new这类接口,建议密切关注上述 issue 与后续版本 changelog(CHANGELOG.md),以便平滑迁移。

  • 后端

【免费下载链接】nokogiri

Nokogiri (鋸) makes it easy and painless to work with XML and HTML from Ruby.

项目地址:https://gitcode.com/gh_mirrors/no/nokogiri
点击查看免费下载

相关推荐

上一篇:Microsoft Activation Scripts (MAS) 完整教程:免费激活 Windows 和 Office 的 3 步实操
下一篇:从零跑通 yet-another-anime-game-launcher:Mac 动漫游戏安装、更新与调参完整指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询