JavaRush /Курси /Kotlin SELF /XPath поверх DOM

XPath поверх DOM

Kotlin SELF
Рівень 50 , Лекція 2
Відкрита

1. Ще один спосіб роботи з XML

Коли ви вперше опановуєте DOM, зʼявляється відчуття: «Ну все, я можу дістатися до будь-якого вузла — просто ходитиму по дереву». Це правда… приблизно до першого XML, який трохи складніший за навчальний. А потім починається класика: багато getElementsByTagName, купа індексів item(0), приведення типів, перевірки на порожнечу — і код для читання даних стає довшим, ніж сам XML.

XPath потрібен для того, щоб описувати вибірку декларативно: «дай мені імʼя користувача з id=42», а не «спочатку знайди всіх user, потім обійди їх, тоді порівняй id, а вже потім витягни name».

XPath можна сприймати як «мінімову запитів» до XML-дерева. За відчуттями це схоже на навігацію теками (шляхи на кшталт /users/user/name), але з фільтрами та «пошуком углиб». І так, іноді XPath виглядає як заклинання. Але ми писатимемо заклинання короткі й зрозумілі — інакше магія перетворюється на техборщ.

2. XPath працює поверх DOM

Перш ніж писати XPath, важливо прибрати одну типову плутанину. XPath — це не «альтернативний парсер XML». У нашому сьогоднішньому підході XPath не розбирає текст. Спочатку ми парсимо XML у DOM (отримуємо Document), і лише потім виконуємо XPath-вирази над уже готовим деревом.

Тобто наш конвеєр виглядає так:

flowchart LR
    A["XML (String)"] --> B["DOM parse → Document"]
    B --> C["XPath query → value/nodes"]
    C --> D["Kotlin-обʼєкти (наприклад, Expense)"]

DOM відповідає за те, щоб XML перетворився на структуру в памʼяті. XPath відповідає за зручні вибірки з цієї структури.

3. Мінісловник XPath: шляхи, //, атрибути й text()

XPath спочатку може здаватися страшним через символи /, //, @, []. Але якщо розкласти все за змістом, там доволі людська логіка. Ми не будемо заглиблюватися в «осі» й просунуті можливості — нам вистачить повсякденного набору, який справді використовують у прикладному коді.

Нижче — невелика таблиця-шпаргалка. Її можна тримати поруч і не намагатися вивчити з першого разу (ми ж не на іспиті з давніх рун).

Фрагмент XPath Що означає за змістом Приклад
/a/b/c
шлях від кореня документа
/users/user/name
//c
знайти c будь-де в документі (пошук «углиб»)
//item
@id
атрибут id у поточного вузла
/user/@id
[@id='42']
фільтр (предикат) за атрибутом
//user[@id='42']
text()
текстовий вузол (вміст)
/user/name/text()

Два зауваження, які економлять нерви.

По-перше, // зручний, але небезпечний: він може знайти більше вузлів, ніж ви очікували. Особливо якщо документ великий або його структура змінилася.

По-друге, XPath часто повертає рядки з пробілами й перенесеннями рядків (через форматування XML). Тож trim() — ваш друг, а не ворог.

4. Підготовка та базові режими XPath у Kotlin

Створюємо Document і XPath

Зараз ми зберемо мінімальну технічну «обвʼязку»: як отримати Document і як створити обʼєкт XPath, який уміє виконувати запити. Це стандартний Java API, але Kotlin чудово його викликає. На цьому етапі ми вже не боїмося взаємодії з Java.

Скелет main у Kotlin виглядає звично, тож почнемо з простого прикладу: розпарсили XML → створили XPath → дістали значення.


import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory

fun main() {
    val xml = """<user id="42"><name>Ann</name></user>"""

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

    val xPath = XPathFactory.newInstance().newXPath()
    val name = xPath.evaluate("/user/name/text()", doc).trim()

    println(name) // Ann
}

Зверніть увагу: evaluate(...) тут повернув рядок. Це найпростіший режим роботи XPath: «дай мені результат як текст». Він ідеальний для точкових вибірок на кшталт імені, однієї суми або одного статусу.

Режим «дай рядок»: evaluate(expr, doc) і порожні результати

Коли ви використовуєте evaluate(...) без зазначення типу результату, зазвичай отримуєте рядок. І тут є важлива практична деталь: якщо вузол не знайдено, найчастіше повертається не null, а порожній рядок. Це не баг — просто контракт цього режиму.

Щоб не змішувати «порожнє значення в XML» і «нічого не знайдено», зручно зробити маленьку утиліту: «поверни String?».

import javax.xml.xpath.XPath

fun xPathTextOrNull(xPath: XPath, expr: String, doc: Any): String? {
    val raw = xPath.evaluate(expr, doc).trim()
    return raw.takeIf { it.isNotEmpty() }
}

І застосування:

import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory

