Pemrosesan XML

Mengurai XML dengan XmlSlurper dan menghasilkan XML dengan MarkupBuilder.

Daftar isi

Groovy menyediakan empat kelas yang menutup hampir seluruh pekerjaan XML sehari-hari, yaitu XmlSlurper untuk membaca, MarkupBuilder untuk menghasilkan, serta XmlParser dan XmlUtil untuk memodifikasi lalu menulis kembali. Sejak Groovy 5, keempat kelas tersebut berada di paket groovy.xml, sehingga setiap skrip perlu mengimpornya secara eksplisit. Topik ini menelusuri keempatnya melalui tiga data netral, yaitu katalog buku perpustakaan, jadwal piket kelas, dan catatan sensor suhu. Setelah membacanya, dokumen XML dapat diurai dengan navigasi GPath, dibangun dari kode Groovy, dimodifikasi dan ditulis ulang, serta diperiksa letak kesalahannya ketika struktur dokumen rusak.

Membaca XML dengan XmlSlurper#

XmlSlurper mengurai dokumen XML menjadi pohon objek GPathResult yang dinavigasi dengan GPath, yaitu gaya penulisan jalur yang memakai nama metode untuk menunjuk elemen anak, tanda @ untuk atribut, dan text() untuk mengambil isi teks. Penulisan katalog.buku berarti seluruh elemen buku yang berada langsung di bawah katalog, sedangkan katalog.buku[0] mengambil elemen pertamanya dengan indeks yang dimulai dari nol. Hasil navigasi ini dapat dihitung dengan size(), diulang dengan each, serta disaring dengan findAll dan collect sebagaimana koleksi biasa. Karena seluruh pohon diurai sekaligus ke memori, XmlSlurper paling tepat dipakai untuk berkas berukuran wajar, bukan untuk aliran XML yang sangat besar.

Skrip berikut menyiapkan berkas katalog.xml sendiri agar selalu dapat dijalankan ulang, lalu membacanya dengan beberapa pola navigasi sekaligus.

groovy baca-katalog.groovy
// baca-katalog.groovy: membaca XML dengan XmlSlurper dan GPath
import groovy.xml.XmlSlurper

def berkas = new File('katalog.xml')
berkas.write('''<?xml version="1.0" encoding="UTF-8"?>
<katalog perpustakaan="kota">
    <buku rak="A1">
        <judul>Algoritma Dasar</judul>
        <tahun>2019</tahun>
        <pengarang>Ali</pengarang>
        <pengarang>Budi</pengarang>
    </buku>
    <buku rak="B2">
        <judul>Struktur Data</judul>
        <tahun>2021</tahun>
        <pengarang>Citra</pengarang>
    </buku>
</katalog>''')

def katalog = new XmlSlurper().parse(berkas)

println "Nama root    : ${katalog.name()}"
println "Atribut root : ${katalog.@perpustakaan}"
println "Jumlah buku  : ${katalog.buku.size()}"
println "Judul pertama: ${katalog.buku[0].judul.text()}"
println "Pengarang    : ${katalog.buku[0].pengarang.collect { it.text() }.join(', ')}"

println '--- seluruh buku'
katalog.buku.each { b ->
    println "${b.@rak}: ${b.judul.text()} (${b.tahun.text()})"
}

println '--- pencarian lintas kedalaman'
println "Anak langsung : ${katalog.'*'.collect { it.name() }}"
katalog.'**'.findAll { it.name() == 'pengarang' }.each { println "Pengarang     : ${it.text()}" }
text Keluaran
Nama root    : katalog
Atribut root : kota
Jumlah buku  : 2
Judul pertama: Algoritma Dasar
Pengarang    : Ali, Budi
--- seluruh buku
A1: Algoritma Dasar (2019)
B2: Struktur Data (2021)
--- pencarian lintas kedalaman
Anak langsung : [buku, buku]
Pengarang     : Ali
Pengarang     : Budi
Pengarang     : Citra

Keluaran di atas memperlihatkan navigasi bertingkat sekaligus pencarian lintas kedalaman. Baris pertama sampai kelima membaca root beserta anaknya, di antaranya katalog.buku[0].pengarang yang mengembalikan dua elemen sekaligus sehingga perlu dikumpulkan lewat collect sebelum digabung dengan join. Perulangan each menyalurkan setiap buku ke closure, dan atribut rak diambil dengan b.@rak. Pada bagian terakhir, jalur '**' menelusuri seluruh kedalaman pohon sehingga ketiga elemen pengarang yang tersarang di dalam buku ditemukan, sedangkan jalur '*' hanya menunjuk anak langsung root, yaitu dua elemen buku.

