Mengatasi Permasalahan N+1 Pada Query Di Hibernate

Apa itu permasalahan N+1? Sebagai contoh, anggap saja saya memiliki sebuah JPA entity seperti berikut ini:

@NamedEntityGraphs([
    @NamedEntityGraph(name='FakturJualOlehSales.Piutang', attributeNodes=[
        @NamedAttributeNode('listItemFaktur'),
        @NamedAttributeNode('piutang')
    ])
])
class FakturJualOlehSales extends FakturJual {

    @NotNull(groups=[Default,InputPenjualanOlehSales]) @ManyToOne
    Konsumen konsumen

    @NotNull(groups=[Default]) @Type(type="org.jadira.usertype.dateandtime.joda.PersistentLocalDate")
    LocalDate jatuhTempo

    @OneToOne(cascade=CascadeType.ALL, orphanRemoval=true, fetch=FetchType.LAZY)
    KewajibanPembayaran piutang

    @OneToOne(cascade=CascadeType.ALL, orphanRemoval=true, fetch=FetchType.LAZY)
    BonusPenjualan bonusPenjualan

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true, fetch=FetchType.EAGER)
    @JoinTable(name='FakturJual_retur')
    @OrderColumn
    List<PenerimaanBarang> retur = []

    ...

}

Class FakturJualOlehSales memiliki banyak relasi dengan class lainnya. Ini adalah sesuatu yang wajar terutama bila perancangan dilakukan dengan menggunakan metode DDD yang mengedepankan aggregation. Sebagai turunan dari FakturJual, class FakturJualOlehSales memiliki relasi one-to-many dengan ItemFaktur. Class FakturJualOlehSales juga memiliki relasi one-to-one dengan KewajibanPembayaran (untuk mewakili piutang yang ditimbulkan oleh faktur ini) dan BonusPenjualan (untuk mewakili bonus yang diberikan pada faktur ini). Class KewajibanPembayaran selanjutnya memiliki relasi one-to-many dengan Pembayaran. Begitu juga, class BonusPenjualan selanjutnya memiliki relasi one-to-many dengan ItemBarang. Selain itu, class FakturJualOlehSales juga memiliki relasi one-to-many dengan PenerimaanBarang untuk mewakili retur.

Sebuah class dengan relasi yang kompleks, bukan? Tapi saya tidak selalu butuh semua nilai relasi yang ada setiap kali berhadapan dengan FakturJualOlehSales. Sebagai contoh, pada screen untuk entry data faktur, saya tidak perlu menampilkan nilai piutang. Tetapi pada screen untuk memasukkan data pembayaran piutang, saya perlu nilai piutang tetapi tidak perlu informasi seperti bonusPenjualan.

Oleh sebab itu, saya memberikan nilai atribut fetch=FetchType.LAZY pada beberapa atribut agar Hibernate tidak men-query atribut tersebut. Teknik ini disebut lazy loading. Nilai dari atribut piutang atau bonusPenjualan hanya akan di-query pada saat ia diakses. Ini hanya berlaku selama entity masih berada dalam pengelolaan EntityManager. Bila sudah diluar cakupan EntityManager, saya akan memperoleh pesan kesalahan LazyLoadingException yang sangat terkenal.

Cara lain untuk membaca nilai yang lazy adalah dengan menggunakan query JP QL yang melakukan join fetch secara manual. Khusus untuk JPA 2.1, pilihan yang lebih nyaman adalah dengan menggunakan fasilitas named entity graph. Dengan fasilitas ini, saya tidak perlu menghabiskan banyak waktu memikirkan query! Sebagai contoh, saya mendeklarasikan sebuah named entity graph dengan nama FakturJualOlehSales.Piutang yang akan menyertakan nilai atribut piutang pada saat FakturJualOlehSales dibaca dari database.

Berikut adalah contoh kode program yang memakai named entity graph melalui simple-jpa:

FakturJualRepository repo = simplejpa.SimpleJpaUtil.instance
    .repositoryManager.findRepository('FakturJual')

int start = System.currentTimeMillis();
repo.findAllFakturJualOlehSalesFetchPiutang();
int stop = System.currentTimeMillis();

println "Delta = ${stop-start}"

Walaupun kode program di atas terlihat sederhana, tapi kinerjanya tidak memuaskan! Hibernate ternyata mengerjakan sangat banyak query, seperti:

select distinct ... from FakturJual fakturjual0_ left outer join KewajibanPembayaran kewajibanp1_ on fakturjual0_.piutang_id=kewajibanp1_.id left outer join FakturJual_listItemFaktur listitemfa2_ on fakturjual0_.id=listitemfa2_.FakturJual_id where ...

select ... from Produk produk0_ inner join Satuan satuan1_ on produk0_.satuan_id=satuan1_.id left outer join Supplier supplier2_ on produk0_.supplier_id=supplier2_.id where produk0_.id=?

select ... from Produk produk0_ inner join Satuan satuan1_ on produk0_.satuan_id=satuan1_.id left outer join Supplier supplier2_ on produk0_.supplier_id=supplier2_.id where produk0_.id=?

select ... from Produk produk0_ inner join Satuan satuan1_ on produk0_.satuan_id=satuan1_.id left outer join Supplier supplier2_ on produk0_.supplier_id=supplier2_.id where produk0_.id=?

...

select ... from Konsumen konsumen0_ inner join Region region1_ on konsumen0_.region_id=region1_.id left outer join Region region2_ on region1_.bagianDari_id=region2_.id inner join Sales sales3_ on konsumen0_.sales_id=sales3_.id inner join Gudang gudang4_ on sales3_.gudang_id=gudang4_.id where konsumen0_.id=?

select ... from Konsumen konsumen0_ inner join Region region1_ on konsumen0_.region_id=region1_.id left outer join Region region2_ on region1_.bagianDari_id=region2_.id inner join Sales sales3_ on konsumen0_.sales_id=sales3_.id inner join Gudang gudang4_ on sales3_.gudang_id=gudang4_.id where konsumen0_.id=?

select ... from Konsumen konsumen0_ inner join Region region1_ on konsumen0_.region_id=region1_.id left outer join Region region2_ on region1_.bagianDari_id=region2_.id inner join Sales sales3_ on konsumen0_.sales_id=sales3_.id inner join Gudang gudang4_ on sales3_.gudang_id=gudang4_.id where konsumen0_.id=?

...

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

...

select ... from kewajibanpembayaran_items listpembay0_ left outer join BilyetGiro bilyetgiro1_ on listpembay0_.bilyetGiro_id=bilyetgiro1_.id where listpembay0_.KewajibanPembayaran_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from kewajibanpembayaran_items listpembay0_ left outer join BilyetGiro bilyetgiro1_ on listpembay0_.bilyetGiro_id=bilyetgiro1_.id where listpembay0_.KewajibanPembayaran_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from kewajibanpembayaran_items listpembay0_ left outer join BilyetGiro bilyetgiro1_ on listpembay0_.bilyetGiro_id=bilyetgiro1_.id where listpembay0_.KewajibanPembayaran_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