fun main() {
    val xml = """<user id="42"></user>"""
    val doc = DocumentBuilderFactory.newInstance()
        .newDocumentBuilder()
        .parse(xml.byteInputStream())

    val xPath = XPathFactory.newInstance().newXPath()
    val name = xPathTextOrNull(xPath, "/user/name/text()", doc)

    println(name) // null
}

Тут ми свідомо перетворюємо «порожній рядок» на «немає значення». Так логіка в Kotlin стає чеснішою: null прямо каже «не знайшли», а не змушує вас тримати в голові, що «порожній рядок іноді означає відсутність».

Режим «дай вузли»: NODESET і NodeList

Коли потрібно дістати не одне значення, а багато однотипних елементів (наприклад, список покупок, список транзакцій або список користувачів), рядок уже не підходить. Потрібен «набір вузлів».

У Java XPath API це робиться через XPathConstants.NODESET, а результатом буде NodeList.

Важливо: NodeList — це не List. Його не можна нормально map{} і filter{} без перетворення. Зате його можна обходити за індексами: 0 until nodes.length (так, знову індекси з нуля). І сьогодні це буде наш основний стиль роботи.

Мініприклад:

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

fun main() {
    val xml = """
        <items>
            <item>Milk</item>
            <item>Bread</item>
        </items>
    """.trimIndent()

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

    val xPath = XPathFactory.newInstance().newXPath()
    val nodes = xPath.evaluate("//item", doc, XPathConstants.NODESET) as NodeList

    for (i in 0 until nodes.length) {
        println(nodes.item(i).textContent.trim())
        // Milk
        // Bread
    }
}

Сам цикл for у Kotlin працює поверх ітератора, але NodeList не Iterable. Тому ми використовуємо старий добрий індексний стиль.

5. Імпорт витрат з XML через XPath

Зараз зробимо те, заради чого все й затівалося: додамо в наш консольний застосунок (умовно — трекер витрат) можливість витягувати витрати з XML. Ми не будемо ускладнювати дати й валюти: нехай сума буде Double, а категорія й опис — рядками. XML візьмемо простий, але схожий на реальність.

Модель даних

Зробимо компактну модель Expense. У реальному проєкті вона у вас може бути й багатшою, але сьогодні нам важливий саме імпорт.

data class Expense(
    val amount: Double,
    val category: String,
    val note: String
)

XML, який будемо імпортувати

val xml = """
    <expenses>
        <expense category="food">
            <amount>12.50</amount>
            <note>Lunch</note>
        </expense>
        <expense category="transport">
            <amount>2.75</amount>
            <note>Bus</note>
        </expense>
    </expenses>
""".trimIndent()

Парсимо рядок у Document

Тримаємо функцію короткою: одна відповідальність — отримати DOM.

import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Document

fun parseXml(xml: String): Document =
    DocumentBuilderFactory.newInstance()
        .newDocumentBuilder()
        .parse(xml.byteInputStream())

Дістаємо вузли <expense> через XPath

Тут XPath дає нам список елементів <expense ...>...</expense>.

import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory
import org.w3c.dom.Document
import org.w3c.dom.NodeList

fun selectExpenseNodes(doc: Document): NodeList {
    val xPath = XPathFactory.newInstance().newXPath()
    return xPath.evaluate("/expenses/expense", doc, XPathConstants.NODESET) as NodeList
}

Перетворюємо NodeList на List<Expense>

Ось тут і починається «міст» між XML-світом і Kotlin-світом.

Зауважте: ми читаємо атрибут category і тексти amount/note. Для amount робимо акуратний toDoubleOrNull(), тому що дані можуть бути «криві», а наш код не має падати через один дивний символ.

import org.w3c.dom.Element
import org.w3c.dom.NodeList

fun parseExpenses(nodes: NodeList): List<Expense> {
    val result = mutableListOf<Expense>()

    for (i in 0 until nodes.length) {
        val el = nodes.item(i) as Element
        val category = el.getAttribute("category").trim()
        val amount = el.getElementsByTagName("amount")
            .item(0).textContent.trim().toDoubleOrNull() ?: 0.0
        val note = el.getElementsByTagName("note")
            .item(0).textContent.trim()

        result.add(Expense(amount, category, note))
    }

    return result
}

Так, тут усе ще є DOM-методи (getElementsByTagName) — і це нормально. XPath не зобовʼязаний замінювати взагалі все. Практична мета інша: XPath допомагає швидко знайти потрібні «точки інтересу» (наприклад, усі expense), а далі ви можете читати вкладені поля так, як вам зрозуміліше.

Збираємо в main і друкуємо результат

fun main() {
    val doc = parseXml(xml)
    val nodes = selectExpenseNodes(doc)
    val expenses = parseExpenses(nodes)

    for (e in expenses) {
        println("${e.category}: ${e.amount} — ${e.note}")
        // food: 12.5 — Lunch
        // transport: 2.75 — Bus
    }
}

