Java处理Markdown用CommonMark库就够了

CommonMark是个Java库,能把Markdown变成HTML,也能把HTML变回Markdown。它能解析文档结构,让你改属性、定制渲染方式。看完这篇,你就能在项目里随便玩转Markdown了。

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互相转换变得特别简单,还能定制渲染和修改节点。用它处理文档又快又稳,扩展包还能支持表格和警告框这些高级语法。