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 interoperability к этому дню).

Скелет 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, где вы контролируете типы и ошибки.

1
Задача
Kotlin SELF, 50 уровень, 2 лекция
Недоступна
Имя по следу
Имя по следу
1
Задача
Kotlin SELF, 50 уровень, 2 лекция
Недоступна
Пропускной id
Пропускной id
1
Задача
Kotlin SELF, 50 уровень, 2 лекция
Недоступна
Список покупок
Список покупок
1
Задача
Kotlin SELF, 50 уровень, 2 лекция
Недоступна
Фильтр по категории
Фильтр по категории
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