@Singleton dan @Log

Pola satu instans dan anotasi logging @Log dalam pekerjaan sehari-hari.

Daftar isi

@Singleton dan @Log menghapus dua jenis boilerplate yang masih sering ditulis manual, yaitu pola satu instans (singleton) dan pencatatan peristiwa (log). Topik ini membahas akses instans tunggal melalui getInstance(), perbedaan mode eager dan lazy, penolakan pemanggilan new, serta opsi property dan strict. Dua bahasan terakhir menyusuri keluarga anotasi logging pihak ketiga dan formatter kustom yang membuat keluaran log stabil. Contoh-contohnya dijalankan pada Groovy 5.1.3, dan setiap keluaran yang tercantum adalah keluaran aslinya.

Instans tunggal dengan @Singleton#

@Singleton ditempelkan pada kelas untuk menghasilkan satu instans bersama. Anotasi ini menambahkan field statis bernama instance, metode akses getInstance(), dan konstruktor pribadi yang menjaga jalur pembuatan objek. Secara default anotasi bekerja secara eager, yaitu instans dibangun pada saat kelasnya diinisialisasi, bukan saat program mulai berjalan. Inisialisasi kelas sendiri terjadi pada sentuhan pertama terhadap kelas tersebut, sehingga pembangunan instans ikut tertunda hingga saat itu. Pemanggilan getInstance() maupun pembacaan properti instance mengembalikan objek yang sama.

groovy konfigurasi-aplikasi.groovy
import groovy.lang.Singleton
import java.lang.reflect.Modifier

// Satu instans bersama untuk seluruh program
@Singleton
class KonfigurasiAplikasi {
    Map<String, String> nilai = [lingkungan: 'dev', batasWaktu: '30']
}

def bidang = KonfigurasiAplikasi.getDeclaredField('instance')
println "deklarasi field : ${Modifier.toString(bidang.modifiers)}"
println "instans tunggal : ${KonfigurasiAplikasi.instance.is(KonfigurasiAplikasi.getInstance())}"

KonfigurasiAplikasi.instance.nilai.lingkungan = 'prod'
println "setelah diubah  : ${KonfigurasiAplikasi.instance.nilai.lingkungan}"

try {
    new KonfigurasiAplikasi()
} catch (RuntimeException e) {
    println "Gagal: ${e.getClass().simpleName}"
    println "Pesan: ${e.message}"
}
text Keluaran
deklarasi field : public static final
instans tunggal : true
setelah diubah  : prod
Gagal: RuntimeException
Pesan: Can't instantiate singleton KonfigurasiAplikasi. Use KonfigurasiAplikasi.instance

Hasil refleksi membaca deklarasi field public static final, yang berarti instans sudah terpasang sejak kelas KonfigurasiAplikasi diinisialisasi. Perbandingan is() menghasilkan true karena instance dan getInstance() menunjuk objek yang sama, dan perubahan nilai lingkungan dari dev menjadi prod langsung terlihat pada setiap akses berikutnya terhadap instans itu. Pemanggilan new di akhir skrip gagal dengan RuntimeException berpesan Can't instantiate singleton KonfigurasiAplikasi. Use KonfigurasiAplikasi.instance. Pesan itu berasal dari konstruktor pribadi hasil anotasi, yang melempar error setiap kali instans resmi sudah ada.

Pola ini cocok untuk keadaan yang memang dimiliki satu saja dan dibagi seluruh program, misalnya konfigurasi aplikasi atau kumpulan sumber daya yang mahal dibuat. Keuntungannya, setiap pemanggil memperoleh objek yang sama tanpa harus mengoper referensinya berulang kali. Konsekuensinya adalah keadaan global, dan keadaan global punya harga pada saat pengujian.

Mode lazy dan jebakan new#

