Tento článek má především sloužit jako pomůcka mému budoucímu já, až opět někdy za rok budu potřebovat parsovat XML, na které se bude XPath hodit, ať se příliš dlouho netrápím, jak že se to vlastně používá a jak na vnořené parsování již nalezených nodů.

Use case

Řekněme, že jednou za čas nastane chvíle, kdy jste postaveni před úkol parsovat XML soubor, který obsahuje až nezdravě moc zanoření a krom toho vás budou zajímat pouze některé informace, které jsou jakoby z Babylonské věže pohozeny do různých koutů elementového světa 🙂 . Chtěl-li bych použít svůj oblíbený XStream, tak parsovat něco takového je už docela bolest, protože každé zanoření == nová třída. Další variantou je starý dobrý DOM parser, to už zní lépe, ale pokud píšu např. v Javě, kdo by chtěl dělat tu spoustu null checků (v Kotlinu by to až takový problém nebyl), protože v některých elementech budou data chybět úplně. Nejlepší variantou se mi proto jeví XPath, který je dle mého názoru na tohleto „částečné vyzobávání odkudkoliv“ ideální.

XPath a příklad

Na StackOverflow jsem našel tohle pěkné XMLko a záludně jsem do jednoho z tagů title přídal nested_title. Budeme tedy hledat knížky, vypisovat jejich názvy a pokud má kniha i „vnořený název“ (heh, teď už to zní docela směšně), tak ho vypíšeme taky, jinak nic. Když v XPathu začínám od kořene dokumentu, hledám nody stylem „/inventory/book“, kdežto vnořené hledání na již nalezeném nodu je bez lomítka na začátku: „title/text()“ – kde text() vrátí obsah daného XML elementu.

Nevím sice, ve kterém roce byla třída XPath do Javy přidána, ale vzhledem k indexovanému přístupu k elementům to už bude asi nějaký pátek. Já vím, chtít něco jako forEach by už bylo asi trochu moc, ale co třeba alespoň Iterator (se kterým se dá v Kotlinu díky různým extension functions už docela slušně vyhrát) ? No nic, vezmeme, co je a s trochou refaktoringu si to vyladíme až později. Začneme vytvořením Dokument instance, se kterou nám pomůže DocumentBuilder a jeho metoda parse(), do které v tomto případě budeme cpát InputStream. Nyní nastává chvíle, aby se Kotlin předvedl. Nemá náhodou již nějaké rozšíření, které by mi převedlo String na InputStream? 🙂 Zkouším čistě intuitivně a hned na druhý pokus mi code completion doplňuje možnost napsat: xml.byteInputStream(). Ou yeah!, to vypadá hezky 🙂 … srovnej s ByteArrayInputStream(xml.toByteArray())

A nyní rovnou ke kódu:

package xpathexample

import org.w3c.dom.Node
import org.w3c.dom.NodeList
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathConstants.NODE
import javax.xml.xpath.XPathConstants.NODESET
import javax.xml.xpath.XPathFactory

object XPathParsingExample {

    @JvmStatic
    fun main(args: Array<String>) {

        val xml = """
            <inventory>
                <book year="2000">
                    <title>Snow Crash</title>
                    <author>Neal Stephenson</author>
                    <publisher>Spectra</publisher>
                    <isbn>0553380958</isbn>
                    <price>14.95</price>
                </book>

                <book year="2005">
                    <title>Burning Tower</title>
                    <author>Larry Niven</author>
                    <author>Jerry Pournelle</author>
                    <publisher>Pocket</publisher>
                    <isbn>0743416910</isbn>
                    <price>5.99</price>
                </book>

                <book year="1995">
                    <title>Zodiac
                        <nested_title>Ram</nested_title>
                    </title>
                    <author>Neal Stephenson</author>
                    <publisher>Spectra</publisher>
                    <isbn>0553573862</isbn>
                    <price>7.50</price>
                </book>

                <!-- more books... -->

            </inventory>
        """

        val document = DocumentBuilderFactory.newInstance()
                .newDocumentBuilder()
                .parse(xml.byteInputStream())

        val xpath = XPathFactory.newInstance().newXPath()

        val books = xpath.evaluate("/inventory/book", document, NODESET) as NodeList

        for (i in 0 until books.length) {
            val book = books.item(i)

            val title = xpath.evaluate("title/text()", book, NODE) as Node
            println("Title: ${title.nodeValue}")

            val nestedTitle = xpath.evaluate("title/nested_title/text()", book, NODE) as Node?
            nestedTitle?.let {
                println("Nested title: ${it.nodeValue}")
            }
        }

    }
}

Výstupem je:

Title: Snow Crash
Title: Burning Tower
Title: Zodiac
            
Nested title: Ram

Zkrášlujeme

Jak jsem již psal výše, aktuální API XPathu v Javě mi přijde dost legacy, tak si ho pojďme vylepšit. Do kódu přidáme pár extension funkcí, kde nyní zajdu trochu do extrému jen čistě z ukázkových důvodů 🙂 . Malinko si zde pomůžeme singleton instancí XPathu (což bych normálně nedělal), ale řekněme, že nám jde o nejvymazlenější možnou syntaxi, tak to tam šoupneme a mezi importy si přidáme import xpathexample.SingleXPath.xpath . Naše rozšíření bude vypadat takto:

object SingleXPath {
    val xpath = XPathFactory.newInstance().newXPath()
}

private fun Node.findNode(expr: String) = xpath.evaluate(expr, this, NODE) as Node?

private fun Node.findNodeList(expr: String) = xpath.evaluate(expr, this, NODESET) as NodeList?

private fun NodeList.forEach(action: (Node) -> Unit) {
    for (i in 0 until length) {
        action(item(i))
    }
}

A co nám to umožní? Umožní nám to původní kód přepsat následujícím způsobem (včetně kompletní null-safety):

        val books = document.findNodeList("/inventory/book")

        books?.forEach { book ->
            val title = book.findNode("title/text()")
            println("Title: ${title?.nodeValue}")

            val nestedTitle = book.findNode("title/nested_title/text()")
            nestedTitle?.let {
                println("Nested title: ${it.nodeValue}")
            }
        }

Já myslím, že na oko to již vypadá mnohem lépe i když bych to kvůli nějakému malému kusu kódu zas až tolik nehrotil.

Toť ode mně vše, mějte se hezky a zkuste Kotlin! 🙂