IO berkas ala GDK

Membaca dan menulis berkas dengan eachLine, withReader, traverse, dan pengelolaan resource otomatis.

Daftar isi

Metode ekstensi GDK (Groovy Development Kit) menambahkan kemampuan praktis pada kelas java.io.File, sehingga pembacaan, penulisan, dan penjelajahan berkas selesai dalam beberapa baris tanpa kelas bantu yang ditulis sendiri. Topik ini memperluas pengenalan singkat yang dimulai pada topik metode ekstensi GDK. Di dalamnya, isi berkas dibaca dengan text, readLines, eachLine, withReader, dan bytes, ditulis dengan write, append, dan withWriter, resource dikelola otomatis, folder dijelajahi, serta utilitas kecil seperti exists, mkdirs, dan length dipakai. Setelah membacanya, pekerjaan berkas pada skrip otomasi sehari-hari dapat ditangani lengkap karena penutupan resource dan struktur folder diurus oleh metode yang dipanggil, sedangkan kebutuhan yang lebih besar ditangani NIO milik JDK.

Membaca isi berkas#

Pembacaan berkas teks memiliki empat jalur GDK dengan tingkat kontrol yang bertingkat, yaitu properti text untuk seluruh isi sekaligus, readLines untuk daftar baris, eachLine untuk pemrosesan per baris, dan withReader untuk pembacaan yang diatur di dalam closure. Properti text paling ringkas tetapi menampung seluruh isi dalam memori, sehingga cocok untuk berkas kecil seperti arsip catatan. Metode readLines memecah isi menjadi List<String> yang setiap elemennya dapat diindeks. Metode eachLine menyalurkan baris demi baris ke closure dan mengirim nomor baris mulai dari 1 sebagai argumen kedua, sehingga perhitungan nomor tidak perlu ditulis sendiri. Skrip berikut menyiapkan arsip catatan contoh, lalu membacanya dengan tiga jalur pertama.

groovy baca-catatan.groovy
// baca-catatan.groovy: membaca isi berkas dengan metode ekstensi GDK
def arsip = new File('arsip-catatan.txt')
// berkas contoh disiapkan dengan metode write()
arsip.write('21 Januari: merapikan arsip\n' +
            '22 Januari: menandai catatan lama\n' +
            '23 Januari: menyusun indeks\n')

println '--- properti text'
println arsip.text

println '--- readLines'
println "Jumlah baris: ${arsip.readLines().size()}"
println "Baris kedua : ${arsip.readLines()[1]}"

println '--- eachLine dengan nomor baris'
arsip.eachLine { baris, nomor -> println "${nomor}: ${baris}" }
text Keluaran
--- properti text
21 Januari: merapikan arsip
22 Januari: menandai catatan lama
23 Januari: menyusun indeks

--- readLines
Jumlah baris: 3
Baris kedua : 22 Januari: menandai catatan lama
--- eachLine dengan nomor baris
1: 21 Januari: merapikan arsip
2: 22 Januari: menandai catatan lama
3: 23 Januari: menyusun indeks

Keluaran di atas memperlihatkan perbedaan ketiganya secara langsung. Properti text mengembalikan isi persis sebagaimana tersimpan, termasuk baris kosong yang berasal dari tanda baris baru pada ujung berkas. Metode readLines mengabaikan baris kosong penutup dan menghasilkan tepat tiga elemen, sedangkan eachLine melaporkan nomor baris tanpa perhitungan manual. Untuk isi biner, properti bytes membaca berkas sebagai array byte, dan penugasan ke bytes menulis isi tersebut balik ke berkas yang sama. Pembacaan yang lebih terarah memakai withReader, yaitu membuka Reader yang dibaca baris demi baris dengan readLine(), sehingga isi diperoleh secara bertahap dan pembacaan dapat berhenti lebih awal begitu baris yang dicari ditemukan.

groovy Contoh: withReader dan pembacaan byte
def arsip = new File('arsip-catatan.txt')
arsip.write('21 Januari: merapikan arsip\n' +
            '22 Januari: menandai catatan lama\n' +
            '23 Januari: menyusun indeks\n')

