Merancang Satuan Dan Harga

Seorang teman beberapa kali ini menghubungi saya untuk berdiskusi mengenai masalah yang dihadapinya. Ia sedang merancang sistem retail dimana ia harus mengelola produk yang bisa memiliki harga yang berbeda di satuan yang berbeda. Apa yang harus saya lakukan? Tidak ada jawaban ‘terbaik’ karena seberapa efektif sebuah rancangan tidak hanya mengenai seberapa jelas dan mudah dipahami, tetapi juga kebutuhan bisnis, teknologi dan pengalaman programmer yang dimiliki. Seperti biasa, saya akan mencoba membuat rancangan yang diimplementasikan dengan Groovy, Griffon dan simple-jpa.

Saya akan mulai dengan merancangan satuan. Sebuah nilai dalam satuan tertentu harus bisa di-konversi ke nilai dalam satuan lainnya. Sebagai contoh, 1 m adalah 100 cm, 1.000 mm, dan 39,3701 inch. Contoh lainnya, 1 lusin terdiri atas 12 unit dan 1 gross terdiri atas 144 unit. Tidak semua satuan bisa saling dikonversi. Sebagai contoh, satuan seperti meter tidak boleh dikonversi menjadi lusin.

Rancangan awal saya yang saya buat terlihat seperti pada gambar berikut ini:

Kuantitas, Satuan dan Konversi

Kuantitas, Satuan dan Konversi

Pada rancangan di atas, saya berusaha memisahkan tanggung jawab dan peran sebisa mungkin menjadi sebuah class tersendiri. Tujuannya adalah agar suatu saat nanti setelah class semakin berkembang, tidak ada class ‘gendut’ yang sulit dimodifikasi. Yang dimaksud ‘sulit’ disini adalah penuh kekhawatiran akan dampak buruk bila di-’senggol’ sedikit. Secara umum, rancangan yang baik menghasilkan class yang bebannya ditanggung secara bersama dan merata.

Selain itu, saya tidak lupa menambahkan perilaku (behavior /method). Sebuah rancangan yang hanya berisi class beserta attribut tanpa method adalah gejala rancangan yang tidak baik yang disebut sebagai anemic domain model. Mengapa anemic domain model adalah gejala rancangan yang buruk? Sebuah aplikasi PASTI memiliki operasi. Bila rancangan hanya berisi data dalam bentuk atribut, maka dimana letaknya operasi tersebut? Kenapa operasi tersebut tidak ada dalam diagram? Dimana letaknya kode program seperti if, for, while yang mengolah data? Mereka tentunya harus punya ‘tempat’ di class yang sedang dirancang.

Salah satu prinsip yang dapat dipakai untuk mengukur loose coupling dari rancangan adalah Law of Demeter (LoD). Pada LoD, sebuah class hanya boleh berkomunikasi dengan class yang berhubungan langsung dengannya. Sebagai contoh, class Kuantitas akan memanggil method milik class Satuan, tapi tidak boleh mengakses Konversi milik Satuan tersebut secara langsung. Oleh sebab itu, class Satuan menyediakan wrapper yang mewakili nilai milik Konversi. Dengan demikian, bila suatu saat nanti class Konversi perlu berubah drastis, maka dampak terbesarnya hanya pada class Satuan karena class lain tidak mengakses Konversi secara langsung.

Seperti apa contoh implementasinya di Groovy? Class SatuanTidakCocok adalah sebuah Exception yang mewakili kesalahan sehingga saya bisa membuatnya seperti berikut ini:

import ...

@Canonical
class SatuanTidakCocok extends RuntimeException {

    Satuan dari
    Satuan ke

    @Override
    String getMessage() {
        "Tidak dapat melakukan konversi dari $dari ke $ke"
    }
}

Class Konversi adalah sebuah value object sehingga pada JPA, ia perlu memakai annotation @Embeddable seperti yang terlihat berikut ini:

import ...

@Embeddable @Canonical
class Konversi {

    @NotNull
    BigDecimal faktorSatuanBaku

    @ManyToOne
    Satuan satuanBaku

}

Class Satuan adalah sebuan entity yang implementasinya bisa berupa:

import ...

@DomainClass @Entity @Canonical
class Satuan {

    @NotEmpty
    String nama

    @Embedded
    Konversi konversi

    BigDecimal faktor(Satuan satuanLain) {
        if (satuanLain.equals(this)) {
            return 1
        } else if (satuanLain.equals(satuanBaku())) {
            return konversi.faktorSatuanBaku
        } else if (satuanLain.satuanBaku().equals(satuanBaku())) {
            return faktor(satuanBaku()) / satuanLain.faktor(satuanBaku())
        } else {
            throw new SatuanTidakCocok(satuanLain, this)
        }
    }

    Satuan satuanBaku() {
        konversi? konversi.satuanBaku: this
    }

    boolean equals(o) {
        if (this.is(o)) return true
        if (getClass() != o.class) return false

        Satuan satuan = (Satuan) o

        if (id != satuan.id) return false
        if (nama != satuan.nama) return false

        return true
    }

    int hashCode() {
        return nama.hashCode() + id
    }

}

Karena Satuan memiliki operasi yang cukup rumit, maka saya akan membuat sebuah unit test untuk menguji class tersebut, misalnya:

import ...

class SatuanTests {

    @Test
    void testSatuanBaku() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        assertEquals(satuanM, satuanM.satuanBaku())
        assertEquals(satuanM, satuanCM.satuanBaku())
        assertEquals(satuanM, satuanMM.satuanBaku())
        assertEquals(satuanM, satuanInch.satuanBaku())

        assertEquals(satuanPcs, satuanPcs.satuanBaku())
        assertEquals(satuanPcs, satuanLusin.satuanBaku())
        assertEquals(satuanPcs, satuanGross.satuanBaku())
    }

    @Test
    public void testFaktorUntukSatuanSama() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        assertEquals(0, satuanM.faktor(satuanM).compareTo(1))
        assertEquals(0, satuanCM.faktor(satuanCM).compareTo(1))
    }

    @Test
    public void testFaktor() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        assertEquals('100,000', String.format("%.3f",satuanM.faktor(satuanCM)))
        assertEquals('0,010', String.format("%.3f", satuanCM.faktor(satuanM)))
        assertEquals('2,540', String.format("%.3f", satuanInch.faktor(satuanCM)))
        assertEquals('0,039', String.format("%.3f", satuanMM.faktor(satuanInch)))

        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        assertEquals(12, satuanLusin.faktor(satuanPcs).intValue())
        assertEquals(144, satuanGross.faktor(satuanPcs).intValue())
    }

    @Test
    public void testSatuanTidakCocok() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanPcs = new Satuan('Pcs')

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            satuanM.faktor(satuanPcs)
        }
    }
}

Pada pengujian diatas, saya memakai salah satu fasilitas unik di Groovy yaitu GroovyAssert.shouldFail(). Method tersebut akan menggagalkan pengujian bila seandainya Exception yang diharapkan tidak terjadi.

Saya kemudian menjalankan pengujian dan memastikan bahwa semua method di atas lulus, seperti yang terlihat pada gambar berikut ini:

Hasil unit test yang sukses

Hasil unit test yang sukses

Berikutnya, saya akan membuat implementasi untuk class Kuantitas seperti berikut ini:

import ...

@Embeddable @Canonical
class Kuantitas {

    @NotNull
    BigDecimal jumlah

    @ManyToOne @NotNull
    Satuan satuan

    Kuantitas to(Satuan satuanLain) {
        new Kuantitas(jumlah * satuan.faktor(satuanLain), satuanLain)
    }

    Kuantitas plus(Kuantitas kuantitas) {
        new Kuantitas(jumlah + kuantitas.to(satuan).jumlah, satuan)
    }

    Kuantitas minus(Kuantitas kuantitas) {
        plus(new Kuantitas(-kuantitas.jumlah, kuantitas.satuan))
    }

}

Pada kode program di atas, method minus() akan memanggil method plus() dengan nilai negatif karena pada dasarnya a-b adalah a+(-b). Saya juga bisa melakukannya untuk pembagian dimana a/b adalah a*(1/b).

Berikutnya, saya membuat unit test baru untuk menguji class Kuantitas seperti berikut ini:

import ...

class KuantitasTests {

    @Test
    void testTo() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        Kuantitas nilai1 = new Kuantitas(10, satuanM)
        assertEquals(1000, nilai1.to(satuanCM).jumlah.intValue())
        assertEquals(10000, nilai1.to(satuanMM).jumlah.intValue())
        assertEquals('393,701', String.format('%.3f', nilai1.to(satuanInch).jumlah))

        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        Kuantitas nilai2 = new Kuantitas(15, satuanLusin)
        assertEquals(180, nilai2.to(satuanPcs).jumlah.intValue())
        assertEquals('1,250', String.format('%.3f', nilai2.to(satuanGross).jumlah))

        Kuantitas nilai3 = new Kuantitas(2, satuanGross)
        assertEquals(288, nilai3.to(satuanPcs).jumlah.intValue())
        assertEquals(24, nilai3.to(satuanLusin).jumlah.intValue())
    }

    @Test
    void testPlus() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        Kuantitas nilai1 = new Kuantitas(5, satuanM)
        Kuantitas nilai2 = new Kuantitas(10, satuanCM)
        Kuantitas nilai3 = new Kuantitas(2, satuanInch)
        Kuantitas hasil = nilai1 + nilai2 + nilai3

        assertEquals('5,1508', String.format('%.4f', hasil.jumlah))
        assertEquals(satuanM, hasil.satuan)

        hasil += nilai1
        assertEquals('10,1508', String.format('%.4f', hasil.jumlah))
        assertEquals(satuanM, hasil.satuan)
    }

    @Test
    void testMinus() {
        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        Kuantitas nilai1 = new Kuantitas(2, satuanGross)
        nilai1 -= new Kuantitas(3, satuanLusin)
        nilai1 -= new Kuantitas(10, satuanPcs)

        assertEquals(242, nilai1.to(satuanPcs).jumlah.intValue())
        assertEquals(satuanGross, nilai1.satuan)
    }

    @Test
    void testSatuanTidakSama() {
        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanM = new Satuan('Meter')

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            new Kuantitas(2,satuanPcs) + new Kuantitas(3,satuanM)
        }
    }

}

