- 后端
【免费下载链接】nokogiri
Nokogiri (鋸) makes it easy and painless to work with XML and HTML from Ruby.
本文基于 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位掩码决定:
| 常量 | 值 | 含义 |
|---|---|---|
FORMAT | 1 | 序列化时格式化输出(缩进) |
NO_DECLARATION | 2 | 不输出 XML 声明 |
NO_EMPTY_TAGS | 4 | 不输出空标签(如<br/>) |
NO_XHTML | 8 | 不按 XHTML 序列化 |
AS_XHTML | 16 | 按 XHTML 序列化 |
AS_XML | 32 | 按 XML 序列化 |
AS_HTML | 64 | 按 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)覆盖两种提示:
- XML 声明:
<?xml ... ?>中的 encoding,通过Nokogiri.XML小文档取.encoding; - 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。核心位常量:
| 常量 | 位值 | 含义与安全提示 |
|---|---|---|
STRICT | 0 | 严格解析(等价于"关闭 RECOVER") |
RECOVER | 1<<0 | 从输入错误中恢复 |
NOENT | 1<<1 | 替换实体。⚠️ 名字与行为相反(是"启用"实体替换),解析不可信文档时设置它不安全 |
DTDLOAD | 1<<2 | 加载外部子集,不可信文档不安全 |
DTDATTR | 1<<3 | 默认 DTD 属性 |
DTDVALID | 1<<4 | 用 DTD 校验 |
NOERROR | 1<<5 | 抑制错误报告 |
NOWARNING | 1<<6 | 抑制警告报告 |
PEDANTIC | 1<<7 | 启用迂腐式错误报告 |
NOBLANKS | 1<<8 | 移除空白节点 |
XINCLUDE | 1<<10 | 执行 XInclude 替换 |
NONET | 1<<11 | 禁止网络访问。解析不可信文档时关闭它不安全 |
NSCLEAN | 1<<13 | 移除冗余命名空间声明 |
NOCDATA | 1<<14 | 将 CDATA 合并为文本节点 |
NOXINCNODE | 1<<15 | 不生成 XInclude START/END 节点 |
COMPACT | 1<<16 | 压缩小文本节点;⚠️ 解析后禁止再修改 DOM |
OLD10 | 1<<17 | 按 XML 1.0 update 5 之前的版本解析 |
NOBASEFIX | 1<<18 | 不修复 XInclude 的 xml:base URI |
HUGE | 1<<19 | 放宽解析器硬性限制。⚠️不可信文档不安全 |
BIG_LINES | 1<<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 的"难用"之处,并全部能在源码中印证:
- 用块设置/取消选项很笨重。虽然支持块式写法(
XML::parse(xml) { |o| o.strict }),但每次都要包一层 block。 - 拼位掩码常量很笨重。例如
MY_PARSE_OPTIONS = ParseOptions::STRICT | ParseOptions::RECOVER | ...,长且易错。 - 命名危险。最典型的是
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 演进上的几条方法论:
- 先立"反例"再动手:每项改造都绑定具体的 issue/PR(#530、#415、#679、#765、#528、#451、#656、#885、#1582……),保证改动有真实痛点支撑;
- 以文档安全为硬约束:
NOENT、DTDLOAD、HUGE、NONET的安全警示贯穿 ParseOptions 设计,"可信/不可信文档"将成为未来选项分组的主轴; - 跨平台对齐优先:反复强调 libxml2 与 Xerces-J(JRuby)的行为差异,选项、编码、命名空间处理都要在两个解析器间收敛;
- 惯例优于魔法:
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.
相关推荐
image_picker_for_web 演进全解析:Flutter 官方 Web 端图片选择插件的架构、限制与版本路线图
image_picker_for_web 演进全解析:Flutter 官方 Web 端图片选择插件的架构、限制与版本路线图 本篇技术指南以 Flutter 官方
跨平台移动开发UI组件开发工具为什么选择haloregnetz_b.ra3_in1k:与其他图像分类模型的全面对比分析
为什么选择haloregnetz_b.ra3_in1k:与其他图像分类模型的全面对比分析 在当今深度学习领域,图像分类模型的 选择 变得至关重要。 halore
HackMyResume 开发路线图解读:从 1.7 到 2.0 的技术演进蓝图
HackMyResume 开发路线图解读:从 1.7 到 2.0 的技术演进蓝图 这篇技术指南以 HackMyResume 仓库的 ROADMAP.md htt
CLI开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考