// withReader: Reader dibuka dan ditutup otomatis
arsip.withReader { pembaca ->
    println "Baris pertama : ${pembaca.readLine()}"
    println "Baris kedua   : ${pembaca.readLine()}"
}

// bytes: membaca dan menulis isi biner
def stempel = new File('stempel-arsip.bin')
stempel.bytes = [0x47, 0x44, 0x4B] as byte[]
def bacaan = stempel.bytes
println "Jumlah byte : ${bacaan.length}"
println "Isi teksnya : ${new String(bacaan, 'US-ASCII')}"
text Keluaran
Baris pertama : 21 Januari: merapikan arsip
Baris kedua   : 22 Januari: menandai catatan lama
Jumlah byte : 3
Isi teksnya : GDK

Fragmen tersebut menggabungkan dua kebutuhan yang berbeda. withReader menyediakan Reader untuk teks yang dibaca bertahap, sedangkan bytes menangani isi biner seperti stempel pengenal dalam bentuk tiga byte yang kebetulan juga terbaca sebagai teks GDK. Keduanya menutup sumber datanya sendiri sesuai pola yang dibedah pada bagian berikutnya.

Penulisan isi berkas memakai pasangan metode yang setara dengan pembacaannya, yaitu write untuk menimpa isi dari awal, append untuk menambah di akhir, dan withWriter untuk menulis banyak baris melalui satu penulis. Metode write membuat berkas baru bila belum ada dan mengganti seluruh isinya bila sudah ada, sedangkan append menambahkan teks tanpa menyentuh isi lama. Sebagai jalan pintas, penugasan ke properti text adalah bentuk singkat dari write. Skrip berikut merekam catatan suhu sensor dengan ketiga metode itu, lalu memanggil write untuk kedua kalinya agar efek penimpaan terlihat jelas.

groovy tulis-catatan.groovy
// tulis-catatan.groovy: menimpa, menambah, dan menulis lewat penulis
def catatan = new File('catatan-sensor.txt')
catatan.write('07.00, suhu 24\n')
catatan.append('13.00, suhu 27\n')
catatan.append('17.00, suhu 25\n')
catatan.eachLine { println it }

// write() kedua menghapus seluruh isi lama tanpa peringatan
catatan.write('07.00, suhu 24\n')
println '--- sesudah write() ulang'
catatan.eachLine { println it }

// withWriter: banyak baris ditulis melalui satu penulis
catatan.withWriter { penulis ->
    penulis.writeLine('07.00, suhu 24')
    penulis.writeLine('13.00, suhu 27')
    penulis.writeLine('17.00, suhu 25')
}
println '--- hasil withWriter'
catatan.eachLine { println it }
text Keluaran
07.00, suhu 24
13.00, suhu 27
17.00, suhu 25
--- sesudah write() ulang
07.00, suhu 24
--- hasil withWriter
07.00, suhu 24
13.00, suhu 27
17.00, suhu 25

Tiga bagian keluaran menelusuri siklusnya secara berurutan. Bagian pertama berisi tiga baris, yaitu hasil write awal ditambah dua panggilan append. Bagian kedua menyisakan satu baris saja karena write kedua menghapus seluruh isi lama tanpa konfirmasi. Bagian ketiga menulis ulang tiga baris sekaligus melalui withWriter, yaitu metode yang menerima closure dengan Writer sebagai parameternya. Metode writeLine milik penulis itulah yang menambahkan tanda baris baru pada setiap panggilan. Untuk kebutuhan serupa dalam mode tambah, GDK menyediakan withWriterAppend yang bekerja seperti withWriter tetapi mempertahankan isi lama.

Pengelolaan resource otomatis#

