14生产化返工:Renderer 接口与插件机制
从字符串拼接到 io.Writer(基线快 3.5×);从硬编码 switch 到规则注册表(ruler);删除线扩展为证。
mdparser/render.go:writeEscapedmdparser/config.go:Configmdparser/ext_gfm.go:Strikethrough上一章证明了增量渲染、重排序、自愈、背压这四大机制都立住了,解析器已经能干活。但教学玩具与生产可用之间,除了正确性还差一层架构可维护性——最初版本里有两处设计,一旦拿去让别人接手维护,立刻就会露馅。这一章就是把这两处返工成生产可用的形状,再用一个真实的第三方风格扩展证明”插件机制”这四个字不是写在注释里哄人的。
渲染层:从字符串拼接到可插拔的 Renderer
最初的 renderLeafHTML 长这样:
// 反面教材return "<" + tag + ">" + escapeHTML(parseInlines(leaf.Content)) + "</" + tag + ">\n"这一行代码背着三宗罪。手写转义是 XSS 与实体 bug 的温床——很容易把转义顺序搞反(该先转 & 再转其它字符,顺序反了就会把已经转义出来的实体二次转义),或者忘记属性值里的 " 也要转,而且一旦某天发现了 bug,没有一个统一的地方去改。输出格式被焊死成 HTML:"<" + tag + ">" 这种拼接方式没有留任何缝隙给 AST-JSON 渲染器、纯文本渲染器或调试渲染器插进来,想要不同的输出只能去 fork 这个函数。每一次片段拼接都在分配:Go 的字符串不可变,a + b + c 会先造出若干个用完即弃的中间字符串,原始代码还在每次调用里都新建一个 strings.NewReplacer,就为了转义寥寥几个字符。
返工之后,Renderer 变成一个接口,解析与序列化彻底解耦——解析器只产出 []Inline 与 BlockEvent,渲染器负责把它们写出去。接口本身刻意做得很小,只有两个方法,切分方式和解析器内部本就存在的那条缝对齐:
// Renderer 把解析后的结构序列化为某种输出格式。//// RenderLeaf 收到一个已闭合叶子块及其【已解析好的行内节点】(代码块/分割线的// inlines 为 nil);RenderContainer 写出容器块的开/闭标签。两者都写入 w。type Renderer interface { RenderLeaf(w Writer, leaf Leaf, inlines []Inline) RenderContainer(w Writer, ev BlockEvent)}RenderLeaf 收到一个已闭合的叶子块及其已经解析好的行内节点(代码块与分割线没有行内内容,传的是 nil);RenderContainer 只从一个 BlockEvent 写出容器块(引用块、列表、列表项)的开/闭标签。这两者分开是有实打实理由的——它们跑在前几章讲过的 Actor 分工两侧:叶子块的 HTML 由 InlineWorker 拼出,容器块的由 DocActor 自己拼出;一个只会吐出”整篇文档一坨 markup”的渲染器,没法被这两处同时安全调用。想要完全不同的输出格式?实现一个 Renderer,用 SetRenderer 换上就是,流水线里其余代码毫不知情。
// HTMLRenderer 是默认的 HTML 渲染器,输出对齐 CommonMark 参考实现的实体选择。//// 它持有一张分派表:overrides 覆盖内建节点类型的渲染,custom 按 Tag 渲染扩展节点。// 扩展通过 OverrideKind / RegisterCustom 改写或新增渲染,而不必 fork 本包。type HTMLRenderer struct { overrides map[NodeKind]InlineRenderFunc custom map[string]InlineRenderFunc}所有输出一律写入 Writer——不是裸的 io.Writer,而是 io.Writer + io.StringWriter + io.ByteWriter 组成的一个小组合约束。多出来的两个方法不是摆设:io.Writer.Write 收的是 []byte,把字符串字面量写进去得先做一次 string→[]byte 的转换(某些逃逸路径下还得拷贝一次);WriteString、WriteByte 直接绕开这一步。本包实际用到的两个 sink——*strings.Builder(Actor 流水线里 LeafHTML/ContainerHTML 便捷方法用的)和 *bufio.Writer(批量输出用的)——原生就满足这三个方法,约束在调用点不花一分钱,还省掉每次写入的一次转换。
🔑 设计钥匙
“不手写转义”的终点,就是直接用标准库
html.EscapeString——这是安全攸关的代码,交给 Go 团队维护,而不是自己养一张转义表。它转义& ' < > "五个字符("→"、'→'),与 CommonMark 参考实现 cmark 的实体选择略有出入(cmark 用"、且不转'),但两者在浏览器里渲染完全等价。优先用标准库,并把黄金文件改成对齐html.EscapeString的输出——正确性要对齐的是”浏览器怎么渲染”,而不是某个库的实体偏好。而真正要避免的”每拼一段就分配一个新字符串”,html.EscapeString也满足:它在无需转义时原样返回入参,常见的干净正文零额外分配。
// writeEscaped 用标准库 html.EscapeString 转义后写入 w。// EscapeString 在无需转义时原样返回,故干净文本零额外分配。func writeEscaped(w Writer, s string) { w.WriteString(html.EscapeString(s))}落到代码里就是一行:writeEscaped 只是 w.WriteString(html.EscapeString(s)) 的一层集中封装。html.EscapeString 先扫一遍字符串,没有任何需要转义的字符就原样返回入参(零分配)——这正是绝大多数正文的情形;只有夹杂 <、&、引号的文本,才分配一份转义后的副本。渲染层仍然一律写入 Writer(见上),所以”输出格式可插拔 + 流式写出”的性质丝毫不受影响——变的只是”谁来做转义”:从本包自己维护,交回给标准库。
这不是纯粹的洁癖,是可以量出来的性能。仅”拼接改写 Writer”这一处改动,单文档批量基线就从 358 µs 降到 101 µs(3.5×),内存分配从 5764 次降到 2562 次(少 55%),分配的字节数少了约 13 倍。数字背后是同一个机制:+ 拼接每次都新分配一段底层数组、立刻扔掉旧的;写 Writer 则是往一个按倍增摊还增长的缓冲区里追加——写下同样多的字节,分配器要做的工作少了一个数量级,GC 要扫的垃圾也少了一个数量级。
渲染器内部还多了一张按节点类型/标签分派的渲染表,对齐 markdown-it 的 renderer.rules:overrides 是 map[NodeKind]InlineRenderFunc,custom 是 map[string]InlineRenderFunc——如果你更熟悉 TypeScript,可以把它们类比成两张 Record<NodeKind, RenderFunc> 形状的表。OverrideKind 在不碰 RenderInlines 里那个 switch 的前提下改写内建节点的渲染方式——比如从包外部给所有链接的属性加上 rel="nofollow",或者改写相对图片路径。RegisterCustom 对 KindCustom 节点做同样的事,按它的 Tag 查表,这正是下面删除线扩展要用的钩子。两张表都在内建 switch 之前被查询,一旦命中,覆盖必定生效——这张表不是兜底路径,而是主分派机制,switch 反倒是”没人认领这个槽位”时才会跑到的分支。
解析层:从硬编码 switch 到规则注册表
第二处返工在解析侧。最初的 continueLine 是一个硬编码的 switch:每加一种块级语法,都得回去改这个核心函数。练习题让读者”给解析器加一种语法”,却没有给出加的地方——这不是练习设计得偷懒,是一个真实的架构缺陷:语法和核心逻辑焊在了一起。
返工对齐 markdown-it 的 ruler 思路:把解析拆成三张可注册、可替换、可扩展的规则表,每张表都有自己一个很窄的接口。
| 注册表 | 接口 | 内建规则 | 触发时机 |
|---|---|---|---|
| 容器规则 | ContainerRule | 引用块、列表 | 打开容器块 |
| 叶子规则 | LeafRule | 空行、分割线、ATX 标题、围栏代码(段落兜底) | 一行归类 |
| 行内规则 | InlineRule | 转义、行内代码、链接、强调 | 按触发字符 |
三张表里,行内表是真正要跑得快的那张,因为行内解析在每个段落、每个标题的每一个字符上都要跑一遍。InlineParser 用”按触发字节索引规则”而不是”依次尝试每条规则”来保证这一点便宜:
// 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}rules 是一张 map[byte][]InlineRule——扫描器停在 ` 上时,只会尝试注册在这个字节上的寥寥几条规则,不会挨个试遍所有规则;一篇通篇没有 [ 的文档,压根不会为 linkRule.Match 付出任何代价。每条规则都遵守同一个契约:Match 从 InlineState 的当前位置看起,要么推进游标并返回 true,要么原封不动地返回 false,让这个字节落回默认的字面文本路径。“不匹配就原样返回 false”这一条约定,正是这张注册表敢被随便扩展的原因——一条新规则永远不可能因为”认领了一个它其实不认识的语法的字节”而破坏既有文本。逐字节扫描之后,还有一条很短的 inlinePost 链跑全局的后处理;目前只有一步,emphasisPost,负责把 */_ 的分隔符运行配对成 Emph/Strong 节点——这活儿得看到整行才能判定,没法逐字节决定,所以刻意没有建模成一条 InlineRule。
Extension 把”一组规则 + 渲染改写”打包成一个可复用插件。NewConfig() 注册好标准规则;NewConfig(exts...) 按传入顺序在标准规则之上依次叠加扩展:
// Config 持有一整套解析规则与渲染器。它是不可变使用的:构造后在多个 goroutine// 间共享只读(Actor 流水线里所有 worker 共用同一个 Config)。规则本身无状态,// 每次解析的可变状态都在 BlockState / InlineState 里。type Config struct { containerRules []ContainerRule leafRules []LeafRule paragraph LeafRule // 兜底叶子规则,永远最后尝试 inline *InlineParser renderer Renderer}// NewConfig 构造一个注册了标准规则的配置,并依次应用扩展。func NewConfig(exts ...Extension) *Config { c := &Config{ renderer: NewHTMLRenderer(), paragraph: paragraphRule{}, inline: &InlineParser{ rules: make(map[byte][]InlineRule), }, }
// 容器:引用块、列表。 c.AddContainerRule(blockquoteRule{}) c.AddContainerRule(listRule{})
// 叶子:空行、分割线、ATX 标题、围栏代码(段落是 c.paragraph 兜底)。 c.AddLeafRule(blankRule{}) c.AddLeafRule(thematicBreakRule{}) c.AddLeafRule(atxHeadingRule{}) c.AddLeafRule(fenceRule{})
// 行内:转义、行内代码、链接、强调分隔符 + 强调配对后处理。 c.AddInlineRule(escapeRule{}) c.AddInlineRule(codeSpanRule{}) c.AddInlineRule(linkRule{}) c.AddInlineRule(emphasisRule{}) c.inline.post = []inlinePost{emphasisPost{}}
for _, e := range exts { e.Extend(c) } return c}Config 本身是不可变使用的——NewConfig 返回之后,包里再没有任何代码会去改它的规则切片或渲染器的分派表,所以可以在多个 goroutine 间只读共享,不需要加锁。这在具体位置上是实打实的:Actor 流水线里每一个 InlineWorker 持有的都是同一个 *Config 指针,不是各拷贝一份;规则对象本身(strikeRule{}、emphasisRule{} 等)也不带任何字段——一次解析真正的可变数据全都收在各个 goroutine 自己新建的 BlockState / InlineState 里。如果规则自己带可变状态,要么每个 worker 各配一份 Config,要么每次 Match 都得加锁;规则保持无状态,才让 NewConfig 能在启动时调用一次,整个进程生命周期里复用。
graph LR container["ContainerRule<br/>引用块 · 列表"] --> config["Config<br/>不可变、多 goroutine 只读共享"] leaf["LeafRule<br/>标题 · 围栏代码 · 分割线"] --> config inline["InlineRule<br/>转义 · 行内代码 · 链接 · 强调"] --> config ext["Extension<br/>如 Strikethrough()"] -.AddInlineRule.-> inline config --> renderer["Renderer 接口<br/>默认 HTMLRenderer"] ext -.RegisterCustom.-> renderer renderer --> writer["Writer<br/>*strings.Builder / *bufio.Writer"]
规则注册表如何汇入 Config 与 Renderer
插件是真的:用一条删除线证明
光有注册表还不够,得证明它真能被外部代码用起来,而不是只有包内代码能调用。ext_gfm.go 里的删除线扩展就是这个证明——只用公开 API,给解析器加上 GFM 的 ~~text~~ → <del>,完全不碰本包其余任何文件:
func Strikethrough() Extension { return ExtensionFunc(func(c *Config) { c.AddInlineRule(strikeRule{}) // 解析侧:新增一条按 '~' 触发的行内规则 if h, ok := c.Renderer().(*HTMLRenderer); ok { // 渲染侧:为它的开放节点注册输出 h.RegisterCustom("del", func(w Writer, n Inline, r *HTMLRenderer) { w.WriteString("<del>"); r.RenderInlines(w, n.Children); w.WriteString("</del>") }) } })}// Strikethrough 返回一个启用 GFM 删除线(~~text~~)的扩展。func Strikethrough() Extension { return ExtensionFunc(func(c *Config) { c.AddInlineRule(strikeRule{}) if h, ok := c.Renderer().(*HTMLRenderer); ok { // 逐令牌:开令牌写 <del>,闭令牌写 </del>;其间的内容令牌由主循环照常渲染。 h.RegisterCustom("del", func(w Writer, tok Inline, r *HTMLRenderer) { if tok.Close { w.WriteString("</del>") } else { w.WriteString("<del>") } }) } })}strikeRule.Match 是”不匹配就原样返回 false”这条约定真的在发挥作用的一个小例子。它先看是不是 ~~ 前缀,再用 strings.Index 找下一个 ~~;找到了,就对两个分隔符之间的文本调用 s.Parse(inner)——这是一次递归调用,回到同一个 InlineParser 里,所以 ~~a *b* c~~ 能正确地在删除线的子节点里嵌一个 Emph,而不是把内部文本当成不透明字符串直接搬过去。如果找不到闭合的 ~~,Match 返回 false,对状态什么都不做——不产出半成品节点,不消费任何字节——两个前导波浪线就落回 InlineParser.Parse 里”没有规则命中,按字面文本处理”的默认路径,和一个不闭合的 ` 或 [ 待遇完全一样。把这个退回路径做对,是注册表里每一条规则都必须遵守的契约,也恰恰是最容易做错的地方——一条规则如果在判定”能不能匹配”之前就先消费了字节,会悄悄搞坏任何在它不认识的语境里出现了触发字符的文档。
💡 小贴士
如果你要写自己的
InlineRule:先想清楚失败路径,再想成功路径。Match只有在完全确定能匹配之后,才可以去改InlineState——先用前瞻(strings.Index、一次有界扫描)探路,确认真的有闭合之后,才调用s.Emit/s.Advance。内建的每一条规则(escapeRule、codeSpanRule、linkRule、emphasisRule)都按同样的顺序写,原因一样:早一点、廉价地return false,才护得住那些和它无关的文本。
strikeRule 产出的是 Inline{Kind: KindCustom, Tag: "del"}——一个开放节点类型。与其让 NodeKind 的枚举每来一个扩展就多一个常量(那意味着每个第三方语法都要改本包源码,完全违背初衷),KindCustom 加一个字符串 Tag,能让数量不限的扩展共存:枚举本身保持封闭,增长的只是 RegisterCustom 那张表。渲染这一侧,如果没人认领某个 Tag,RenderInlines 会退化成”直接渲染它的子节点”——所以一个只加了语法、忘了注册渲染函数的扩展,退化效果是”打印出内部文本”,而不是悄悄丢内容或直接崩溃。用法对称地穿过批量与流式两条路径:
mdparser.ParseHTML(src, mdparser.Strikethrough()) // 批量mdparser.New(sys, "doc", mdparser.WithExtensions(mdparser.Strikethrough())) // Actor 流式TestExtensionStreamsThroughActors 验证的是扩展确实穿过了整条 Actor 流水线——InlineWorker 池拿到的是同一份带扩展规则的 Config,而不只是批量路径侥幸能用;这个区分很要紧,因为”批量模式测试全过,一流式跑就悄悄丢删除线输出”正是这类 bug 最典型的样子。TestRendererOverride 验证渲染同样可以被覆盖而不动核心。加语法、改输出,现在都不再需要碰 continueLine 或 renderLeafHTML 这两处曾经的焊死点。
延伸阅读
这一章里的 ruler 说法——一组可注册、可替换、(在 markdown-it 里)运行期可开关的命名规则链——直接借自 markdown-it 的架构文档。markdown-it 把解析拆成三条规则链(core、block、inline),背后是同一个 Ruler 原语,任何一条规则都能按名字单独启用或禁用,不需要 fork 整个库。本包借用了它的形状——容器/叶子/行内三张表,加一个打包用的 Extension 类型——但也得诚实说清楚舍弃了什么:没有运行期的 ruler.disable("emphasis"),没有规则优先级或 before/after 插入,也没有一条独立于叶子分类的”块打开阶段”规则链。这些是真实的缺口,不是碰巧没做的疏漏;把它们补上,正是留给下一章的练习之一。
小结
- 渲染层从字符串拼接改成面向
Renderer接口 +Writer输出:RenderLeaf/RenderContainer沿 Actor 分工的同一条缝切开,writeEscaped用集中、经测试、按”运行”计费的转义代替手写拼接,单文档基线快 3.5×,分配少 55%。 - 解析层从硬编码
switch改成容器/叶子/行内三张规则表(ruler),InlineParser按触发字节索引规则做到近似 O(1) 分派;Extension把”新语法 + 新渲染”打包成可插拔单元,Config不可变、可在多 goroutine 间安全共享。 Strikethrough()只用AddInlineRule与RegisterCustom两个公开 API,就在批量与 Actor 流式两条路径上都跑通了 GFM 删除线;KindCustom让节点枚举保持封闭却对扩展开放——插件机制不是文档里的承诺,是能被测试挂钩验证的事实。- 下一章把这一路的收益摊开算账:把”增量渲染带来的收益”和”Actor 架构本身的税”分开计量,给出诚实的边界判断与可验收的练习。