Permasalahan ini sering kali disebut sebagai permasalahan N+1. Nilai 1 adalah query pertama untuk SELECT * FROM x. Setelah itu, untuk N jumlah record yang diperoleh dari query pertama, lakukan query lain untuk membaca nilai di tabel lain seperti SELECT * FROM y WHERE y.x_id = x.id. Dengan demikian, semakin banyak jumlah record yang hendak dibaca, semakin banyak juga query tambahan yang perlu dilakukan. Permasalahan N+1 biasanya adalah pemborosan kinerja yang tak seharusnya terjadi karena ia dapat digantikan dengan join dan/atau subquery.

Sebagai patokan, saya akan menyimpan hasil eksekusi program di atas dan membuat versi grafik-nya yang terlihat seperti pada gambar berikut ini:

Grafik yang menunjukkan kinerja awal

Grafik yang menunjukkan kinerja awal

Pada grafik di atas, pada eksekusi pertama kali, saya akan memperoleh penalti kinerja yang cukup tinggi. Ini adalah karakteristik dari simple-jpa. Selain itu, hal ini juga ditambah lagi dengan server database yang belum memiliki cache hasil query.

Saya akan mulai dengan berusaha menghilangkan query N+1 ke tabel konsumen. FakturJualOlehSales memiliki hubungan @ManyToOne dengan konsumen. Saya menemukan bahwa dengan menyertakan definisi atribut konsumen pada named entity graph, maka query N+1 untuk relasi ke konsumen tidak akan muncul lagi. Perubahan yang saya lakukan menyebabkan definisi named entity graph saya menjadi seperti berikut ini:

@NamedEntityGraph(name='FakturJualOlehSales.Piutang', attributeNodes=[
    @NamedAttributeNode('listItemFaktur'),
    @NamedAttributeNode(value='konsumen', subgraph='konsumen'),
    @NamedAttributeNode('piutang')
], subgraphs = [
    @NamedSubgraph(name='konsumen', attributeNodes=[
        @NamedAttributeNode('region'),
        @NamedAttributeNode('sales')
    ])
])

Sekarang, nilai untuk konsumen tidak akan di-query satu per satu lagi, melainkan diperoleh melalui join pada saat mengambil nilai FakturJualOlehSales seperti yang terlihat pada SQL yang dihasilkan oleh Hiberate:

select distinct ... from FakturJual fakturjual0_ ... left outer join Konsumen konsumen2_ on fakturjual0_.konsumen_id=konsumen2_.id left outer join Region region3_ on konsumen2_.region_id=region3_.id left outer join Sales sales4_ on konsumen2_.sales_id=sales4_.id ...

Sayang sekali saya tidak dapat melakukan hal yang sama untuk Produk karena listItemFaktur adalah sebuah @ElementCollection yang tidak dianggap sebuah entity sehingga tidak dapat diatur melalui named entity graph.

Sampai disini, apakah versi yang memakai left outer join akan lebih cepat dari versi N+1? Saya akan kembali melakukan sedikit percobaan dan menemukan hasil seperti yang terlihat pada gambar berikut ini:

Grafik yang menunjukkan kinerja setelah perubahan.

Grafik yang menunjukkan kinerja setelah perubahan.

Pada grafik di atas, terlihat bahwa perubahan yang saya lakukan memberikan sedikit peningkatan kinerja (sekitar 8%). Hal ini karena pada rancangan saya, tidak banyak yang bisa dioptimalkan dengan cara seperti ini.

Sebagai langkah berikutnya, saya akan menghindari query N+1 untuk retur dengan menjadikannya sebagai 1 query tunggal yang terpisah. Saya dapat menggunakan @Fetch(FetchMode.SUBSELECT) untuk keperluan seperti ini. Sebagai informasi, @Fetch adalah annotation khusus dari Hibernate dan bukan merupakan bagian dari JPA! Sebagai contoh, saya mengubah kode program menjadi seperti berikut ini:

class FakturJualOlehSales extends FakturJual {

   ...

   @OneToMany(...) @JoinTable()
   @Fetch(FetchMode.SUBSELECT)
   List<PenerimaanBarang> retur = []

   ...

}

Konfigurasi di atas akan menyebabkan seluruh query N+1 yang tadinya seperti:

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

...

digantikan oleh sebuah query tunggal yang isinya seperti berikut ini:

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id in (select fakturjual0_.id from FakturJual fakturjual0_ ...)

Saya segera menambahkan @Fetch(FetchMode.SUBSELECT) pada beberapa atribut lainnya yang memiliki permasalahan N+1. Setelah itu, saya mencoba menjalankan program dan kini memperoleh kinerja seperti yang terlihat pada grafis berikut ini:

Grafik yang menunjukkan kinerja setelah perubahan.

Grafik yang menunjukkan kinerja setelah perubahan.

Kali ini saya memperoleh peningkatan kinerja yang cukup drastis karena saya menemukan banyak atribut yang bisa dioptimalkan melalui @Fetch(FetchMode.SUBSELECT). Sebagai hasil akhir, setelah berupaya menghilangkan sebagian besar query N+1, saya memperoleh peningkatan kinerja sebesar 56%. Tidak ada perubahan yang perlu saya lakukan pada kode program yang membaca entitas; ia tetap merupakan sebuah baris yang polos seperti findAllFakturJualFetchPiutang().

Iklan

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)).

Memakai @Formula Di Hibernate

Pada suatu hari, saya mengimplementasikan rancangan seperti yang terlihat pada UML Class Diagram berikut ini:

Rancangan UML

Rancangan UML

Sebuah Barang memiliki hubungan one-to-many dengan EntriStok.   Class EntriStok mewakili setiap perubahan jumlah stok untuk sebuah Barang dimana nilai jumlah positif menyebabkan jumlah barang bertambah dan sebaliknya, nilai jumlah negatif menyebabkan jumlah barang berkurang.   Pada kebanyakan kasus, perubahan jumlah stok untuk sebuah barang selalu disebabkan oleh transaksi seperti pembelian atau penjualan; perubahan ini diwakili oleh class EntriStokFaktur.   Terkadang jumlah barang dapat berkurang atau bertambah oleh hal-hal lain seperti bonus, kerusakan yang tidak dapat diganti, dan kesalahan yang tidak diketahui; perubahan ini diwakili oleh class EntriStokPenyesuaian.

Saya kemudian membuat implementasi dari diagram di atas dengan menggunakan simple-jpa.   Berikut ini adalah isi dari file EntriStok.groovy:

@DomainModel @Entity @Canonical @Inheritance
class EntriStok {

