1. Вступ
Якщо ви бодай раз бачили код, який «парсить XML» через substringBefore("</tag>"), то напевно знаєте дві речі. Такий код інколи навіть працює… і саме тому він особливо небезпечний. DOM‑парсинг — це підхід, у якому XML перетворюється на дерево об’єктів у пам’яті. Ми перестаємо «ловити кути» рядками й починаємо ходити структурою — як папками у провіднику.
DOM зручний, коли документ не гігантський, а вам потрібно вільно діставати різні фрагменти даних. Так, DOM завантажує XML повністю в пам’ять (це ціна зручності), зате далі ви можете дістати кореневий елемент, переглянути атрибути, знайти всі <expense>, прочитати текст усередині <amount> тощо.
Є й приємний бонус: Kotlin на JVM спокійно викликає Java‑API, а DOM‑парсер — класичний Java‑інструмент. Тож ми користуватимемося готовими, перевіреними часом бібліотеками й не «винаходитимемо велосипед» зі split() та надії. Kotlin позиціонується як мова, інтероперабельна з Java, тому «викликати Java DOM‑API з Kotlin» — цілком звичний робочий сценарій.
Міні‑конвеєр DOM‑парсингу
Перш ніж заглиблюватися в деталі, зберімо загальну картину процесу. У DOM‑підході зазвичай є два етапи: спочатку ми парсимо XML у Document, а потім дістаємо дані з цього дерева.
Ось проста блок‑схема (так, програмісти малюють стрілочки, щоб потім менше плакати під час налагодження):
flowchart TD
A[XML як String / файл] --> B["DocumentBuilder.parse(...)"]
B --> C[Document]
C --> D[documentElement: Element]
D --> E[Пошук елементів / читання атрибутів / textContent]
E --> F[Ваші Kotlin-обʼєкти / рядки / числа]
У цій лекції нас цікавить усе до блоку E включно: як коректно отримати Document, як безпечно дістати Element, як читати атрибути й текст.
2. DOM‑модель: Document, Element, NodeList
Перш ніж писати код, корисно навести лад у термінах. DOM — це «Document Object Model», тобто модель документа у вигляді дерева. На вершині — сам документ, нижче — елементи (теги), а всередині елементів — інші елементи й текст.
Щоб мозок не намагався запам’ятати все одразу, тримайте просту асоціацію: XML → дерево → вузли.
Нижче — невелика таблиця відповідностей, щоб ви не плуталися, де «XML‑поняття», а де «DOM‑клас»:
| В XML | У DOM (JVM) | Що це означає в коді |
|---|---|---|
| Документ цілком | |
«Коробка», усередині якої — усе дерево |
| Тег <user>...</user> | |
Вузол‑елемент: має ім’я тега, атрибути й вміст |
| Атрибут id="42" | |
Дістаємо рядкове значення атрибута |
| Набір однотипних тегів <item>...</item> | |
Колекція зі світу DOM: довжина + доступ за індексом |
| Текст усередині тега | |
Увесь текст усередині елемента (часто з пробілами й переносами) |
А тепер важливий «підводний камінь» для новачків: NodeList — це не List. У нього немає map, forEach, indices та інших радощів Kotlin‑колекцій. У світі DOM часто доводиться жити «по‑старому»: 0 until nodes.length і nodes.item(i).
3. Отримуємо Document з XML
XML‑рядок → Document
Щоб отримати Document, потрібен DOM‑парсер. У Java/JVM його створюють через фабрику DocumentBuilderFactory, яка повертає DocumentBuilder. А DocumentBuilder уже вміє викликати parse(...).
Почнемо з мінімального прикладу: XML лежить просто в Kotlin‑рядку.
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val xml = """<user id="42"><name>Ann</name></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
println(doc.documentElement.tagName) // user
}
Зверніть увагу на byteInputStream(). Парсер зазвичай працює з потоками байтів (InputStream), а рядок — це лише зручність для нас. Ми перетворюємо рядок на потік байтів і «годуємо» ним парсер.
Тут є важлива практична думка: XML — це текст, але парсер читає байти. У реальних проєктах ви ще багато думатимете про кодування, а в межах цієї лекції достатньо пам’ятати просте правило: «парсер отримує InputStream, а не “просто рядок”».
Кореневий елемент: documentElement
Коли Document уже отримано, хочеться зрозуміти: «а що всередині?» Стартова точка майже завжди одна — кореневий елемент.
doc.documentElement повертає Element. Це й є корінь дерева (той самий «верхній тег», який обгортає все інше).
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val xml = """<user id="42"><name>Ann</name></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val root = doc.documentElement
println(root.tagName) // user
}
Чому це важливо? Тому що далі ви часто читатимете дані «в контексті»: спочатку переконаєтеся, що корінь — це <expenses>, і лише потім шукатимете всередині нього <expense>. Такий підхід робить код передбачуванішим — і залишає менше місця для магії, яку ви самі ж собі й підкладаєте.
XML із файла: parse(File)
Коли XML приходить не рядком, а файлом (класичний випадок «експорту звіту»), зручно читати його напряму. DOM‑парсер уміє парсити файл без проміжного readText(). Тобто ви не зобов’язані спочатку завантажувати файл у рядок.
Міні‑приклад «файл → Document»:
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
fun parseXmlFile(path: String) =
DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(File(path))
Використання:
import org.w3c.dom.Element
fun main() {
val doc = parseXmlFile("expenses.xml")
val root = doc.documentElement
println(root.tagName) // expenses
val first = root.getElementsByTagName("expense").item(0) as? Element
println(first?.getAttribute("id")) // наприклад: e1
}
Чому так може бути зручніше? Тому що parse(File) сам відкриє потік і прочитає дані. А якщо ви зробите File(path).readText(), а потім parse(...), то спершу створите великий рядок у пам’яті, а потім знову перетворите його на байти. Для маленьких файлів це не трагедія, але звичка «не копіювати великі дані без потреби» майже завжди корисна.
Помилки парсингу й try/catch
DOM‑парсер доволі строгий: якщо XML не well‑formed, він викине виняток. І це нормально: він не зобов’язаний здогадуватися, що саме ви мали на увазі, коли забули закрити тег. (Хоча було б непогано, якби він ще приносив чай і співчутливо кивав.)
Обгортати парсинг у try/catch — хороша практика, особливо в CLI‑застосунках, де ви хочете продовжити роботу, а не «впасти».
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val brokenXml = "<user><name>Ann</user>" // name не закрито
try {
DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(brokenXml.byteInputStream())
println("Парсинг успішний")
} catch (e: Exception) {
println("Помилка парсингу XML: ${e.message}")
}
}
Тут ми перехоплюємо Exception максимально широко (для навчального прикладу це нормально). У робочому коді часто ловлять конкретніші типи, але зараз важливіше зрозуміти саму ідею: «парсинг — операція, яка потенційно може завершитися помилкою».
4. Читаємо дані з DOM: атрибути, елементи, текст
Атрибути: getAttribute(...)
Атрибути в XML виглядають так: id="42". У DOM їх читають ось так:
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val xml = """<user id="42"></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val id = doc.documentElement.getAttribute("id")
println(id) // 42
}
І ось тут починаються «веселі» моменти: якщо атрибута немає, багато хто очікує null. Але getAttribute(...) зазвичай повертає порожній рядок.
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val xml = """<user></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val id = doc.documentElement.getAttribute("id")
println("id='$id'") // id=''
}
Це означає, що для вас «атрибут є, але порожній» і «атрибута немає» виглядають однаково — в обох випадках ви отримаєте "". Тому в реальному коді майже завжди додають додаткову перевірку:
val id = root.getAttribute("id").trim()
val idOrNull = id.takeIf { it.isNotEmpty() }
Ми використовуємо trim(), бо інколи атрибут може містити пробіли. І ви точно не хочете з радістю прийняти " " за осмислене значення.
Пошук елементів: getElementsByTagName(...)
Коли в нас є корінь, наступний типовий крок — знайти вкладені елементи. Найпростіший спосіб: getElementsByTagName("name"). Метод повертає NodeList.
Зробімо міні‑приклад:
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """<user><name>Ann</name></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nodes = doc.getElementsByTagName("name")
println(nodes.length) // 1
val nameEl = nodes.item(0) as Element
println(nameEl.tagName) // name
}
Тут одразу є кілька нюансів:
По‑перше, nodes.length — це кількість елементів. Якщо length == 0, то item(0) поверне null, а приведення as Element «завалить» програму. Тому правило просте: спочатку перевірка, потім доступ.
По‑друге, getElementsByTagName шукає по всьому піддереву, а не лише серед прямих дітей. Тобто якщо у вас <user><profile><name>...</name></profile></user>, то пошук user.getElementsByTagName("name") знайде <name> і всередині <profile>. Іноді це зручно, іноді — несподівано. На цьому кроці достатньо запам’ятати: пошук «глибокий».
Щоб не «впасти» на порожньому результаті, можна використати безпечне приведення as? і перевірку на null:
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """<user></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nameEl = doc.getElementsByTagName("name").item(0) as? Element
println(nameEl?.tagName) // null
}
Текст усередині тега: textContent
Тепер дістаємося най«смачнішого»: як витягнути значення з <name>Ann</name>.
У DOM це роблять через textContent:
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """<user><name>Ann</name></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nameEl = doc.getElementsByTagName("name").item(0) as Element
println(nameEl.textContent) // Ann
}
А тепер практичний приклад: XML часто красиво форматують переносами рядків і відступами. Для людини це чудово, а для парсера — це теж текст.
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """
<user>
<name>
Ann
</name>
</user>
""".trimIndent()
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nameEl = doc.getElementsByTagName("name").item(0) as Element
println(nameEl.textContent) // "\n Ann\n "
println(nameEl.textContent.trim()) // Ann
}
Ось чому trim() поруч із textContent — це не «так, про всяк випадок», а майже стандартна гігієна.
Ще один нюанс: textContent повертає увесь текст усередині елемента, зокрема й текст вкладених елементів. Наприклад:
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """<msg>Hello <b>World</b>!</msg>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val msgEl = doc.documentElement
println(msgEl.textContent.trim()) // Hello World!
}
Це нормально, просто важливо розуміти: textContent — це «все текстове, що всередині», а не «лише прямий текстовий нащадок».
Трохи DOM‑гігієни, щоб код був спокійнішим
Коли ви починаєте писати DOM‑код, з’являється типова спокуса: всюди робити item(0) as Element і вірити, що світ добрий. Світ не добрий. Він просто ще не встиг показати вам XML, у якому <amount> забули або написали <Amount> (інший регістр — інший всесвіт).
Тому хороший стиль — винести перевірки в маленькі функції‑помічники. Вони сховають перевірки, trim() і безпечні приведення. Ми зробимо це в наступному розділі: там з’явиться firstTagText. Основний код парсингу стане помітно читабельнішим, а перевірки зберуться в одному місці.
Ще корисна техніка — нормалізувати рядки одразу під час читання: trim(), інколи lowercase() для категорій (якщо ваш формат це дозволяє), перевірка на порожнечу. DOM‑парсинг майже завжди йде поруч із «підготовкою даних». Інакше ви отримаєте category = " food " і потім дивуватиметеся, чому групування не працює.
5. Приклад: імпорт витрат з XML у список
Щоб приклади не жили окремим життям «у вакуумі», вбудуймо DOM‑читання в знайому логіку. У нас є консольний застосунок обліку витрат, і раптом бухгалтерія (або банк) віддає експорт… в XML. Бо «так історично склалося». Не питайте: це як «чому в офісі принтер працює лише тоді, коли попросити його лагідно».
Нехай XML має такий вигляд:
<expenses>
<expense id="e1">
<amount>120.50</amount>
<category>food</category>
<comment>Lunch</comment>
</expense>
</expenses>
Для простоти заведемо модель витрати (якщо у вашому проєкті вона вже є — використовуйте її). Тут важлива не модель, а саме DOM‑витягування.
data class Expense(
val id: String,
val amount: Double,
val category: String,
val comment: String
)
Тепер зробимо невеликого «помічника»: «дістаньте текст першого тега tag усередині parent». Зверніть увагу: ми повертаємо String?, бо елемент може бути відсутнім.
import org.w3c.dom.Element
fun firstTagText(parent: Element, tag: String): String? {
val node = parent.getElementsByTagName(tag).item(0) as? Element ?: return null
return node.textContent.trim()
}
А тепер основна функція: розпарсити XML й отримати список Expense.
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun parseExpenses(xml: String): List<Expense> {
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nodes = doc.getElementsByTagName("expense")
val result = mutableListOf<Expense>()
for (i in 0 until nodes.length) {
val expenseEl = nodes.item(i) as? Element ?: continue
val id = expenseEl.getAttribute("id").trim()
val amount = firstTagText(expenseEl, "amount")?.toDoubleOrNull()
val category = firstTagText(expenseEl, "category")
val comment = firstTagText(expenseEl, "comment") ?: ""
if (id.isNotEmpty() && amount != null && category != null) {
result.add(Expense(id, amount, category, comment))
}
}
return result
}
Тут спеціально використано максимально «приземлені» техніки: toDoubleOrNull(), перевірки на null, trim(). Ми поки не будуємо красивої системи помилок — важливо навчитися акуратно читати DOM без падінь. Бо вхідні дані майже завжди «інколи нормальні, інколи… ну, ви зрозуміли».
Перевірмо на міні‑вході:
fun main() {
val xml = """
<expenses>
<expense id="e1">
<amount>120.50</amount>
<category>food</category>
<comment>Lunch</comment>
</expense>
<expense id="e2">
<amount>999</amount>
<category>tech</category>
<comment>Keyboard</comment>
</expense>
</expenses>
""".trimIndent()
val items = parseExpenses(xml)
println(items.size) // 2
println(items.first().id) // e1
}
6. Типові помилки під час DOM‑парсингу
Помилка №1: брати item(0) без перевірки length.
Це найчастіша причина раптових падінь. В XML елемент може бути відсутній: файл старої версії формату, інший постачальник, або просто людський фактор. Якщо ви робите nodes.item(0) as Element, то за порожнього NodeList item(0) поверне null, а далі або впаде приведення, або ви зловите NPE трохи пізніше. Лікується звичкою: «спочатку перевірка, потім доступ» або використанням as? і раннього return.
Помилка №2: очікувати null від getAttribute(...).
Новачки часто пишуть val id = el.getAttribute("id") ?: ..., а потім дивуються, чому ?: не спрацьовує. getAttribute зазвичай повертає порожній рядок, і це окрема гілка логіки. У результаті без trim() і isNotEmpty() ви можете «прийняти» відсутність атрибута так, ніби значення є.
Помилка №3: забути про trim() у textContent і отримати «невидимі» пробіли.
Форматування XML (відступи, переноси рядків) — це теж текст. Тому textContent може містити "\n 120.50\n" замість "120.50". Якщо потім викликати toDouble() (або робити порівняння рядків), ви отримаєте помилки там, де наче «все гарно». У навчальних прикладах це виглядає дрібницею, а в реальному проєкті перетворюється на години налагодження в стилі «чому не парситься число, воно ж число».
Помилка №4: думати, що getElementsByTagName шукає лише серед прямих дітей.
Цей метод шукає по всьому піддереву, і інколи це дає несподівані результати. Ви хотіли знайти <amount> саме всередині конкретного <expense>, а натомість отримали всі <amount> із вкладених блоків (або взагалі з іншого місця, якщо структура складніша). У невеликих документах це не впадає в очі, а потім раптово «перший <amount>» виявляється не тим. На цьому етапі рятує дисципліна: шукати від потрібного елемента (контексту) і тримати структуру XML максимально зрозумілою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