Keluarga metode with* mengurus resource dengan pola yang seragam, yaitu sumber daya seperti berkas terbuka yang wajib ditutup setelah selesai dipakai. Metode membuka resource, menjalankan closure yang menerimanya, lalu menutup resource itu kembali, dan penutupan tetap terjadi sekalipun closure melempar exception. Pola ini menggantikan bentuk manual Java yang menaruh close di dalam blok finally. Metode pada berkas yang mengikuti pola ini antara lain withReader, withWriter, withWriterAppend, withInputStream, dan withOutputStream, sedangkan withCloseable berlaku untuk setiap objek Closeable. Karena penutupan pada berkas sungguhan sulit diamati, skrip berikut membuktikannya dengan kelas Writer uji yang penghitung statisnya bertambah setiap kali close dipanggil.

groovy Contoh: close tetap dipanggil meski terjadi exception
// Kelas Writer uji: setiap close() menambah penghitung statis
class PenulisUji extends Writer {
    static int jumlahTutup = 0
    void write(char[] cbuf, int off, int len) { }
    void flush() { }
    void close() { jumlahTutup += 1 }
}

// withCloseable menutup resource meski closure melempar exception
try {
    new PenulisUji().withCloseable { penulis ->
        penulis.write('data')
        throw new RuntimeException('simulasi kegagalan')
    }
} catch (RuntimeException e) {
    println "dengan withCloseable : close terpanggil ${PenulisUji.jumlahTutup} kali"
}

// penutupan manual tanpa try/finally: exception memotong jalur ke close()
def tugasGagal = { throw new RuntimeException('simulasi kegagalan') }
try {
    PenulisUji.jumlahTutup = 0
    def penulis = new PenulisUji()
    penulis.write('data')
    tugasGagal()
    penulis.close()          // baris ini tidak pernah tercapai
} catch (RuntimeException e) {
    println "tanpa try/finally    : close terpanggil ${PenulisUji.jumlahTutup} kali"
}
text Keluaran
dengan withCloseable : close terpanggil 1 kali
tanpa try/finally    : close terpanggil 0 kali

Keluaran membuktikan dua jalur sekaligus. Pemanggilan withCloseable yang gagal di tengah closure tetap meninggalkan penghitung bernilai satu, yang berarti close tetap dijalankan ketika exception menyebar keluar. Pada blok kedua, penutupan yang ditulis manual tanpa try dan finally terpotong oleh exception yang sama, sehingga penghitung tetap nol dan resource tertinggal terbuka. Penghitung dipakai alih-alih mencetak sesuatu dari close, karena penutupan pada Writer uji tidak menghasilkan keluaran yang dapat diamati. Mekanisme yang sama berlaku pada withReader dan withWriter milik File, sehingga pilihan with* bukan sekadar pemendekan baris, melainkan jaminan penutupan yang mudah luput bila ditulis manual.

Menjelajah isi folder#

Penjelajahan folder tersedia dalam tiga metode dengan kedalaman yang berbeda, yaitu eachFile untuk satu level, eachFileRecurse untuk seluruh turunan, dan traverse untuk kunjungan yang dapat diatur lewat opsi. Ketiganya menerima closure yang dipanggil untuk setiap objek File yang ditemukan. Metode traverse menerima opsi berbentuk map, antara lain type dengan nilai FileType.FILES untuk berkas saja, filter berupa closure pemilih, dan sort berupa closure pembanding untuk mengurutkan kunjungan. Kelas FileType berada di paket groovy.io, sehingga skrip perlu mengimpornya terlebih dahulu. Skrip berikut menyiapkan folder arsip contoh, lalu menjelajahnya dengan ketiga metode tersebut.

groovy jelajah-direktori.groovy
// jelajah-direktori.groovy: menjelajah folder dengan tiga metode GDK
import groovy.io.FileType

// siapkan struktur arsip contoh. deleteDir menghapus sisa uji sebelumnya
def arsip = new File('data/arsip')
arsip.deleteDir()
arsip.mkdirs()
new File(arsip, 'catatan-a.txt').write('isi A')
new File(arsip, 'catatan-b.txt').write('isi B')
def lampiran = new File(arsip, 'lampiran')
lampiran.mkdirs()
new File(lampiran, 'lampiran-1.txt').write('isi lampiran 1')
new File(lampiran, 'lampiran-2.txt').write('isi lampiran 2')