Opsi lazy = true mengubah waktu pembangunan instans. Field hasilnya menjadi private static volatile tanpa nilai awal, dan getInstance() baru membangun instans pada pemanggilan pertama. Prosesnya dilindungi dua pemeriksaan null beserta sinkronisasi terhadap objek Class, pola yang dikenal sebagai double-checked locking, sehingga aman dipanggil dari beberapa thread sekaligus. Mode ini cocok bila pembangunan instans mahal dan belum tentu dipakai.

groovy jadwal-piket.groovy
import groovy.lang.Singleton
import java.lang.reflect.Modifier

// lazy = true: instans dibangun saat akses pertama
@Singleton(lazy = true)
class PengaturPiket {

    // blok inisialisasi instans: penanda kapan objek dibangun
    {
        println 'instans PengaturPiket dibangun'
    }

    List<String> jadwal = ['Senin: Ali', 'Selasa: Budi']
}

def bidang = PengaturPiket.getDeclaredField('instance')
println "deklarasi field : ${Modifier.toString(bidang.modifiers)}"
println "sebelum akses pertama"
def liar = new PengaturPiket()
println "objek liar      : ${liar.getClass().simpleName}"
println "instans resmi   : ${PengaturPiket.instance.jadwal[0]}"
println "dua objek beda  : ${!liar.is(PengaturPiket.instance)}"
text Keluaran
deklarasi field : private static volatile
sebelum akses pertama
instans PengaturPiket dibangun
objek liar      : PengaturPiket
instans PengaturPiket dibangun
instans resmi   : Senin: Ali
dua objek beda  : true

Dua baris instans PengaturPiket dibangun berasal dari dua pembangunan yang berbeda. Pembangunan pertama terjadi ketika skrip memanggil new sebelum akses resmi mana pun, sehingga terbentuk objek liar di luar instans tunggal. Pembangunan kedua terjadi pada akses pertama melalui instance, dan perbandingan is() menegaskan keduanya bukan objek yang sama. Jebakan ini sunyi karena tidak ada error yang muncul, tetapi keadaan yang ditulis ke objek liar tidak pernah terlihat oleh pemakai instans resmi. Karena itu, seluruh akses ke kelas singleton sebaiknya selalu melalui metode akses hasil anotasi.

Setelah akses pertama terjadi, jalur new kembali ditutup sebagaimana mode eager. Penjaganya sama, sehingga perbedaan kedua mode hanya pada waktu pembangunan instans, bukan pada penolakan new. Skrip berikut memanggil instance lebih dahulu, lalu mencoba new yang kini ditolak.

groovy Contoh: new pada singleton lazy setelah akses pertama
import groovy.lang.Singleton

@Singleton(lazy = true)
class PengaturPiket {
    List<String> jadwal = ['Senin: Ali', 'Selasa: Budi']
}

PengaturPiket.instance
try {
    new PengaturPiket()
} catch (RuntimeException e) {
    println "Gagal: ${e.getClass().simpleName}"
    println "Pesan: ${e.message}"
}
text Keluaran
Gagal: RuntimeException
Pesan: Can't instantiate singleton PengaturPiket. Use PengaturPiket.instance

Opsi property dan strict#

Nama field instance dan metode getInstance() bukan aturan yang mengikat. Atribut property mengganti keduanya sekaligus, sehingga @Singleton(property = 'utama') menghasilkan field utama dan metode getUtama(). Penamaan seperti ini berguna ketika pemanggil ingin mengakses instans tanpa metode akses, misalnya PengaturJadwal.utama pada skrip pemakai kelas itu. Bentuk propertinya pun mengikuti nama baru tersebut, sehingga utama dapat dibaca langsung sebagaimana properti kelas biasa. Contoh berikut memeriksa nama field, metode akses, dan pemakaian propertinya.

groovy Contoh: atribut property
import groovy.lang.Singleton

// property mengganti nama field sekaligus nama metode aksesnya
@Singleton(property = 'utama')
class PengaturJadwal {
    List<String> piket = ['Ali', 'Budi']
}

def bidang = PengaturJadwal.getDeclaredField('utama')
println "nama field   : ${bidang.name}"
println "getUtama()   : ${PengaturJadwal.getUtama().piket.join(', ')}"
println "properti     : ${PengaturJadwal.utama.piket.size()}"
text Keluaran
nama field   : utama
getUtama()   : Ali, Budi
properti     : 2