MarkupBuilder menghasilkan dokumen XML dari kode Groovy dengan tiga aturan penafsiran, yaitu setiap pemanggilan metode menjadi elemen, map pertama menjadi atribut, dan argumen string menjadi isi teks, sedangkan closure yang mengikutinya menjadi isi elemen. Mekanisme ini bekerja melalui MOP (Metaobject Protocol), karena metode seperti jadwal atau petugas tidak terdefinisi di kelas mana pun dan disadap oleh methodMissing lalu ditafsirkan sebagai elemen baru. Keluarannya ditampung ke StringWriter dan setelah itu diperlakukan sebagai teks biasa. Deklarasi XML tidak tersedia sebagai metode, sehingga ditulis sendiri ke penampung sebelum dokumen dibangun. Pemanggilan xmlDeclaration() justru dianggap elemen kosong bernama xmlDeclaration. Untuk teks campuran yang berselang-seling dengan elemen anak, mkp.yield menuliskan teks mentah pada posisi tersebut.

groovy tulis-jadwal.groovy
// tulis-jadwal.groovy: menghasilkan XML dengan MarkupBuilder
import groovy.xml.MarkupBuilder

def penampung = new StringWriter()
penampung.write('<?xml version="1.0" encoding="UTF-8"?>\n')
def xml = new MarkupBuilder(penampung)

xml.jadwal(pekan: '42') {
    senin { petugas('Ali') }
    selasa { petugas('Budi') }
    rabu {
        petugas('Citra')
        catatan { mkp.yield 'bertukar dengan '; penting('petugas jaga malam') }
    }
}

def hasil = penampung.toString()
print hasil
println()
new File('jadwal-piket.xml').write(hasil)
println "Tersimpan: jadwal-piket.xml"
text Keluaran
<?xml version="1.0" encoding="UTF-8"?>
<jadwal pekan='42'>
  <senin>
    <petugas>Ali</petugas>
  </senin>
  <selasa>
    <petugas>Budi</petugas>
  </selasa>
  <rabu>
    <petugas>Citra</petugas>
    <catatan>bertukar dengan 
      <penting>petugas jaga malam</penting>
    </catatan>
  </rabu>
</jadwal>
Tersimpan: jadwal-piket.xml

Keluaran memperlihatkan atribut pekan diapit kutip tunggal beserta indentasi dua spasi, keduanya format bawaan MarkupBuilder. Elemen catatan memuat teks campuran, yaitu kalimat dari mkp.yield yang berlanjut ke elemen penting, dan builder menyisipkan tanda baris baru beserta indentasinya di sekitar elemen anak tersebut. Baris terakhir menuliskan dokumen yang sama ke berkas jadwal-piket.xml, sehingga hasilnya langsung siap dipakai oleh pembaca XML lain, misalnya XmlSlurper.

Memodifikasi XML dengan XmlParser dan XmlUtil#

XmlSlurper nyaman untuk membaca, tetapi hasilnya tidak dirancang untuk menambah atau menghapus simpul, sehingga pekerjaan modifikasi memakai XmlParser yang mengembalikan pohon Node, yaitu kelas groovy.util.Node yang isinya dapat diubah. Nilai atribut diganti melalui map yang dikembalikan attributes(), elemen baru ditambahkan dengan appendNode, dan elemen yang tidak diperlukan dibuang dengan remove. Setelah pohon selesai diubah, XmlUtil.serialize menyusunnya kembali menjadi teks XML yang siap ditulis ke berkas. Karena seluruh perubahan terjadi pada pohon yang sama, mengganti atribut, menambah, dan menghapus dapat dilakukan berurutan sebelum hasilnya ditulis sekali di akhir.

groovy ubah-catatan.groovy
// ubah-catatan.groovy: memodifikasi XML dengan XmlParser dan XmlUtil
import groovy.xml.XmlParser
import groovy.xml.XmlSlurper
import groovy.xml.XmlUtil

def berkas = new File('catatan-sensor.xml')
berkas.write('''<?xml version="1.0" encoding="UTF-8"?>
<catatan sensor="S-01">
    <pengukuran waktu="07.00" suhu="24"/>
    <pengukuran waktu="13.00" suhu="27"/>
    <pengukuran waktu="17.00" suhu="25"/>
</catatan>''')

