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なのですが、現在の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>© <> </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>© <>" + 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の
デフォルトの設定は、こちらを見るとよいでしょう。
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から脱線しましたけど、こんなところでしょうか。