Atribut strict mengatur sikap anotasi terhadap konstruktor yang ditulis manual. Nilai default strict = true membuat kompilasi gagal ketika kelas masih memiliki konstruktor, sedangkan @Singleton(strict = false) membiarkannya tetap ada. Perlu dicatat bahwa penjaga anti-new hanya terpasang pada konstruktor yang dihasilkan anotasi, sehingga kelas dengan strict = false kehilangan perlindungan itu dan pemanggilan new kembali sah. Gunakan opsi ini hanya ketika konstruktor tambahan memang diperlukan, misalnya untuk kelas yang harus tetap bisa dibangun lewat jalur biasa.

Pencatatan peristiwa dengan @Log#

@Log menambahkan field log bertipe java.util.logging.Logger yang siap dipakai di seluruh isi kelas tanpa deklarasi tambahan. Nama logger mengikuti nama kelasnya, sedangkan metode yang boleh dipanggil mengikuti level, yaitu tingkat keparahan pesan pada java.util.logging, di antaranya info, warning, dan severe. Setiap pemanggilan juga dibungkus pemeriksaan isLoggable, sehingga pembentukan pesan yang mahal dilewati ketika levelnya sedang tidak aktif. Atribut value mengganti nama field, sedangkan atribut category mengganti nama logger.

Keluaran java.util.logging pada pengaturan bawaannya kurang cocok untuk skrip yang harus stabil, karena formatnya memuat tanggal dan jam serta tercetak ke aliran error standar. Cara paling ringkas mengarahkan ulang keluarannya adalah memasang handler terpisah, karena metode setOutputStream milik ConsoleHandler bersifat protected dan tidak dapat dipanggil dari luar. Skrip berikut memasang handler dengan formatter kustom yang hanya mencetak LEVEL: pesan, lalu mematikan pewarisan ke handler bawaan melalui useParentHandlers = false.

groovy pencatat-suhu.groovy
import groovy.util.logging.Log
import java.util.logging.Formatter
import java.util.logging.Level
import java.util.logging.LogRecord
import java.util.logging.StreamHandler

@Log
class PencatatSuhu {

    void catat(double suhu) {
        if (suhu > 30.0) {
            log.warning "suhu melampaui batas: $suhu"
        } else {
            log.info "suhu terbaca: $suhu"
        }
    }

    void sensorMati() {
        log.severe 'sensor tidak merespons'
    }
}

// handler dan formatter kustom agar keluaran stabil tanpa tanggal
def formatter = new Formatter() {
    String format(LogRecord rekaman) {
        "${rekaman.level.name}: ${rekaman.message}\n".toString()
    }
}
def handler = new StreamHandler(System.out, formatter) {
    void publish(LogRecord rekaman) {
        super.publish(rekaman)
        flush()
    }
}
handler.level = Level.ALL

def logger = PencatatSuhu.log
logger.addHandler(handler)
logger.useParentHandlers = false

println "field log : ${logger.name}"
def pencatat = new PencatatSuhu()
pencatat.catat(25.5)
pencatat.catat(31.0)
pencatat.sensorMati()
text Keluaran
field log : PencatatSuhu
INFO: suhu terbaca: 25.5
WARNING: suhu melampaui batas: 31.0
SEVERE: sensor tidak merespons

Baris pertama keluaran memperlihatkan nama logger yang sama dengan nama kelasnya, lalu tiga peristiwa tercetak dengan format LEVEL: pesan. Pemanggilan catat(25.5) berjalan pada level info, catat(31.0) melampaui ambang dan naik ke warning, sedangkan sensorMati() mewakili kondisi terburuk pada level severe. Seluruh keluaran bebas dari tanggal dan jam, sehingga hasilnya dapat dibandingkan langsung antar eksekusi.

Keluarga anotasi logging pihak ketiga#