  @NotNull @Type(type="org.jadira.usertype.dateandtime.joda.PersistentLocalDate")
  LocalDate tanggal

  @NotNull
  Integer jumlah

  @NotNull @ManyToOne
  Barang barang

}

Berikut ini adalah isi dari file EntriStokFaktur.groovy:

@DomainModel @Entity @Canonical
class EntriStokFaktur extends EntriStok {

  @NotBlank @Size(min=2, max=100)
  String nomorFaktur

}

Berikut ini adalah isi dari file EntriStokPenyesuaian.groovy:

@DomainModel @Entity @Canonical
class EntriStokPenyesuaian extends EntriStok {

  @NotBlank @Size(min=2, max=100)
  String keterangan

}

Berikut ini adalah isi dari file Barang.groovy:

@DomainModel @Entity @Canonical
class Barang {

  @NotBlank @Size(min=2, max=10)
  String kode

  @NotBlank @Size(min=2, max=100)
  String nama

  @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true, mappedBy="barang")
  List<EntriStok> entriStokList = []

  Integer getJumlahTersedia() {
    entriStokList.sum { EntriStok entriStok -> entriStok.jumlah } ?: 0
  }
}

Pada kode program di atas, getJumlahTersedia() akan mengembalikan jumlah barang berdasarkan seluruh EntriStok untuk barang tersebut.   Saya memilih untuk selalu menghitung ulang jumlah barang setiap kali nilai tersebut dibutuhkan/dibaca.   Alternatif lainnya yang lebih menekankan pada sisi ‘write’ adalah menghitung ulang jumlah barang setiap kali terdapat EntriStok baru untuk Barang tersebut, setiap kali EntriStok untuk barang tersebut di-update, dan setiap kali EntriStok untuk barang tersebut dihapus. Terdengar kompleks bukan?   Itu sebabnya saya memilih berhadapan dengan sisi ‘read’-nya saja.

Pada saat program dijalankan, Hibernate akan menghasilkan tabel secara otomatis dengan struktur yang terlihat seperti pada gambar berikut ini:

Tabel yang dihasilkan

Tabel yang dihasilkan

Saya kemudian mengisi kedua tabel di atas dengan data yang jumlahnya cukup banyak, seperti yang terlihat pada hasil query berikut:

mysql> SELECT COUNT(*) FROM barang;
+----------+
| COUNT(*) |
+----------+
|     5449 |
+----------+
1 row in set (0.00 sec)

mysql> SELECT COUNT(*) FROM entristok;
+----------+
| COUNT(*) |
+----------+
|   152206 |
+----------+
1 row in set (0.05 sec)

Kemudian, saya memakai fasilitas scaffolding dari simple-jpa untuk menghasilkan MVC untuk menampilkan daftar barang beserta entri stok untuk barang tersebut.   Saya kemudian mengubah kode program di BarangView.groovy menjadi seperti berikut ini:

...
panel(constraints: CENTER) {
  ...
  scrollPane(constraints: CENTER) {
    table(rowSelectionAllowed: true, id: 'table') {
      eventTableModel(list: model.barangList,
       columnNames: ['Kode', 'Nama', 'Jumlah Tersedia'],
       columnValues: ['${value.kode}', '${value.nama}', '${value.jumlahTersedia}'])
      table.selectionModel = model.barangSelection
    }
  }
  ...
}

Saya menambahkan sebuah kolom pada tabel untuk menampilkan jumlah barang yang tersedia.   Hasilnya akan terlihat seperti pada gambar berikut ini:

Tampilan program

Tampilan program

Secara default, simple-jpa akan sebisa mungkin ‘berbagi’ EntityManager bila diakses dari thread yang berbeda.   Pada aplikasi di atas, daftar EntriStok dan total dari sebuah Barang akan dibaca pada saat akan ditampilkan di tabel.   Dengan demikian, tidak ada waktu loading yang lama pada saat menampilkan seluruh Barang yang ada.   Tapi akibatnya:  aplikasi bisa menjadi tidak responsif bila pengguna men-scroll tabel karena query untuk membaca EntriStok akan dikerjakan pada saat tersebut.

Griffon bekerja dengan baik dalam memanfaatkan threading, tapi masalahnya adalah EntityManager TIDAK thread-safe!! Bila saya tidak ingin mengambil resiko terjadinya kesalahan aneh akibat EntityManager diakses dari thread yang tidak seharusnya, maka saya dapat menambahkan baris berikut ini pada Config.groovy:

griffon.simplejpa.entityManager.lifespan = "transaction"

Konfigurasi ini akan menyebabkan setiap transaksi yang ditangani oleh simple-jpa selalu memakai EntityManager baru. Konsekuensinya?   Saya harus mengucapkan selamat tinggal pada lazy loading di transaksi berbeda!   Seluruh data yang perlu ditampilkan di tabel harus di-load secara lengkap terlebih dahulu.   Pertanyaannya adalah apakah ini adalah sebuah proses yang berat?   Saya mengubah kode program di BarangController.groovy agar menampilkan berapa lama waktu yang diperlukan untuk me-load seluruh objek seperti pada berikut ini:

@SimpleJpaTransaction(newSession = true)
def listAll = {
    long startTime = System.currentTimeMillis()
    execInsideUIAsync {
        model.barangList.clear()
    }

    List barangResult = findAllBarang()
    barangResult.each { Barang barang -> barang.jumlahTersedia }

    execInsideUISync {
        model.barangList.addAll(barangResult)
        model.kodeSearch = null
        model.searchMessage = app.getMessage("simplejpa.search.all.message")
        long endTime = System.currentTimeMillis()
        JOptionPane.showMessageDialog(view.mainPanel, "Total waktu yang dibutuhkan = ${(endTime-startTime)/1000} detik")
    }
}

Percobaan pertama menunjukkan bahwa dibutuhkan waktu 91,883 detik untuk membaca seluruh Barang beserta dengan EntriStok-nya.   Ini berarti saya harus menunggu hingga 1 menit lebih hanya untuk menunggu tabel terisi dengan data yang dibutuhkan!   Terlalu lama…!!!

Sepertinya Hibernate kewalahan harus me-load 5.449 object Barang dan 152.206 object EntriStok dari database.   Tunggu…!   Bukankah saya perlu menampilkan total (SUM) dari jumlah untuk seluruh EntriStok per Barang?   Saya tidak membutuhkan masing-masing objek EntriStok yang di-load untuk saat ini.   Oleh sebab itu saya akan menggunakan annotation @Formula.   Ini adalah fitur khusus untuk Hibernate dan bukan bagian dari spesifikasi Java Persistence API (JPA) sehingga belum tentu ada di provider JPA selain Hibernate.

