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.
Скелет 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, де ви контролюєте типи й помилки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