CLOVER🍀

That was when it all began.

Thymeleafの内部で使われているXML/HTMLパーサー、attoparserを試す

JavaにおけるHTMLパーサーといえば、jsoupやNekoHTMLなイメージだったのですが、Thymeleaf 3の
情報を知った時に、一緒にattoparserというHTMLパーサーがあることを知りました。

attoparser: powerful and easy java parser for XML and HTML markup

ちょっと気になっていたので、試してみます。

attoparserとは?

Thymeleafの作者である、Daniel Fernándezさんが作られているXML/HTMLパーサーです。

次のような特徴があるのだとか。

  • 使うのが簡単
  • 速い
  • well-formedかどうかのチェック、ソースコードの行などの位置、元のドキュメントを再構築可能
  • 簡素化されたパーサー(Validationや数値文字/実体参照の解決を行わない、これらは多くの場合不要)

そして、SAXスタイルなイベントベースなパーサーです。とはいっても「SAXスタイル」と言っているように、標準のSAXに
沿っているわけではありません。

またDOMスタイルのパーサーのように振る舞うとも書かれていますが、確かにDOMっぽい機能も用意されているのですが、
こちらも標準のDOMに沿っているわけではありません。

機能としては、次のようなことが挙げられています。

  • Java 5以上
  • 依存関係なしのSingle JARで、85KBと軽量
  • イベントベース(SAXスタイル)
  • HTML固有の処理(閉じていないタグの処理など)
  • (オプションで)DOMスタイル
  • (オプションで)well-formedness(well-formedなXMLではなくてもパースできる)
  • 小さいメモリフットプリント
  • 各イベントにおいて、オリジナルのDocumentでの行と列番号を提供
  • ユーザーが求めるレベルに応じて、最適なHandler実装を選択することが可能(興味のない要素や属性を無視するなど)
  • Documentの再構築が可能
  • エスケープ/アンエスケープがない(構文解析時に、エスケープ/アンエスケープは行われず、Entity Referenceの置換も行われない)

基本的に、使いたい機能に絞って、あとは必要に応じて使う方でカスタマイズしてね、というSAXフレームワークのようです。
エスケープ/アンエスケープもないというのには驚きましたが、XMLとHTMLの差異に悩まないように、という意図みたいです。

主要な要素

で、実際に使う前に、attoparserの主だった登場人物を。

Javadocは、こちら。

attoparser 2.0.4.RELEASE API

現時点でのattoparserのバージョンは2.0.4.RELEASEなのですが、現在のattoparserのサイトのドキュメントは、1系時点のもので
内容が古いです。

ですので、Javadocソースコードを見ながら理解していくことになります。

主要な構成要素は、次でしょう。

  • IMarkupParser … attoparserでの、Markup Parserを表すインターフェース。デフォルト実装として、MarkupParserクラスがあり、このインターフェースの実装はスレッドセーフであることが求められます
  • IMarkupHandler … IMarkupParserの実装による、Documentパース時のイベントを受け取るインターフェースです。Markup Handlerは状態を持つことが許されており、パース処理ごとにインスタンスを作成する必要があります。スレッドセーフであることは求められません
  • ParseConfiguration … Parserの振る舞いを設定することができます。例えば、XML/HTMLのモード、well-formedを要求するか、タグのバランシングのルールなどが設定可能です

IMarkupHandlerの実装および骨格実装、attoparserによっていくつか提供されており、ユーザー側で好みの実装を作成するというのが
attoparserの本来の使い方になるでしょう。

IMarkupHandlerは、SAX/JAXPでいうDefaultHandlerあたりに該当する位置づけです。このインターフェースのパース時のイベントに対応した
各メソッド、タグの開始、終了などに関するイベント処理を実装していきます。

Thymeleafも、このIMarkupHandler(の骨格実装を継承したクラス)の実装を作成しています。

準備

それでは前置きはこれくらいにして、実際に使ってみましょう。

Maven依存関係は、こちら。

        <dependency>
            <groupId>org.attoparser</groupId>
            <artifactId>attoparser</artifactId>
            <version>2.0.4.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.8.0</version>
            <scope>test</scope>
        </dependency>

JUnitとAssertJは、テストコード用です。

また、テストコードの雛形は、こちら。
src/test/java/org/littlewings/attoparser/example/AttoParserTest.java

package org.littlewings.attoparser.example;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;