Annotation @Formula akan menyebabkan sebuah atribut menjadi bersifat read-only dimana nilai dari atribut tersebut diperoleh dari ekspresi SQL.   Sebagai contoh, saya akan mengubah Barang.groovy menjadi seperti berikut ini:

@DomainModel @Entity @Canonical
class Barang {

    @NotBlank @Size(min=2, max=10)
    String kode

    @NotBlank @Size(min=2, max=100)
    String nama

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true, mappedBy="barang")
    List<EntriStok> entriStokList = []

    @Formula("(SELECT SUM(e.jumlah) FROM entristok e WHERE e.barang_id = id)")
    Integer jumlahTersedia
}

Saat saya kembali menjalankan program, saya menemukan bahwa waktu yang dibutuhkan untuk menampilkan seluruh Barang yang ada adalah 3,374 detik.   Ini adalah selisih yang cukup jauh dibanding dengan sebelumnya!!   Salah satu konsekuensinya adalah karena saya tidak me-load EntriStok sama sekali, maka pada saat user akan menampilkan EntriStok untuk sebuah Barang, saya harus me-load EntriStok secara manual (atau men-merge object Barang tersebut ke EntityManager baru sehingga lazy loading bisa bekerja).

Kesimpulannya:   @Formula membuat kode program menjadi tidak portabel di JPA provider selain Hibernate, tapi ia sangat berguna untuk meningkatkan kinerja terutama untuk kasus saya.   Solusi lain yang dapat saya tempuh adalah memakai materialized view. Sayang sekali MySQL Server belum mendukung materialized view.   Selain itu saya dapat meningkatkan kecepatan operasi SUM dengan menggunakan data partitioning.  MySQL Server memang sudah mendukung data partitioning seperti melakuan partisi tabel entristok per tahun berdasarkan tanggal.   Akan tetapi, untuk saat ini, partitioning di MySQL Server belum mendukung parallelization yang seharusnya dapat mempercepat fungsi agregasi seperti SUM (karena total untuk setiap tahun boleh dihitung secara bersamaan, baru kemudian ditambahkan).  Fitur ini mungkin akan ditambahkan di kemudian hari.

Memakai Oracles TimesTen Dengan simple-jpa

Oracle TimesTen adalah sebuah database in-memory yang mendukung SQL.   Karena TimesTen masih mendukung SQL, maka ia dapat dipakai pada aplikasi yang menggunakan Java Persistence API (JPA) dalam mengakses database.   Oleh sebab itu, saya akan mencoba melakukan migrasi database pada sebuah aplikasi Griffon yang memakai plugin simple-jpa.   Aplikasi desktop tersebut sebelumnya memakai database MySQL, dan saya ingin mengubahnya agar memakai database Oracle TimesTen.   Apa saja perubahan yang harus dilakukan?

Langkah pertama adalah menambahkan driver JDBC ke proyek saya.   Driver JDBC untuk TimesTen tidak ada di repository global Maven (karena ia bukan produk open-source!) sehingga saya tidak bisa menambahkannya pada file BuildConfig.groovy seperti biasa.   Sebagai alternatifnya, saya akan men-copy file C:\TimesTen\tt1122_32\lib\ttjdbc7.jar secara manual ke folder C:\proyekGriffon\lib.

Langkah berikutnya adalah menyiapkan dialek TimesTen untuk Hibernate JPA.   Setiap database, walaupun mendukung SQL secara umum, masing-masing memiliki sedikit perbedaan.   Sebagai contoh, tipe data tulisan (karaketer) yang disarankan untuk TimesTen dan Oracle Database adalah VARCHAR2, sementara pada MySQL yang tersedia adalah VARCHAR.   Contoh lainnya, untuk melihat tanggal hari ini di MySQL, saya memakai function seperti SELECT CURRENT_DATE. Sementara itu, jika di TimesTen dan Oracle Database, saya akan memakai function SELECT SYSDATE FROM DUAL.

Hibernate JPA dapat mendukung berbagai variasi SQL pada masing-masing database, yang disebutnya sebagai dialek.   Secara bawaan, Hibernate memiliki beberapa dialek yang dapat dipilih dengan cara dilewatkan melalui property hibernate.dialect.  Contoh dialek bawaan adalah MySQLMyISAMDialect, MySQLInnoDBDialect, Oracle9Dialect, PostgreSQL81Dialect, SAPDBDialect, dan sebagainya.   Sayang sekali, dari seluruh dialek bawaan Hibernate, tidak ada dialek untuk TimesTen.  Tapi saya tidak perlu khawatir karena TimesTen telah menyediakan dialek untuk dipakai di Hibernate.   Lokasinya terletak di C:\TimesTen\tt1122_32\quickstart\sample_code\j2ee_orm\config\hibernate4\TimesTenDialect1122.java.   Saya segera membuat folder untuk package org.hibernate.dialect dan meletakkan file tersebut pada folder ini, seperti yang diperlihatkan oleh gambar berikut ini:

Menambah dialek TimesTen untuk Hibernate

Menambah dialek TimesTen untuk Hibernate

Ada sedikit kekurang TimesTenDialect1122 yang perlu saya perbaiki terlebih dahulu.   Sama seperti Oracle Database, TimesTen tidak mendukung tipe data boolean di SQL.   Sementara itu, di MySQL, bila saya mendefinisikan sebuah kolom dengan tipe data boolean, ia akan diterjemahkan menjadi tinyint(1).   Oleh sebab itu, saya menambahkan baris berikut ini pada class TimesTenDialect1122:

registerColumnType(Types.BOOLEAN, "TT_TINYINT");

Sekarang, saya akan melakukan perubahan pada file persistence.xml sehingga terlihat seperti berikut ini:

<persistence ...>
  <persistence-unit name="default" transaction-type="RESOURCE_LOCAL">
    ...
    <properties>
      <property name="javax.persistence.jdbc.driver" value="com.timesten.jdbc.TimesTenDriver"/>
      <property name="javax.persistence.jdbc.url" value="jdbc:timesten:direct:LATIHAN"/>
      <property name="javax.persistence.jdbc.user" value="solid"/>
      <property name="javax.persistence.jdbc.password" value="snake"/>
      <property name="hibernate.connection.autocommit" value="false"/>
      <property name="hibernate.hbm2ddl.auto" value="create-drop"/>
      <property name="hibernate.dialect" value="org.hibernate.dialect.TimesTenDialect1122"/>
      <property name="jadira.usertype.autoRegisterUserTypes" value="true"/>
    </properties>
  </persistence-unit>
</persistence>

Saya mengubah nilai property hibernate.hbm2dll.auto menjadi create-drop supaya Hibernate membuat tabel baru di database TimesTen yang masih kosong.   Baris ini boleh dihapus bila tidak dibutuhkan lagi.

