Parser先把Markdown吞进去变成文档树
写代码处理Markdown最麻烦的事情就是自己写解析器。你得处理各种符号,还得想着转义,脑子不够用。CommonMark的Parser类直接帮你搞定这活,它把文本拆成一棵树,每个节点代表一个元素。
先看最基础的用法,把Markdown转成HTML。写个方法叫markDownToHtml,里头先建个Parser对象,然后调用parse方法把字符串变成Node。Node就是文档树上的节点,啥标题、段落、加粗都是Node。
public static String markDownToHtml(String markdown) {
Parser parser = Parser.builder().build();
Node document = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder().build();
return renderer.render(document);
}
接着再建一个HtmlRenderer,调用render方法把Node变成HTML字符串。整个过程不到五秒钟就写完了。你看这个例子,"Welcome to *Baeldung*"传进去,出来的是带em标签的HTML,星号自动变成斜体。
单元测试跑一下就能看到效果。assertEquals一对比,发现输出的HTML跟预期一模一样,末尾还带个换行符。这就说明解析器干活利索,没偷懒。
你可能会问,这Parser和HtmlRenderer是不是每次都得新建。官方建议用builder模式创建,这样以后加扩展啥的方便。builder().build()连着写,代码看起来很清爽。
访问者模式溜进文档树里数单词
解析完文档之后你可能会想,能不能从这棵树里挖点信息出来。比如统计一篇文章有多少个单词,或者找出所有标题。CommonMark提供了AbstractVisitor类,让你随便遍历这棵树。
写个WordCountVisitor继承AbstractVisitor,里面放个计数器。重点在visit方法,每次碰到Text节点就干活。Text是叶子节点,存着实际的文字内容。调用getLiteral拿到字符串,用正则切分出单词个数。
class WordCountVisitor extends AbstractVisitor {
int wordCount = 0;
@Override
public void visit(Text text) {
wordCount += text.getLiteral().split("\\w+").length;
visitChildren(text);
}
}
注意visitChildren这个调用,它负责继续往下走。如果你忘了调,子节点就全被跳过了。这种设计挺合理,每个访问者只管自己关心的节点,别的交给父类处理。
在processParsedNode方法里,建好Parser解析Markdown,拿到Node后创建访问者实例。调用node.accept(visitor)启动遍历,访问者会把所有Text节点都走一遍。最后返回计数器,得到的就是总单词数。
public static int processParsedNode(String markdown) {
Parser parser = Parser.builder().build();
Node node = parser.parse(markdown);
WordCountVisitor visitor = new WordCountVisitor();
node.accept(visitor);
return visitor.wordCount;
}
测试的时候传"Welcome to *Baeldung*",虽然Baeldung带星号,但Text节点存的是去掉格式后的纯文本,所以数出来正好三个单词。这个功能对于做文档分析非常有用。
HtmlRenderer顺手把HTML打回原形变成Markdown
有时候你得反过来干活,手里拿着HTML结构,想生成Markdown格式。CommonMark的MarkdownRenderer就是干这个的,它能把文档对象树渲染成带井号和星号的文本。
看htmlToMarkDown这个方法,先new一个Heading对象,setLevel设成2。这个2代表二级标题,就是两个井号。然后新建Text节点,把标题文字塞进去。再把Heading挂到Document下面。
public static String htmlToMarkDown(String htmlHeading) {
Heading heading = new Heading();
heading.setLevel(2);
heading.appendChild(new Text(htmlHeading));
Document document = new Document();
document.appendChild(heading);
MarkdownRenderer renderer = MarkdownRenderer.builder()
.build();
return renderer.render(document);
}
Document是整棵树的根,所有节点都得挂到它下头。最后用MarkdownRenderer.builder().build()建个渲染器,render一下就把整个文档变成Markdown字符串了。
测试的时候传入"Java Tutorial",输出是"Java Tutorial\n"。井号数量和setLevel保持一致,数字越大井号越少。这个转换过程不需要你操心空格和换行,渲染器自动处理格式规范。
你还可以用这个功能做更多事情,比如把富文本编辑器里生成的结构转成Markdown。或者从数据库里取出HTML片段,统一转成Markdown存到文件里。整个双向转换的链路算是打通了。
AttributeProvider给图片标签偷偷加上CSS类
HTML渲染出来的结果有时候不够灵活,你想给img标签加个边框或者圆角。CommonMark的AttributeProvider接口让你在渲染过程中插手改属性。
写个ImageAttributeProvider实现AttributeProvider,在setAttributes方法里头干活。参数里有Node、tagName和attributes这个Map。先判断当前节点是不是Image类型,是的话就往Map里put一个class属性。
public class ImageAttributeProvider implements AttributeProvider {
@Override
public void setAttributes(Node node, String tagName, Map attributes) {
if (node instanceof Image) {
attributes.put("class", "border");
}
}
}
这个判断很关键,如果不加判断,所有节点都会被加上属性。到时候p标签和h1标签都挂个class,页面样式就乱套了。所以必须用instanceof卡一下。
在changingHtmlAttribute方法里,建HtmlRenderer的时候调用attributeProviderFactory。里面用lambda表达式返回你的ImageAttributeProvider实例。这样每个节点渲染之前都会路过你的setAttributes方法。
public static String changingHtmlAttribute(String source) {
Parser parser = Parser.builder()
.build();
Node node = parser.parse(source);
HtmlRenderer renderer = HtmlRenderer.builder()
.attributeProviderFactory(context -> new ImageAttributeProvider())
.build();
return renderer.render(node);
}
测试代码传"!text",这是Markdown的图片语法。渲染出来的HTML里,img标签多了个class="border"。截图或者图标都能用上这个特性,不用手工改每个图片标签。
AttributeProvider不只改class,你想加data-*属性也行,想改src也行。只要能从Map里拿到key,随便你怎么折腾。这个接口给了你很大的操作空间。
NodeRenderer接管缩进代码块的渲染大权
AttributeProvider只能改属性,改不了标签结构。如果你想给代码块外面包个div或者加个行号,就得用NodeRenderer接口。这个接口让你完全接管某个类型节点的渲染逻辑。
写个IndentedCodeBlockNodeRenderer实现NodeRenderer。构造函数接收HtmlNodeRendererContext,里面藏着HtmlWriter。getNodeTypes方法返回一个Set,告诉框架你管哪些节点。
public class IndentedCodeBlockNodeRenderer implements NodeRenderer {
private final HtmlWriter html;
public IndentedCodeBlockNodeRenderer(HtmlNodeRendererContext context) {
this.html = context.getWriter();
}
@Override
public Set> getNodeTypes() {
return Set.of(IndentedCodeBlock.class);
}
@Override
public void render(Node node) {
IndentedCodeBlock codeBlock = (IndentedCodeBlock) node;
html.line();
html.tag("pre");
html.text(codeBlock.getLiteral());
html.tag("/pre");
html.line();
}
}
render方法是重头戏,参数是Node。先强转成IndentedCodeBlock,然后调用html.line()换行。接着html.tag("pre")开始写开始标签,html.text写代码内容,最后html.tag("/pre")闭合。
注意这个顺序不能乱,先开始标签再内容再结束标签。HtmlWriter帮你处理转义,不用担心代码里的尖括号把页面搞乱。写完记得调line方法换行,这样生成的HTML源码比较整洁。
在customizingHtmlRendering方法里,建HtmlRenderer的时候调用nodeRendererFactory。这里用方法引用IndentedCodeBlockNodeRenderer::new,框架会按需创建渲染器实例。
public static String customizingHtmlRendering(String source) {
Parser parser = Parser.builder()
.build();
Node node = parser.parse(source);
HtmlRenderer renderer = HtmlRenderer.builder()
.nodeRendererFactory(IndentedCodeBlockNodeRenderer::new)
.build();
return renderer.render(node);
}
测试的时候传"Example:\n\n code",注意code前面有四个空格,这是Markdown的缩进代码块语法。渲染出来是p标签加pre标签,pre里包着code内容。如果没有自定义渲染器,默认输出可能带div,但这里完全按照你的pre结构来。
NodeRenderer的适用范围很广,你可以自己写个渲染器专门处理引用块,把>变成带颜色的背景框。也可以处理列表项,把数字序号改成罗马数字。只要实现了接口,想怎么改就怎么改。
扩展包让表格和警告框也能正常显示
普通Markdown功能有限,表格和警告框这些高级语法需要额外扩展。CommonMark提供了好几个扩展包,比如commonmark-ext-gfm-tables处理表格,commonmark-ext-gfm-alerts处理警告框。
用扩展包的时候在Parser和HtmlRenderer的builder里调用extensions方法。传入你需要的扩展实例列表,这样解析器就能识别表格语法了。表格的竖线和横线不会被当成普通文本,而是变成table、tr、td标签。
警告框语法是GitHub风格的那套,比如> [!NOTE]开头。扩展包会把它渲染成带颜色边框的div,视觉上很醒目。这些扩展包都在Maven中央仓库里,加个依赖就能用。
建议把常用扩展都加上,哪怕你现在用不到。因为Markdown内容来源可能五花八门,万一别人发了带表格的文本,没扩展就直接显示原文了。加了扩展包就自动转成漂亮表格,用户体验好很多。
扩展包的版本要和commonmark主库保持一致,不然可能会报类找不到的错误。目前最新版本是0.28.0,用的时候看一眼官方文档确认版本号。依赖加好了之后,builder里加上extensions就完事。
文档节点能增删改查像操作小树苗
拿到Node之后不只是能读,你还能改。Node有getFirstChild、getNext这些方法,可以遍历整棵树。想删除某个节点就调unlink,把它从树里摘出来。想插入新节点就调appendChild。
比如你解析完一篇文章,想把所有二级标题改成三级标题。遍历所有Heading节点,判断getLevel等于2就改成3。改完重新渲染,输出就变了。这种操作在文档转换工具里非常常见。
还可以结合访问者模式做更复杂的修改。在visit方法里判断节点类型,然后调用节点的修改方法。比如把所有Text节点的内容转成大写,或者过滤掉敏感词。因为访问者会遍历整棵树,你改完所有节点只需要一次遍历。
需要注意的是修改节点之后最好重新渲染一下验证结果。因为有些修改可能导致结构不合法,比如把标题节点改成文本节点,树的结构就变了。CommonMark不会拦着你乱改,所以操作的时候多测试。
高级玩法是把多个文档拼到一起。解析两个Markdown字符串得到两个Document,然后把第二个文档的根节点挂到第一个文档的某个节点下面。这样就能实现文档合并,适合做内容聚合。
实际项目里什么时候该用这套工具
写技术博客的时候,编辑器里存的是Markdown,展示给读者看的是HTML。用CommonMark一套转换,连样式都不用调。如果你有个后台管理系统,编辑写的Markdown内容,前端展示直接调用转换方法。
做文档生成工具也很好使。从数据库里查出Markdown内容,转成HTML之后生成PDF。或者反过来,把用户上传的Word文档转成HTML结构,再用CommonMark转成Markdown统一存储。
静态网站生成器里也能用。把Markdown文件解析成Node,然后塞进模板引擎渲染。因为能拿到AST,你可以在生成HTML之前做SEO优化,比如给所有图片加alt属性,给标题自动生成目录。
在聊天机器人里,用户发的消息可能带Markdown格式。你用CommonMark转成HTML再展示,比直接显示原文好看多了。尤其是代码块和列表,展示效果差距巨大,用户体验完全不一样。
还可以做内容分析工具。统计文章里有多少个标题、多少个链接、多少个图片。用访问者遍历Node,遇到对应节点就计数器加一。这些数据能帮你了解内容结构,做优化决策。
性能方面这个库会不会拖慢项目节奏
CommonMark的解析速度挺快,几千字的文章毫秒级完成。因为它是纯Java实现,没有原生调用,在JVM上跑得很稳。如果频繁调用,建议把Parser和Renderer建好之后复用,不要每次都重新build。
Parser是线程安全的,可以多个线程共享一个实例。Renderer也是线程安全的,放心在多线程环境里用。如果你的应用需要同时处理大量Markdown内容,可以提前建好单例,避免重复创建对象的开销。
内存占用方面,每个文档解析完都会生成一棵AST树。树的大小跟内容成正比,但每个节点对象占的内存不多。处理完记得让Node对象尽快释放,别在内存里攒着,尤其是大文档。
如果要做极致优化,可以考虑用缓冲流。从文件读Markdown的时候用BufferedReader,写HTML的时候用BufferedWriter。这样IO开销会小很多,整体吞吐量能上去。
实测下来这个库处理十万字的文档完全没问题。内存占用大概几十MB,GC压力也不大。对于大多数业务场景,性能不是需要担心的问题。放心用就行,别过早优化。
对比其他Markdown库为什么选CommonMark
Java生态里还有flexmark和pegdown这些库。flexmark功能非常全,支持很多扩展语法,但也正因为太全了,jar包体积大,学习成本高。pegdown是早期的库,现在更新比较慢,有些语法支持不全。
CommonMark走的是标准化路线,严格按照CommonMark规范实现。这意味着你写的Markdown在不同平台展示效果一致。不会出现同一个文件在GitHub显示正常,在你自己网站就变样的问题。
扩展机制设计得也很清晰,AttributeProvider和NodeRenderer两个接口基本覆盖了所有定制需求。不用继承一大堆抽象类,也不用记复杂的配置参数。实现两个方法就能搞定自定义渲染。
社区活跃度也不错,GitHub上issue和PR都在处理。版本迭代稳定,不会频繁发大版本导致兼容性问题。对于长期维护的项目来说,这是个很大的优势。
如果你需要处理GitHub风格的Markdown,官方正好提供了对应的扩展包。如果你只需要基本功能,主库就够了。这种模块化设计让包大小可控,不用为了用个小功能而引入整个生态。
这种处理方式还能延伸出什么新玩法
你可以结合CommonMark做自动化测试。把测试用例写成Markdown格式,包含输入和预期输出。解析之后提取代码块内容,用访问者拿到所有代码块,动态执行并验证结果。这样测试文档和测试代码合二为一。
做代码生成器也不错。把接口文档写成Markdown,解析成AST之后遍历,遇到特定标记就生成Java接口文件或者TypeScript类型定义。文档即源码,省去重复劳动。
还可以做国际化工具。把Markdown里的文本节点提取出来,送去翻译,翻译完再填回去。因为AST保留了位置信息,你替换文本之后结构不变,翻译完成直接渲染输出。
做课件生成系统也行。老师写Markdown教案,解析后自动生成PPT的HTML版本。因为你能控制每个节点的渲染样式,标题转成幻灯片标题,列表转成要点,代码块转成高亮区域。
结合AI做内容改写也很有意思。用访问者找出所有段落文本,调AI接口重写一遍,再填回Node。这样批量改写文章风格,从技术文档改成大白话,或者反过来。整个过程全是自动化。
篇尾总结
CommonMark把Markdown和HTML互相转换变得特别简单,还能定制渲染和修改节点。用它处理文档又快又稳,扩展包还能支持表格和警告框这些高级语法。