import org.attoparser.IMarkupHandler;
import org.attoparser.IMarkupParser;
import org.attoparser.MarkupParser;
import org.attoparser.ParseException;
import org.attoparser.config.ParseConfiguration;
import org.attoparser.discard.DiscardMarkupHandler;
import org.attoparser.dom.DOMMarkupParser;
import org.attoparser.dom.DOMWriter;
import org.attoparser.dom.DocType;
import org.attoparser.dom.Document;
import org.attoparser.dom.Element;
import org.attoparser.dom.IDOMMarkupParser;
import org.attoparser.dom.INode;
import org.attoparser.dom.Text;
import org.attoparser.output.OutputMarkupHandler;
import org.attoparser.select.BlockSelectorMarkupHandler;
import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class AttoParserTest {
    String loadHtmlFile(String path) {
        try (InputStream is = getClass().getClassLoader().getResourceAsStream(path);
             InputStreamReader isr = new InputStreamReader(is, StandardCharsets.UTF_8);
             BufferedReader reader = new BufferedReader(isr)) {
            StringBuilder builder = new StringBuilder();

            int c;
            while ((c = reader.read()) != -1) {
                builder.append((char) c);
            }

            return builder.toString();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    // ここに、テストを書く1
}

クラスパス上からファイルを文字列としてロードするための、ヘルパーメソッド付きです。

まずは使ってみる

それでは、attoparserを使ったコードを書いてみます。今回は、IMarkupHandlerの実装クラスは作成せずに、attoparser側で用意済みのクラスを使用して
確認していきます。

パース対象のHTMLは、こちらとします。
src/test/resources/test.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>タイトル</title>
</head>
<body>
<p>テスト
</body>
</html>

で、書いたコードがこちら。

    @Test
    public void gettingStarted() throws ParseException {
        StringWriter writer = new StringWriter();
        IMarkupHandler handler = new OutputMarkupHandler(writer);
        IMarkupParser parser = new MarkupParser(ParseConfiguration.htmlConfiguration());

        parser.parse(loadHtmlFile("test.html"), handler);

        assertThat(writer.toString())
                .isEqualTo("<!DOCTYPE html>" + System.lineSeparator() +
                        "<html>" + System.lineSeparator() +
                        "<head>" + System.lineSeparator() +
                        "    <meta charset=\"utf-8\">" + System.lineSeparator() +
                        "    <title>タイトル</title>" + System.lineSeparator() +
                        "</head>" + System.lineSeparator() +
                        "<body>" + System.lineSeparator() +
                        "<p>テスト" + System.lineSeparator() +
                        "</body>" + System.lineSeparator() +
                        "</html>" + System.lineSeparator());
    }

最初に、IMarkupHandlerの実装クラスのインスタンスを作成します。OutputMarkupHandlerはWriterに対して、パースした内容を
書き出すIMarkupHandlerの実装です。

        StringWriter writer = new StringWriter();
        IMarkupHandler handler = new OutputMarkupHandler(writer);

IMarkupHandlerインターフェースには、骨格実装としてAbstractMarkupHandlerクラスが用意されており、こちらを元にHandlerを実装することが
多くなるようです。

OutputMarkupHandlerクラスも、AbstractMarkupHandlerクラスを継承して実装されています。

続いて、IMarkupParserのインスタンスを作成します。IMarkupParserのデフォルト実装である、MarkupParserを使用します。

        IMarkupParser parser = new MarkupParser(ParseConfiguration.htmlConfiguration());

MarkupParserのコンストラクタにはParseConfigurationクラスのインスタンスを引数に取りますが、XML、HTMLそれぞれ向けに
デフォルトの設定がありますので、通常はそちらを利用するでしょう。ここでは、HTML用の設定(ParseConfiguration#htmlConfiguration)を
使います。XMLの場合は、ParseConfiguration#xmlConfigurationとなります。

なお、ParseConfiguration#htmlConfigurationなどで取得したParseConfigurationのインスタンスは、さらに個別にカスタマイズ可能です。

あとは、IMarkupParser#parseを呼び出すだけです。引数には、文字列やReaderでXML/HTML文書を与え、それからIMarkupHandlerを渡します。

        parser.parse(loadHtmlFile("test.html"), handler);

        assertThat(writer.toString())
                .isEqualTo("<!DOCTYPE html>" + System.lineSeparator() +
                        "<html>" + System.lineSeparator() +
                        "<head>" + System.lineSeparator() +
                        "    <meta charset=\"utf-8\">" + System.lineSeparator() +
                        "    <title>タイトル</title>" + System.lineSeparator() +
                        "</head>" + System.lineSeparator() +
                        "<body>" + System.lineSeparator() +
                        "<p>テスト" + System.lineSeparator() +
                        "</body>" + System.lineSeparator() +
                        "</html>" + System.lineSeparator());

続いて、IMarkupParserの他の実装も試してみましょう。

セレクタで対象の要素を絞り込める、BlockSelectorMarkupHandlerを使用します。このHandlerの場合、セレクタに一致した時、一致しなかった時に
それぞれどうするのかということを、それぞれ別のHandlerに任せます。よって、BlockSelectorMarkupHandlerを使用するには、2つの
別のIMarkupHandlerのインスタンスが必要になります。

    @Test
    public void selectedParser() throws ParseException {
        StringWriter writer = new StringWriter();
        IMarkupHandler selectedHandler = new OutputMarkupHandler(writer);
        IMarkupHandler nonSelectedHandler = new DiscardMarkupHandler();
        IMarkupHandler handler =
                new BlockSelectorMarkupHandler(selectedHandler, nonSelectedHandler, "body/p");
        IMarkupParser parser = new MarkupParser(ParseConfiguration.htmlConfiguration());

        parser.parse(loadHtmlFile("test.html"), handler);

        assertThat(writer.toString()).isEqualTo("<p>テスト" + System.lineSeparator());
    }

ここでは、セレクタに一致した場合は先ほど使用したWriterに書き出すOutputMarkupHandler、一致しなかった場合は受け取った内容を破棄する
DiscardMarkupHandlerを使用します。

        IMarkupHandler selectedHandler = new OutputMarkupHandler(writer);
        IMarkupHandler nonSelectedHandler = new DiscardMarkupHandler();
        IMarkupHandler handler =
                new BlockSelectorMarkupHandler(selectedHandler, nonSelectedHandler, "body/p");
        IMarkupParser parser = new MarkupParser(ParseConfiguration.htmlConfiguration());

あとはパースしてみれば、セレクタで絞り込んだ結果を取得できます。

        parser.parse(loadHtmlFile("test.html"), handler);

        assertThat(writer.toString()).isEqualTo("<p>テスト" + System.lineSeparator());

IMarkupHandler/AbstractMarkupHandlerの実装クラスには、以下があるので見ておくとよいでしょう。AbstractChainedMarkupHandlerは、
別のHandlerとチェインするためにありますが。

  • AbstractChainedMarkupHandler
  • BlockSelectorMarkupHandler
  • DiscardMarkupHandler
  • DOMBuilderMarkupHandler
  • DuplicateMarkupHandler
  • NodeSelectorMarkupHandler
  • OutputMarkupHandler
  • PrettyHtmlMarkupHandler
  • SimplifierMarkupHandler
  • TextOutputMarkupHandler
  • TraceBuilderMarkupHandler

これらのクラスやAbstractMarkupHandlerクラスを拡張して、自作のMarkupHandlerを作り込んでいくのでしょう。

数値文字/実体参照

数値文字/実体参照が無視されるという話でしたが、確認してみましょう。

こんなHTMLを用意してみます。
src/test/resources/with-reference.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>タイトル</title>
</head>
<body>
<p>&copy; &lt;&#62;
</body>
</html>

Javaコード。まあ、そのままですね…。

    @Test
    public void ignoreReference() throws ParseException {
        StringWriter writer = new StringWriter();
        IMarkupHandler selectedHandler = new OutputMarkupHandler(writer);
        IMarkupHandler nonSelectedHandler = new DiscardMarkupHandler();
        IMarkupHandler handler =
                new BlockSelectorMarkupHandler(selectedHandler, nonSelectedHandler, "body/p");
        IMarkupParser parser = new MarkupParser(ParseConfiguration.htmlConfiguration());

        parser.parse(loadHtmlFile("with-reference.html"), handler);

        assertThat(writer.toString()).isEqualTo("<p>&copy; &lt;&#62;" + System.lineSeparator());
    }

なお、Thymeleafの場合はエスケープ/アンエスケープにunbescapeというライブラリを使っているみたいですよ。

unbescape: powerful, fast and easy escape/unescape operations for Java

って、これも作っている人がDaniel Fernándezさんな件…。

DOMスタイルで使ってみる

最後に、attoparserをDOMスタイルで使ってみます。
※後半にXMLとHTMLパースの差異やParseConfigurationのカスタマイズも書いてあるので、そこはちょっと見た方がよいかも?

といっても、先に書いたとおり標準のDOMではありません。XPathなども使えませんし、数値文字/実体参照エスケープ/アンエスケープも
備わっていないので、実質使わないのでは?という気がします。

そんな感じなので、簡単に紹介します。

パースするHTMLは、こちら。
src/test/resources/test.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>タイトル</title>
</head>
<body>
<p>テスト
</body>
</html>

Javaコード。

    @Test
    public void parseAsHtml() throws ParseException {
        // HTMLパーサーとしてパース
        IDOMMarkupParser parser = new DOMMarkupParser(ParseConfiguration.htmlConfiguration());
        Document document = parser.parse(loadHtmlFile("test.html"));

        // 最初のNodeを取得
        INode docType = document.getFirstChild();
        assertThat(docType)
                .isInstanceOf(DocType.class);

        // 子Nodeを一括して取得
        List<INode> documentChildren = document.getChildren();
        // 間にText Nodeが挟まっているので、インデックスが2
        Element html = (Element) documentChildren.get(2);
        assertThat(html.getElementName()).isEqualTo("html");
        assertThat(html.getLine()).isEqualTo(1);  // 行番号も取れる

        // 型を絞って、最初の子要素を取得
        Element head = html.getFirstChildOfType(Element.class);

        Element meta = head.getFirstChildOfType(Element.class);
        assertThat(meta.getElementName()).isEqualTo("meta");

        // 属性の取得
        assertThat(meta.getAttributeValue("charset")).isEqualTo("utf-8");
        Map<String, String> attributes = meta.getAttributeMap();
        assertThat(attributes.get("charset")).isEqualTo("utf-8");

        // 型を絞って、一括して子要素を取得
        List<Element> htmlElements = html.getChildrenOfType(Element.class);
        Element body = htmlElements.get(1);

        Element p = body.getFirstChildOfType(Element.class);
        Text text = p.getFirstChildOfType(Text.class);
        assertThat(text.getContent()).isEqualTo("テスト" + System.lineSeparator());
    }

DOMの場合、IDOMMarkupParserインターフェースおよびその実装としてDOMMarkupParserクラスが提供されているので、そちらを使用します。

        // HTMLパーサーとしてパース
        IDOMMarkupParser parser = new DOMMarkupParser(ParseConfiguration.htmlConfiguration());
        Document document = parser.parse(loadHtmlFile("test.html"));

DOMMarkupParserの内部ではMarkupParserが使われていますし、
https://github.com/attoparser/attoparser/blob/attoparser-2.0.4.RELEASE/src/main/java/org/attoparser/dom/DOMMarkupParser.java#L103

IMarkupHandlerの実装としてはDOMBuilderMarkupHandlerが使用されます。
https://github.com/attoparser/attoparser/blob/attoparser-2.0.4.RELEASE/src/main/java/org/attoparser/dom/DOMBuilderMarkupHandler.java

あとは、独自のINodeインターフェースを実装したDOMツリーが取得できるので、要素やテキストの取得などが行えるようになります。

        Document document = parser.parse(loadHtmlFile("test.html"));

        // 最初のNodeを取得
        INode docType = document.getFirstChild();
        assertThat(docType)
                .isInstanceOf(DocType.class);

        // 子Nodeを一括して取得
        List<INode> documentChildren = document.getChildren();
        // 間にText Nodeが挟まっているので、インデックスが2
        Element html = (Element) documentChildren.get(2);
        assertThat(html.getElementName()).isEqualTo("html");
        assertThat(html.getLine()).isEqualTo(1);  // 行番号も取れる

申し訳程度に、DOMの書き出しも可能ですが…使わない気がします。

    @Test
    public void writeHtml() throws IOException {
        Document document = new Document("sample.html");

        DocType docType = new DocType("html", null, null, null);
        document.addChild(docType);

        Element html = new Element("html");
        document.addChild(html);

        Element head = new Element("head");
        html.addChild(head);

        Element meta = new Element("meta");
        meta.addAttribute("charset", "utf-8");
        head.addChild(meta);
        Element title = new Element("title");
        title.addChild(new Text("タイトル"));
        head.addChild(title);

        Element body = new Element("body");
        html.addChild(body);

        StringWriter writer = new StringWriter();
        DOMWriter.writeDocument(document, writer);

        assertThat(writer.toString())
                .isEqualTo("<!DOCTYPE html><html><head><meta charset=\"utf-8\"/><title>タイトル</title></head><body/></html>");
    }

HTML文書をXMLとしてパースすると、もちろんパースエラーとなります(metaなどに閉じタグを今回入れていないので)。

    @Test
    public void parseAsXml() throws ParseException {
        // XMLパーサーとしてパース
        IDOMMarkupParser parser = new DOMMarkupParser(ParseConfiguration.xmlConfiguration());
        assertThatThrownBy(() -> parser.parse(loadHtmlFile("test.html")))
                .isInstanceOf(ParseException.class)
                .hasMessage("(Line = 6, Column = 1) Malformed markup: element \"meta\" is never closed");
    }

XMLとHTMLの差は、ParseConfigurationにParsingModeというものがあり、こちらで切り替わることになります。XMLおよびHTMLの
デフォルトの設定は、こちらを見るとよいでしょう。

https://github.com/attoparser/attoparser/blob/attoparser-2.0.4.RELEASE/src/main/java/org/attoparser/config/ParseConfiguration.java#L131-L162

XMLモードとHTMLモードの差異ですが、大きくはパース時にIMarkupParser#parseに渡したIMarkupHandlerが、HtmlMarkupHandlerに
包まれるようになります。
https://github.com/attoparser/attoparser/blob/attoparser-2.0.4.RELEASE/src/main/java/org/attoparser/MarkupParser.java#L205-L207
https://github.com/attoparser/attoparser/blob/attoparser-2.0.4.RELEASE/src/main/java/org/attoparser/MarkupParser.java#L240-L242

HtmlMarkupHandlerを使うと、HTML要素に特化した振る舞いになります。

それに、各HTML要素に対応したクラスは、org.attoparserパッケージにぶら下がっていますしね。
https://github.com/attoparser/attoparser/tree/attoparser-2.0.4.RELEASE/src/main/java/org/attoparser

また、ParseConfigurationの設定を変えることで、たとえばHTMLのまま閉じタグなしを許容しない、という設定にすることもできます。

    @Test
    public void requireBalancingTag() throws ParseException {
        ParseConfiguration htmlConfiguration = ParseConfiguration.htmlConfiguration();
        htmlConfiguration.setElementBalancing(ParseConfiguration.ElementBalancing.REQUIRE_BALANCED);
        IDOMMarkupParser parser = new DOMMarkupParser(htmlConfiguration);
        assertThatThrownBy(() -> parser.parse(loadHtmlFile("test.html")))
                .isInstanceOf(ParseException.class)
                .hasMessage("(Line = 9, Column = 1) Malformed markup: element \"p\" is never closed");
    }

ParseConfiguration#htmlConfigurationで取得したParseConfigurationのインスタンスを、ちょっとカスタマイズ。

        ParseConfiguration htmlConfiguration = ParseConfiguration.htmlConfiguration();
        htmlConfiguration.setElementBalancing(ParseConfiguration.ElementBalancing.REQUIRE_BALANCED);

xmlConfigurationやhtmlConfigurationの戻り値は、デフォルト設定をcloneしたものが返ってくるので、カスタマイズしても
問題ありません。
https://github.com/attoparser/attoparser/blob/attoparser-2.0.4.RELEASE/src/main/java/org/attoparser/config/ParseConfiguration.java#L632-L643

ただ、pタグの閉じタグがないとエラーになっていますが、その前にmetaが閉じていないのですが…?

        assertThatThrownBy(() -> parser.parse(loadHtmlFile("test.html")))
                .isInstanceOf(ParseException.class)
                .hasMessage("(Line = 9, Column = 1) Malformed markup: element \"p\" is never closed");

こういう用途だと、XMLとしてパースした方がいいのかなぁ…。

とまあ、後半はDOMから脱線しましたけど、こんなところでしょうか。

まとめ

Thymeleafの中で使用されているXML/HTMLパーサー、attoparserを試してみました。

ちょっとレイヤーの低めなものですが、そもそもSAXスタイルのパーサーってそういうものかなとも思います。数値文字/実体参照の解決などを
思い切って削ぎ落としていたりしたのには驚きました。

なので、素のままだと作り込まないといけないので、簡単に済ませるのならやっぱりjsoupとかになるのかなぁという気がします。
パース速度などにこだわって、作り込む時にattoparserは活躍するのでしょう。