JavaRush /Курси /Kotlin SELF /DOM‑парсинг: Document, Element, атрибути й textContent

DOM‑парсинг: Document, Element, атрибути й textContent

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

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) Що це означає в коді
Документ цілком
org.w3c.dom.Document
«Коробка», усередині якої — усе дерево
Тег <user>...</user>
org.w3c.dom.Element
Вузол‑елемент: має ім’я тега, атрибути й вміст
Атрибут id="42"
Element.getAttribute("id")
Дістаємо рядкове значення атрибута
Набір однотипних тегів <item>...</item>
org.w3c.dom.NodeList
Колекція зі світу DOM: довжина + доступ за індексом
Текст усередині тега
Element.textContent
Увесь текст усередині елемента (часто з пробілами й переносами)

А тепер важливий «підводний камінь» для новачків: 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 максимально зрозумілою.

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