Kode program pada unit test akan mewakili seperti apa class saat dipakai (misalnya saat dipanggil di controller atau dipanggil dari class lain). Pada contoh ini, saya memanfaatkan fasilitas khusus dari Groovy untuk melakukan operator overloading. Method dengan nama plus() milik Kuantitas akan dikerjakan bila terdapat ekspresi dengan tanda tambah seperti kuantitas1 + kuantitas2. Begitu juga dengan method minus() yang akan dikerjakan bila terdapat ekspresi seperti kuantitas1 - kuantitas2. Ini salah satu contoh dimana operator overloading sangat berguna: ia membuat class Kuantitas lebih mudah dipakai dan dipahami.

Berikutnya, saya perlu merancang class yang mewakili produk dan harganya. Setiap produk memiliki jumlah dalam satuan tertentu. Mereka juga bisa dijual dalam bentuk satuan yang berbeda dengan harga yang berbeda. Saya tidak tahu seperti apa pricing model yang dipakai karena saya tidak memiliki akses ke domain expert. Oleh sebab itu saya mencari informasi di buku atau web mengenai pricing model. Agar sederhana, saya akan menganggap klien ingin memakai model all-units discounts dimana bila pembelian mencapai kuantitas tertentu (atau pada satuan tertentu), maka harga seluruh unit akan mendapatkan diskon yang sama. Sebagai contoh, harga sebuah produk Snack A bila dibeli secara eceran adalah Rp 3.000,-/pcs. Bila dibeli secara lusin, maka harga Snack A menjadi Rp. 2.800,-/pcs sehingga harga 1 lusin adalah Rp 33.600,-/lusin (dimana tanpa diskon adalah Rp 36.000,-/lusin). Bila dibeli secara gross, maka harga Snack A menjadi Rp 2.600,-/pcs sehingga harga 1 gross adalah Rp Rp 374.400,-/gross (dimana tanpa diskon adalah Rp. 432.000,-/gross). Dengan kata lain, saya dapat menyimpulkan bahwa harga Snack A mendapat diskon Rp 200,- dari harga eceran bila dibeli per lusin dan mendapat diskon Rp 400,- dari harga eceran bila dibeli per gross.

Saya kemudian mengubah rancangan sehingga menjadi seperti berikut ini:

Hasil akhir setelah penambahan Produk, Diskon dan Harga

Hasil akhir setelah penambahan Produk, Diskon dan Harga

Pada rancangan di atas, saya membuat sebuah value object baru yang diwakili class Harga. Pada dasarnya Harga dan Kuantitas hampir sama; perbedaannya adalah mereka saling terbalik dalam melakukan konversi. Untuk Kuantitas, 2 lusin adalah 2 x 12= 24 pieces. Untuk Harga, Rp 12.000,-/lusin adalah 12.000 / 12 = Rp 1.000,-/pieces. Karena kesamaannya, hubungan antara Kuantitas dan Harga dapat berupa spesialisasi/inheritance. Akan tetapi, karena saya memakai Hibernate, saya menjumpai keterbatasan teknologi dimana sebuah class @Embeddable tidak dapat diturunkan dari class lain. Keputusan seperti ini perlu diperhatikan sehingga tidak menghasilkan rancangan yang tidak dapat diimplementasikan pada teknologi yang dipakai.

Saya memakai qualifier Satuan pada hubungan antara Produk dan Diskon untuk memperlihatkan bahwa setiap Produk memiliki Diskon per Satuan. Saya meniru teknik ini setelah membacanya di buku Domain Driven Design (DDD). Implementasi untuk rancangan tersebut dilakukan dengan menggunakan Map yang memiliki asosiasi keyvalue.

Mengapa ada method addDiskonByHargaSatuan(Harga) di Produk? Teman saya menjelaskan bahwa terkadang pengguna hanya ingin memasukkan harga per satuan seperti Rp 33.600,- per lusin (dimana harga per piece adalah Rp 3.000,-). Padahal, sistem harus menyimpannya dengan nilai berupa diskon Rp 200,- per piece. Ia kemudian mengemukakan idenya bahwa sebaiknya pengguna menghitung nilai diskon secara manual melalui kalkulator. Atau alternatif lainnya, biarkan programmer menghitung sendiri nilai ini di view. Tapi, sebuah solusi yang lebih baik adalah memasukkan proses perhitungan ini pada rancangan dalam bentuk sebuah method karena perhitungan ini adalah bagian dari business logic.

Contoh implementasi yang mungkin untuk class Diskon adalah:

import ...

@Embeddable @Canonical
class Diskon {

    BigDecimal potonganLangsung

    BigDecimal potonganPersen

    BigDecimal hasilDiskon(BigDecimal nilai) {
        nilai - jumlahDiskon(nilai)
    }

    BigDecimal jumlahDiskon(BigDecimal nilai) {
        (potonganPersen?:0) / 100 * nilai + (potonganLangsung?:0)
    }
}

Untuk menguji class ini, saya membuat unit test seperti:

import ...

class DiskonTests {

    @Test
    void testHasilDiskon() {
        Diskon diskon = new Diskon(1000)
        assertEquals(49000, diskon.hasilDiskon(50000).intValue())

        diskon = new Diskon(potonganPersen: 5)
        assertEquals(47500, diskon.hasilDiskon(50000).intValue())

        diskon = new Diskon(1000, 5)
        assertEquals(46500, diskon.hasilDiskon(50000).intValue())
    }

    @Test
    void testJumlahDiskon() {
        Diskon diskon = new Diskon(1000)
        assertEquals(1000, diskon.jumlahDiskon(50000).intValue())

        diskon = new Diskon(potonganPersen: 5)
        assertEquals(2500, diskon.jumlahDiskon(50000).intValue())

        diskon = new Diskon(1000, 5)
        assertEquals(3500, diskon.jumlahDiskon(50000).intValue())
    }
}

Setelah memastikan bahwa Diskon sudah bisa bekerja baik dan benar, saya lanjut membuat implementasi untuk Harga seperti yang terlihat berikut ini:

import ...

@Embeddable @Canonical
class Harga {

    @NotNull
    BigDecimal jumlah

    @NotNull @ManyToOne
    Satuan satuan

    public Harga to(Satuan satuanLain) {
        new Harga(jumlah / satuan.faktor(satuanLain), satuanLain)
    }

    public Harga plus(Harga hargaLain) {
        if (!hargaLain.satuan.equals(satuan)) {
            throw new SatuanTidakCocok(hargaLain.satuan, satuan)
        }
        new Harga(jumlah + hargaLain.to(satuan).jumlah, satuan)
    }

    public Harga minus(Harga hargaLain) {
        plus(new Harga(-hargaLain.jumlah, hargaLain.satuan))
    }

    public Harga multiply(Number angka) {
        new Harga(angka * jumlah, satuan)
    }

    public Harga multiply(Kuantitas kuantitas) {
        if (!kuantitas.satuan.equals(satuan)) {
            throw new SatuanTidakCocok(kuantitas.satuan, satuan)
        }
        multiply(kuantitas.jumlah)
    }

    public Harga diskon(Diskon diskon) {
        new Harga(diskon.hasilDiskon(jumlah), satuan)
    }

}

Class Harga memiliki cukup banyak operasi, sehingga saya perlu lebih rajin membuat unit test untuk menguji kebenarannya:

import ...

class HargaTests {

    @Test
    void testTo() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))

        Harga harga = new Harga(1000, satuanM)
        assertEquals(1000, harga.to(satuanM).jumlah.intValue())
        assertEquals(10, harga.to(satuanCM).jumlah.intValue())
        assertEquals(1, harga.to(satuanMM).jumlah.intValue())

        harga = new Harga(2000, satuanCM)
        assertEquals(200000, harga.to(satuanM).jumlah.intValue())
        assertEquals(2000, harga.to(satuanCM).jumlah.intValue())
        assertEquals(200, harga.to(satuanMM).jumlah.intValue())
    }

    @Test
    void testPlus() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        Harga harga1 = new Harga(1000, satuanM)
        Harga harga2 = new Harga(1100, satuanM)
        Harga harga3 = new Harga(2100, satuanM)
        assertEquals(harga3, harga1+harga2)

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            harga1 + new Harga(1100, satuanCM)
        }
    }

    @Test
    void testMinus() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        Harga harga1 = new Harga(1100, satuanM)
        Harga harga2 = new Harga(1000, satuanM)
        Harga harga3 = new Harga(100, satuanM)
        assertEquals(harga3, harga1-harga2)

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            harga1 - new Harga(1100, satuanCM)
        }
    }

    @Test
    void testMultiplyNumber() {
        Satuan satuanM = new Satuan('Meter')

        Harga harga1 = new Harga(1000, satuanM)
        Harga harga2 = new Harga(5000, satuanM)
        assertEquals(harga2, harga1 * 5)

        harga2 = new Harga(3300, satuanM)
        assertEquals(harga2, harga1 * 3.3)
    }

    @Test
    void testMultiplyKuantitas() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        Harga harga = new Harga(1000, satuanM)
        Kuantitas kuantitas = new Kuantitas(5, satuanM)
        Harga hasil = new Harga(5000, satuanM)
        assertEquals(hasil, harga * kuantitas)

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            kuantitas = new Kuantitas(5, satuanCM)
            harga * kuantitas
        }
    }

    @Test
    void testDiskon() {
        Satuan satuanM = new Satuan('Meter')
        Harga harga = new Harga(1000, satuanM)

        Diskon diskon = new Diskon(100)
        Harga hasil = new Harga(900, satuanM)
        assertEquals(hasil, harga.diskon(diskon))

        diskon = new Diskon(potonganPersen: 1.5)
        hasil = new Harga(985, satuanM)
        assertEquals(hasil, harga.diskon(diskon))

        diskon = new Diskon(100, 3)
        hasil = new Harga(870, satuanM)
        assertEquals(hasil, harga.diskon(diskon))
    }
}

Berikutnya, saya membuat implementasi untuk class Produk yang terlihat seperti berikut ini:

import ...

@DomainClass @Entity @Canonical(excludes='diskon')
class Produk {

    @NotBlank
    String kode

    @NotBlank
    String nama

    @NotNull @Embedded
    Kuantitas jumlah

    @NotNull @Embedded
    Harga harga

    @ElementCollection(fetch=FecthType.EAGER)
    Map<Long, Diskon> diskon = [:]

    void setDiskon(Satuan satuan, Diskon jumlahDiskon) {
        diskon[satuan.id] = jumlahDiskon
    }

    Diskon getDiskon(Satuan satuan) {
        diskon[satuan.id]
    }

    void addDiskonByHargaSatuan(Harga hargaSatuan) {
        Satuan satuanBaku = harga.satuan.satuanBaku()
        Harga selisih = harga.to(satuanBaku) - hargaSatuan.to(satuanBaku)
        diskon[hargaSatuan.satuan.id] = new Diskon(selisih.jumlah)
    }