Lalu, apa langkah berikutnya? Cukup sampai disini!   Saya tidak perlu mengubah kode program karena saya tidak memberikan perintah SQL secara langsung, melainkan melalui melalui dynamic finders dan JP QL (bahasa query untuk JPA).   Dengan demikian, saya tidak perlu khawatir dengan SQL yang tidak compatible antara MySQL dan TimesTen (selama dialek Hibernate-nya tidak bermasalah!).

Tapi ada satu pandangan naif yang perlu dihindari, terutama bagi programmer yang tidak mau berurusan dengan cara kerja database, yaitu menganggap bahwa dengan memakai sebuah database ‘keren’ maka kinerja aplikasi secara otomatis akan meningkat.   Ini adalah sebuah pandangan yang salah karena masing-masing database memiliki karakteristik tersendiri.   Terkadang aplikasi harus menyesuaikan dengan karakteristik database.   Sebagai contoh, ada dua ‘aliran‘ dalam mengakses data di memori.   Sebuah tabel terdiri atas dua dimensi (baris dan kolom) dengan data seperti:

        Kolom1   Kolom2   Kolom3
---------------------------------
Baris1   A1      B1       C1
Baris2   A2      B2       C2
Baris3   A3      B3       C3   
---------------------------------

Skema di atas adalah visualisasi tabel dalam benak manusia. Tapi saat disimpan di memori dan piringan harddisk, segala sesuatunya disimpan dalam blok per blok (anggap setiap blok adalah sebuah kotak yang dapat diisi).  Setiap blok di memori (atau blok di sektor harddisk) memiliki alamat berupa posisi offset, misalnya dari  0 hingga ke byte terakhir di offset 0xffffffff.   Jadi, di media penyimpanan, struktur tabel yang dua dimensi (baris dan kolom) harus disimpan ke dalam struktur satu dimensi (kumpulan blok).  Perancang database (pembuat DBMS) dapat memilih menyimpan data tabel per baris sehingga data akan terlihat seperti: A1 B1 C1 A2 B2 C2 A3 B3 C3.   Tapi ia juga dapat memilih menyimpan per kolom sehingga data di memori akan terlihat seperti: A1 A2 A3 B1 B2 B3 C1 C2 C3.   Tabel yang disimpan per kolom memungkinkan query yang cepat (seperti agregasi per kolom) dan memungkinkan kompresi kolom.   Tapi kelemahannya adalah proses penambahan data baru menjadi lebih lambat.   Hal ini menyebabkan DBMS yang menyimpan data per baris lebih tepat dipakai untuk aplikasi OLTP (pemrosesan transaksi), sementara DBMS yang menyimpan data per kolom lebih tepat dipakai untuk aplikasi OLAP (analisa dan laporan).  Contoh sederhana ini menunjukkan bahwa sebaiknya aplikasi mengerti karakteristik database sehingga dapat menggunakannya secara maksimal.

Kembali ke aplikasi saya, saat setelah mengganti database dari MySQL ke TimesTen, saya mencoba menguji perbedaan kecepatannya.   Saya membuat kode program yang menambah 94.521 faktur (termasuk diantaranya query untuk mencari konsumen berdasarkan kode, mencari barang berdasarkan kode, dan menambah pembayaran).   Saat memakai database MySQL, transaksi tersebut selesai dalam waktu 13 menit. Sementara bila memakai TimesTen, dibutuhkan waktu hingga mencapai 14 menit!   Mengapa TimesTen lebih lambat dibanding MySQL??

Investigasi lebih lanjut menunjukkan bahwa TimesTen kesulitan menangani transaksi yang isinya sangat besar.   Pada percobaan pertama, 94.521 faktur tersebut merupakan bagian dari sebuah transaksi tunggal.   Untuk percobaan kedua, saya menjadikan setiap proses penyimpanan masing-masing faktur menjadi sebuah transaksi tunggal.   Hasilnya? Oracle TimesTen berhasil memproses seluruh faktur dalam waktu 10 menit!   MySQL malah kewalahan melakukan banyak commit sehingga pada percobaan kedua ini, ia membutuhkan waktu hingga 49 menit.   Kali ini, TimesTen hampir lima kali lebih cepat dibandingkan dengan MySQL.

Melihat Kinerja Aplikasi Desktop Berbasis Griffon dan simple-jpa

Sepuluh tahun silam saat bekerja sebagai developer di salah satu software house, seorang technical leader melarang saya untuk memakai Java Reflection dalam menghasilkan data transfer objects (DTO).   Alasannya: reflection lebih ‘berat’ dibanding copy paste!   Karena larangan tersebut, saya dan rekan-rekan menghabiskan waktu hampir seminggu lebih untuk modul tersebut.   Delivery proyek menjadi terlambat.   Iya, pada zaman tersebut, memakai reflection memang lebih ‘berat’ , tapi pertanyaan penting yang sering terlupakan adalah: seberapa ‘berat‘ atau seberapa lambat??   Apakah penalti kinerja ini cukup berharga untuk ditukarkan dengan berkurangnya ketepatan waktu dalam delivery proyek?  Apakah penalti kinerja ini memiliki poin lebih penting sehingga tidak apa-apa bila aplikasi menjadi rumit dan sulit di-maintain? Mempertimbangkan kinerja di atas segala-galanya adalah salah satu kecendurangan negatif dalam dunia software engineering yang disebut sebagai premature optimization.

Griffon adalah sebuah framework untuk aplikasi desktop yang berdasarkan pada bahasa Groovy.   Groovy sendiri adalah sebuah bahasa pemograman dinamis yang lebih lambat dibandingkan bahasa statis seperti Java.   Tapi pertanyaan adalah: seberapa lambat sebuah aplikasi desktop yang dikembangkan dengan Groovy? Apakah masih bisa diterima dalam batas kewajaran?

Untuk menjawab pertanyaan tersebut, saya akan melakukan pengujian dengan menggunakan sebuah aplikasi desktop yang dibuat dengan framework Griffon dan plugin simple-jpa.   Aplikasi ini akan membaca data invoice (faktur) dari sebuah file teks kemudian memproses setiap baris data tersebut: menyimpan invoice, menambah entri pada inventory, serta menambah entri pada pembayaran bila diperlukan.   Data lainnya yang dibutuhkan oleh invoice sudah tersimpan di tabel dengan jumlah yang mendekati pemakaian sehari-hari. Sebagai contoh, di tabel produk terdapat 7.360 record.  Sebuah jumlah yang tidak terlalu kecil dan tidak terlalu besar.

Jumlah baris yang harus diproses oleh aplikasi ini adalah 91.190 baris yang mewakili data invoice dan item-nya.   Aplikasi ini akan memakai Griffon 1.2.0, simple-jpa 0.4.2, Hibernate JPA 4.2.0.Final dan MySQL Server 5.6.11.   Selama pengujian, database hanya akan dipakai oleh program tersebut saja.   Pengujian dipakai untuk mensimulasikan aktifitas single user.  Btw, hasil percobaan ini hanya sebagai gambaran umum yang terbatas pada aplikasi yang saya uji tersebut.   Untuk hasil yang lebih akurat tentunya dibutuhkan aplikasi dan environment yang lebih terkendali dan fair.

