目录 · 第 15 / 16 章
ActorPart IV · 流式 Markdown 解析器

15极致 Pull 模式:扁平事件流,像 pulldown

单线程 pull 的一等 API(复用 Config、写穿 io.Writer、拉取式迭代器);把行内从 AST 树拍扁成开/闭令牌;块与行内交织成一条统一事件流。

mdparser/pull.go:PullParsermdparser/ast.go:Inlinemdparser/pull.go:Events

前四章把解析器织成了一台 Actor 流水线:DocActor 串行推进、InlineWorker 池并行、Assembler 重排序。那是为「多会话、慢消费者、局部故障隔离」的在线服务准备的形态。但本书从第 10 章起就立下一个立场——pull 优先,而不是 AST 全量扫描。这一章把这个立场贯彻到底:先给出一套不含任何 goroutine 的「极致单线程 pull」API,再把行内层最后一处 AST 树拍扁成事件流,最终让整台解析器——块级 + 行内——变成一条能像 pulldown-cmark 那样被逐事件拉取的线性流。

一、为什么是 pull,而不是一棵 AST 全量树

全量扫描 AST 的解析器有两个与 AI 流式场景正面冲突的代价。其一是时间:必须等整篇文档解析完、树建好,才能开始遍历产出——而流式的第一诉求恰恰是首字延迟。其二是空间:必须为整篇文档在内存里立一棵树,树的生命周期横跨「解析完」到「渲染完」——而一个迟钝的慢消费者会把这棵树连同它引用的所有子串在内存里钉住。

pull 把主动权交给调用方:产出是一条惰性事件流,调用方拉一个、处理一个、丢一个,任何时刻内存里只有「当前这一个事件」而非「整篇文档的树」。这不是本书的发明——你熟悉的两个世界早已如此:markdown-it 用扁平 token 流(它的架构文档把「不建 AST」当成一条 KISS 原则),pulldown-cmarkStart/End 事件迭代器。两者都不是「先建树,再遍历树」。

🔑 设计钥匙

本书的块级解析从第一行起就是 pull 的:feedLine 逐行吐出 BlockEvent,没有任何「文档树」。唯一的例外,是行内层——它一直是一棵带 Children 的小树。这一章的技术核心,就是把这最后一棵树也拍扁,让「pull 优先」从一句口号变成端到端的事实。

二、PullParser:极致单线程的一等 API

与 Actor 流水线相对,pull 模式没有 mailbox、没有调度、没有 goroutine——调用方驱动、同步、零架构开销。这正是批量转换 / CLI / 库交付的正确形态。它有三点「极致」,每一点都直指 AST 全量树的一个代价:

// PullParser 是一个可复用的单线程解析器:构建一次,解析多篇。
type PullParser struct {
cfg *Config
}
  • 复用 Config:规则表与对象池一次构建,解析千篇文档只付一次。ParseHTML 每次调用都重建 NewConfig,而长期服务应当持有一个 PullParser 反复用。
  • 写穿 io.Writer:Render 把 HTML 直接流进调用方的 Writer(如 http.ResponseWriter 或文件),不在中途攒一个「整篇文档大小」的字符串。
  • 拉取式事件迭代器:BlockEvents 惰性产出块级事件,调用方 range 驱动、可随时 break,不构建任何树。
// Render 把 src 的 HTML 直接流式写入 w——不构造整篇文档大小的中间字符串。
// w 被包一层 bufio.Writer(既满足内部 Writer 约束的 WriteString/WriteByte,又批量落盘/落网)。
func (p *PullParser) Render(w io.Writer, src string) error {
bw := bufio.NewWriter(w)
p.renderTo(bw, src)
return bw.Flush()
}

三种用法各取所需:

p := mdparser.NewPullParser() // 规则表 + 对象池只建一次,可复用、并发安全
p.Render(w, src) // ① 写穿:HTML 直接流进 http.ResponseWriter,不攒整篇字符串
html := p.HTML(src) // ② 便捷:要一整篇字符串时
for ev := range p.BlockEvents(src) { // ③ 拉取:自驱动、可随时 break,零文档树
if ev.Kind == mdparser.EvLeaf {
// 只处理你关心的块,拉到一半就能停
}
}