Groovy menyediakan anotasi logging untuk pustaka pihak ketiga dengan pola yang sama, yaitu field log beserta metode pencatatan milik pustakanya. Perbedaannya ada pada tipe field dan sumber logger, dan seluruhnya memerlukan berkas jar pustaka tersebut di classpath. Tanpa jar itu, kelas yang dianotasi gagal dipakai saat program berjalan karena kelas pustakanya tidak ditemukan. Sandbox pada jalur ini tidak membawa jar pihak ketiga, sehingga contoh @Slf4j berikut ditandai tidak-dijalankan.

AnotasiPustakaTipe field log
@Logjava.util.logging (JDK)java.util.logging.Logger
@PlatformLogplatform logging JDK 9+java.lang.System.Logger
@CommonsCommons Loggingorg.apache.commons.logging.Log
@Log4jLog4j 1.xorg.apache.log4j.Logger
@Log4j2Log4j 2.xorg.apache.logging.log4j.Logger
@Slf4jSLF4Jorg.slf4j.Logger
groovy Contoh: @Slf4j dengan pustaka pihak ketiga
// tidak-dijalankan
// @Slf4j memerlukan jar SLF4J di classpath, yang tidak tersedia di sandbox ini
import groovy.util.logging.Slf4j

@Slf4j
class PencatatArsip {
    void tulis(String pesan) {
        log.info pesan
    }
}

Metodenya pun mengikuti pustaka masing-masing, misalnya error, warn, info, debug, dan trace pada @Slf4j, sedangkan @Commons dan keluarga @Log4j menambahkan fatal di ujungnya. Ketika sebuah proyek sudah memiliki pustaka logging tertentu, anotasi yang tepat membuat kelas Groovy langsung mengikuti kesepakatan proyek tanpa menulis pembentukan logger manual. Anotasi yang dipakai juga boleh berbeda antar kelas dalam satu program, meskipun menyatukannya pada satu pustaka lebih mudah dipelihara.

Menggabungkan @Singleton dan @Log#

Keduanya dapat dipakai bersama pada satu kelas, dan urutan penulisan anotasinya bebas. Kelas ArsipCadangan berikut dibuat tunggal dengan mode lazy karena instansnya hanya diperlukan saat arsip mulai dipakai, sementara setiap peristiwanya dicatat melalui log hasil @Log. Penyetelan handler dan formatter dilakukan sekali dari luar kelas, lalu seluruh isi kelas cukup memanggil log.info atau log.warning.

groovy arsip-cadangan.groovy
import groovy.lang.Singleton
import groovy.util.logging.Log
import java.util.logging.Formatter
import java.util.logging.Level
import java.util.logging.LogRecord
import java.util.logging.StreamHandler

// satu instans arsip cadangan yang mencatat setiap peristiwanya
@Singleton(lazy = true)
@Log
class ArsipCadangan {

    List<String> riwayat = []

    void simpan(String berkas) {
        riwayat << berkas
        log.info "tersimpan: $berkas"
    }

    void pulihkan(String berkas) {
        log.warning "pemulihan langka: $berkas"
    }
}

def formatter = new Formatter() {
    String format(LogRecord rekaman) {
        "${rekaman.level.name}: ${rekaman.message}\n".toString()
    }
}
def handler = new StreamHandler(System.out, formatter) {
    void publish(LogRecord rekaman) {
        super.publish(rekaman)
        flush()
    }
}
handler.level = Level.ALL

def logger = ArsipCadangan.log
logger.addHandler(handler)
logger.useParentHandlers = false

def arsip = ArsipCadangan.instance
arsip.simpan('catatan-2026-11-01.zip')
arsip.simpan('catatan-2026-11-02.zip')
arsip.pulihkan('catatan-2026-10-30.zip')
println "riwayat : ${arsip.riwayat.size()} berkas"
println "instans : ${ArsipCadangan.instance.is(arsip)}"
text Keluaran
INFO: tersimpan: catatan-2026-11-01.zip
INFO: tersimpan: catatan-2026-11-02.zip
WARNING: pemulihan langka: catatan-2026-10-30.zip
riwayat : 2 berkas
instans : true