Percobaan 1

Saya akan mulai dengan menjalankan aplikasi dalam modus hanya membaca dari database.   Pengguna biasanya akan lebih sering melihat data inventory atau mencari data pembayaran ketimbang membuat data invoice baru.   Hasil percobaan menunjukkan bahwa 91.190 baris data berhasil diproses dalam waktu 49 menit.   Ini sudah meliputi membaca tabel produk, membaca data suplier/konsumen, mengerjakan business logic, tapi hasil prosesnya tidak disimpan (tidak ada query INSERT).

Aplikasi percobaan berbasis Griffon dan simple-jpa ini dapat memproses hingga 31 transaksi dalam waktu 1 detik.   Saya akan menyebutnya sebagai 31 TPS.   Perhitungannya adalah 91.190 baris dibagi dengan 49 x 60 = 2940 detik.   Transaksi disini bukanlah transaksi dalam arti operasi database, melainkan satu kesatuan operasi membuat invoice baru serta implikasinya (pada pembayaran dan inventory).  Termasuk juga didalamnya operasi mencari suplier, konsumen atau produk berdasarkan kode.   Dengan demikian, sebuah transaksi akan terdiri atas lebih dari satu operasi SQL.

Percobaan 2

Walaupun saya yakin pengguna tidak akan men-klik setiap menu sebanyak 31 kali per detik, tapi angka tersebut cukup kecil.   Saya pun menyelidiki kenapa aplikasi Griffon ini begitu lambat.  Pada akhirnya saya menemukan jawabannya:

  1. Kode program memproses setiap baris data dalam looping.  Keseluruh looping ini akan dianggap sebagai satu transaction (disini saya merujuk pada database transaction).
  2. EntityManager selaku first level cache akan menampung data hasil query SELECT.   Beberapa query men-trigger terjadinya flushing.
  3. Semakin banyak data yang ada di first level cache, maka semakin lama proses validasi yang timbul akibat flushing yang di-trigger secara otomatis.

Untuk mengatasinya, saya mengubah struktur program dari:

...
beginTransaction()
baris.each {
   ... // baca data
   Product product = findProductByCode(productCode)
   Invoice invoice = new Invoice()
   invoice.tambahItem(...)
   if (bayar) {
      invoice.bayar(...)
   }
}
commitTransaction()
...

menjadi:

...
def total = 0
beginTransaction()
baris.each {
   ... // baca data
   Product product = findProductByCode(productCode)
   Invoice invoice = new Invoice()
   invoice.tambahItem(...)
   if (bayar) {
      invoice.bayar(...)
   }
   total++
   if (total%20000==0) clear() // memanggil EntityManager.clear()
}
commitTransaction()
...

Setelah perubahan, saya menemukan bahwa aplikasi tersebut dapat menyelesaikan tugasnya dalam waktu 6 menit.   Hal ini berarti meningkat dari 31 TPS menjadi 253 TPS.   Ini adalah peningkatan yang cukup drastis.

Kesimpulannya:  kode program business logic dengan Groovy terlihat memiliki kinerja yang wajar.   Bahasa dinamis memang lebih lambat dari bahasa statis, tapi seberapa besar dampaknya?   Kode program aplikasi ini juga memakai dynamic finders di simple-jpa untuk mencari entity berdasarkan kodenya.   Penggunaan method dinamis sering kali dianggap lebih lambat dibanding memanggil method secara biasa. Tapi seberapa besar selisih lambatnya? Hari ini saya menemukan jawabannya: layak dipakai 🙂

Percobaan 3

Perjalanan belum selesai!   Kali ini saya akan mengubah kode program sehingga tidak hanya membaca dan membuat object baru di memori, tapi juga menuliskan hasil proses ke database.   Kali ini, saya perlu men-flush() EntityManager setiap 1000 kali baris diproses, sebelum men-clear() EntityManager tersebut.   Perintah flush() akan memaksa Hibernate untuk menuliskan perubahan tersebut ke database dengan melakukan eksekusi SQL.   Walaupun demikian, perubahan yang permanen tetap akan terjadi hanya setelah perintah commit() diberikan.   Jadi, bila saya membatalkan aplikasi di tengah perjalanan, maka tidak akan ada tabel yang isinya berubah.

Lalu, berapa banyak waktu yang dibutuhkan?  Hasil pengujian menunjukkan bahwa untuk memproses 91.190 baris dibutuhkan waktu selama 38 menit.   Dengan demikian kecepatannya adalah 40 TPS.   Hasilnya adalah 91.190 record di tabel Invoice, 17.484 record di tabel pembayaran, dan 129.049 record di tabel inventory.   Nilai 40 TPS adalah hal wajar dan bisa diterima, tapi masalahnya adalah data yang dihasilkan tidak akurat!!   Banyak baris duplikat ditemukan di tabel inventory.   Mengapa demikian?

Percobaan 4

Hal ini berkaitan dengan penggunaan @Canonical di Groovy.   Keinginan saya untuk mendapatkan ‘kenyamanan’ serba otomatis akhirnya membuat saya memperoleh ‘hukuman’.   simple-jpa memberikan @Canonical pada seluruh domain classes yang ada. Annotation ini akan membuat Groovy menciptakan constructor, equals(), hashCode() dan toString() secara otomatis.   Hibernate (lebih tepatnya Java) akan memakai hashCode() dan equals() untuk membandingkan apakah dua buah entity adalah entity yang sama atau berbeda.   Masalahnya: hashCode() dan equals() yang dihasilkan oleh @Canonical sepertinya tidak memperhatikan atribut pada superclass.   Padahal untuk sebuah entity yang diturunakn dari entity lainnya,  biasanya id atau natural primary key yang bisa dipakai untuk membandingkan kesamaan akan terletak di superclass.   Solusinya, saya perlu membuat isi equals() dan hashCode() secara manual pada domain class yang bermasalah.

Setelah dijalankan ulang, tidak ada lagi record yang duplikat di tabel inventory.   Saya juga sempat memanfaatkan kesempatan untuk meningkatkan efisiensi kode program.   Hasil akhirnya, seluruh baris bisa diproses dalam waktu 32 menit (47 TPS).   Data yang dihasilkan adalah 91.190 record di tabel invoice, 17.485 record di tabel pembayaran, dan 73.413 record di tabel inventory.

Kecepatan ini sudah lebih dari cukup mengingat ini adalah aplikasi desktop yang dipakai oleh pengguna tunggal dan hanya sesekali berinteraksi dengan sistem lainnya.

Percobaan 5