PullParser 可被多个 goroutine 并发使用:每次调用自带一份 BlockState,共享的 InlineParsersync.Pool(并发安全),HTMLRenderer 构造后只读。这一点很关键——一个 Web 服务里一个 PullParser 就能服务所有并发请求,而不必每请求重建。

三、把行内从树拍扁成开/闭令牌

这是这一章的心脏。旧的 Inline 是一棵小树,成对节点(强调、加粗、链接、扩展)把内容收在 Children 里:

// 旧:带 Children 的树
type Inline struct { Kind NodeKind; Text, Dest, Tag string; Children []Inline }

新的 Inline 是一枚扁平令牌。成对节点不再持有子节点,而是拆成两枚令牌:一枚 Close=false 的开、一枚 Close=true 的闭,二者之间的内容就是流里紧随其后的那些令牌:

// Inline 是行内层的一个【扁平事件/令牌】——与 pulldown-cmark 的 Start/End 事件、
// markdown-it 的 _open/_close token 同款,不再是拥有 Children 的树。
//
// - 成对节点(Emph / Strong / Link / Custom)由两枚令牌表达:Close=false 的开、
// Close=true 的闭,二者之间的内容就是流里紧随其后的那些令牌;
// - 叶子令牌(Text / CodeSpan)Close 恒为 false,自带内容。
//
// 这样行内也彻底 pull 化:线性产出、线性渲染,零树、零 Children 切片分配——
// 与块级的事件流一脉相承(见 PARSER-NOTES.md「为什么是 pull 而非 AST 全量树」)。
type Inline struct {
Kind NodeKind
Close bool // 成对节点:false=开令牌,true=闭令牌;叶子令牌恒 false
Text string // KindText / KindCodeSpan 的字面内容
Dest string // KindLink 开令牌的目标 URL
Tag string // KindCustom 开令牌的类型标识:渲染器据此查找对应渲染函数
}
// 新:扁平令牌,与 pulldown 的 Start/End、markdown-it 的 _open/_close 同款
type Inline struct { Kind NodeKind; Close bool; Text, Dest, Tag string }

同一段 ***both***,两种表示的差别一目了然:

flowchart LR
  subgraph tree["旧 · AST 树(嵌套 Children)"]
    direction TB
    E["Emph"] --> S["Strong"] --> T["Text: both"]
  end
  subgraph flat["新 · 扁平令牌流(开/闭配对)"]
    direction LR
    a["EmphOpen"] --> b["StrongOpen"] --> c["Text: both"] --> d["StrongClose"] --> e["EmphClose"]
  end
  tree -.拍扁.-> flat

行内表示:AST 树 → 扁平令牌流

关键在于强调消解算法怎么变。旧算法把配对区间的内容「拉进」一个新建的子节点;新算法原地括入——在区间两侧插入一枚开令牌、一枚闭令牌,中间内容原封不动:

// processEmphasis 第二遍:把待定分隔符配对成 Emph/Strong。
func processEmphasis(items []inlineItem) []inlineItem {
for {
matched := false
for ci := 0; ci < len(items); ci++ {
closer := items[ci].delim
if !closer.isDelim || !closer.canClose || closer.length == 0 {
continue
}
oi := -1
for k := ci - 1; k >= 0; k-- {
opener := items[k].delim
if opener.isDelim && opener.canOpen && opener.length > 0 && opener.char == closer.char {
oi = k
break
}
}
if oi < 0 {
continue
}
use := 1
kind := KindEmph
if items[oi].delim.length >= 2 && items[ci].delim.length >= 2 {
use = 2
kind = KindStrong
}
items[oi].delim.length -= use
items[ci].delim.length -= use
// 扁平化:不再把中间内容收进 Children,而是在其两侧【括入】一枚开令牌、一枚闭令牌。
// 中间 items[oi+1:ci] 原样保留(它们已是扁平令牌 / 更内层配对的结果)。
rebuilt := make([]inlineItem, 0, len(items)+2)
rebuilt = append(rebuilt, items[:oi]...)
if items[oi].delim.length > 0 { // 剩余分隔符留待后续配对或最终物化
rebuilt = append(rebuilt, items[oi])
}
rebuilt = append(rebuilt, inlineItem{tok: Inline{Kind: kind}}) // 开令牌
rebuilt = append(rebuilt, items[oi+1:ci]...)
rebuilt = append(rebuilt, inlineItem{tok: Inline{Kind: kind, Close: true}}) // 闭令牌
if items[ci].delim.length > 0 {
rebuilt = append(rebuilt, items[ci])
}
rebuilt = append(rebuilt, items[ci+1:]...)
items = rebuilt
matched = true
break
}
// … 省略 5 行;完整声明 L331–383,点击上方「浏览完整文件」

渲染于是退化成「线性走一遍」:遇开令牌写开标签,遇闭令牌写闭标签,再没有递归下降:

// RenderInlines 把行内 AST 写入 w,优先走覆盖表 / 自定义表,再落到内建渲染。
// RenderInlines 线性走过扁平令牌流:成对节点在开/闭令牌处分别写开/闭标签,无递归、无 Children。
// 覆盖表 / 自定义表按令牌回调,回调自行根据 tok.Close 决定写开标签还是闭标签(对齐 markdown-it 的
// renderer.rules 逐 token 语义)。
func (h *HTMLRenderer) RenderInlines(w Writer, toks []Inline) {
for _, t := range toks {
if fn, ok := h.overrides[t.Kind]; ok {
fn(w, t, h)
continue
}
switch t.Kind {
case KindText:
writeEscaped(w, t.Text)
case KindCodeSpan:
w.WriteString("<code>")
writeEscaped(w, t.Text)
w.WriteString("</code>")
case KindEmph:
w.WriteString(openClose(t.Close, "<em>", "</em>"))
case KindStrong:
w.WriteString(openClose(t.Close, "<strong>", "</strong>"))
case KindLink:
if t.Close {
w.WriteString("</a>")
} else {
w.WriteString(`<a href="`)
writeEscaped(w, t.Dest)
w.WriteString(`">`)
}
case KindCustom:
if fn, ok := h.custom[t.Tag]; ok {
fn(w, t, h)
} // 未注册:开/闭令牌不写标签,其间的内容令牌照常渲染
}
}
}

覆盖渲染与扩展的回调也随之变成逐令牌的:一次回调只处理一枚令牌,自己看 tok.Close 决定写开标签还是闭标签。第 14 章那个「给所有链接加 rel="nofollow"」的覆盖、以及删除线扩展,都改成了这个形状——回调里一个 if tok.Close 分叉,而不是一次性拿到整个子树。

四、统一事件流:整台解析器都变成 pull

块级事件本已是线性的(BlockEvent 流),行内令牌现在也线性了。把两者交织进同一条流,就得到 pulldown-cmark 的完整模型:一条 Enter / Leave / Text / Code 事件流,块与行内摊平在一起,零文档树,可随时 break

// PullEvent 是统一拉取事件流的一枚事件。块级与行内被摊平进同一条线性流:
// 容器/叶子/强调/链接都表现为 PullEnter … PullLeave 的配对,文本/行内代码是叶子事件。
// 这就是 pulldown-cmark 的模型——消费方线性驱动、零文档树、可随时 break。
type PullEvent struct {
Kind PullEventKind
Node NodeKind // Enter/Leave 时:哪种节点
Text string // PullText / PullCode 的内容;CodeBlock 的字面内容
Dest string // KindLink 的目标
Tag string // KindCustom 的标识
Info string // KindCodeBlock 的 info string
Level int // KindHeading 层级
Ordered bool // KindList 是否有序
Start int // KindList 起始编号
Tight bool // 紧凑列表项内的段落
}

Events 就是这条统一流的迭代器。它内部驱动块级状态机,每遇一个叶子块就调 emitLeaf 把它展开成行内事件——标题/段落展开其令牌流,代码块产出字面文本,分割线成对进出:

// Events 返回统一拉取事件流:块级 + 行内交织成一条线性 Enter/Leave/Text/Code 流。
// 叶子在此被展开为行内事件——真正的「端到端 pull」。调用方 `for ev := range p.Events(src)`
// 驱动,可随时 break,全程零文档树。
func (p *PullParser) Events(src string) iter.Seq[PullEvent] {
return func(yield func(PullEvent) bool) {
bp := newBlockState(p.cfg)
emit := func(events []BlockEvent) bool {
for i := range events {
ev := events[i]
switch ev.Kind {
case EvOpen:
if !yield(PullEvent{Kind: PullEnter, Node: ev.Container, Ordered: ev.Ordered, Start: ev.Start}) {
return false
}
case EvClose:
if !yield(PullEvent{Kind: PullLeave, Node: ev.Container, Ordered: ev.Ordered, Start: ev.Start}) {
return false
}
default: // EvLeaf
if !p.emitLeaf(ev.Leaf, yield) {
return false
}
}
}
return true
}
rest := src
for {
i := strings.IndexByte(rest, '\n')
if i < 0 {
break
}
if !emit(bp.feedLine(strings.TrimSuffix(rest[:i], "\r"))) {
return
}
rest = rest[i+1:]
}
if rest != "" {
if !emit(bp.feedLine(strings.TrimSuffix(rest, "\r"))) {
return
}
}
emit(bp.closeAll())
}
}

于是 ## Hi *there* 拉出来是这样一条流:

for ev := range p.Events("## Hi *there*") {
switch ev.Kind {
case mdparser.PullEnter: // 进入一个节点(容器 / 叶子 / 成对行内)
case mdparser.PullLeave: // 离开一个节点
case mdparser.PullText: // 一段字面文本
case mdparser.PullCode: // 一段行内代码
}
}
// Enter(Heading,Level:2) · Text("Hi ") · Enter(Emph) · Text("there") · Leave(Emph) · Leave(Heading)
flowchart LR
  src["源文本"] --> bp["块级状态机<br/>feedLine"]
  bp -->|"容器 开/闭"| stream["统一 PullEvent 流<br/>Enter · Leave · Text · Code"]
  bp -->|"叶子块"| leaf["emitLeaf<br/>展开行内令牌"]
  leaf --> stream
  stream --> consumer["调用方<br/>range,可随时 break"]

统一事件流:块级状态机 + 行内令牌交织成一条线性流

树形结构和「开/闭配平的事件流」在数学上是同构的——扁平流只是把「开了必须闭」这条不变量从结构保证降成了运行时性质。所以我们用一枚栈守住它:TestUnifiedPullEvents 遍历整条流,每遇 PullEnter 压栈、PullLeave 弹栈并断言栈顶匹配,最后断言栈空。良栈性一旦被测试钉死,扁平流就和树一样安全,却省掉了树。

五、拍扁换来了什么:一笔诚实的账

先说结论,免得你把它当成性能优化:拍扁行内在墙钟上几乎是中性的。

指标树(旧)扁平(新)变化
分配次数 allocs/op462412−11%
内存 B/op117 KB130 KB+11%
耗时~52 µs~52 µs持平

Children 切片没了,分配次数降了;但扁平流里成对节点变成两枚令牌,[]Inline 更长,字节数反倒略升。二者大致抵消。拍扁的价值不是速度,是架构一致性——整条链路 pull,再没有任何一处「先建树」。

那么 pull 基线从早先的 79 µs 一路降到 52 µs,靠的是什么?是另一批落在共享内核上的 profile 制导优化(见第 16 章的前后对比表),它们让 pull / 融合 / 扇出三条路径同时受益:

// InlineParser 是行内解析器:触发字符 → 规则,外加全局后处理链。无状态、可并发共享。
// statePool 复用 InlineState(含 items 暂存数组):一篇文档几十上百个叶子块共享少数几个
// scratch buffer,而不是每块新分配一份。sync.Pool 天然并发安全,fan-out 模式下多 worker 并发调用也无碍。
// triggers 是「哪些字节可能开启一个行内构造」的位图,用于无标记快速路径。
type InlineParser struct {
rules map[byte][]InlineRule
post []inlinePost
triggers [256]bool
statePool sync.Pool
}
  • 无标记快速路径:InlineParser 用一张 triggers [256]bool 位图,一段不含任何行内标记字符的纯文本直接产出一枚文本令牌,跳过分隔符栈与消解全流程——绝大多数散文段落走的就是这条路。
  • InlineState 池化 + []byte 文本缓冲:sync.Pool 复用行内解析的暂存状态,累积文本用一块可复用的 []byte 而非每次新建 strings.Builder
  • leafStore 复用:块级同一时刻只有一个打开的叶子块,于是复用同一个 openLeaf 而非每块一分配。

💡 小贴士

一条没走完的路:强调消解目前仍是「每配对一次、重建一遍 items 切片」,这正是扁平版字节数略升的来源(重建时把中间内容留在切片里而非收进子节点)。换成 CommonMark 经典的单遍双向链表 + 分隔符栈原地消解,就能同时干掉重建开销和这点字节回涨——留给读者作为练习。

六、与你熟悉的两个世界对齐

如果你写过 markdown-it 插件或用过 pulldown-cmark,这套模型不该有任何陌生感:

  • markdown-it 的 _open / _close token、pulldown-cmark 的 Start / End 事件,和这里的 Inline{Close}PullEvent{PullEnter/PullLeave} 是同一个东西——成对结构用两枚事件表达。
  • 唯一的分野在方向:pulldown 是消费者主动 pull(调用方拉下一个事件),而本书的 Actor 外壳是把事件 push 给下游 Actor。同一套纯逻辑内核,套上 PullParser 就是 pull 形态、套上 DocActor 就是 push 形态——这正是第 12 章「纯逻辑内核 + Actor 外壳」分层的红利:pull 与 push 只是内核的两种交付方式,而非两套解析器。

所以本章不是对前四章的否定,而是它的对偶:同一台解析器,既能作为库以极致单线程 pull 交付给不想继承你运行时的调用方,也能作为在线服务以 Actor 流水线交付给需要隔离与背压的多会话场景。下一章(也是最后一章)就用基准把这两种形态、连同增量与朴素重解析,放在同一把尺子上诚实地量一遍。

延伸阅读

  • pulldown-cmark:pull 迭代器与 Start/End 事件模型,以及它对 push 式回调接口的警告——本章的统一事件流正是照着它的形状收口的。
  • markdown-it — architecture.md:扁平 token 流与 _open/_close 配对,「拒绝 AST」的 KISS 论证是本章拍扁行内的直接思想来源。
  • CommonMark Spec — Appendix A:强调消解的双向链表 + 分隔符栈算法,即上文「没走完的那条路」的规范出处。

小结

  • pull 优先不是风格偏好,而是对 AI 流式场景两个硬约束的回应:AST 全量树既拖高首字延迟,又把整篇文档钉在内存里陪着慢消费者。
  • PullParser 是极致单线程 pull 的一等 API:复用 Config、写穿 io.Writer、拉取式 BlockEvents 迭代器,零 goroutine、零调度。
  • 行内层从带 Children 的树拍扁成开/闭令牌,processEmphasis 从「拉进子节点」改为「原地括入一对令牌」,渲染退化为线性走一遍;至此整台解析器再无一棵树。
  • Events 把块级与行内交织成一条统一的 PullEvent 流——Enter/Leave/Text/Code,与 pulldown-cmark 同构,良栈性由测试守卫。
  • 拍扁本身性能中性(分配少了、字节多了、耗时持平),它买到的是端到端的 pull 一致性;真正的提速来自共享内核上的快速路径、池化与复用。
源码

正在读取完整文件…