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-кусочек | Что означает по смыслу | Пример |
|---|---|---|
|
путь от корня документа | |
|
найти c где угодно в документе (поиск «в глубину») |
|
|
атрибут id у текущего узла |
|
|
фильтр (предикат) по атрибуту | |
|
текстовый узел (содержимое) | |
Два замечания, которые экономят нервы.
Во-первых, // удобен, но опасен: он может найти больше узлов, чем вы ожидали, особенно если документ большой или структура изменилась.
Во-вторых, 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, где вы контролируете типы и ошибки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