Apakah masih bisa lebih cepat?  simple-jpa 0.4.2 memungkinkan pengaturan flush mode untuk EntityManager secara global.   Nilai yang diperboleh adalah "AUTO" atau "COMMIT".   Secara default, di Hibernate JPA, nilainya adalah "AUTO".   Nilai ini menyebabkan Hibernate terkadang akan men-flush() secara otomatis.   Pada saat memanggil sebuah query,  Hibernate bisa saja akan men-flush() bila dirasa perlu.   Bila nilai flush mode adalah "COMMIT", maka proses flush() hanya akan dikerjakan pada saat transaksi di-commit() atau saat flush() dipanggil secara manual.   Pada dokumentasi JPA, disebutkan bahwa nilai "COMMIT" tidak disarankan karena terdapat kemungkinan menghasilkan data yang tidak konsisten. Tapi tidak ada salahnya saya mencoba.

Untuk itu, saya menambahkan baris berikut ini di Config.groovy:

griffon.simplejpa.entityManager.defaultFlushMode = "COMMIT"

Saya cukup terkejut melihat kecepatannya meningkat drastis.   Bahkan saya bisa dengan aman membuang flush() dan clear() yang tadinya secara periodik membersihkan EntityManager agar tidak lambat.   Seluruh perubahan akan diproses ke dalam memori terlebih dahulu tanpa menyentuh database (butuh memori yang besar?), lalu setelah selesai, mereka baru dimasukkan ke database secara keseluruhan.   Hasilnya? Transaksi dapat diselesaikan dalam waktu 8 menit (190 TPS)!   Atau dengan kata lain, untuk jangka waktu 1 detik, aplikasi yang saya uji ini dapat memproses hingga maksimal 190 penyimpanan data invoice baru termasuk segala business process-nya. Ini adalah nilai yang cukup menggembirakan.

Memakai @OrderColumn Di Hibernate JPA

Anggap saja saya membuat dua buah class, Faktur dan ItemFaktur.   Sebuah Faktur dapat memilih lebih dari satu ItemFaktur, dengan demikian hubungan mereka adalah one-to-many.   Saya menginginkan mereka mereka memiliki hubungan bidirectional: sebuah Faktur bisa mengetahui apa saja ItemFaktur-nya; sebaliknya sebuah ItemFaktur bisa mengetahui siapa Faktur ‘pemilik’-nya. Berikut adalah contoh implementasi yang saya buat (memakai Griffon dan simple-jpa):

package domain

import ...

@DomainModel @Entity @Canonical(excludes='itemFakturList')
class Faktur {

    @NotBlank
    String nomor

    @NotEmpty @OneToMany(cascade=CascadeType.ALL, mappedBy="faktur")
    List<ItemFaktur> itemFakturList = []

}

@DomainModel @Entity @Canonical
class ItemFaktur {

    @NotBlank
    String namaBarang

    @NotNull
    Integer jumlah

    @NotNull @ManyToOne
    Faktur faktur

}

Pada sebuah faktur, umumnya akan terdapat item faktur yang memiliki nomor urut.   Akan tetapi pada implementasi di atas, saya bisa mendapatkan hasil berurut hanya karena kebetulan generated id yang dihasilkan untuk ItemFaktur berurut.   Seandainya saya tiba-tiba menyisipkan sebuah ItemFaktur diantara ItemFaktur yang sudah ada, maka urutan akan menjadi salah! Untuk membuktikannya, saya akan membuat test case seperti ini di simple-jpa:

public void testFaktur() {
   Faktur faktur = new Faktur("FAKTUR1")
   ItemFaktur item1 = new ItemFaktur("BARANG1", 10, faktur)
   ItemFaktur item2 = new ItemFaktur("BARANG2", 10, faktur)
   ItemFaktur item3 = new ItemFaktur("BARANG3", 10, faktur)
   faktur.itemFakturList.addAll([item1, item2, item3])
   controller.persist(faktur)

   //
   // Hasil berurut sesuai dengan yang diharapkan
   //
   controller.destroyEntityManager()
   Faktur hasil1 = controller.findFakturByNomor("FAKTUR1")[0]
   assertEquals(3, hasil1.itemFakturList.size())
   assertEquals(item1, hasil1.itemFakturList[0])
   assertEquals(item2, hasil1.itemFakturList[1])
   assertEquals(item3, hasil1.itemFakturList[2])

   //
   // Menyisipkan item4 pada baris kedua
   //
   ItemFaktur item4 = new ItemFaktur("BARANG4", 10, hasil1)
   hasil1.itemFakturList.add(1, item4)
   controller.merge(hasil1)

   //
   // Urutan tidak lagi sesuai dengan harapan
   //
   controller.destroyEntityManager()
   Faktur hasil2 = controller.findFakturByNomor("FAKTUR1")[0]
   assertEquals(4, hasil2.itemFakturList.size())
   assertEquals(item1, hasil2.itemFakturList[0])
   assertEquals(item4, hasil2.itemFakturList[1])
   assertEquals(item2, hasil2.itemFakturList[2])
   assertEquals(item3, hasil2.itemFakturList[3])

   //
   // Seharusnya:       Yang Diperoleh:
   //
   // "BARANG1" 10      "BARANG1" 10
   // "BARANG4" 10      "BARANG2" 10
   // "BARANG2" 10      "BARANG3" 10
   // "BARANG3" 10      "BARANG4" 10
   //
}

Bila test case di atas dijalankan, saya akan memperoleh kesalahan seperti berikut ini:

junit.framework.AssertionFailedError: junit.framework.AssertionFailedError:
expected:<domain.ItemFaktur(BARANG4, 10, domain.Faktur(FAKTUR1))>
but was:<domain.ItemFaktur(BARANG2, 10, domain.Faktur(FAKTUR1))>

Pengguna tentu akan terkejut bila melihat urutan item di Faktur menjadi tidak sesuai seperti saat dimasukkan. Lalu, bagaimana solusinya?

Beruntungnya, pada JPA 2.0 terdapat @OrderColumn dan @OrderBy.   Annotation @OrderColumn dipakai bila kita menginginkan pengurutan secara otomatis sesuai dengan Collection yang dipakai.   Kolom di tabel yang dipakai untuk menyimpan pengurutan tidak di-ekspos ke aplikasi melainkan dikelola oleh JPA provider secara otomatis sesuai dengan urutan di List.   Sebaliknya, bila kolom tersebut akan di-isi oleh aplikasi secara manual, maka gunakan @OrderBy.

Saya segera mengubah definisi class saya menjadi seperti berikut ini:

...
@DomainModel @Entity @Canonical(excludes='itemFakturList')
class Faktur {

    @NotBlank
    String nomor

    @NotEmpty @OneToMany(cascade=CascadeType.ALL, mappedBy="faktur") @OrderColumn
    List<ItemFaktur> itemFakturList = []

}
...