Уже зараз це схоже на реальну задачу: «у мене є XML звідкись (файл, мережа, інтеграція) — дістаньте мені список сутностей».

6. Фільтри та читабельність XPath у проєкті

Фільтрація: предикати [...]

На практиці XPath люблять не лише за «короткий шлях», а й за фільтри. Фільтр — це квадратні дужки, які уточнюють вибірку. Найчастіший випадок — фільтр за атрибутом.

Скажімо, нам потрібні лише витрати категорії food. XPath-вираз буде таким:

/expenses/expense[@category='food']

Покажемо це прямо в коді — мінімально:

import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory
import org.w3c.dom.NodeList

fun selectFoodExpenses(doc: Any): NodeList {
    val xPath = XPathFactory.newInstance().newXPath()
    val expr = "/expenses/expense[@category='food']"
    return xPath.evaluate(expr, doc, XPathConstants.NODESET) as NodeList
}

А тепер важлива «доросла» думка про підтримку: фільтри краще тримати короткими. Якщо ви починаєте писати XPath, який виглядає як SQL «на максималках», це зазвичай знак, що ви намагаєтеся запхати в XPath бізнес-логіку. XPath хороший як мова вибірки, але «правила продукту» краще тримати в Kotlin-коді — там їх простіше тестувати й читати.

Як тримати XPath читабельним

Коли XPath зʼявляється в кодовій базі, з часом він починає розмножуватися. І якщо ставитися до нього без дисципліни, ви отримаєте «рядки-заклинання» без назв і пояснень. Це той випадок, коли код працює… але ніхто не хоче до нього торкатися.

Зазвичай допомагає простий прийом: виносити вирази в val з нормальною назвою, а не тримати їх прямо всередині evaluate.

const val XP_EXPENSES = "/expenses/expense"
const val XP_FOOD_EXPENSES = "/expenses/expense[@category='food']"
const val XP_FIRST_NOTE = "(/expenses/expense/note/text())[1]"

І потім:

val nodes = xPath.evaluate(XP_EXPENSES, doc, XPathConstants.NODESET) as NodeList

Ще одна обережна порада: // використовуйте лише тоді, коли ви справді хочете «шукати всюди». Якщо структура документа вам відома, явний шлях /expenses/expense зазвичай надійніший і передбачуваніший, ніж //expense.

7. Типові помилки під час роботи з XPath поверх DOM

Помилка №1: очікувати, що XPath поверне null, якщо нічого не знайдеться.
У рядковому режимі evaluate(...) найчастіше повертає порожній рядок, а не null. Якщо потім викликати toInt() або toDouble() без перевірок, отримаєте виняток уже на «порожнечі». Зручніше одразу завести утиліту на кшталт xPathTextOrNull(...): String? і чесно розрізняти «не знайдено» та «знайдено, але порожньо».

Помилка №2: зловживати // і отримувати «занадто багато» вузлів.
//item здається зручним, доки документ маленький. Але якщо XML виросте або зʼявляться вкладені item в іншому місці, ви раптом почнете імпортувати зайві сутності. Це одна з найнеприємніших помилок: код не падає, він просто робить «не те». Зазвичай краще починати з точного шляху від кореня.

Помилка №3: забувати про пробіли й перенесення рядків у textContent.
Відформатований XML містить відступи й перенесення рядків, і це теж текст. Тому textContent і рядковий evaluate(...) нерідко повертають значення з «невидимими» пробілами. Якщо не робити trim(), можна зловити дивні баги на кшталт «категорія ніби food, але чомусь не збігається». Лікується дисципліною нормалізації рядків.

Помилка №4: плутати режими результату, а потім падати на приведеннях.
Якщо ви зробили evaluate(..., NODESET) — очікуйте NodeList. Якщо зробили просто evaluate(expr, doc) — очікуйте рядок. Коли це змішується, зʼявляється ClassCastException у найнесподіваніший момент. Добре допомагає розділення на дві утиліти: selectNodes(...) і selectText(...), щоб уже з назви було видно контракт.

Помилка №5: брати item(0) без перевірки, що вузол узагалі є.
NodeList може бути порожнім. Якщо ви одразу робите item(0) і приводите до Element, ризикуєте отримати NullPointerException або помилку приведення. У коді імпорту корисно спочатку перевіряти nodes.length. А якщо очікується рівно один вузол — чесно обробляти ситуацію «немає даних» (повідомлення, null, виняток — залежно від контракту функції).

Помилка №6: робити XPath надто «розумним» і ховати бізнес-логіку в рядок.
Коли вираз у квадратних дужках перетворюється на «мініпрограму», підтримка різко погіршується: складно налагоджувати, складно читати, складно змінювати. XPath — чудовий ніж для нарізання, але погана кухня для приготування всієї страви. Зазвичай розумніше вибрати вузли XPathʼом, а далі фільтрувати й перевіряти дані в Kotlin, де ви контролюєте типи й помилки.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