println '--- eachFile: satu level'
def levelPertama = []
arsip.eachFile { f -> levelPertama << "${f.name} (${f.isDirectory() ? 'folder' : 'berkas'})" }
levelPertama.sort().each { println it }

println '--- eachFileRecurse: seluruh berkas turunan'
def seluruhnya = []
arsip.eachFileRecurse(FileType.FILES) { f -> seluruhnya << f.path }
seluruhnya.sort().each { println it }

println '--- traverse: berkas catatan saja, terurut'
arsip.traverse(type: FileType.FILES, filter: { it.name.startsWith('catatan') },
               sort: { a, b -> a.name <=> b.name }) { f -> println f.path }
text Keluaran
--- eachFile: satu level
catatan-a.txt (berkas)
catatan-b.txt (berkas)
lampiran (folder)
--- eachFileRecurse: seluruh berkas turunan
data/arsip/catatan-a.txt
data/arsip/catatan-b.txt
data/arsip/lampiran/lampiran-1.txt
data/arsip/lampiran/lampiran-2.txt
--- traverse: berkas catatan saja, terurut
data/arsip/catatan-a.txt
data/arsip/catatan-b.txt

Bagian pertama hanya menyentuh level teratas, sehingga folder lampiran muncul sebagai satu entri berlabel folder. Bagian kedua memakai FileType.FILES sehingga hanya berkas yang dilaporkan, termasuk dua berkas yang berada di dalam lampiran. Bagian ketiga mempersempit hasil lewat filter dan mengurutkan kunjungan dengan closure pembanding sort yang memakai operator <=> sebagaimana lazim pada pengurutan koleksi. Untuk kebutuhan yang lebih besar, misalnya pohon folder yang dalam atau aturan kunjungan yang kompleks, JDK menyediakan pasangan modernnya lewat NIO, yaitu Path dan Files.walkFileTree dalam paket java.nio.file. GDK tetap menjadi pilihan ringkas untuk skrip otomasi sehari-hari.

Utilitas kecil pada File#

Di luar isi berkasnya, File membawa utilitas kecil untuk memeriksa dan merapikan keberadaan berkas serta folder. Metode exists menguji keberadaan, isFile dan isDirectory membedakan jenisnya, length mengukur ukuran dalam byte, mkdirs membuat folder lengkap dengan seluruh induknya, delete menghapus berkas, dan deleteDir menyapu folder beserta isinya. Nilai kembalian metode-metode ini berupa boolean, sehingga tahap demi tahapnya mudah ditelusuri lewat keluaran. Pemeriksaan exists sebelum menjalankan aksi juga menjadi pembuka yang wajar pada skrip yang dijalankan ulang. Skrip berikut mengikuti kehidupan singkat sebuah daftar belanja dan satu folder arsip tahunan.

groovy Contoh: utilitas kecil pada File
def belanja = new File('daftar-belanja.txt')
belanja.delete()          // bersihkan sisa uji sebelumnya
println "Sebelum dibuat : ada=${belanja.exists()}"
belanja.write('beras\nminyak goreng\ntelur\n')
println "Sesudah dibuat : ada=${belanja.exists()}, berkas=${belanja.isFile()}"
println "Ukuran         : ${belanja.length()} byte"

// folder dibuat berjenjang sampai induknya sekalian
def arsipBaru = new File('data/arsip-2026')
arsipBaru.deleteDir()
println "Folder dibuat  : ${arsipBaru.mkdirs()}"
println "Folder ada     : ${arsipBaru.isDirectory()}"
println "Hapus berkas   : ${belanja.delete()}"
println "Sesudah dihapus: ada=${belanja.exists()}"
text Keluaran
Sebelum dibuat : ada=false
Sesudah dibuat : ada=true, berkas=true
Ukuran         : 26 byte
Folder dibuat  : true
Folder ada     : true
Hapus berkas   : true
Sesudah dihapus: ada=false