    Harga harga(Satuan satuan) {
        Satuan satuanBaku = harga.satuan.satuanBaku()
        Harga hargaSatuan = harga.to(satuanBaku)
        if (diskon[satuan.id]) {
            hargaSatuan = hargaSatuan.diskon(diskon[satuan.id])
        }
        hargaSatuan.to(satuan)
    }

}

Salah satu keterbatasan pada @Embeddable Map di JPA Hibernate saat ini adalah bila key berupa entity maka eager loading tidak dapat dilakukan! Ini adalah sebuah bug yang masih belum diselesaikan hingga saat ini. Karena saya ingin fasilitas eager loading, saya tidak bisa mendefinisikan Map. Sebagai gantinya, saya memakai Map dimana Long adalah id dari Satuan yang hendak dijadikan sebagai key. Untuk mempermudah mengakses map tersebut, saya menyediakan setDiskon(Satuan, Diskon) dan Diskon getDiskon(Satuan).

Hal lain yang harus diperhatikan bila memakai Hibernate dalam penggunaan @Embeddable adalah kemungkinan dua atribut di @Embeddable berbeda dengan nama yang sama. Hibernate akan gagal membuat tabel bila terdapat kolom dengan nama yang sama, seperti kolom satuan di Kuantitas dan kolom satuan di Harga dimana baik Kuantitas dan Harga dipakai oleh Produk. Untuk menghindari hal ini, saya menambahkan baris berikut ini pada persistence.xml:

...
<property name="hibernate.ejb.naming_strategy" value="org.hibernate.cfg.DefaultComponentSafeNamingStrategy" />
...

Memakai Hibernate rasanya ribet sekali, bukan? Tapi sejujurnya kompleksitas ini terlahir dari ketidaksesuaian antara OOP dan tabel relasional. Bila seandainya saya harus membuat sebuah framework ORM dari awal, saya bisa menciptakan sesuatu yang sangat sederhana untuk operasi CRUD. Namun seiring berbagai permasalahan yang muncul, pada akhirnya mungkin akan kompleks seperti Hibernate. Hal ini akibat masalah-masalah relasional yang memang tidak dapat dihindari. Jadi, untuk saat ini, lebih baik memakai sesuatu yang sudah matang seperti Hibernate.

Saya kemudian membuat unit test untuk menguji kebenaran class Produk:

import ...

class ProdukTests {

    @Test
    void testSetDiskonByHargaSatuan() {
        Satuan satuanPcs = new Satuan(id: 1, nama: 'Pcs')
        Satuan satuanLusin = new Satuan(id: 2, nama: 'Lusin', konversi: new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan(id: 3, nama: 'Gross', konversi: new Konversi(144, satuanPcs))

        Produk produk = new Produk('SA', 'Snack A')
        produk.harga = new Harga(3000, satuanPcs)

        produk.addDiskonByHargaSatuan(new Harga(3000, satuanPcs))
        produk.addDiskonByHargaSatuan(new Harga(33600, satuanLusin))
        produk.addDiskonByHargaSatuan(new Harga(374400, satuanGross))

        assertEquals(0, produk.getDiskon(satuanPcs).potonganLangsung.intValue())
        assertEquals(200, produk.getDiskon(satuanLusin).potonganLangsung.intValue())
        assertEquals(400, produk.getDiskon(satuanGross).potonganLangsung.intValue())
    }

    @Test
    void testHarga() {
        Satuan satuanPcs = new Satuan(id: 1, nama: 'Pcs')
        Satuan satuanLusin = new Satuan(id: 2, nama: 'Lusin', konversi: new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan(id: 3, nama: 'Gross', konversi: new Konversi(144, satuanPcs))

        Produk produk = new Produk('SA', 'Snack A')
        produk.harga = new Harga(3000, satuanPcs)
        produk.setDiskon(satuanLusin, new Diskon(200))
        produk.setDiskon(satuanGross, new Diskon(400))
        assertEquals(3000, produk.harga(satuanPcs).jumlah.intValue())
        assertEquals(satuanPcs, produk.harga(satuanPcs).satuan)
        assertEquals(33600, produk.harga(satuanLusin).jumlah.intValue())
        assertEquals(satuanLusin, produk.harga(satuanLusin).satuan)
        assertEquals(374400, produk.harga(satuanGross).jumlah.intValue())
        assertEquals(satuanGross, produk.harga(satuanGross).satuan)

        produk = new Produk('SB', 'Snack B')
        produk.harga = new Harga(150000, satuanLusin)
        produk.setDiskon(satuanGross, new Diskon(0, 0.5))
        assertEquals(12500, produk.harga(satuanPcs).jumlah.intValue())
        assertEquals(satuanPcs, produk.harga(satuanPcs).satuan)
        assertEquals(150000, produk.harga(satuanLusin).jumlah.intValue())
        assertEquals(satuanLusin, produk.harga(satuanLusin).satuan)
        assertEquals(1791000, produk.harga(satuanGross).jumlah.intValue())
        assertEquals(satuanGross, produk.harga(satuanGross).satuan)
    }
}

Salah satu konsekuensi dari perancangan class Produk seperti ini adalah ia terus mengingat diskon per satuan dasar walaupun harga dimodifikasi ke satuan lain. Sebagai contoh:

Produk produk = new Produk('SA', 'Snack A')
produk.harga = new Harga(3000, satuanPcs)
produk.addDiskonByHargaSatuan(new Harga(33600, satuanLusin))
assertEquals(33600, produk.harga(satuanLusin).jumlah.intValue())

// Perubahan harga dasar
produk.harga = new Harga(39000, satuanLusin)
assertEquals(3250, produk.harga(satuanPcs).jumlah.intValue())
assertEquals(36600, produk.harga(satuanLusin).jumlah.intValue())

Perilaku ini mungkin diharapkan dan mungkin tidak diharapkan tergantung pada kebutuhan pengguna. Bila diskon tidak perlu di-ingat, maka setiap kali harga baru di-set, nilai dari diskon perlu dikosongkan, seperti:

class Produk {

  ...

  void setHarga(Harga hargaBaru) {
     diskon.clear()
     this.harga = hargaBaru
  }

  ...

}

Lalu bagaimana dengan class ProdukRepository dan SatuanRepository? Mereka adalah repository yang bertugas menyimpan dan mencari entity Produk dan Satuan. Plugin simple-jpa telah menyediakan fasilitas tersebut sehingga saya tidak perlu membuat kode program yang mengerjakan SQL lagi. Contoh implementasinya terlihat seperti:

import ...

@Transaction
class ProdukRepository {

    public void create(Produk produk) {
        persist(produk)
    }

}
import ...

@Transaction
class SatuanRepository {

    public void create(Satuan satuan) {
        persist(satuan)
    }

}

Bagaimana cara menguji repository? Karena mereka mengambil dan menyimpan object dari dan ke database, maka pengujian sebaiknya dilakukan pada integration test. Perbedaan integration test dan unit test adalah unit test hanya melibatkan object yang terisolasi di memori (tanpa hubungan ke dunia luar seperti database, internet, dsb). Berbeda dengan unit test, pada integration test, saya harus mendefinisikan object secara lengkap karena kali ini validasi akan dilakukan pada saat menyimpan object ke database. Integration test juga lebih lambat dikerjakan bila dibandingkan dengan unit test.

Contoh integration test yang saya buat untuk menguji penyimpanan di database adalah:

import ...

class ProdukTest {

    @Test
    public void testCreate() {
        SatuanRepository satuanRepository = new SatuanRepository()
        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))
        satuanRepository.create(satuanPcs)
        satuanRepository.create(satuanLusin)
        satuanRepository.create(satuanGross)

        ProdukRepository produkRepository = new ProdukRepository()
        Produk produkA = new Produk(kode: 'SA', nama: 'Snack A', harga: new Harga(3000, satuanPcs), jumlah: new Kuantitas(10, satuanPcs))
        Produk produkB = new Produk(kode: 'SB', nama: 'Snack B', harga: new Harga(2000, satuanPcs), jumlah: new Kuantitas(10, satuanPcs))
        produkB.setDiskon(satuanLusin, new Diskon(200))
        produkRepository.create(produkA)
        produkRepository.create(produkB)

        Produk p = produkRepository.findProdukByNama('Snack A')
        assertEquals(produkA, p)
        assertEquals(36000, p.harga(satuanLusin).jumlah.intValue())

        p = produkRepository.findProdukByNama('Snack B')
        assertEquals(produkB, p)
        assertEquals(21600, p.harga(satuanLusin).jumlah.intValue())

        assertEquals(2, produkRepository.findAllProdukByNamaLike('Snack%').size())
    }
}

Bila saya menjalankan integration test di atas, Hibernate akan membuat struktur tabel yang terlihat seperti berikut ini:

Struktur tabel yang dihasilkan Hibernate

Struktur tabel yang dihasilkan Hibernate

Mereka yang terbiasa mengakses tabel secara langsung melalui SQL biasanya sering kali menganggap bahwa sebuah class adalah padanan dari sebuah tabel di database. Tapi tidak selalu demikian. Sebagai contoh, pada rancangan saya terdapat 9 class tetapi tabel yang dibutuhkan hanya 3 tabel (plus 1 tabel khusus untuk keperluan hibernate). Tidak semua dari class tersebut perlu disimpan nilainya dalam tabel. Beberapa bahkan bisa digabung, seperti class yang mengandung inheritance dan embeddable. Selain itu, perbedaan antara ERD dan class diagram terlihat secara jelas: tidak ada operasi di ERD sehingga saya tidak tahu bagaimana proses dan interaksi yang ada dalam program bila hanya melihat ERD!

Pada integration test di atas, tidak ada query SQL yang dilakukan karena repository memakai simple-jpa yang menyediakan dynamic finders. Lalu seperti ada SQL yang dihasilkan di balik layar? Berikut adalah cuplikan log yang saya peroleh:

insert into hibernate_sequences(sequence_name, sequence_next_hi_value) values('Satuan', ?)

update hibernate_sequences set sequence_next_hi_value = ? where sequence_next_hi_value = ? and sequence_name = 'Satuan'

insert into Satuan (createddate, deleted, konversi_faktorsatuanbaku, konversi_satuanbaku_id, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?)

insert into Satuan (createddate, deleted, konversi_faktorsatuanbaku, konversi_satuanbaku_id, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?)

insert into Satuan (createddate, deleted, konversi_faktorsatuanbaku, konversi_satuanbaku_id, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?)

select sequence_next_hi_value from hibernate_sequences where sequence_name = 'Produk' for update

insert into hibernate_sequences(sequence_name, sequence_next_hi_value) values('Produk', ?)