def catatan = new XmlParser().parse(berkas)

// ganti nilai atribut pada root dan pada pengukuran ketiga
catatan.attributes().sensor = 'S-02'
catatan.pengukuran[2].attributes().suhu = '26'

// tambah satu pengukuran baru, lalu hapus pengukuran pertama
catatan.appendNode('pengukuran', [waktu: '21.00', suhu: '22'])
catatan.remove(catatan.pengukuran[0])

// tulis kembali ke berkas
berkas.write(XmlUtil.serialize(catatan))

// baca ulang dengan XmlSlurper untuk membuktikan perubahan bertahan
def hasil = new XmlSlurper().parse(berkas)
println "Sensor: ${hasil.@sensor}, jumlah pengukuran: ${hasil.pengukuran.size()}"

print XmlUtil.serialize(hasil)
text Keluaran
Sensor: S-02, jumlah pengukuran: 3
<?xml version="1.0" encoding="UTF-8"?><catatan sensor="S-02">
  <pengukuran waktu="13.00" suhu="27"/>
  <pengukuran waktu="17.00" suhu="26"/>
  <pengukuran waktu="21.00" suhu="22"/>
</catatan>

Keluaran membuktikan keempat perubahan sekaligus, yaitu sensor berganti menjadi S-02, suhu pukul 17.00 menjadi 26, satu pengukuran pukul 21.00 bertambah, dan pengukuran pertama terhapus sehingga jumlahnya tetap tiga. XmlUtil.serialize menerima Node maupun GPathResult, tetapi keduanya dicetak oleh penulis yang berbeda, yaitu versi Node memuat baris kosong berisi spasi antarelemen, sedangkan versi GPathResult lebih ringkas. Karena itu isi berkas pada keluaran dicetak melalui hasil bacaan XmlSlurper, dan pembuka <?xml memang menempel pada tag root karena begitulah penulisnya bekerja. Berkas catatan-sensor.xml tetap XML yang valid dan terbaca kembali tanpa masalah.

Menangani XML rusak#

XML tidak memberi toleransi pada kesalahan struktur, sehingga satu tag yang tidak tertutup membuat seluruh dokumen gagal diurai. Ketika hal itu terjadi, parser mencetak pesan [Fatal Error] ke standard error sebelum melempar SAXParseException, sehingga kedua jejak tersebut muncul berurutan pada keluaran skrip. SAXParseException membawa posisi kejadian melalui properti lineNumber dan columnNumber, sedangkan message berisi penjelasan dari parser. Dengan menangkap SAXParseException secara spesifik, skrip dapat melaporkan letak kesalahan dengan ringkas tanpa menampilkan jejak tumpukan penuh, dan error lain di luar keluarga SAX tetap menyebar tanpa tersamarkan.

groovy Contoh: menangkap SAXParseException
// XML rusak: tag penutup yang tidak lengkap
import groovy.xml.XmlSlurper
import org.xml.sax.SAXParseException

def rusak = '''<?xml version="1.0" encoding="UTF-8"?>
<catatan>
    <pengukuran suhu="24">
</catatan>'''

try {
    new XmlSlurper().parseText(rusak)
} catch (SAXParseException e) {
    println "Gagal : ${e.getClass().simpleName}"
    println "Baris : ${e.lineNumber}, kolom ${e.columnNumber}"
    println "Pesan : ${e.message}"
}
text Keluaran
[Fatal Error] :4:3: The element type "pengukuran" must be terminated by the matching end-tag "</pengukuran>".
Gagal : SAXParseException
Baris : 4, kolom 3
Pesan : The element type "pengukuran" must be terminated by the matching end-tag "</pengukuran>".

Keluaran di atas terdiri atas dua bagian yang sumbernya berbeda. Baris [Fatal Error] dicetak oleh penangan error bawaan parser sebelum exception dilempar, dan angkanya menunjuk baris 4 kolom 3, yaitu posisi tag penutup catatan yang ditemui saat elemen pengukuran belum tertutup. Tiga baris berikutnya berasal dari blok catch yang membaca posisi kejadian langsung dari exception-nya. Pesan semacam ini biasanya dibutuhkan ketika XML datang dari sistem lain, karena nomor barisnya langsung menunjuk tempat dokumen rusak.

Kesalahan umum#

