跳到主要内容

那 80% 才是 AngleSharp 存在的理由

原石
原石

· 阅读约 5 分钟

先把代码贴上。

dotnet add package AngleSharp

再是最小可用的一段。

var context = BrowsingContext.New(Configuration.Default);
var document = await context.OpenAsync(req => req.Content("<p>hi</p>"));
var p = document.QuerySelector("p");

拿到一个符合规范的 DOM。没有浏览器,没有 WebView,也没有 headless Chrome 那一坨。

这层 API 不是我想聊的。看文档五分钟就会写。

我想聊它里面那 80%。

Florian 在 2013 年飞 MVP Summit 的航班上,带了一份 HTML5 规范的打印稿,边飞边写解析器。落地的时候正常路径已经能跑,能吐出 DOM。然后他往下翻,发现正常路径大概只占 20%——剩下的 80% 全是错误处理和边缘情况。

这段我看了两遍。它把我对"写个 HTML 解析器"的直觉整个翻过来了。正常人的直觉是:标签配对着扫一遍,压个栈,拆个属性,一天能出来个能跑的东西,那 20% 就是全部工作量。不是。那 20% 是入场券,剩下 80% 才是这个库存在的理由。

这 80% 长什么样,我举个方向。人类写的 HTML 是坏的,这不是意外,是常态。从网上随便抓一页回来,里面会有没闭合的 <b>,会有嵌错的列表,会有表格里散落在外面的文字。HTML5 规范没说"这是坏输入,请拒绝",它极其精确地规定了解析器遇到这些情况时应该怎么把它修回来。

修回来。不是报错,也不是跳过。是修。

这件事的性质是:错误恢复规则不是一堆可以独立开关的选项,它是一台互相咬合的状态机。你改一处,旁边三处的行为跟着变。

所以我第一次翻 AngleSharp 的 API 面的时候,是在找它的"错误恢复策略"接口。没找到。一个都没有。

这就是对的。

如果让我来设计,第一版八成会写成这样:

// 一个"灵活"的解析器大概会这么开头
public interface IHtmlParsePolicy
{
    void OnUnexpectedEndTag(Tokenizer t, string name);
    void OnMisnestedElement(Stack<Element> open, Element current);
    void OnTextInTable(Tokenizer t, string text);
    // 通常还有三四十个这样的方法
}

每个方法都能替换,每个行为都能配置,recovery 逻辑从算法里抽出来变成回调。听起来可扩展。实际上等于把规范降格成一份日志——规范说的是一套算法,你把它拆成一串回调,就没法再验证它实现的到底是不是那套算法。

它的做法是把这些规则焊死在解析器里,对外只给配置,不给策略。这个区别值得记住:配置是让你选装什么,策略是让你改算法。前者可控,后者失控。

那 80% 到底有没有实现对,不看文档,看测试。核心包近 4000 个单元测试,CSS 包近 3000 个,还跑 WPT。WPT 是各家浏览器共同跑的那套,不是自己给自己出的卷子。一个库敢说自己符合 HTML5,唯一能证明的方式就是这个。

再说模块化。这个是 0.10 引入的转折点,也直接决定你装进来多少东西。

核心包只做解析和 DOM。要 CSS,单独装包,单独配:

var context = BrowsingContext.New(Configuration.Default.WithCss());

要跑页面里的 JS,装 AngleSharp.Js,底层是 Jint。XML 又是一个包。这几个全在核心包外面。

我特别喜欢这个切法。因为反过来的做法也很常见——核心包里塞进 CSS、塞进 JS、塞进 XML,然后给你一堆开关,WithEverything() 一开,一个包两兆。它没有。核心包只干它该干的事,剩下的是你自己决定要不要背进项目。你写个 HTML 清洗工具,根本不需要 CSS 引擎,那你就不用把它拉进来。

说到清洗——HtmlSanitizer 就是建在它上面的,干的是剔 XSS 载荷。这件事有个细节:清洗必须先把输入解析成真实 DOM,在 DOM 上删,再重新渲染,不能拿正则上。因为"看起来像标签的东西"和"解析器会当成标签的东西"是两回事,攻击面正好在后者。这就是为什么它需要一个真按规范实现了错误恢复的解析器当底座。光有一个"能读 HTML"的正则或者简易解析器,差得远。

对了,它有一个服务化的配置系统,一个轻量的依赖注入。我知道"依赖注入"这四个字听着像要你上框架。这里更像一张注册表,用来把 tokenizer、DOM 实现这类东西换掉。不是那种你要先写三个 module、再注册一个 async provider 的容器。够用就行。

再说个细节。

它是在 API 事实上稳定了很多年之后才发的 1.0。之后的原则是:破坏性变更必须足够有价值,才值得引入。

这个我在意。大部分库的 1.0 是个营销事件,版本号往前跳一格,稿子发一篇。少部分库的 1.0 是个承诺:往后我要动这个东西,得先证明我值得动。后一种不多。

说句题外话。我最近越来越觉得,判断一个库值不值得用,不用看它宣传什么,看它把多少复杂度关在了门后面。AngleSharp 的门后面关着 80%,它没打算让你看见,也没打算让你改。这个取舍是有意的——规范怎么修错,不该是使用者操心的事。

扯远了。

它现在没有布局引擎,不渲染像素,JS 也只到 Jint 那一档,跟 V8 不是一回事。这些是客观的。可这些功能一旦加进来,门后面那点东西就守不住了。做无头渲染是下一步的事,它也没假装现在就有。

原石
原石

把代码当文章写的系统工程师,以源码立论、单线程式拒绝复杂度。

查看主页 →