update hibernate_sequences set sequence_next_hi_value = ? where sequence_next_hi_value = ? and sequence_name = 'Produk'

insert into Produk (createddate, deleted, harga_jumlah, harga_satuan_id, jumlah_jumlah, jumlah_satuan_id, kode, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

insert into Produk (createddate, deleted, harga_jumlah, harga_satuan_id, jumlah_jumlah, jumlah_satuan_id, kode, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

insert into Produk_diskon (Produk_id, diskon_KEY, diskon_value_potonganlangsung, diskon_value_potonganpersen) values (?, ?, ?, ?)

select distinct produk0_.id as id1_4_, produk0_.createddate as createdd2_4_, produk0_.deleted as deleted3_4_, produk0_.harga_jumlah as harga_ju4_4_, produk0_.harga_satuan_id as harga_sa9_4_, produk0_.jumlah_jumlah as jumlah_j5_4_, produk0_.jumlah_satuan_id as jumlah_10_4_, produk0_.kode as kode6_4_, produk0_.modifieddate as modified7_4_, produk0_.nama as nama8_4_ from Produk produk0_ where produk0_.nama=?

select satuan0_.id as id1_6_0_, satuan0_.createddate as createdd2_6_0_, satuan0_.deleted as deleted3_6_0_, satuan0_.konversi_faktorsatuanbaku as konversi4_6_0_, satuan0_.konversi_satuanbaku_id as konversi7_6_0_, satuan0_.modifieddate as modified5_6_0_, satuan0_.nama as nama6_6_0_, satuan1_.id as id1_6_1_, satuan1_.createddate as createdd2_6_1_, satuan1_.deleted as deleted3_6_1_, satuan1_.konversi_faktorsatuanbaku as konversi4_6_1_, satuan1_.konversi_satuanbaku_id as konversi7_6_1_, satuan1_.modifieddate as modified5_6_1_, satuan1_.nama as nama6_6_1_ from Satuan satuan0_ left outer join Satuan satuan1_ on satuan0_.konversi_satuanbaku_id=satuan1_.id where satuan0_.id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

select distinct produk0_.id as id1_4_, produk0_.createddate as createdd2_4_, produk0_.deleted as deleted3_4_, produk0_.harga_jumlah as harga_ju4_4_, produk0_.harga_satuan_id as harga_sa9_4_, produk0_.jumlah_jumlah as jumlah_j5_4_, produk0_.jumlah_satuan_id as jumlah_10_4_, produk0_.kode as kode6_4_, produk0_.modifieddate as modified7_4_, produk0_.nama as nama8_4_ from Produk produk0_ where produk0_.nama=?

select satuan0_.id as id1_6_0_, satuan0_.createddate as createdd2_6_0_, satuan0_.deleted as deleted3_6_0_, satuan0_.konversi_faktorsatuanbaku as konversi4_6_0_, satuan0_.konversi_satuanbaku_id as konversi7_6_0_, satuan0_.modifieddate as modified5_6_0_, satuan0_.nama as nama6_6_0_, satuan1_.id as id1_6_1_, satuan1_.createddate as createdd2_6_1_, satuan1_.deleted as deleted3_6_1_, satuan1_.konversi_faktorsatuanbaku as konversi4_6_1_, satuan1_.konversi_satuanbaku_id as konversi7_6_1_, satuan1_.modifieddate as modified5_6_1_, satuan1_.nama as nama6_6_1_ from Satuan satuan0_ left outer join Satuan satuan1_ on satuan0_.konversi_satuanbaku_id=satuan1_.id where satuan0_.id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

select distinct produk0_.id as id1_4_, produk0_.createddate as createdd2_4_, produk0_.deleted as deleted3_4_, produk0_.harga_jumlah as harga_ju4_4_, produk0_.harga_satuan_id as harga_sa9_4_, produk0_.jumlah_jumlah as jumlah_j5_4_, produk0_.jumlah_satuan_id as jumlah_10_4_, produk0_.kode as kode6_4_, produk0_.modifieddate as modified7_4_, produk0_.nama as nama8_4_ from Produk produk0_ where produk0_.nama like ?

select satuan0_.id as id1_6_0_, satuan0_.createddate as createdd2_6_0_, satuan0_.deleted as deleted3_6_0_, satuan0_.konversi_faktorsatuanbaku as konversi4_6_0_, satuan0_.konversi_satuanbaku_id as konversi7_6_0_, satuan0_.modifieddate as modified5_6_0_, satuan0_.nama as nama6_6_0_, satuan1_.id as id1_6_1_, satuan1_.createddate as createdd2_6_1_, satuan1_.deleted as deleted3_6_1_, satuan1_.konversi_faktorsatuanbaku as konversi4_6_1_, satuan1_.konversi_satuanbaku_id as konversi7_6_1_, satuan1_.modifieddate as modified5_6_1_, satuan1_.nama as nama6_6_1_ from Satuan satuan0_ left outer join Satuan satuan1_ on satuan0_.konversi_satuanbaku_id=satuan1_.id where satuan0_.id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

Terlihat bahwa terdapat permasalahan N+1 query dimana setelah mengerjakan query SELECT untuk memperoleh sebuah Produk dari tabel, Hibernate akan mengerjakan query terpisah untuk membaca Produk.Kuantitas.satuan dan masing-masing Produk.Diskon. Perilaku ini bisa dioptimalkan lagi dengan menggunakan left join atau dengan memberikan query secara manual, tapi selama tidak ada penalti yang siginifikan pada kinerja (seperti pada contoh di atas), ini adalah sesuatu yang bisa diterima.

Langkah berikutnya adalah membuat presentation untuk rancangan. Designer yang baik biasanya tidak merancang berdasarkan patokan presentation (tampilan). Oleh sebab itu, saya tidak membatasi jenis presentation yang bisa dipakai untuk rancangan ini. Selama merancang, saya hanya memikirkan bagaimana programmer nantinya memakai object yang ada. Bisa saja presentation berupa komponen Swing untuk aplikasi desktop atau tampilan HTML berbasis web. Atau bisa saja tidak ada presentation sama sekali, misalnya terdapat services layer yang mempublikasikan domain model dalam bentuk web services. Apapun jenis presentation yang dipakai, cara kerjanya tetap sama, controller di presentation akan memanggil repository untuk mengambil dan membuat root aggregate (seperti Produk dan Satuan), lalu mengerjakan business logic yang ada di domain object yang diperolehnya (seperti to(), addDiskon(), harga() dan sebagainya).

Implementing Aggregate Root In JPA: @OneToMany or @ElementCollection?

In domain driven design, an aggregate root contains one or more entities that represent a bounded context. Those entities should be only manipulated from their aggregate root. In UML class diagram, this is represented as composition (a filled diamond in relationship). Note that UML class diagram also has a concept of aggregation (a hollow diamond in a relationship). Despite similarity in the name, the contained part in UML aggregation can exists without its container. Thus, it is not something like DDD’s aggregate root in which the contained part should not exist without their container.

For example, the following is an UML class diagram with composition:

Composition in UML Class Diagram

Composition in UML Class Diagram

Invoice is the root aggregate that manages LineItem. Every LineItem is a value object. No one should be able to add or delete LineItem directly without obtaining an Invoice first. Because instances of LineItem are value objects, they don’t have a global identity. In the other side, instances of Invoice class are entities so they can be searched by a global identity (for example: invoice number). Each LineItem is associated with a Product entity. This is valid in domain driven design though some people will recommend using value object instead. The value object will store the Product identity (for example: product number). See this article for more information: http://dddcommunity.org/wp-content/uploads/files/pdf_articles/Vernon_2011_2.pdf.

The question is how to implement the classes in our diagram using JPA? Well, there are several possibilities with surprising caveats. The recommended way for @OneToMany relationship is bidirectional with owner on the many side. But this violates our aggregate root rules. No one should select existing LineItem or add new LineItem directly! They must manipulate LineItem from Invoice only. This can be solved by using unidirectional @OneToMany with @JoinColumn. But it is still not a containment. To implement the real containment, use @ElementCollection and mark all value objects as @Embeddable. Note that @ElementCollection is introduced in JPA 2.

For example, this is an implementation using @OneToMany and @JoinColumn in Groovy + Hibernate +simple-jpa:

import ...

@DomainClass @Entity @Canonical
class Invoice {

    @NotEmpty
    String number

    @OneToMany @JoinColumn
    List<LineItem> lineItems = []

    public void add(LineItem lineItem) {
        lineItems << lineItem
    }

}


@DomainClass @Entity @Canonical
class LineItem {

    @NotNull @ManyToOne
    Product product

    @NotNull
    Integer quantity

}


@DomainClass @Entity @Canonical
class Product {

    @NotEmpty
    String name

}

The code above will produce the following database tables:

Tables for @OneToMany with @JoinColumn

Tables for @OneToMany with @JoinColumn

Table for LineItem has an identity. This primary key is required for one to many relationships. In our case, it is pretty useless because LineItem should only be identified with their Invoice. The identity of LineItem has no meaning in global context.

This code will create several objects based on our domain classes:

def productA = new Product('Product A')
persist(productA)
def productB = new Product('Product B')
persist(productB)

def invoice = new Invoice('Invoice-01')
invoice.add(new LineItem(productA, 10))
invoice.add(new LineItem(productB, 20))
persist(invoice)

But if you execute the code above, you will get org.hibernate.TransientObjectException!! Every single LineItem must be persisted before persisting Invoice. This is a bit annoying. It doesn’t show that our Invoice is the boss – the aggregate root. To solve this problem, you will need to add @OneToMany(cascade=CascadeType.ALL) to Invoice.lineItems:

...
@OneToMany(cascade=CascadeType.ALL) @JoinColumn
List<LineItem> lineItems = []
...

Now if you run the code, Hibernate will actually perform the following SQL queries:

insert into Product (createdDate, deleted, modifiedDate, name, id) values (?, ?, ?, ?, ?)
insert into Product (createdDate, deleted, modifiedDate, name, id) values (?, ?, ?, ?, ?)
insert into Invoice (createdDate, deleted, modifiedDate, number, id) values (?, ?, ?, ?, ?)
insert into LineItem (createdDate, deleted, modifiedDate, product_id, quantity, id) values (?, ?, ?, ?, ?, ?)
insert into LineItem (createdDate, deleted, modifiedDate, product_id, quantity, id) values (?, ?, ?, ?, ?, ?)
update LineItem set lineItems_id=? where id=?
update LineItem set lineItems_id=? where id=?

Note that Hibernate will issue both insert and update query for every LineItem. So, if I insert 10 LineItem objects, Hibernate will issue 20 queries: 10 insert queries and another 10 update queries. Isn’t this a bit overwhelming?

What happened if I update existing LineItem or insert new LineItem such as shown in the following code:

def invoice = findInvoiceByNumberFetchComplete('Invoice-01')
invoice.lineItems[0].product = findProductByName('Product B')
invoice.lineItems[0].quantity = 999
invoice.lineItems.remove(1)
merge(invoice)

If I run the code, Hibernate will execute the following queries:

...
update LineItem set createdDate=?, deleted=?, modifiedDate=?, product_id=?, quantity=? where id=?
update LineItem set lineItems_id=null where lineItems_id=? and id=?
...

Hibernate only issue two update queries! Even when we remove the second LineItem in our Invoice in the source code, Hibernate doesn’t actually remove it from database. Hibernate only set LineItem.lineItems_id to null so in the next select query, we will not see that second item. To force Hibernate to delete the second item, add orphanRemoval=true to @OneToMany as shown in the following code:

...
@OneToMany(cascade=CascadeType.ALL, orphanRemoval=true) @JoinColumn
List<LineItem> lineItems = []
...

The updated mapping will generate the following queries:

update LineItem set createdDate=?, deleted=?, modifiedDate=?, product_id=?, quantity=? where id=?
update LineItem set lineItems_id=null where lineItems_id=? and id=?
delete from LineItem where id=?

While it is possible to implement aggregate roots and their managed objects using @OneToMany, developers can still manipulate objects directly without their aggregate roots. Our LineItem is required to have an identity in the mapping but we know that value objects shouldn’t have a global identity. Now imagine if you have several genius kids in your team who don’t like to follow your domain driven design rules! They code in whatever direction they want because they think they can!! When the system grows larger, some of the genius kids resigned and new kids join your team. They even do a big refactoring. At the end, you may have a big ball of mud. See http://laputan.org/mud/ for more information about this anti pattern.

To create a more restricted implementation, you can use @ElementCollection. One of the possible implementation using @ElementCollection will be:

@DomainClass @Entity @Canonical
class Invoice {

    @NotEmpty
    String number

    @ElementCollection
    List<LineItem> lineItems = []

    public void add(LineItem lineItem) {
        lineItems << lineItem
    }

}

@Embeddable @Canonical
class LineItem {

    @NotNull @ManyToOne
    Product product

    @NotNull
    Integer quantity

}

@DomainClass @Entity @Canonical
class Product {

    @NotEmpty
    String name

}

Note that LineItem is annotated with @Embeddable not @Entity. In JPA, @Embeddable is used for value object. @Embeddable class doesn’t have an identity just like what a value object should be. But this imposes a limitation: an embeddable class can have collections, but if it is embedded in another embeddab class, it can’t have collections. This is an important limitation if you’re going to implement all managed classes as @Embeddable!

The new domain classes will produce the following tables:

Tables for @ElementCollection and @Embeddable

Tables for @ElementCollection and @Embeddable

To insert new objects, I use the same code as in @OneToMany mapping. While it is the same code, Hibernate now generates different queries:

insert into Product (createdDate, deleted, modifiedDate, name, id) values (?, ?, ?, ?, ?)
insert into Product (createdDate, deleted, modifiedDate, name, id) values (?, ?, ?, ?, ?)
insert into Invoice (createdDate, deleted, modifiedDate, number, id) values (?, ?, ?, ?, ?)
insert into Invoice_lineItems (Invoice_id, product_id, quantity) values (?, ?, ?)
insert into Invoice_lineItems (Invoice_id, product_id, quantity) values (?, ?, ?)

Note that there are no more annoying updates like in @OneToMany mapping. How about update and delete? The same code now will generate the following queries:

delete from Invoice_lineItems where Invoice_id=?
insert into Invoice_lineItems (Invoice_id, product_id, quantity) values (?, ?, ?)

Wait, this is a big difference!! Hibernate will always delete all LineItem records from our Invoice before re-inserting old, updated and new records. Hibernate must do this because our LineItem doesn’t have an identity. Hibernate doesn’t know which records to update or delete if there is no identity on the records.
This behavior is acceptable in small collection. But this is not efficient for a large collection because Hibernate will re-create all records even when only one LineItem is changed. To avoid such case, you can use @OrderColumn in the List, such as:

...
@ElementCollection @OrderColumn
List<LineItem> lineItems = []
...

This new mapping will add a new field to Lineitem table that stores index number (remember that List is a number indexed collection).

Tables for @ElementCollection with @OrderColumn

Tables for @ElementCollection with @OrderColumn

With this mapping, the update and delete code will execute the following queries:

delete from Invoice_lineItems where Invoice_id=? and lineItems_ORDER=?
update Invoice_lineItems set product_id=?, quantity=? where Invoice_id=? and lineItems_ORDER=?

Now, Hibernate will not delete all line item but only delete the second line item (because it was deleted in the following code: invoice.lineItems.remove(1)).

A sample application for simple-jpa: laundry

The source code for this article can be found at https://github.com/JockiHendry/simple-jpa-demo-laundry

Griffon is a presentation layer framework for Java desktop-based application. This framework provides infrastructure to implement MVC pattern in desktop application. Griffon is best used with Groovy programming language although this may be subjective. In fact, Groovy is the default language for Griffon’s artifacts. Groovy is a Ruby-like dynamic programming language for Java platform. Example of Groovy’s cool features are closures (lambda expression) and metaprogramming. While it is a bit slower, code written in Groovy is much more straightforward and simpler than code written in old Java. But this may be changing in the future as the upcoming release of Java will introduce lambda expression.

Griffon is an application framework for presentation tier. Modern enterprise applications will likely have multiple tiers, such as presentation tier, business logic tier, and data access tier. In such architecture, Griffon based desktop application is the presentation tier. Presentation tier doesn’t have business logic. Instead, they invoke business logic in other tiers by using REST or SOAP. Griffon is doing a great job here.

Unfortunately, not all desktop applications are ‘thin client’. Some may want to access database and process the data directly. They have both presentation and business logic. While it is not attractive from the point of view of system architecture, ‘fat client’ is often effective for small organization with limited resources.

Does Griffon support fat desktop applications? Yes, in fact, Griffon supports a wide range implementation of desktop applications through its plugins. Griffon has a lot of plugins. For example, to create a desktop application that access database directly, we can use Griffon’s gsql plugin, hibernate4 plugin, jpa plugin, etc. And, of course, simple-jpa plugin is one of them. What is simple-jpa? It is something like JPA plugin but integrated deep into Griffon and exploits Groovy’s potential. simple-jpa is not a lightweight plugin. It has custom Swing’s nodes, validation and presentation of validation errors, scaffolding, and many mores. So, why use simple-jpa? Because it is the simplest way to create real life domain based desktop application in Griffon.

As a relatively new technology, can Groovy, Griffon and simple-jpa produce working application painlessly in real life? Yes, at least in my case! In this article, I’m introducing a sample application developed using these technologies. User interface and constants are declared using Bahasa Indonesia to reflect the requirements in my country. This sample application manages orders for laundry business. It records customers, orders and their status. Orders can be in one of the following states: received, in-progress, done, and delivered. To keep things simple, this sample application doesn’t include identity managements (login, user & privileges managements).

When user starts the application, Hibernate JPA will recreate database tables based on domain entities and executes griffon-app\resources\import.sql to populate tables with predefined data. This is configured in griffon-app\meta-inf\persistence.xml. Populating tables with predefined data every time application is launched makes manual testing become easy. Plugin simple-jpa also supports similar features for integration testing by using dbUnit but it is not used in this sample application.

Customer Screen

Customer Screen

Display Orders From Customer Screen

Display Orders From Customer Screen

There are two types of clients: corporate and outsider (or personal). User can filter customers by name or member type. They can also display orders details on selected customer. I use mvcPopupButton() node from simple-jpa to generate a JButton that will display another MVCGroup when clicked.

Order Screen

Order Screen

The add order view is quite complex. It has a button to select customer. This will invoke the same MVCGroup that is used to display customers in the previous menu.

Add Line Items From Order Screen

Add Line Items From Order Screen

One order has one or many line items. Clicking on the ‘add line items’ button will display another MVCGroup so user can add or edit line items here.

Find Job From Line Item View

Find Job From Line Item View

Every line item is associated with a job. Every job has a category (such as children, gentlemen, or ladies) and a job type (such as dry cleaning, laundry, or pressing). To help user in finding the right job, the sample application allow searching by job’s name, job’s category or job’s type. Another useful feature is that it will remember the last search criteria so user doesn’t need to enter the same search criteria in the next invocation.

Express Order Will Double The Cost

Express Order Will Double The Cost

Business rule states that express orders will double the cost (increase by 100%). So, if user checks on express checkbox, the total amount will be increased by 100%. There are also hidden text fields in this screen. When user selects one of the payment methods, new text fields will appear so he can enter required information. For example, if user selects credit card payment, he must enter credit card number. If user select signed bill payment, he must enter the amount of down payment.

Invoice Preview Will Be Displayed After Saving New Order

Invoice Preview Will Be Displayed After Saving New Order

When user clicks on save button, a preview window for invoice will be displayed. User can then print this invoice or save it as pdf file. The sample application use Jasper Reports to generate and preview all invoices and reports.

Order In-Progress Screen

Order In-Progress Screen

Delivery Screen

Delivery Screen

The last step of orders processing is delivery. This screen will display balance due. User can also click on status button to display more information about orders’ status.

Displaying Order Status Detail From Delivery Screen

Displaying Order Status Detail From Delivery Screen

History Screen

History Screen

The history screen will display all orders. User can search them by order number, customer’s name, or order’s state.

Day-End Closing Screen

Day-End Closing Screen

Day end closing menu will display daily total amount of cash, credit card or debit card transaction. User can use the values to confirm balance in cash drawer and card terminals for that day.

Selecting Report To Display

Selecting Report To Display

Filtering Data For Report

Filtering Data For Report

Reports

Reports

When user selects a report to display, he will be presented by a dialog where he can add filter criteria for that report.

Maintenance Menu

Maintenance Menu

Maintenance menu is an example of how to create drop down menu in Griffon.

That is a pretty long introduction. For convenience’ sake, the following class diagram shows domain classes in this sample application:

UML Class Diagram

UML Class Diagram

So, what’s next? Explore the code! See how Groovy, Griffon and simple-jpaplugin produces such a simple and easy to understand code. They will increase the agility of your project.

What’s New In simple-jpa 0.6?

One of the major changes in simple-jpa 0.6 is upgrade from JPA 2.0 to 2.1. This allow the implementation of generate-schema and fetchGraph/loadGraph in finders config. The next major change is @Transaction now can be used in any classes inside domain packages. This means any domain classes can act as repository if they have @Transaction on them. In the previous version, only Griffon’s artifacts such as controllers and services that can have dynamic finders on them.

The new generate-schema command will generate database objects to database or SQL scripts based on current domain models. For example, to drop existing tables and recreate new tables based on current domain models to JDBC connection defined in persistence.xml, you can use the following command:

griffon generate-schema -target=database -action=drop-and-create

By default, projects that use simple-jpa will recreate database objects (such as tables and its foreign keys) when they are launched. This mean generating database objects to database manually is unusual.

It is more typical to use generate-schema to generate SQL scripts that can be executed on another database (such as production database). generate-schema can generate two SQL scripts, one for drop statements and another one for create statements. For example, you can use the following command to generate SQL scripts based on current domain models:

griffon generate-schema -target=script -action=drop-and-create
                        -dropTarget=drop.sql -createTarget=create.sql

The command above will generate two files in current project directory: drop.sql containing SQL drop statements and create.sql containing SQL create statements.

simple-jpa now supports JPA 2.1 entity graphs. To use entity graphs, you must define a named entity graph (using @NamedEntityGraph annotation) or define it programmatically. For example, the following entity declares a named entity graph called StudentScores:

@DomainClass @Entity @TupleConstructor
@ToString(excludes = 'scores')
@NamedEntityGraph(name='StudentScores',
    attributeNodes = [@NamedAttributeNode('scores')]
)
class Student {

    String nama

    @OneToMany(mappedBy='student')
    List scores= []

}

By default, collections fetching strategy is lazy. For example, the following finder will only select from student table in database:

findAllStudent()

If you are passing the student entities outside transaction scope and trying to get their scores, you will get the famous org.hibernate.LazyInitializationException. To avoid that problem, you can instruct JPA provider to fetch scores for student (as defined in named entity graph) using the following code:

findAllStudent([loadGraph: 'StudentScores'])

simple-jpa 0.6 supports both fetchGraph and loadGraph. In Hibernate JPA, they do the same thing. This behaviour may be different in other JPA providers. For example, in EclipseLink, fetchGraph will not fetch unlisted attributes (making them lazy) while loadGraph will use fetchType specified in mapping.

To use dynamic finders in domain classes, they must be annotated with @Transaction in class level. For example, the following code implements repository pattern:

package domain.repository

...

@Transaction
class ProductRepository {

  public Produk save(Product product) {
     if (findProductByName(product.name)) {
        throw new DuplicateData(product)
     }
     persist(product)
     product
  }

}

Because simple-jpa always injects finders as public methods, the following code is also possible:

ProductRepository productRepo = new ProductRepository()
Product p = productRepo.findProductByName(name)

Membaca Change Journal File Di NTFS Dengan Visual C++

Kode program untuk artikel ini dapat dijumpai di https://gist.github.com/JockiHendry/9263890.

Pada volume yang memakai file system NTFS, Windows 7 akan mencatat setiap aktifitas perubahan pada file dan directory. Dengan demikian, pengguna bisa tahu apa saja file dimodifikasi di volume/partisi tersebut. Windows akan menyimpan informasi ini di sebuah file bernama \$Extend:$J (file bernama \$Extend di alternate data stream bernama $J). File ini tidak akan penuh karena isinya akan terus ditimpa. Lalu bagaimana cara melihat file ini? Ini adalah salah satu file internal yang dipakai oleh NTFS dan tidak dapat dilihat oleh pengguna (sama seperti file lain seperti $MFT, $MFTMirr, $Boot dan sebagainya). Cara yang paling akurat adalah dengan memeriksa isinya (membaca dari sector). Sebagai alternatifnya, Windows juga menyediakan control code FSCTL_READ_USN_JOURNAL di API DeviceIOControl(). Cara ini lebih mudah karena saya tidak perlu membaca sector secara langsung. Tapi cara ini tidak selalu efektif dalam setiap kondisi karena ia hanya bisa diterapkan di partisi yang sedang di-mount (memiliki drive letter seperti C atau D).

Pada artikel ini saya akan memakai Visual C++ karena API Windows ditulis dan dipublikasikan dengan bahasa C. Memakai bahasa tingkat tinggi seperti .NET dan Java hanya akan menambah repotnya mengurus interoperability. Mahasiswa yang baru belajar sering kali menganggap C dan C++ sudah ‘punah’ digantikan bahasa tingkat tinggi (terutama bila melihat dari kronologi perkembangan bahasa pemograman). Tapi faktanya tidak demikian, developer tetap butuh bahasa yang dekat dengan mesin agar bisa mengendalikan mesin. Mesin komputer tidak bisa membaca HTML dan tidak mengerti OOP; mesin komputer hanya mengerti bit dan byte. Bahasa C tetap merupakan bahasa pilihan saya dalam pemograman low-level hingga saat ini karena ia memang lebih dekat dengan mesin (misalnya memiliki pointer untuk membaca alamat memori secara langsung).

Saya akan mulai dengan membuat sebuah proyek Win32 Console Application di Visual Studio. Karena operasi yang dilakukan oleh program ini membutuhkan hak akses Administrator, maka perlu ada dialog UAC untuk menjalankan program sebagai Administrator (bila belum). Untuk itu saya men-klik kanan nama proyek, memilih Properties. Pada dialog yang muncul, saya memilih Configuration Properties, Linker, Manifest File. Setelah itu, saya mengubah nilai UAC Execution Level menjadi requireAdministrator (/level=’requireAdministrator’).

Langkah pertama yang saya lakukan adalah membuka volume NTFS yang akan dibaca berdasarkan kode hurufnya dengan memanggil Win32 API CreateFile(), seperti yang terlihat pada kode program berikut ini:

#include "stdafx.h"
#include <Windows.h>
#include <WinIoCtl.h>

int _tmain(int argc, _TCHAR* argv[])
{

    // Membaca volume C:
    HANDLE h = CreateFile(L"\.c:", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, 
        NULL, OPEN_EXISTING, 0, NULL);
    if (h == INVALID_HANDLE_VALUE) {
        printf("Gagal membaca drive C. Kode Kesalahan: %dn", GetLastError());
        return;
    }

    // Lanjut disini...

    return 0;
}

Setelah itu, saya akan memanggil DeviceIOControl() dengan code berupa FSCTL_QUERY_USN_JOURNAL untuk mendapatkan nilai USN pertama. Saya membutuhkan nilai ini sebagai parameter untuk FSCTL_READ_USN_JOURNAL nanti. Untuk itu, saya menambahkan kode program seperti berikut ini:

...
// Membaca informasi Change Journal dengan FSCTL_QUERY_USN_JOURNAL
USN_JOURNAL_DATA journal;
DWORD jumlahByte;
if (!DeviceIoControl(h, FSCTL_QUERY_USN_JOURNAL, NULL, 0, &journal, sizeof(journal), &jumlahByte, NULL)) {
    printf("Gagal membaca Change Journal.  Kode kesalahan: %dn", GetLastError());
    return -1;
}
printf("USN_JOURNAL_DATA.UsnJournalID    = %#020llxn", journal.UsnJournalID);
printf("USN_JOURNAL_DATA.FirstUsn        = %#020llxn", journal.FirstUsn);
printf("USN_JOURNAL_DATA.NextUsn         = %#020llxn", journal.NextUsn);
printf("USN_JOURNAL_DATA.LowestValidUsn  = %#020llxn", journal.LowestValidUsn);
printf("USN_JOURNAL_DATA.MaxUsn          = %#020llxn", journal.MaxUsn);
printf("USN_JOURNAL_DATA.MaximumSize     = %#020llxn", journal.MaximumSize);
printf("USN_JOURNAL_DATA.AllocationDelta = %#020llxn", journal.AllocationDelta);
...

Pada kode program diatas, saya memakai %llx pada printf() untuk mencetak versi heksadesimal dari nilai __int64 yang juga dikenal sebagai long long (tipe USN adalah alias dari __int64). Bila saya menjalankan kode program di atas, saya akan memperoleh hasil yang sama dengan perintah fsutil usn readdata c:.

Nilai dari journal.FirstUsn adalah record pertama yang dapat dibaca di Change Journal. Oleh sebab itu, saya akan mulai membaca mulai dari record tersebut dengan menggunakan FSCTL_READ_USN_JOURNAL seperti yang terlihat pada kode program berikut ini:

...
// Membaca isi Change Journal
READ_USN_JOURNAL_DATA cariUSN;
CHAR hasil[4096];

cariUSN.StartUsn = journal.FirstUsn;
cariUSN.UsnJournalID = journal.UsnJournalID;
memset(hasil, 0, 4096);
if (!DeviceIoControl(h, FSCTL_READ_USN_JOURNAL, &cariUSN, sizeof(cariUSN), &hasil, 4096, &jumlahByte, NULL)) {
    printf("Gagal membaca record di Change Journal.  Kode kesalahan: %dn", GetLastError());
    return -1;
}

printf("nDaftar Record di Change Journal:nn");

PUSN_RECORD record = (PUSN_RECORD)(((PUCHAR)hasil) + sizeof(USN));
printf("USN        : %#020llxn", record->Usn);
printf("Nama File  : %Sn", record->FileName);
printf("Reason     : %#lxnn", record->Reason);

record = (PUSN_RECORD)(((PCHAR) record) + record->RecordLength);
printf("USN        : %#020llxn", record->Usn); 
printf("Nama File  : %Sn", record->FileName);
printf("Reason     : %#lxnn", record->Reason);
...

Pada kode program di atas, saya menampilkan dua record pertama. Hasil kembalian dari FSCTL_READ_USN_JOURNAL ditampung dalam variabel hasil. Delapan (8) byte pertama dari hasil adalah nilai USN berikutnya setelah USN yang terakhir kali dikembalikan. Ukuran hasil hanya 4096 bytes sehingga ada kemungkinan besar tidak seluruh record tertampung sehingga saya perlu melakukan perulangan dengan kembali memanggil FSCTL_READ_USN_JOURNAL. Selain itu, saya juga harus memperhatikan nilai variabel jumlahByte berisi jumlah byte yang terpakai dari 4096 bytes yang saya sediakan.

Kode program di atas hanya menampilkan dua record saja. Ini tidak begitu berguna! Oleh sebab itu, saya akan menghapus dan menggantinya menjadi sebuah perulangan yang menampilkan seluruh record di Change Journal seperti yang terlihat berikut ini:

...
// Membaca isi Change Journal
printf("nDaftar Record di Change Journal:nn");

READ_USN_JOURNAL_DATA cariUSN;
PUSN_RECORD record;
CHAR hasil[4096];
USN nextUSN;

cariUSN.StartUsn = journal.FirstUsn;
cariUSN.ReasonMask = 0xFFFFFFFF;
cariUSN.ReturnOnlyOnClose = 0;  
cariUSN.BytesToWaitFor = 0;
cariUSN.UsnJournalID = journal.UsnJournalID;

while (1) {
    memset(hasil, 0, 4096);
    if (!DeviceIoControl(h, FSCTL_READ_USN_JOURNAL, &cariUSN, sizeof(cariUSN), &hasil, 4096, &jumlahByte, NULL)) {
        printf("Gagal membaca record di Change Journal.  Kode kesalahan: %dn", GetLastError());
        return -1;
    }           

    record = (PUSN_RECORD)(((PUCHAR)hasil) + sizeof(USN));
    jumlahByte -= sizeof(USN);      

    while (jumlahByte > 0) {
        printf("USN        : %#020llxn", record->Usn);
        printf("Nama File  : %Sn", record->FileName);
        printf("Reason     : %#lxnn", record->Reason);

        jumlahByte -= record->RecordLength;
        record = (PUSN_RECORD)(((PCHAR) record) + record->RecordLength);
    }

    nextUSN = *(USN*) &hasil;       
    if (nextUSN==cariUSN.StartUsn) break;       
    cariUSN.StartUsn = nextUSN;
}
...

Pada kode program di atas, saya selalu memakai sebuah variabel yang sama untuk menampung entry yang dibaca yaitu hasil (sebuah array 4096 bytes). Setiap kali akan membaca di perulangan berikutnya, saya mengosongkan nilai hasil dengan memset(). Dengan demikian, saya dapat membaca Change Journal yang memiliki ukuran besar (dalam satuan GB) tanpa harus takut kehabisan memori. Untuk menentukan kapan harus berhenti, saya memeriksa apakah USN berikutnya yang dikembalikan oleh FSCTL_READ_USN_JOURNAL sama dengan nilai cariUSN.StartUsn yang saya berikan diawal. Bila sama, ini berarti tidak ada lagi yang dapat dibaca dan FSCTL_READ_USN_JOURNAL tidak boleh dipanggil lagi.

Bila saya menjalankan program, saya akan memperoleh banyak output. Alangkah baiknya bila program berhenti setiap kali mencapai batas layar. Oleh sebab itu, saya kemudian menambahkan kode program seperti berikut ini:

...
int _tmain(int argc, _TCHAR* argv[])
{
    // Mengatur console
    CONSOLE_SCREEN_BUFFER_INFO screenBufferInfo;
    HANDLE hStdOut = GetStdHandle(STD_OUTPUT_HANDLE);
    SetConsoleTitle(L"Change Journal Viewer by TheSolidSnake");   
    ...
    while (1) {
        ...

        while (jumlahByte > 0) {

            ...

            GetConsoleScreenBufferInfo(hStdOut, &screenBufferInfo);                 
            if (screenBufferInfo.dwCursorPosition.Y + 4 > screenBufferInfo.srWindow.Bottom) {                                
                printf("Tekan sembarang tombol untuk melanjutkan...");
                getchar();
                system("cls");
            }
        }

        ...
    }
    return 0;
}
...

Sekarang, hasil dari program sudah mulai bisa dimengerti. Tapi informasi yang ditampilkan rasanya terlalu banyak. Oleh sebab itu, saya akan membatasi hanya menampilkan Reason yang berupa USN_REASON_FILE_CREATE, USN_REASON_FILE_DELETE dan USN_REASON_RENAME_NEW_NAME. Selain itu, saya akan menampilkan Reason yang saat ini dalam bentuk bilangan heksadesimal menjadi tulisan yang lebih mudah dimengerti. Untuk itu, saya melakukan perubahan kode program seperti berikut ini:

...
cariUSN.ReasonMask = USN_REASON_FILE_CREATE | USN_REASON_FILE_DELETE | USN_REASON_RENAME_NEW_NAME;
...
printf("USN        : %#020llxn", record->Usn);
printf("Nama File  : %Sn", record->FileName);
printf("Reason     : ");
if (record->Reason & USN_REASON_CLOSE) {
    printf(" CLOSE");
}
if (record->Reason & USN_REASON_FILE_CREATE) {
    printf(" FILE_CREATE");
}
if (record->Reason & USN_REASON_FILE_DELETE) {
    printf(" FILE_DELETE");
}
if (record->Reason & USN_REASON_RENAME_NEW_NAME) {
    printf(" RENAME_NEW_NAME");
}
printf("nn");
...

Sekarang, bila saya menjalankan program, saya akan memperoleh hasil berupa riwayat file yang dibuat atau dihapus pada volume C: seperti yang terlihat pada gambar berikut ini:

Tampilan program saat dijalankan

Tampilan program saat dijalankan

Isi File Yang Tak Terlihat Di NTFS Alternative Data Stream

Secara fisik, sebuah media penyimpanan seperti hard disk drive (HDD) menyimpan data dalam bentuk byte per byte. Satuan terkecil yang dapat dialokasikan adalah sebuah sector. Pada kebanyakan HDD, 1 sector terdiri atas 512 byte. HDD dengan fitur Advanced Format (AF) memungkinkan 1 sector terdiri atas 4K byte guna meningkatkan efisiensi pada ukuran yang besar. Tapi pengguna tidak menulis dan membaca sector secara langsung. Sistem operasi menawarkan sebuah lapisan abstraksi yang disebut sebagai file. Sebuah file dapat menempati satu atau lebih sector di harddisk. Kenapa tidak langsung menyimpan data ke sector? Karena pengguna awam tidak ingin menghafal nomor sector :) File lebih mudah dipakai karena ia memiliki konsep direktori dan metadata seperti nama dan ukuran.

Tata cara pengelolaan file oleh sistem operasi disebut sebagai file system. Contoh file system yang menjadi legenda di Windows adalah File Allocation Table (FAT). FAT sudah ada sejak Microsoft lahir dimana ia dirancang oleh seorang karyawan pertama di Microsoft. Tidak ada perubahan signifikan pada file system ini selain mengubah pengenal cluster menjadi 32-bit pada FAT32. Walaupun demikian, FAT32 masih populer hingga sekarang, padahal di Windows 7, Microsoft telah menciptakan penerus FAT yang disebut sebagai exFAT (Extended File Allocation Table). exFAT lebih efisien untuk flash drive dan memory card. Tapi penggunaan FAT32 masih bisa dijumpai dengan mudah di media penyimpanan kamera digital, MP3 player murah, dan sebagainya. Lisensi menjadi masalah besar karena perangkat yang memakai exFAT tanpa perjanjian kerja sama dengan Microsoft dapat dituntut ;) Ini adalah pukulan keras bagi dunia open-source karena exFAT adalah file system ‘rahasia’ dimana orang-orang tidak bisa tahu apa yang sesungguhnya disimpan.