Keluaran menelusuri setiap tahap secara berurutan, dari ketiadaan berkas, keberadaannya bersama ukuran 26 byte, pembuatan folder, hingga penghapusannya kembali. Perhatikan bahwa length dipanggil dengan tanda kurung karena File tidak memiliki properti length dan penulisan tanpa tanda kurung melempar MissingPropertyException. Nilai true yang dikembalikan mkdirs berarti pembuatan berhasil pada pemanggilan itu, sedangkan pemanggilan kedua pada folder yang sudah ada mengembalikan false tanpa melempar error. Untuk folder yang masih berisi isi, delete mengembalikan false, dan penghapusannya memakai deleteDir yang menyapu seluruh turunannya.

Kesalahan umum#

Kesalahan pertama adalah membaca berkas yang belum ada. Pembacaan apa pun pada File yang tidak ada melempar FileNotFoundException, dan pesan lengkapnya menyebut alamat berkas yang gagal dibaca. Ketiadaan berkas kadang memang kondisi yang wajar, misalnya pada skrip yang dijalankan pertama kali, sehingga penanganannya dipilih sesuai situasi.

groovy Contoh: membaca berkas yang belum ada
try {
    new File('catatan-tidak-ada.txt').text
} catch (FileNotFoundException e) {
    println "Gagal: ${e.getClass().simpleName}"
}
text Keluaran
Gagal: FileNotFoundException

Praktik yang sehat adalah memeriksa exists terlebih dahulu bila ketiadaan berkas adalah kondisi yang diharapkan, dan menangkap exception bila ketiadaannya berarti gangguan yang perlu dilaporkan. Penangkapan pada contoh menyasar FileNotFoundException secara spesifik, sehingga error lain tetap menyebar dan tidak tersamarkan. Perlu diingat, exception yang sama juga muncul bila folder induk berkas belum ada, sehingga konteks pemanggilan yang menentukan penanganannya.

Kesalahan kedua adalah menulis ke alamat yang folder induknya belum ada. Metode write membuat berkasnya, tetapi tidak membuat foldernya, sehingga penulisan ke folder-tidak-ada/laporan.txt melempar FileNotFoundException yang sama dengan kasus pembacaan.

groovy Contoh: menulis tanpa folder induk
new File('folder-tidak-ada').deleteDir()      // bersihkan sisa uji sebelumnya
def laporan = new File('folder-tidak-ada/laporan.txt')
try {
    laporan.write('laporan harian')
} catch (FileNotFoundException e) {
    println "Gagal: ${e.getClass().simpleName}"
}
laporan.parentFile.mkdirs()
laporan.write('laporan harian')
println "Sesudah folder dibuat: ada=${laporan.exists()}"
text Keluaran
Gagal: FileNotFoundException
Sesudah folder dibuat: ada=true

Perbaikannya memakai properti parentFile, yaitu objek File yang menunjuk folder induk berkas tersebut. Memanggil parentFile.mkdirs() lebih dahulu membuat seluruh induknya, lalu penulisan diulang dan berhasil. Pola ini lazim dipakai pada skrip yang menyusun struktur keluarannya sendiri, misalnya membuat folder per bulan sebelum menulis catatan harian.

Ringkasan#

  • Properti text, metode readLines, eachLine, withReader, dan bytes menutup kebutuhan pembacaan teks maupun biner, sedangkan write, append, withWriter, dan withWriterAppend menutup kebutuhan penulisannya.
  • Metode with* membuka resource, menjalankan closure, dan menutupnya kembali meski exception terjadi, sehingga blok try dan finally manual tidak perlu ditulis.
  • eachFile, eachFileRecurse, dan traverse menjelajah folder dengan kedalaman serta penyaringan yang berbeda, dan urutan kunjungannya mengikuti sistem berkas sehingga perlu diurutkan bila keluaran harus stabil.
  • Utilitas exists, isFile, length, mkdirs, delete, dan deleteDir mengelola keberadaan berkas dan folder, sedangkan penjelajahan berskala besar diserahkan ke java.nio.file.

Referensi#