Kesalahan pertama adalah mengimpor kelas XML dari paket lamanya, misalnya groovy.util.XmlSlurper. Sejak Groovy 5, kelas-kelas XML pindah ke paket groovy.xml, sehingga impor lama gagal pada saat kompilasi dengan pesan unable to resolve class, bukan saat skrip berjalan. Karena kegagalan itu terjadi sebelum kode dieksekusi, contoh berikut menguji potongan kecilnya melalui GroovyShell agar skrip utuh tetap dapat menampilkan hasilnya. Perbaikannya sederhana, yaitu memakai paket baru pada seluruh impor.

groovy Contoh: impor paket lama
// Kegagalan impor paket lama terjadi saat kompilasi, sehingga diuji lewat GroovyShell
def kodeLama = '''
import groovy.util.XmlSlurper

def dokumen = new XmlSlurper().parseText('<pesan><isi>halo</isi></pesan>')
println dokumen.isi.text()
'''

try {
    new GroovyShell().evaluate(kodeLama, 'KodeLama.groovy')
} catch (Exception e) {
    println "Gagal: ${e.getClass().simpleName}"
    println e.message.readLines().findAll { it.contains('unable to resolve') }[0].trim()
}
text Keluaran
Gagal: MultipleCompilationErrorsException
KodeLama.groovy: 2: unable to resolve class groovy.util.XmlSlurper

Pesan error menyebut baris 2 dari potongan yang dikompilasi, tepat pada baris impor yang tidak lagi valid. Nama KodeLama.groovy pada pesan berasal dari argumen kedua evaluate, yaitu nama yang dipakai kompilator untuk menunjuk sumber kegagalan. Pola yang sama berlaku untuk XmlParser, MarkupBuilder, XmlUtil, dan kelas XML lain yang ikut pindah ke groovy.xml, sehingga seluruh impor perlu diperbarui sekaligus.

Kesalahan kedua adalah menganggap bahwa nama elemen yang salah ketik akan melempar error. Navigasi GPath pada elemen yang tidak ada tidak melempar apa pun, melainkan mengembalikan kumpulan kosong. Atribut yang tidak ada pun menghasilkan string kosong. Akibatnya data dapat hilang diam-diam, misalnya laporan yang selalu kosong karena nama elemennya keliru, tanpa satu pun pesan error. Cara paling murah untuk menemukannya adalah memeriksa size() atau mencetak pohon dengan XmlUtil.serialize selama pengembangan.

groovy Contoh: elemen dan atribut yang tidak ada
// Elemen atau atribut yang tidak ada bukan error: hasilnya kumpulan kosong
import groovy.xml.XmlSlurper

def katalog = new XmlSlurper().parseText('<katalog><buku rak="A1"><judul>Algoritma Dasar</judul></buku></katalog>')

println "buku ada      : ${katalog.buku.size()}"
println "bukus (salah) : ${katalog.bukus.size()}"
println "buku[0]       : ${katalog.buku[0].name()}"
println "buku[1]       : ${katalog.buku[1].size()}"
println "edisi         : '${katalog.buku[0].@edisi}'"
text Keluaran
buku ada      : 1
bukus (salah) : 0
buku[0]       : buku
buku[1]       : 0
edisi         : ''

Keluaran memperlihatkan bahwa bukus yang tidak pernah ada menghasilkan nol, indeks di luar jumlah anak mengembalikan kumpulan kosong, dan atribut edisi yang tidak ada berupa string kosong. Nama bukus pada contoh sengaja berbeda satu huruf dari buku, karena kesalahan ketik sekecil itu paling sering lolos saat data dibaca. Tidak ada satu pun baris di atas yang melempar exception, sehingga kesalahan semacam ini hanya terlihat dari data yang tidak muncul.

Ringkasan#

  • XmlSlurper mengurai XML menjadi GPathResult yang dinavigasi dengan nama elemen, @atribut, text(), dan indeks dari nol, sedangkan '*' menunjuk anak langsung dan '**' menelusuri seluruh kedalaman pohon.
  • MarkupBuilder menafsirkan pemanggilan metode sebagai elemen, map sebagai atribut, dan closure sebagai isi, dengan kutip tunggal serta indentasi bawaan, dan mkp.yield untuk teks campuran.
  • XmlParser mengembalikan pohon yang dapat diubah melalui attributes(), appendNode, dan remove, lalu XmlUtil.serialize menuliskannya kembali sebagai teks XML.
  • XML rusak melempar SAXParseException yang membawa lineNumber dan columnNumber, dan seluruh kelas XML berada di paket groovy.xml sejak Groovy 5.

Referensi#