Bila exFAT ditujuan untuk flash drive, maka NTFS (New Technology File System) adalah penerus FAT untuk media penyimpanan permanen seperti HDD. Beruntungnya, NTFS tidak dilindungi oleh paten. Selain itu, hampir semua informasi NTFS sudah di-reverse engineer dan hasilnya telah dipublikasikan secara umum. Ini sebabnya Linux bisa mendukung NTFS secara resmi dalam bentuk driver NTFS-3g. Linux sendiri memiliki file system andalannya yang disebut sebagai ext4.

Jadi, sebuah file adalah kumpulan byte yang mewakili sebuah data yang tersimpan, bukan? Ini adalah pandangan yang normal di UNIX dan turunannya dimana segala sesuatunya adalah file. Tapi pada beberapa sistem operasi lain seperti Windows dan Mac OS, sebuah file dapat terdiri atas satu atau lebih data stream (disebut juga fork). Pada artikel ini, saya akan melihat contoh file yang memiliki lebih dari satu stream di NTFS.

Saya mulai dengan membuat sebuah file bernama latihan.txt secara biasa seperti pada gambar berikut ini:

Membuat file biasa

Membuat file biasa

Sekarang, saya akan menulis ke file yang sama tetapi pada stream yang berbeda seperti yang terlihat pada gambar berikut ini:

Menambahkan isi  file pada alternate data stream

Menambahkan isi file pada alternate data stream

Terlihat bahwa sekarang file latihan.txt memiliki dua isi yang berbeda.

Isi pertama adalah isi di main data stream yang terlihat di perintah dir dan Explorer. Bila saya melihat di Explorer, tetap hanya ada sebuah file dengan ukuran 25 bytes. Bila saya men-double click file tersebut, maka notepad akan muncul dengan isi sesuai pada main data stream. Hal ini menunjukkan bahwa pengguna selalu bekerja pada main data stream pada aktifitas hariannya.

Isi yang kedua adalah isi yang tersimpan di alternate data stream bernama rahasia. Banyak pengguna yang tidak menyadari adanya ‘isi sampingan’ dari sebuah file. Hal ini karena perintah dir, Explorer, dan aplikasi seperti Notepad dan Word selalu bekerja pada main data stream.

Untuk melihat apakah sebuah file memiliki alternate data stream atau tidak, saya dapat menggunakan perintah dir/r seperti yang terlihat pada gambar berikut ini:

Melihat file dengan alternate data stream

Melihat file dengan alternate data stream

Lalu apa gunanya alternate data stream? Salah satu fitur Windows yang aktif menggunakannya adalah Attachment Execution Service. Fitur ini akan menampilkan peringatan keamanan pada saat pengguna menjalankan sebuah program yang di-download dari Internet. Untuk mengenali apakah sebuah file berasal dari Internet, Windows menambahkan sebuah stream bernama Zone.Identifier pada executable file yang di-download.