Seharusnya ini mengatasi masalah, tapi tampaknya saya tidak selalu beruntung!   Saya malah memperoleh kesalahan seperti berikut ini:

org.hibernate.HibernateException: null index column 
for collection: domain.Faktur.itemFakturList

simple-jpa saat ini memakai Hibernate sebagai JPA provider, lebih tepatnya org.hibernate:hibernate-entitymanager:4.1.9.Final.   Ternyata, versi Hibernate yang dipakai tidak mendukung penggunaan @OrderColumn bersamaan dengan mappedBy.   Saya diwajibkan untuk mengisi nilai urut ini secara manual.   Mengisi nilai urut untuk setiap ItemFaktur setiap kali menyimpan, men-update, atau men-delete bukanlah hal yang sulit, tapi hal itu seharusnya adalah sesuatu yang transparan dan otomatis.   Bila harus membuat kode program baru yang serupa setiap kali menjumpai List yang harus berurut di database, saya khawatir kemungkinkan ada sesuatu yang terlupakan dan timbulnya bug menjadi semakin besar.   Jadi ini bukan persoalan malas dan tergantung pada framework, tapi ini adalah urusan menjaga kualitas 😉

Kabar gembiranya adalah pada versi 4.2.0.Final, Hibernate sudah mendukung penggunaan @OrderColumn bersamaan dengan mappedBy.   Informasi ini saya peroleh dari https://hibernate.atlassian.net/browse/HHH-5732.   Saya segera mencoba Hibernate 4.2.0.Final.

Bagaimana caranya memakai versi Hibernate yang berbeda dari simple-jpa?   Saya membuka file griffon-app\conf\BuildConfig.groovy, kemudian mengubah beberapa bagian sehingga terlihat seperti berikut ini:

...
griffon.project.dependency.resolution = {
    ...
    repositories {
        griffonHome()

        // uncomment the below to enable remote dependency resolution
        // from public Maven repositories
        mavenLocal()
        mavenCentral()
        ...
    }
    dependencies {
        // specify dependencies here under either 'build', 'compile', 'runtime' or 'test' scopes eg.
        compile'org.hibernate:hibernate-entitymanager:4.2.0.Final'
        ...
    }
}
...

Kemudian, saya membersihkan proyek dengan:

griffon clean

Setelah ini, saya kembali menjalankan test case di atas dengan perintah:

griffon test-app FakturTest.testFaktur

Akhirnya test case berhasil dilalui dengan baik tanpa ada kesalahan lagi.   Hibernate secara otomatis memberikan urutan di tabel seperti yang terlihat pada gambar berikut ini:

Nomor urut untuk List yang diisi secara otomatis

Nomor urut untuk List yang diisi secara otomatis

Hibernate Interceptor : Contoh Mempermudah Hidup

Aku memiliki sejumlah class POJO yang di-map ke database. Setiap class tersebut punya atribut “createdDate” dan “updatedDate”. Sesuai namanya, “createdDate” akan menyimpan informasi kapan entitas tersebut dibuat, dan “updatedDate” akan menyimpan informasi kapan entitas tersebut terakhir kali dirubah. Seperti biasanya, aku harus mengisi nilai “createdDate” sebelum aku memanggil save(), dan aku harus mengisi nilai “updatedDate” sebelum memanggil update() atau merge(). Tapi kalau class-nya banyak, lama-lama bosan juga. Kenapa aku tidak mencoba memakai interceptor di Hibernate? Aku mulai dengan membuat sebuah class yang merupakan turunan dari EmptyInterceptor seperti berikut ini:

import java.util.*;
import java.io.*;

import org.hibernate.*;
import org.hibernate.type.Type;

public class TimeInfoInterceptor extends EmptyInterceptor {

  public boolean onSave(Object entity, Serializable id, Object[] state,
                                 String[] propertyName, Type[] types) {
     for (int i=0; i<propertyName.length; i++) {
        if ("createdDate".equals(propertyName[i])) {
           state[i] = new Date();
           return true;
        }
     }
     return false;
  }

  public boolean onFlushDirty(Object entity, Serializable id,
                                       Object[] currentState,
                                       Object[] previousState,
                                       String[] propertyNames,
                                       Type[] types) {

     for (int i=0; i<propertyNames.length; i++) {
        if ("updatedDate".equals(propertyNames[i])) {
           currentState[i] = new Date();
           return true;
        }
     }
     return false;
  }

}

Setelah itu, aku tinggal merubah sedikit kode yang berhubungan dengan pembuatan SessionFactory, dimana aku meminta agar seluruh session nantinya akan memakai class interceptor yang baru aku buat.

SessionFactory sessionFactory = new
   Configuration().configure().setInterceptor(
         new TimeInfoInterceptor()).buildSessionFactory();

Setelah program di-compile, aku mencoba membuat entitas baru dan meng-edit-nya. Kini, “createdDate” dan “updatedDate” secara otomatis sudah di-isi oleh Hibernate. Coding-pun menjadi lebih nyaman lagi.

Hibernate: Model Yang Selalu Berubah

Hibernate tidak harus melakukan mapping dari database ke class Java. Ia juga bisa menghasilkan mapping dari database ke dalam object Map. Sebagai contoh, aku membuat file mapping sebagai berikut:

<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping PUBLIC
  "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
  "http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd">

<hibernate-mapping>

  <class entity-name="Person">

      <id name="id" column="PERSON_ID" type="long">
           <generator class="native" />
      </id>

      <property name="firstName" column="FIRSTNAME" type="string"/>

      <property name="lastName" column="LASTNAME" type="string" />

      <property name="age" column="AGE" type="int" />

  </class>

</hibernate-mapping>

Untuk menyimpan data ke tabel PERSON, aku menggunakan kode program sebagai berikut:

SessionFactory sessionFactory = new
   Configuration().configure().buildSessionFactory();
Session session = sessionFactory.getCurrentSession();
session.beginTransaction();

Map mapPerson = new HashMap();
mapPerson.put("firstName", "Solid");
mapPerson.put("lastName", "Snake");
mapPerson.put("age", 15);

session.save("Person", mapPerson);

session.getTransaction().commit();

Dengan coding seperti di atas, aku tidak perlu menggunakan sebuah class POJO, tapi cukup sebuah HashMap. Untuk memastikan data tersimpan ke database, aku menggunakan kode program seperti berikut:

     List lstPersons = session.createQuery("FROM Person").list();

     for (int i=0; i<lstPersons.size(); i++) {
       Map mapPerson = (Map) lstPersons.get(i);
       System.out.println("ID = " + mapPerson.get("id"));
       System.out.println("Firstname = " + mapPerson.get("firstName"));
       System.out.println("Lastname = " + mapPerson.get("lastName"));
       System.out.println("Age = " + mapPerson.get("age"));
     }