Kombinasi ini bekerja tanpa gesekan karena kedua anotasi menyentuh bagian kelas yang berbeda, yaitu konstruksi objek untuk @Singleton dan penambahan field untuk @Log. Pada tiga pemanggilan pertama, instans tunggal terbentuk, dua berkas tersimpan ke riwayat, dan pemulihan langka tercatat pada level warning. Dua baris terakhir menegaskan hasilnya, yaitu riwayat berisi dua berkas dan instans yang dipakai memang objek tunggal yang sama. Pola seperti ini biasa dipakai untuk komponen latar belakang yang mencatat pekerjaannya sendiri, misalnya arsip cadangan berkala atau pencatat suhu yang berjalan terus. Kelas berperan sebagai sumber peristiwa, sedangkan penyetelan handler sepenuhnya berada di tangan pemakainya.

Kesalahan umum#

Kesalahan pertama adalah menambahkan konstruktor pada kelas yang dianotasi @Singleton. Anotasi ini menolaknya saat kompilasi, dan pesan errornya mengarah pada dua pilihan perbaikan. Karena error kompilasi tidak dapat ditangkap di dalam skrip yang sama, contoh berikut mengompilasi kode uji melalui GroovyShell.

groovy Contoh: konstruktor ditolak saat kompilasi
import org.codehaus.groovy.control.CompilationFailedException

// error kompilasi tidak dapat ditangkap di dalam skrip yang sama,
// sehingga kode uji dikompilasi melalui GroovyShell
def shell = new GroovyShell()
def kode = '''
import groovy.lang.Singleton

@Singleton
class KolamSambungan {
    KolamSambungan() { }
}
'''
try {
    shell.parse(kode)
} catch (CompilationFailedException e) {
    println "Gagal: ${e.getClass().simpleName}"
    println "Pesan: ${e.message.readLines().find { it.contains('additional constructors') }}"
}
text Keluaran
Gagal: MultipleCompilationErrorsException
Pesan: Script1.groovy: 6: @Singleton didn't expect to find one or more additional constructors: remove constructor(s) or set strict=false

Ada dua jalan keluar sesuai pesan tersebut, yaitu menghapus konstruktor dan memindahkan inisialisasinya ke deklarasi field atau blok inisialisasi instans, atau menuliskan @Singleton(strict = false) bila konstruktor tambahan memang diperlukan.

Kesalahan kedua adalah menaruh keadaan yang sering berubah di dalam singleton tanpa pengaturan ulang. Satu instans dipakai bersama seluruh program, sehingga keadaan yang diubah satu bagian langsung terlihat oleh bagian lain, dan pada pengujian keadaan itu terbawa antar kasus tanpa pesan apa pun. Kegagalan seperti ini juga sulit dilacak karena tidak ada error yang muncul, melainkan hasil yang berubah mengikuti urutan eksekusi. Perbaikannya bukan membuang polanya seluruhnya, melainkan memisahkan keadaan yang memang tetap, seperti konfigurasi, dari keadaan yang berputar, seperti daftar pekerjaan. Yang terakhir ditaruh di objek biasa yang dibuat ulang.

Ringkasan#

  • @Singleton menghasilkan field instance, metode getInstance(), dan konstruktor penjaga, dengan mode eager sebagai default dan lazy = true untuk pembangunan saat akses pertama.
  • Pemanggilan new ditolak dengan RuntimeException setelah instans resmi ada, tetapi pada singleton lazy sebelum akses pertama justru membentuk objek liar, sehingga akses selalu melalui metode aksesnya.
  • Atribut property mengganti nama field dan metode akses, sedangkan strict = false membiarkan konstruktor manual dengan konsekuensi kehilangan penjaga new.
  • @Log menambahkan field log bertipe java.util.logging.Logger dengan metode info, warning, dan severe, dan keluaran yang stabil diperoleh lewat handler serta formatter kustom.

Referensi#