Saya akan mencoba mensimulasikan perilaku tersebut pada sebuah file exe biasa dengan menambahkan stream Zone.Identifier seperti yang terlihat pada gambar berikut ini:

Menambahkan alternate data stream Zone.Identifier pada file exe

Menambahkan alternate data stream Zone.Identifier pada file exe

Sekarang, bila saya menjalankan file latihan.exe, saya akan memperoleh pesan peringatan keamanan seperti berikut ini:

Windows memakai alternate data stream untuk mengenali file yang di-download dari internet

Windows memakai alternate data stream untuk mengenali file yang di-download dari internet

Sebuah contoh lainnya adalah penggunaan ‘iseng’ adalah dengan menyertakan informasi pengenal pada file exe yang saya sebarkan. Anggap saja saya memiliki file latihan.exe yang hanya saya berikan kepada Perusahaan A, Perusahaan B, dan PerusahaanC. Saya dapat menambahkan informasi lisensi dengan membuat stream lisensi.txt yang berisi nama perusahaan setiap kali mendistribusikan sebuah file latihan.exe seperti yang terlihat pada gambar berikut ini:

Menambah data teks pada file exe dengan alternate data stream

Menambah data teks pada file exe dengan alternate data stream

Bila seseorang men-copy file tersebut ke pihak lain, maka isi dari stream lisensi.txt akan turut di-copy (selama masih memakai NTFS). Dengan demikian, bila terdapat kebocoran, saya dapat memeriksa isi stream lisensi.txt untuk mencari tahu siapa yang membocorkan seperti yang terlihat pada gambar berikut ini:

Membaca data teks pada file exe

Membaca data teks pada file exe

Tentu saja cara ini hanya cara ‘iseng’ yang tidak untuk keperluan serius. Isi dari alternate data stream hanya akan dipertahankan bila file di-copy ke sesama file system NTFS. Bila di-copy ke file system FAT32, maka isi alternate data stream akan hilang. Selain itu, pengguna mahir bisa dengan mudah melihat isi file secara fisik per sector tanpa abstraksi file system.

Membuat Program C# Yang Membaca Data UserAssist

Sistem operasi Windows 7 adalah sebuah sistem operasi yang ‘pintar’. Contohnya, Windows memiliki fitur SuperFetch yang akan menganalisa pola penggunaan program berdasarkan hari dan jam sehingga Windows dapat menebak file-file apa saja yang mungkin akan dipakai pengguna. Berdasarkan informasi tersebut, Windows akan mengisi file ke memori sebelum dipakai sehingga mengurangi kemungkinan page fault. Selain itu, Windows juga terkadang sibuk mengurusi log NTFS di balik layar. Tergantung pada penggunanya, perilaku ‘pintar’ tidak selalu positif. Beberapa pengguna yang ingin punya kendali penuh bisa saja tidak senang dengan Windows yang suka sibuk sendiri tanpa disuruh.

Contoh ‘kepintaran’ Windows 7 yang bisa menuju ke arah berbahaya adalah ia selalu mencatat jumlah eksekusi sebuah program serta kapan sebuah program terakhir kali dijalankan ke lokasi registry HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\UserAssist. Walaupun program dihapus, catatan ini tetap akan ada selama-lamanya :) Dengan fitur ini, Windows 7 dapat menampilkan program populer yang sering dipakai di menu Start. Masalahnya adalah banyak yang tidak tahu bahwa Windows 7 menyimpan catatan seperti ini. Sepertinya Microsoft berusaha menyembunyikan isi registry tersebut dari tangan jahil karena ia menyamarkan isi registry UserAssist tersebut dengan enkripsi ROT13. Ini adalah jenis enkripsi tidak aman yang sangat mudah dipecahkan; ROT13 hanya melakukan subtitusi huruf seperti A menjadi N, B menjadi O, C menjadi P, dan seterusnya. Walaupun mudah dipecahkan, penggunakan enkripsi ROT13 dan nilai dalam bentuk binary membuat saya sulit mencerna isi dari registry UserAssist dengan mudah.

Oleh sebab itu, saya akan mencoba membuat sebuah program C# yang akan membaca isi registry UserAssist tersebut dan menampilkan informasi dalam bentuk tabel yang lebih mudah dicerna. Program ini adalah sebuah aplikasi WPF yang saya ujikan pada Windows 7 32-bit dan 64-bit. Kode program secara lengkap dapat ditemukan di http://github.com/JockiHendry/ProgramExecutionCounter.

Kode program yang saya buat untuk membaca dari registry terlihat seperti berikut ini:

...
private void OnSearch()
{            
   countEntries.Clear();

   RegistryKey reg = Registry.CurrentUser.OpenSubKey(SelectedSourceType.Key);
   foreach (string valueName in reg.GetValueNames())
   {                
      CountEntry entry = new CountEntry();
      entry.Name = valueName;

      // filter by name
      if (!string.IsNullOrEmpty(NameFilter))
      {
          if (!entry.DecodedName.ToUpper().Contains(NameFilter.ToUpper())) continue;
      }

      entry.Value = (byte[]) reg.GetValue(valueName);
      entry.RegKey = reg.ToString();
      ...
      countEntries.Add(entry);
   }
}  
...      

Pada kode program di atas, saya memakai class Registry dari namespace Microsoft.Win32 untuk membaca isi registry. Saya kemudian melakukan konversi masing-masing value yang saya temukan menjadi sebuah object CountEntry.

Pada class CountEntry, saya menerjemahkan nama yang dienkripsi dengan algoritma ROT13 dengan menggunakan method dictionary lookup. Saya memilih cara ini karena lebih mudah dan jumlah kombinasi yang ada sangat sedikit (A-Z, a-z). Selain itu, nama di registry UserAssist juga mengandung GUID yang mewakili folder spesial di Windows (seperti MyComputer, Program Files, dan sebagainya). Saya bisa memperoleh informasi GUID untuk folder spesial di Windows dengan melihat isi header KnownFolders.h yang terdapat di folder Include di Windows SDK. Setelah membuat variabel static yang berisi dictionary lookup untuk ROT13 dan daftar terjemahan GUID folder, saya kemudian memulai proses dekripsi dengan kode program seperti berikut ini:

...
public String DecodedName
{
    get
    {
        if (string.IsNullOrEmpty(Name))
        {
            return "";
        }
        else
        {
            string result = new string(Name.ToCharArray().Select(c =>
            {
                return lookupTable.ContainsKey(c) ? lookupTable[c] : c;
            }).ToArray());
            foreach (var f in folderGUID)
            {
                if (result.Contains(f.Key)) result = result.Replace(f.Key, f.Value);
            }
            return result;
        }
    }
}
...

Nilai dari setiap key di registry UserAssist adalah deretan byte sebesar 72 byte. Tidak ada yang tahu persis apa saja informasi yang tersimpan, selain Microsoft selaku pencipta Windows (rasa waspada ini tidak perlu ada bila memakai sistem operasi open-source ;) ). Berdasarkan informasi yang diperoleh dari hasil pencarian Google, saya hanya akan mengambil 4 byte mulai dari posisi ke-4 yang mewakili jumlah eksekusi program dalam bilangan integer 32-bit. Untuk mengubah deretan byte menjadi sebuah int, saya memakai class BitConverter seperti yang terlihat pada kode program berikut ini:

...
public byte[] Value
{
    get
    {
        return this.value;
    }

    set
    {
        this.value = value;
        executionCount = BitConverter.ToInt32(value, 4);
    }
}
...

Tool sederhana ini juga memungkinkan pengguna untuk mengubah jumlah eksekusi secara langsung dari tabel. Untuk menerjemahkan bilangan int yang dimasukkan oleh pengguna menjadi byte array, saya kembali menggunakan class BitConverter dengan memanggil method GetBytes() seperti yang terlihat pada kode program berikut ini:

...
if (e.PropertyName == "ExecutionCount")
{
    try
    {                            
        byte[] newCount = BitConverter.GetBytes(countEntry.ExecutionCount);
        newCount.CopyTo(countEntry.Value, 4);
        Registry.SetValue(entry.RegKey, entry.Name, entry.Value);
    }
    catch (Exception ex)
    {
        MessageBox.Show("Error Updating Registry: " + ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error);
    }
}
...

Pada kode program diatas, saya memanggil method SetValue() dari class Registry untuk menulis perubahan ke registry.

Selain mengubah, tool ini juga memungkinkan pengguna untuk menghapus entry yang dipilihnya. Untuk itu saya perlu memanggil method DeleteValue() dari sebuah RegistryKey untuk menghapus nilai yang dipilih oleh pengguna, seperti yang terlihat pada kode program berikut ini:

public void OnDelete()
{
    if (MessageBox.Show("Do you really want delete this entry?", "Delete Confirmation",
        MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.Yes)
    {
        RegistryKey key = Registry.CurrentUser.OpenSubKey(RegKey.Replace(@"HKEY_CURRENT_USER", ""), true);
        if (key != null)
        {
            try
            {
                key.DeleteValue(Name);
                PropertyChanged(this, new PropertyChangedEventArgs("DeleteCommand"));
            }
            catch (Exception ex)
            {
                MessageBox.Show("Error Deleting Registry: " + ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }

    }
}

Sekarang, saya akan mencoba menjalankan program. Pada saat saya men-klik tombol Search, saya akan memperoleh hasil seperti pada gambar berikut ini:

Menampilkan seluruh entry yang ada

Menampilkan seluruh entry yang ada

Saya dapat langsung mengubah nilai jumlah eksekusi dengan men-double klik pada kolom angka, mengisi angka baru dan menekan tombol Enter, seperti yang terlihat pada gambar berikut ini:

Mengubah nilai registry untuk jumlah eksekusi

Mengubah nilai registry untuk jumlah eksekusi

Selain itu, saya dapat langsung menghapus sebuah entry dengan men-klik icon dengan tanda silang di baris yang bersesuaian, seperti yang terlihat pada gambar berikut ini:

Menghapus nilai dari registry

Menghapus nilai dari registry

Saya juga dapat menampilkan informasi detail untuk sebuah baris dengan men-klik icon kaca pembesar, seperti yang terlihat pada gambar berikut ini:

Menampilkan informasi detail untuk sebuah entry

Menampilkan informasi detail untuk sebuah entry

Lalu, apa manfaat dari tool sendiri ini? Karena registry UserAssist ini akan tetap menyimpan informasi program yang dijalankan walaupun program sudah dihapus atau di-uninstall, maka saya dapat menggunakannya untuk memeriksa apakah pengguna pernah menjalankan sebuah program atau tidak. Fungsi lainnya, misalnya saya dapat memeriksa apa saja program yang pernah dijalankan langsung melalui flash disk (dengan men-klik di Explorer) dengan fasilitas pencarian seperti pada gambar berikut ini:

Melakukan pencarian

Melakukan pencarian

Apakah kali ini ‘kepintaran’ Windows dirasa membuat pengguna khawatir dengan privasi mereka? Beruntungnya, Windows menyediakan fitur untuk mematikan fasilitas ini. Saya dapat menghilangkan pencatatan ini dengan men-klik kanan pada task bar yang kosong, memilih menu Properties. Pada dialog yang muncul, saya memilih tab Start Menu dan menghilangkan tanda centang pada Store and display recently opened programs in the Start menu seperti yang terlihat pada gambar berikut ini:

Menghilangkan pencatatan pada registry UserAssist

Menghilangkan pencatatan pada registry UserAssist

Ikuti

Get every new post delivered to your Inbox.