Sample inventory application using simple-jpa 0.8

As a companion for simple-jpa 0.8, there is a demo application called simple-jpa-demo-inventory. You can find its source code in https://github.com/JockiHendry/simple-jpa-demo-inventory. To run this project, you must have Griffon 1.5 and Groovy 2.3.

This demo application will ask for username and password everytime it is launched:

Login dialog

Login dialog

This feature is enabled by adding the following line in Config.groovy:

griffon.simplejpa.auditing.loginService = 'UserService'

UserRepository creates a new user called admin if it doesn’t exist. The default pasword for admin is 12345. So you can login by entering admin as user and 12345 as password. You can change default password or creating new user later by using MVC group User. You can even change which menu is displayed for each user.

See Auditing In simple-jpa Documentation for more information about auditing in simple-jpa 0.8.

After login, main screen will be displayed. This is the startup group which is configured in Application.groovy.

The startup MVC group

The startup MVC group

simple-jpa 0.8 now uses a tabbed pane to display instances of MVCGroup. You can display more than one instances of the same MVCGroup. For example, you can open two instances of MVC group Laporan (report), each displaying different report. You easily compare them just by switching tab.

Multiple tabs in `Main` MVC group

Multiple tabs in `Main` MVC group

In simple-jpa 0.8, you can add menuItem() to glazedTable() to add popup menu for that table. For example, in this picture, I can click on Tampilkan Referensi to display related invoice in a new tab:

Popup menu in table

Popup menu in table

See View In simple-jpa Documentation for more information about presentation layer in simple-jpa 0.8.

simple-jpa-demo-inventory uses simple-escp to print invoices to dot matrix printer. All invoice layouts are saved in database. They’re handled by TemplateFakturRepository. User can change or reset existing templates by displaying MVCGroup TemplateFaktur. This will open JSON editor where user can edit JSON template used by simple-escp.

Using simple-escp to print invoice

Using simple-escp to print invoice

Edit or reset invoice layout in JSON format

Edit or reset invoice layout in JSON format

By taking the advantage of Groovy as dynamic language, simple-jpa-demo-inventory even allows user to run custom code on the fly without redeploying entire application.

Launch Groovy Console from running application

Launch Groovy Console from running application

You can find unit test cases in test/unit folder and integration test cases in test/integration. They’re used to test domain classes, services, and repositories. Beware that running integration tests will take a long time because they need to actually hit database and every test cases always repopulate tables. You’re not limited to use Microsoft Excel 97 format because now simple-jpa 0.8 can read test data in form of Office Open XML format (used by Excel 2007 and later).

See Testing In simple-jpa Documentation for more information about testing in simple-jpa 0.8.

Iklan

simple-jpa 0.8 is released

What’s new in this release?

  1. Improvements in default scaffolding’s templates.
  2. Allow nested property in finders.
  3. Allow user to specify custom generator for scaffolding by using generate-all -generator=package.custom.generator.
  4. Allow user to add scaffolding configurations in Config.groovy.
  5. Add griffon.simplejpa.scaffolding.auto to automatically run scaffolding before compiling classes.
  6. Add DDD scaffolding generator that can be selected by using generate-all -generator=simplejpa.scaffolding.generator.ddd.DDDGenerator.
  7. Add new artifact type: repository.
  8. Add create-repository command.
  9. Automatically add createdBy and modifiedBy to domain classes.
  10. Add optional login dialog that will be displayed at startup time.
  11. Fix nested property path for validation wasn’t parsed properly.
  12. Fix TagChooser hover not working properly.
  13. Change DialogUtils methods signature.
  14. Increase performance of DbUnitTestCase by caching IDataSet.
  15. Change DbUnitTestCase to perform insert operation only. This can be changed by overriding cleanDataSet() and insertDataSet().
  16. Change DbUnitTestCase to execute clean.sql before inserting records from dataset if it is exists.
  17. Change DbUnitTestCase to execute before.sql before setUpDatabase() and after.sql after setUpDatabase() operation.
  18. Split setup method in DbUnitTestCase to loadMVC() and setUpDatabase().
  19. Add confirm() and message() to DialogUtils that will display message dialogs from EDT thread.
  20. Don’t execute String in templateRenderer node using SimpleTemplateEngine but treats it as property/function lookup (use closure for more complex expression).
  21. Add default popup menu for glazedTable() to copy a cell value and print table’s content.
  22. Allow using menuItem() inside glazedTable() to define menu items for popup menu.
  23. Add exp as synonym for expression in glazedColumn().

simple-jpa documentation is now generated by using AsciiDoctor. It should be mobile device friendly 🙂 You can visit simple-jpa documentation site in https://jockihendry.github.io/simple-jpa/html.

WARNING: The binary uploaded to Griffon repository is compiled by using Groovy 2.3. Coincidentally, Groovy 2.3 binary is not fully compatible with previous version. Griffon 1.5 by default shipped with Groovy 2.2. If you encounter missing class exception, try to upgrade Groovy library used by Griffon to Groovy 2.3. This version is not compatible with Griffon 2.0.

Menyimpan Template simple-escp Di Database

Ada beberapa pengguna aplikasi yang sering kali ingin mengubah hasil percetakan di printer dari waktu ke waktu. Bila kode program percetakan disimpan di dalam aplikasi, ini berarti saya harus mengubah kode program setiap kali ada perubahan layout. Hal ini lama-lama bisa merepotkan! Oleh sebab itu, pada artikel Memakai simple-escp Di Griffon, saya mendefinisikan template percetakan dalam bentuk file JSON. Setiap kali ada yang ingin merubah layout percetakan, mereka bisa meng-edit file JSON ini sendiri tanpa harus menunggu saya.

Solusi di atas bekerja dengan baik bila template laporan terletak di server yang dipakai bersama (misalnya aplikasi web). Bagaimana dengan aplikasi desktop yang dikembangkan dengan menggunakan Griffon dan simple-jpa? Saya dapat menyimpan template JSON untuk simple-escp ke dalam database. Dengan demikian, perubahan template yang dibuat oleh satu pengguna tetap dapat dilihat dan dipakai oleh pengguna lainnya.

Sebagai contoh, saya bisa membuat sebuah entity untuk mewakili template simple-escp seperti berikut ini:

@DomainClass @Entity @Canonical
class TemplateFaktur {

    @NotBlank
    String nama

    @Lob
    String isi

}

Saya memakai annotation @Lob pada field isi untuk menandakan bahwa kolom tersebut dapat di-isi dengan banyak karaketer (large object type). Pada Hibernate JPA dan database MySQL Server, kombinasi ini akan menghasilkan field dengan tipe longtext yang dapat menampung hingga maksimum 4 GB karakter (bandingkan dengan VARCHAR yang menampung maksimal 255 karakter).

Untuk mencetak template simple-escp berdasarkan TemplateFaktur yang sudah disimpan di database, saya dapat menggunakan kode program seperti berikut ini:

TemplateFaktur template = findTemplateFakturByNama(model.nama)
JsonTemplate template = new JsonTemplate(template.isi)
PrintPreviewPane printPreviewPane = view.printPreviewPane
printPreviewPane.display(template, DataSources.from(model.dataSource, model.options))

Sekarang, saya perlu membuat sebuah MVC untuk mengedit template yang ada. Agar pengguna bisa lebih nyaman dalam meng-edit template, saya akan menggunakan groovy.ui.ConsoleTextEditor bawaan Groovy. ConsoleTextEditor mengandung sebuah JTextPane yang dilengkapi dengan syntax highlighting sehingga sangat berguna untuk menampilkan dokumen teks yang memiliki syntax seperti bahasa pemograman. Sebagai contoh, saya bisa mendefinisikan view seperti berikut ini:

actions {
    action(id: 'cari', name: 'Cari', closure: controller.cari)
    action(id: 'simpan', name: 'Simpan', closure: controller.simpan)
    action(id: 'reset', name: 'Reset', closure: controller.reset)
}

panel(id: 'mainPanel') {
    borderLayout()

    panel(constraints:PAGE_START) {
        flowLayout(alignment: FlowLayout.LEFT)
        comboBox(id: 'namaTemplateFaktur', model: model.namaTemplateFaktur)
        button(action: cari)
    }

    widget(new ConsoleTextEditor(), id: 'inputEditor', constraints: CENTER)

    panel(constraints: PAGE_END) {
        flowLayout(alignment: FlowLayout.LEFT)
        button(action: simpan)
        button(action: reset)
    }
}

Untuk menampilkan template dari database untuk di-edit, saya dapat menggunakan kode program seperti berikut ini di controller:

def cari = {
    execInsideUISync {
        String namaTemplateFaktur = model.namaTemplateFaktur.selectedItem
        if (namaTemplateFaktur) {
            String isi = findTemplateFakturBy(namaTemplateFaktur)?.isi
            TextEditor textEditor = view.inputEditor.textEditor
            DefaultStyledDocument doc = new DefaultStyledDocument()
            doc.setDocumentFilter(new SimpleEscpFilter(doc))
            doc.insertString(0, isi?: '', null)
            textEditor.setDocument(doc)
            textEditor.caretPosition = 0
        }
    }
}

Secara default, ConsoleTextEditor akan melakukan syntax highlighting berdasarkan format Groovy. Karena simple-escp memakai format yang berbeda, saya akan mendefinisikan sebuah DocumentFilter baru yang saya sebut sebagai SimpleEscpFilter yang isinya seperti berikut ini:

class SimpleEscpFilter extends StructuredSyntaxDocumentFilter {

    public static final String VARIABLES = /(?ms:${.*?})/
    public static final String FUNCTIONS = /(?ms:%{.*?})/
    public static final String CODE = /(?ms:{{.*?}})/

    SimpleEscpFilter(DefaultStyledDocument document) {
        super(document)

        StyleContext styleContext = StyleContext.getDefaultStyleContext()
        Style defaultStyle = styleContext.getStyle(StyleContext.DEFAULT_STYLE)

        Style variables = styleContext.addStyle(VARIABLES, defaultStyle)
        StyleConstants.setForeground(variables, Color.GREEN.darker().darker())
        getRootNode().putStyle(VARIABLES, variables)

        Style functions = styleContext.addStyle(FUNCTIONS, defaultStyle)
        StyleConstants.setForeground(functions, Color.BLUE.darker().darker())
        getRootNode().putStyle(FUNCTIONS, functions)

        Style code = styleContext.addStyle(CODE, defaultStyle)
        StyleConstants.setForeground(code, Color.MAGENTA.darker().darker())
        getRootNode().putStyle(CODE, code)
    }

}

StructuredSyntaxDocumentFilter adalah DocumentFilter bawaan Groovy yang melakukan syntax highlighting berdasarkan ekspresi Regex. Pada implementasi di atas, saya memberikan pewarnaan yang berbeda untuk setiap komponen dalam template simple-escp. Bila saya menjalankan program, saya akan memperoleh hasil seperti pada gambar berikut ini:

Tampilan editor

Tampilan editor

Bila pengguna men-klik tombol simpan, saya dapat menyimpan perubahan dengan kode program seperti berikut ini:

def simpan = {
    TemplateFaktur templateFaktur = findTemplateFakturByNama(namaTemplateFaktur)
    if (!templateFaktur) {
       templateFaktur = new TemplateFaktur(nama: model.namaTemplateFaktur.selectedItem)
       persist(templateFaktur)
    }
    templateFaktur.isi = view.inputEditor.textEditor.text
}

Sekarang, saya tidak perlu khawatir lagi harus men-deploy ulang aplikasi hanya karena perubahan kecil di layout percetakan. Bila pengguna tidak memiliki tim IT yang memahami syntax simple-escp, setidaknya saya masih bisa mengirim email berisi template yang sudah dimodifikasi untuk di-copy paste oleh mereka. Ini masih jauh lebih baik daripada harus men-deploy ulang aplikasi 😀

Membuat Tabel Swing Dengan Jumlah Kolom Dinamis Di simple-jpa

Pada simple-jpa, saya dapat mendefinisikan sebuah JTable dengan menggunakan glazedTable() yang didalamnya mengandung satu atau lebih glazedColumn(). Masing-masing glazedColumn() ini mewakili kolom yang akan ditampilkan pada tabel. Bagaimana seandainya bila jumlah kolom di tabel bisa berubah atau dinamis? Sebagai contoh, pada tabel untuk menampilkan jumlah stok tersedia di masing-masing gudang, saya perlu memiliki sebuah kolom jumlah untuk masing-masing gudang. Masalahnya adalah pengguna bisa menambah atau mengurangi jumlah gudang yang ada.

Salah satu kelebihan Swing builder dari Groovy dibandingkan dengan definisi dalam bentuk XML atau sejenisnya adalah Swing builder dapat diprogram! Saya dapat menyisipkan kode program pada builder. Hal ini tidak dapat dilakukan pada deklarasi UI dalam bentuk XML atau sejenisnya. Sebagai contoh, saya dapat mendeklarasikan sebuah JTable dengan menggunakan kode program seperti berikut ini di view:

glazedTable(...) {
  (1..20).each { x ->
    glazedColumn(name: "$x", expression: { x })    
  }
}

Program di atas memakai looping (dapat juga diganti dengan syntax for biasa) untuk membuat 20 kolom. Bila dijalankan, tabel akan terlihat seperti pada gambar berikut ini:

Tabel dengan 20 kolom yang dibuat secara dinamis

Tabel dengan 20 kolom yang dibuat secara dinamis

Dengan memberikan kode program pada builder yang menghasilkan tabel, saya dapat menghasilkan kolom secara dinamis. Kembali ke contoh tabel yang berisi jumlah stok per gudang, agar lebih jelas, anggap saja saya memiliki 3 domain class berupa Gudang, StokProduk, dan Produk. Isinya terlihat seperti berikut ini:

@DomainClass @Entity @Canonical
class Gudang {

   @NotEmpty @Size(min=2, max=50)
   String nama

}

@Embeddable
class StokProduk {

   @NotNull @ManyToOne
   Gudang gudang

   @NotNull
   Integer jumlah

}

@DomainClass @Entity @Canonical
class Produk {

   @NotEmpty @Size(min=2, max=50)
   String nama

   @ElementCollection(fetch=FetchType.EAGER)
   List<StokProduk> stok = []

}

Saya bisa membuat deklarasi tabel menjadi seperti berikut ini:

glazedTable(id: 'table', list: model.produkList) {
  glazedColumn(name: 'Nama', property: 'nama')
  controller.findAllGudang().each { Gudang g ->
    glazedColumn(name: "Qty ${g.nama}", expression: { it.stokUntuk(g)?.jumlah?: 0 }, columnClass: Integer)
  }
}

Contoh tampilan pada program di atas akan terlihat seperti:

Kolom qty gudang yang dibuat secara dinamis

Kolom qty gudang yang dibuat secara dinamis

Pada saat tabel akan ditampilkan, kode program yang memanggil controller.findAllGudang() akan dipanggil untuk membaca daftar gudang dari database. Dengan demikian, jumlah kolom jumlah stok yang dihasilkan akan tergantung pada jumlah gudang di database.

Bagaimana bila seandainya jumlah gudang menjadi banyak sekali suatu hari nanti? Tabel akan tetap muncul, tetapi kolom akan menjadi sangat sempit seperti yang terlihat pada gambar berikut ini:

Tampilan tabel bila tidak muat

Tampilan tabel bila tidak muat

Ini akan membuat tabel sulit dibaca. Karena saya tidak tahu secara persis jumlah kolom yang akan ditampilkan, akan lebih baik bila saya menampilkan scrollbar secara horizontal. Pada Swing, agar scrollbar horizontal ditampilkan di JTable, saya perlu mematikan modus auto resize dengan memberikan autoResizeMode: JTable.AUTO_RESIZE_OFF. Selain itu, karena auto resize tidak lagi bekerja, akan lebih baik bila saya menambahkan pengaturan width. Sebagai contoh, saya mengubah deklarasi tabel saya menjadi seperti berikut ini:

glazedTable(id: 'table', list: model.produkList, autoResizeMode: JTable.AUTO_RESIZE_OFF) {
  glazedColumn(name: 'Nama', property: 'nama', width: [100,200])
  controller.findAllGudang().each { Gudang g ->
    glazedColumn(name: "Qty ${g.nama}", expression: { it.stokUntuk(g)?.jumlah?: 0 },
      columnClass: Integer, width: [80, 100])
  }
}

Sekarang, scrollbar horizontal akan muncul bila ukuran tabel tidak muat, seperti yang terlihat pada gambar berikut ini:

Memunculkan scrollbar horizontal pada tabel

Memunculkan scrollbar horizontal pada tabel

Penggunaan nilai pada width dalam bentuk seperti [100,200] mungkin awalnya agak membingungkan. Nilai 100 akan diberikan sebagai argumen untuk setMinWidth() dan nilai 200 akan diberikan sebagai argumen untuk setPreferredWith(). Jangan lupa bahwa glazedColumn() adalah sebuah TableColumn yang memiliki property seperti minWidth, preferredWidth dan maxWidth. Bila ingin sebuah kolom memiliki ukuran yang statis dan tidak bisa di-resize, maka gunakan angka sebagai nilai pada width seperti width: 200.

Memakai Named Entity Graph Di simple-jpa 0.7

Salah satu fitur baru pada simple-jpa 0.7 adalah dukungan named entity graph. Fasilitas named entity graph yang sudah ada sejak JPA 2.1 memungkinkan pengguna untuk menentukan apa saja atribut yang akan di-load sesuai dengan kebutuhan. Sebagai contoh, saya memiliki sebuah class Faktur yang memiliki relasi one-to-many dengan ItemFaktur, Bonus dan Pengiriman. Pada definisi class secara default, semua relasi tersebut akan di-fetch secara LAZY, seperti pada contoh berikut ini:

@DomainClass @Entity @Canonical
class Faktur {

   @NotBlank
   String nomor

   @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true)
   Set<ItemFaktur> daftarItemFaktur = new HashSet<>()

   @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true)
   Set<Bonus> daftarBonus = new HashSet<>()

   @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true)
   Set<Pengiriman> daftarPengiriman = new HashSet<>()

}

Bila saya memakai salah satu atribut di luar transaksi, seperti pada kode program berikut ini:

Faktur faktur = repo.findFakturByNomor("FA-001")
println faktur.daftarItemFaktur

Saya akan memperoleh pesan kesalahan org.hibernate.LazyInitializationException bila memakai provider Hibernate. Hal ini karena atribut yang di-fetch secara LAZY tidak ikut diambil melalui JOIN ke tabel bersangkutan di query SQL.

Salah satu penyelesaian yang mungkin adalah menambahkan FetchType.EAGER pada seluruh asosiasi yang ada. Akan tetapi ini akan menimbulkan masalah kinerja karena query yang diberikan akan melalukan join ke tiga tabel lain (masing-masing untuk ItemFaktur, Bonus dan Pengiriman).

Saya jarang sekali membutuhkan ketiganya secara bersamaan. Pada layar untuk pengiriman, saya hanya membutuhkan ItemFaktur dan Pengiriman. Pada layar untuk bonus, saya hanya membutuhkan ItemFaktur dan Bonus. Oleh sebab itu, saya bisa mendefinisikan dua entity graph, seperti pada contoh berikut ini:

@NamedEntityGraphs([
   @NamedEntityGraph(name="Faktur.Pengiriman", attributeNodes=[
      @NamedAttributeNode("daftarItemFaktur"),
      @NamedAttributeNode("daftarPengiriman")
   ]),
   @NamedEntityGraph(name="Faktur.Bonus", attributeNodes=[
      @NamedAttributeNode("daftarItemBonus"),
      @NamedAttrbiuteNode("daftarPengiriman")
   ])
])
@DomainClass @Entity @Canonical
class Faktur {
   ...
}

Pada deklarasi Faktur di atas, saya membuat dua entity graph yang bernama Faktur.Pengiriman dan Faktur.Bonus.

Saya dapat menambahkan FetchXXX pada dynamic finders simple-jpa untuk memakai salah satu entity graph yang ada. Sebagai contoh, untuk memakai entity graph Faktur.Pengiriman, saya dapat menggunakan kode program seperti:

Faktur faktur = repo.findFakturByNomorFetchPengiriman("FA-001")
println faktur.daftarItemFaktur
println faktur.daftarPengiriman
println faktur.daftarBonus // <-- ERROR: LazyInitializationException

Saya dapat mengakses daftarItemFaktur dan daftarPengiriman dengan baik. Walaupun demikian, tidak terjadi join ke seluruh tabel yang berelasi. Hal ini dapat dibuktikan karena saya tetap mendapatkan kesalahan bila mengakses daftarBonus karena atribut tersebut tidak didefinisikan pada entity graph Faktur.Pengiriman.

Untuk memakai entity graph Faktur.Bonus, saya dapat menggunakan kode program seperti:

Faktur faktur = repo.findFakturByNomorFetchBonus("FA-001")
println faktur.daftarItemFaktur
println faktur.daftarBonus
println faktur.daftarPengiriman // <-- ERROR: LazyInitializationException

Saya dapat mengakses daftarItemFaktur dan daftarBonus dengan baik. Tetapi, saya tidak boleh membaca daftarPengiriman karena atribut tersebut tidak terdaftar pada entity graph Faktur.Bonus.

Karena masih terbilang baru, implementasi named entity graph pada provider Hibernate masih memiliki sedikit masalah terutama bila berhubungan dengan inheritance. Terkadang saya menjumpai masalah seperti Hibernate menghasilkan query invalid yang memiliki klausa CROSS JOIN LEFT JOIN. Untuk mengatasi hal tersebut, sementara ini, saya memakai Hibernate yang telah dimodifikasi dengan mengubah baris yang memakai Hibernate JPA di BuildConfig.groovy menjadi seperti berikut ini:

griffon.project.dependency.resolution = {
   ...
   dependencies {
       runtime "jockihendry:hibernate-entitymanager:4.3.7-EXPERIMENT"
       ...
   }
   ...
}

Memakai simple-escp Pada Aplikasi Web

Pengguna bisa mencetak melalui browser dengan memilih menu File, Print.. di browser atau melalui JavaScript window.print(). Percetakan yang dilakukan dengan cara seperti ini adalah percetakan graphic mode. Bagaimana bila yang diinginkan adalah percetakan text mode? Pada percetakan text mode, posisi bisa ditentukan secara lebih akurat dan pengaturan halaman dapat dilakukan dalam satuan baris dan karakter. simple-escp adalah salah satu library Java yang dapat dipakai untuk keperluan ini. Untuk memakai simple-escp pada aplikasi web, saya dapat menyertakannya sebagai applet. Applet adalah kode program Java yang akan dikerjakan di sisi client di browser (sama seperti Adobe Flash, Microsoft Silverlight, ActiveX, dan sebagainya). Salah satu fitur andalan applet adalah kode program JavaScript di halaman HTML yang sama dapat dipakai untuk memanipulasi applet. Begitu juga sebaliknya, applet juga dapat memanggil kode program JavaScript yang dideklarasikan pada halaman HTML yang sama.

Untuk membuat applet, saya dapat menggunakan framework Griffon. Saya akan mulai dengan membuat proyek baru dengan memberikan perintah:

griffon create-app simple-escp-applet

Karena applet ini akan memanggil simple-escp, saya perlu mengubah bagian griffon.project.dependency.resolution dari griffon-app/conf/BuildConfig.groovy menjadi seperti berikut ini:

griffon.project.dependency.resolution = {
    // inherit Griffon' default dependencies
    inherits("global") {
    }
    log "warn" // log level of Ivy resolver, either 'error', 'warn', 'info', 'debug' or 'verbose'
    repositories {
        griffonHome()
        mavenRepo "http://dl.bintray.com/jockihendry/maven"
    }
    dependencies {
        compile 'jockihendry:simple-escp:0.4'
    }
}

Saya juga perlu mengubah bagian webstart agar nilai codebase merujuk ke lokasi URL di server nanti, seperti pada contoh berikut ini:

production {
    ...
    griffon {
       ...
       webstart {
          codebase = "http://localhost/cetak"
       }
    }
}

Selain itu, saya akan memanggil JavaScript yang ada di HTML melalui Applet melalui netscape.javascript.JSObject. Class ini terletak pada plugin.jar dan tidak tersedia pada saat kompilasi. Untuk itu, saya men-copy file tersebut dari C:\Program Files\Java\jdk\jre\lib ke folder lib di proyek Griffon.

Karena saya perlu melakukan signing pada applet yang dihasilkan Griffon, maka saya mengubah bagian signingkey di environment production menjadi seperti berikut ini:

production {
   signingkey {
      params {                
         storepass = 'thesolidsnake'
         keypass = 'thesolidsnake'
         lazy = false // sign, regardless of existing signatures
      }
   }
   ...
}

Sekarang, saya siap untuk membuat kode program. Satu hal yang menjadi kendala adalah DataSource. Saat ini simple-escp menerima DataSource dalam bentuk Map atau JavaBean object yang merupakan tipe data Java. Kedua data source tersebut tidak dapat dipakai di JavaScript. Untuk itu, saya perlu membuat DataSource baru yang bisa membaca data dalam bentuk object JavaScript di halaman HTML yang sama. simple-escp memungkinkan pengguna membuat implementasi DataSource sendiri seperti yang terlihat pada kode program berikut ini (saya meletakkannya di src\main\datasource\JSONDataSource.groovy):

package datasource;

import simple.escp.data.DataSource;
import simple.escp.exception.InvalidPlaceholder;
import javax.json.Json;
import javax.json.JsonNumber;
import javax.json.JsonObject;
import javax.json.JsonReader;
import javax.json.JsonValue;
import java.io.StringReader;

public class JSONDataSource implements DataSource {

    private String jsonString;
    private JsonObject json;

    public JSONDataSource(String jsonString) {
        this.jsonString = jsonString;
        try (JsonReader reader = Json.createReader(new StringReader(jsonString))) {
            json = reader.readObject();
        }
    }

    @Override
    public boolean has(String s) {
        return json.containsKey(s);
    }

    @Override
    public Object get(String member) throws InvalidPlaceholder {
        JsonValue value = json.get(member);
        if (value.getValueType() == JsonValue.ValueType.ARRAY) {
            return value;
        } else if (value.getValueType() == JsonValue.ValueType.NUMBER) {
            return ((JsonNumber) value).bigDecimalValue();
        } else {
            return value.toString();
        }
    }

    @Override
    public Object getSource() {
        return jsonString;
    }

    @Override
    public String[] getMembers() {
        return json.keySet().toArray(new String[0]);
    }
}

Saya kemudian mengubah isi dari SimpleEscpAppletView.groovy menjadi seperti berikut ini:

package simple.escp.applet

import java.awt.BorderLayout

application() {

    panel(id: 'mainPanel', layout: new BorderLayout()) {

    }

}

Sebagai langkah terakhir dalam membuat applet, saya mengubah kode program SimpleEscpAppletController.groovy menjadi seperti berikut ini:

package simple.escp.applet

import datasource.JSONDataSource
import simple.escp.fill.FillJob
import simple.escp.json.JsonTemplate
import simple.escp.swing.PrintPreviewPane
import sun.plugin.javascript.JSObject
import javax.swing.JPanel
import java.awt.BorderLayout

class SimpleEscpAppletController {

    def model
    def view

    void mvcGroupInit(Map args) {
        def window = JSObject.getWindow(app)
        def report = new JsonTemplate(window.eval("JSON.stringify(template);")).parse()
        def dataSource = window.eval("JSON.stringify(source);");
        def result = new FillJob(report, new JSONDataSource(dataSource)).fill()
        PrintPreviewPane pane = new PrintPreviewPane(result,
            report.pageFormat.pageLength, report.pageFormat.pageWidth)
        JPanel mainPanel = view.mainPanel
        mainPanel.add(pane, BorderLayout.CENTER)
    }

}

Untuk menghasilkan applet, saya memberikan perintah berikut ini:

griffon package applet

Griffon secara otomatis akan menghasilkan file distribusi applet pada folder dist/applet. Pada folder ini, selain file JAR, saya juga akan menemukan file applet.jnlp yang dapat dipakai untuk memanggil applet di HTML. Seluruh file JAR juga sudah di-sign dengan key yang di-generate untuk keperluan sementara. Saya juga dapat menemukan file applet.html yang berisi contoh pemanggilan applet.

Berikutnya, saya memindahkan seluruh file JAR (dan juga versi yang sudah di-compress dalam bentuk .jar.pack.gz), file gambar, file JNLP dan file HTML ke lokasi deployment di webserver. Sebagai contoh, karena saya memakai NGINX, saya akan memindahkan file tersebut ke folder html/cetak. Saya kemudian mengubah file applet.html menjadi seperti berikut ini:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
        "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
    <title>Latihan Cetak</title>
    <script>
    var template = {
        pageFormat: {
            pageLength: 10,
            pageWidth: 50,
            usePageLengthFromPrinter: false
        },
        template: {
            header: [" PT. XYZ                                    HAL: %{PAGE_NO}"],
            detail: [
                " Nomor Faktur: ${nomorFaktur:20}",
                {
                    "table": "listItemFaktur",
                    "border": true,
                    "columns": [
                        { caption: "Nama Barang", source: "namaBarang", width: 25, wrap: true },
                        { caption: "Qty", source: "qty::right", width: 5 },
                        { caption: "Harga", source: "harga.bigDecimalValue()::right::number", width: 15 }
                    ]
                }
            ]
        }
    };

    var source = {
        nomorFaktur: 'FA-1234-556677-XX-BB-CC',
        listItemFaktur: [
            {namaBarang: 'Plantronics Backbet Go 2 With Charging Case', qty: 1, harga: 13750000},
            {namaBarang: 'CORT Gitar Akustik AD810 - Natural Satin', qty: 1, harga: 14900000},
            {namaBarang: 'SAMSON Monitor Speaker System MediaOne 3A', qty: 1, harga: 14250000}
        ]
    };

  </script>
</head>
<body>

<h1>Latihan Cetak</h1>

<p>Halaman ini akan memanggil applet yang mencetak ke printer dot matrix dengan bantuan JavaScript.</p>

<APPLET CODEBASE='http://localhost/cetak'
        CODE='griffon.swing.SwingApplet'
        ARCHIVE='griffon-swing-runtime-1.4.0.jar,griffon-rt-1.5.0.jar,groovy-all-2.2.1.jar,javax.json-1.0.4.jar,javax.json-api-1.0.jar,jcl-over-slf4j-1.7.5.jar,jul-to-slf4j-1.7.5.jar,log4j-1.2.17.jar,plugin.jar,simple-escp-0.4.jar,simple-escp-applet-0.1.jar,slf4j-api-1.7.5.jar,slf4j-log4j12-1.7.5.jar'
        WIDTH='800' HEIGHT='500'>
    <PARAM NAME="java_arguments" VALUE="-Djnlp.packEnabled=true">
    <PARAM NAME='jnlp_href' VALUE='http://localhost/cetak/applet.jnlp'>
    <PARAM NAME='dragggable' VALUE='true'>
    <PARAM NAME='image' VALUE='griffon.png'>
    <PARAM NAME='boxmessage' VALUE='Loading Simple-escp-applet'>
    <PARAM NAME='boxbgcolor' VALUE='#FFFFFF'>
    <PARAM NAME='boxfgcolor' VALUE='#000000'>
    <PARAM NAME='codebase_lookup' VALUE='false'>
</APPLET>

</body>
</html>

Pada HTML di atas, saya mendeklarasikan dua variabel JavaScript yang wajib ada, yaitu variabel template dan source. Kedua variabel ini akan dibaca oleh applet dan masing-masing diterjemahkan menjadi JsonTemplate dan JSONDataSource. Pada kasus yang lebih kompleks, ini variabel source biasanya akan dihasilkan oleh sisi server. Sebagai contoh, bila bahasa pemograman di sisi server adalah PHP, saya dapat menggunakan function json_encode() untuk menghasilkan object JavaScript dari array atau object PHP yang mengimplementasikan JsonSerializable.

Sekarang, saya akan mencoba membuka halaman http://localhost/cetak/applet.html melalui browser pada sistem operasi yang telah memiliki instalasi Java. Saya menemukan dialog peringatan seperti berikut ini:

Peringatan di browser pada saat menjalankan applet

Peringatan di browser pada saat menjalankan applet

Karena saya memang ingin mengaktifkan Java di browser, maka saya memilih Later. Setelah itu, akan kembali muncul pesan peringatan lainnya seperti pada gambar berikut ini:

Peringatan saat menjalankan applet yang tidak sertifikatnya tidak diverifikasi

Peringatan saat menjalankan applet yang tidak sertifikatnya tidak diverifikasi

Pesan peringatan ini muncul karena JAR saya di-sign sendiri tanpa verifikasi dari pihak terpercaya seperti Verisign, GoDaddy dan sejenisnya (yang memungut biaya verifikasi). Pesan keamanan ini diberikan karena applet yang di-sign oleh publisher tak dikenal memiliki akses yang lebih leluasa dibandingkan applet yang tidak di-sign sama sekali, misalnya baca/tulis file serta melakukan percetakan.

Saya memberi tanda centang pada I accept the risk and want to run this application. dan men-klik tombol Run. Tampilan browser saya akan terlihat seperti pada gambar berikut ini:

Tampilan di browser

Tampilan di browser

Bila saya men-klik tombol Print dari PrintPreviewPane milik simple-escp, maka percetakan akan dilakukan pada text mode ke printer dot matrix yang sedang terpilih.

Memakai simple-escp Di Griffon

Sebagai latihan, saya akan mulai dengan membuat proyek Griffon baru dengan memberikan perintah:

griffon create-app latihan

Untuk menambahkan library simple-jpa, saya membuka file griffon-app\conf\BuildConfig.groovy dan mengubah bagian griffon.project.dependency.resolution sehingga menjadi seperti yang terlihat berikut ini:

griffon.project.dependency.resolution = {
    // inherit Griffon' default dependencies
    inherits("global") {
    }
    log "warn" // log level of Ivy resolver, either 'error', 'warn', 'info', 'debug' or 'verbose'
    repositories {
        griffonHome()
        mavenRepo "http://dl.bintray.com/jockihendry/maven"
    }
    dependencies {
        compile 'jockihendry:simple-escp:0.4'
    }
}

Binary simple-escp telah tersedia di Bintray sehingga dapat di-download oleh Griffon secara otomatis.

Agar lebih nyaman membuat kode program, saya akan menghasilkan proyek IntelliJ IDEA dengan memberikan perintah berikut ini:

griffon integrate-with --idea

Sekarang, saya bisa membuka proyek Griffon tersebut di IntelliJ IDEA. Saya kemudian menambahkan file template JSON di lokasi griffon-app\resources dengan nama template.json yang isinya seperti berikut ini:

{
     "pageFormat": {
         "pageLength": 10,
         "pageWidth": 50,
         "usePageLengthFromPrinter": false
     },
     "template": {
         "header": [ "  PT. XYZ                              HAL: %{PAGE_NO}" ],
         "detail": [
             " Nomor Faktur: ${nomorFaktur:20}",
             {
                "table": "listItemFaktur",
                "border": true,
                "columns": [
                    { "caption": "Nama Barang", "source": "namaBarang", "width": 25, "wrap": true },
                    { "caption": "Qty", "source": "qty::right", "width": 5 },
                    { "caption": "Harga", "source": "harga::right::number", "width": 15 }
                ]
             }
         ]
     }
 }

Berikutnya, saya mengubah file griffon-app\views\latihan\LatihanController.groovy sehingga menjadi seperti berikut ini:

package latihan

import simple.escp.Template
import simple.escp.json.JsonTemplate
import simple.escp.swing.PrintPreviewPane
import javax.swing.JFrame

class LatihanController {

    def model
    def view

    void mvcGroupInit(Map args) {
        def source = [nomorFaktur: 'FA-1234-556677-XX-BB-CC']
        source['listItemFaktur'] = [
            [namaBarang: 'Plantronics Backbeat Go 2 With Charging Case', qty: 1, harga: 13750000],
            [namaBarang: 'CORT Gitar Akustik AD810 - Natural Satin', qty: 1, harga: 14900000],
            [namaBarang: 'SAMSON Monitor Speaker System MediaOne 3A', qty: 1, harga: 14250000]
        ]
        Template template = new JsonTemplate(app.getResourceAsStream("template.json"))
        PrintPreviewPane previewPane = new PrintPreviewPane(template, source, null)
        JFrame startingFrame = app.windowManager.getStartingWindow()
        startingFrame.contentPane.removeAll()
        startingFrame.add(previewPane)
    }

}

Bila saya menjalankan proyek dengan memberikan perintah:

griffon run-app

Saya akan memperoleh hasil seperti pada gambar berikut ini:

Tampilan saat aplikasi dijalankan

Tampilan saat aplikasi dijalankan

Memakai Java Web Start Pada Aplikasi Yang Dibuat Dengan Griffon

Pada aplikasi web, developer hanya perlu melakukan perubahan pada server dan pengguna akan segera memperoleh halaman terbaru. Lalu bagaimana dengan aplikasi desktop? Saya harus men-install aplikasi secara manual satu per satu pada seluruh komputer pengguna. Bila mereka tersebar dalam beberapa lantai berbeda, saya terpaksa harus naik turun tangga. Ini adalah sesuatu yang melelahkan. Beruntungnya, bila memakai Java, saya dapat menggunakan Java Web Start untuk mempermudah distribusi aplikasi desktop yang dibuat dengan Java SE. Griffon membuatnya menjadi lebih mudah lagi.

Langkah pertama yang saya lakukan adalah mengubah isi file griffon-app/conf/BuildConfig.groovy menjadi seperti berikut ini:

environments {
  ...
  production {
    ...
    signingKey {
      params {
         storepass = 'solidsnake'
         keypass = 'solidsnake'
         lazy = false
      }
    }
    ...
  }
  ...
}

Griffon secara otomatis akan melakukan signing pada JAR untuk didistribusikan melalui Java Web Start. Dengan melakukan signing pada JAR, saya menyatakan bahwa file JAR yang saya distribusikan dapat dipercaya sehingga bisa memperoleh hak akses yang lebih banyak seperti menulis file di komputer client. Karena saya tidak membeli private key dari Certificate Authority (seperti VeriSign), maka Griffon akan memakai self-signed certificate. Ini adalah sertifikat digital yang hanya dipakai untuk keperluan percobaan dan masa berlakunya terbatas. Pengguna yang menjalankan aplikasi yang menggunakan self-signed certificate melalui Java Web Start akan memperoleh peringatan keamanan.

Selain itu, saya juga perlu mengubah nilai codebase agar sesuai dengan lokasi URL yang menampung hasil distribusi, misalnya:

environments {
  ...
  production {
    ...
    griffon {
      ...
      webstart {
        codebase = 'http://myserver.com/latihan'
      }
      ...
    }
    ...
  }
  ...
}

Selanjutnya, untuk menghasilkan file yang dapat didistribusikan melalui Java Web Start, saya memberikan perintah berikut ini:

C:\proyek> griffon package webstart

Perintah di atas akan menghasilkan file pada lokasi dist/webstart. Setiap file JAR akan memiliki versi yang telah di-compress dengan tool pack200 dimana hasilnya memiliki ekstensi .pack.gz. File JAR yang telah di-compress dapat di-download lebih cepat oleh pengguna. Griffon juga telah memberikan nilai true untuk jnlp.packEnabled secara otomatis.

File yang paling penting disini adalah application.jnlp. File dalam format Java Network Launch Protocol (JNLP) ini adalah file yang perlu dikerjakan oleh pengguna. Saya melakukan sedikit perubahan pada file ini, seperti mengubah versi Java yang dibutuhkan dari 1.5+ menjadi 1.7. Selain itu, saya menghilangkan komentar pada bagian berikut ini:

<shortcut online="true">
   <desktop/>
</shortcut>
<offline-allowed/>

Tujuannya adalah agar Java Web Start menghasilkan file shortcut di desktop sehingga pengguna nantinya bisa menjalankan aplikasi langsung tanpa harus membuka browser terlebih dahulu.

Langkah berikutnya yang perlu saya lakukan adalah meletakkan file-file yang ada di dist/webstart pada sebuah web server yang dapat diakses oleh seluruh komputer pengguna. Sebagai contoh, saya akan memakai IIS bawaan Windows 7 sebagai web server. Saya men-copy isi file yang dihasilkan Griffon ke lokasi C:\inetpub\webstart. Setelah itu, saya membuat sebuah application baru dengan men-klik kanan sebuah site dan memilih menu Add Application…. Saya kemudian mengisi dialog yang muncul seperti pada gambar berikut ini:

Membuat application baru di IIS

Membuat application baru di IIS

Selain itu, saya juga memilih MIME Types dan menambahkan MIME untuk JNLP seperti berikut ini:

Mendaftarkan MIME untuk JNLP

Mendaftarkan MIME untuk JNLP

Agar lebih mudah dipakai, saya mengubah iisstart.htm menjadi seperti berikut ini:

<html>
<body>
<a href='latihan/application.jnlp'>Klik disini</a> untuk menjalankan aplikasi terbaru kita.
</body>
</html>

Sekarang, bila saya membuka URL di web server seperti http://myserver.com, saya dapat menjalankan aplikasi melalui Java Web Start seperti yang terlihat pada gambar berikut ini:

Menjalankan Aplikasi Dari Browser

Menjalankan Aplikasi Dari Browser

Bila saya memilih Ok, maka Java Web Start Launcher akan men-download file yang dibutuhkan dari server dan menyimpannya sebagai cache di komputer pengguna. Karena saya memakai self-signed certificate yang tidak aman, akan muncul pesan peringatan seperti pada gambar berikut ini:

Pesan Peringatan

Pesan Peringatan

Saya memberi tanda centang pada I accept the risk dan men-klik tombol Run. Aplikasi akan dijalankan. Java Web Start Launcher juga akan membuat sebuah shortcut di Desktop sehingga lain kali, saya dapat menjalankan aplikasi ini tanpa harus membuka browser terlebih dahulu.

Apa yang terjadi bila web server dimatikan? Apakah aplikasi tetap dapat dijalankan? Yup, tetap bisa! Hal ini karena Java Web Start menyimpan aplikasi secara lokal. Untuk membuktikannya, saya membuka Control Panel, Java. Pada bagian General, saya men-klik tombol View…. Saya akan memperoleh tampilan seperti pada gambar berikut ini:

Melihat Aplikasi Yang Ada Di Cache

Melihat Aplikasi Yang Ada Di Cache

Pada dialog di atas, saya juga bisa menghapus cache yang sudah ada, menjalankan aplikasi, atau membuat kembali shortcut yang terhapus.

Lalu, bagaimana bila saya melakukan perubahan pada kode program dan ingin perbaharuan dilakukan pada seluruh klien yang sudah memiliki cache aplikasi di komputer lokal mereka?

Saya tidak perlu khawatir karena setiap kali aplikasi dijalankan, Java Web Start akan men-download ulang seluruh JAR dan memakai JAR yang terbaru. Bila sedang tidak dapat terhubung ke server, maka apa yang ada di cache akan dipakai (dengan catatan konfigurasi di file JNLP membolehkan aplikasi bekerja secara offline).

Untuk menghemat waktu download, JNLP juga memiliki fasilitas version-based download protocol. Dengan version-based download protocol, Java Web Start tidak perlu men-download seluruh JAR melainkan hanya JAR yang versinya berubah atau tidak ada di cache. Namun, sepertinya Griffon belum menghasilkan nama file JAR dan isi JNLP yang mendukung version-based download protocol.

Pengujian Aplikasi Griffon Secara Otomatis Melalui VirtualBox

Salah satu istilah yang diperkenalkan oleh metode pengembangan Extreme Programming (XP) adalah continuous integration, continuous delivery, dan continuous deployment. Penggunaan kata continuous disini menunjukkan semangat XP dimana hasil harus bisa diperoleh secepat mungkin.

Continuous integration (CI) adalah pengujian yang dilakukan secara otomatis setiap kali developer men-push perubahan yang mereka lakukan. Karena CI dilakukan secara otomatis atau berkala, perubahan kode program yang menyebabkan aplikasi tidak dapat berjalan akan diketahui secepat mungkin.

Continous delivery (CD) memastikan bahwa kode program yang telah dibuat dapat segera dijalankan di production bila dibutuhkan. Dengan adanya CD, bila klien meminta demo aplikasi terbaru atau tampilan web terbaru secara tiba-tiba pada hari itu juga, tidak akan ada yang panik.

Continous deployment adalah continous delivery yang direalisasikan secara berkala dan otomatis ke production, misalnya pengguna memperoleh update terbaru setiap hari atau halaman web diperbaharui setiap minggu. Continuous delivery hanya menyatakan sebuah kesiapan tapi continuous deployment adalah realisasi dari continuous delivery secara rutin.

Kata kunci dari semua yang berbau continuous disini adalah otomatis, terstruktur, dan berkala. Oleh sebab itu, ada banyak tools pihak ketiga seperti Travis CI, Jenkins CI, Vagrant, Packer, Chef dan sebagainya yang dapat dipakai untuk mewujudkan berbagai continuous tersebut.

Salah satu fasilitas menarik yang saya temui dari Travis CI adalah kemampuan untuk menguji kode program di beberapa virtual machine berbeda (misalnya versi Java berbeda atau sistem operasi berbeda). Travis kemudian akan melaporkan hasil pengujian dalam bentuk matrix. Jenkins CI juga memungkinkan hal serupa dengan memakai plugin Vagrant. Vagrant adalah sebuah tools untuk mengelola virtual machine (seperti menyalakan, mematikan, memberi perintah, dan sebagainya) secara standar dan dilengkapi dukungan VirtualBox secara bawaan. Selain itu, Vagrant juga memiliki banyak image virtual machine siap pakai di Vagrant Cloud (https://vagrantcloud.com).

Pada artikel ini, saya tidak akan menerapkan semua jenis continuous pada XP (dan sesungguhnya saya bukan fans XP 🙂 ). Saya juga tidak akan memakai Vagrant. Saya hanya ingin menjalankan pengujian aplikasi Griffon di VirtualBox secara otomatis. Untuk itu, saya menyiapkan 2 image yang mewakili Windows 7 dan Linux Mint. VirtualBox dapat dikendalikan dengan menggunakan COM API. Namun, ia juga menyediakan beberapa front-end lain yang lebih mudah dipakai seperti web services (SOAP) dan perintah VBoxManage. Pada kesempatan ini, saya akan mencoba mengendalikan VirtualBox dengan menggunakan VBoxManage yang dipanggil melalui CLI.

Langkah pertama yang saya lakukan adalah membuat sebuah perintah baru yang dapat dipakai di aplikasi Griffon dengan memberikan perintah berikut ini:

C:\proyek> griffon create-script vbox-test

Perintah dalam Griffon adalah sebuah Gant script. Mungkin ini tidak akan berlaku lagi setelah Griffon 2 yang mendukung Gradle dirilis. Saya akan menemukan sebuah file baru bernama VboxTest.groovy di folder scripts. Saya kemudian mengubah isi script tersebut menjadi seperti berikut ini:

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths

def hasil = [:]

def tambahGagal = { String vBoxImage, def jumlahGagal ->
    if (!hasil.containsKey(vBoxImage)) {
        hasil[vBoxImage] = jumlahGagal
    } else {
        hasil[vBoxImage] = hasil[vBoxImage] + jumlahGagal
    }
}

def exec = { String command, String vBoxImage, boolean silentFail, Object... args ->
    List commands = [buildConfig.vbox.vboxManage, command, vBoxImage]
    args?.each { arg ->
        if (arg instanceof Map) {
            arg.each { k, v ->
                commands << ("--" + k)
                commands << v
            }
        } else {
            commands << arg as String
        }
    }
    ProcessBuilder builder = new ProcessBuilder(commands)
    builder.redirectErrorStream(true)
    if (!silentFail) {
        println "Mengerjakan ${commands}"
    }
    Process p = builder.start()
    if (silentFail) {
        p.waitForOrKill(10000000)
    } else {
        p.waitForProcessOutput(System.out, System.out)
        if (p.exitValue() != 0) {
            throw new RuntimeException("Gagal mengerjakan perintah. Exit code: ${p.exitValue()}")
        }
    }
    p.exitValue()
}

def execute = { String vBoxImage, String image, def config, def args = [] ->
    def p = [image: image, username: config.username]
    if (config.containsKey('password')) p.password = config.password
    exec('guestcontrol', vBoxImage, false, 'execute', p, '--wait-exit', '--wait-stdout', '--wait-stderr', *args)
}

def executeSilent = { String vBoxImage, String image, def config, def args = [] ->
    def p = [image: image, username: config.username]
    if (config.containsKey('password')) p.password = config.password
    exec('guestcontrol', vBoxImage, true, 'execute', p, '--wait-exit', '--wait-stdout', '--wait-stderr', *args)
}

def copyTo = { String vBoxImage, def source, def dest, def config ->
    def p = ['username': config.username]
    if (config.containsKey('password')) p.password = config.password
    String strSource = source instanceof Path? source.toAbsolutePath().toString(): source
    String strDest = dest instanceof Path? dest.toAbsolutePath().toString(): dest
    exec('guestcontrol', vBoxImage, false, 'copyto', strSource, strDest, p)
}

def copyFrom = { String vBoxImage, def source, def dest, def config ->
    def p = ['username': config.username]
    if (config.containsKey('password')) p.password = config.password
    String strSource = source instanceof Path? source.toAbsolutePath().toString(): source
    String strDest = dest instanceof Path? dest.toAbsolutePath().toString(): dest
    exec('guestcontrol', vBoxImage, false, 'copyFrom', strSource, strDest, p)
}

def snapshotRestore = { String vBoxImage, def config ->
    exec('controlvm', vBoxImage, false, 'poweroff')
    exec('snapshot', vBoxImage, false, 'restorecurrent')
}

def concat = { String path1, String path2, String separator ->
    if (path1.endsWith(separator)) {
        return path1 + path2
    } else {
        return path1 + separator + path2
    }
}

def proses = {String vBoxImage, def config  ->
    def separator = (config.os == 'windows')? '\': '/'

    exec('startvm', vBoxImage, false, [type: 'headless'])

    // menunggu sistem operasi guest siap
    print "Menunggu virtual machine siap  "
    int exitCode = -1
    while (exitCode != 0) {
        exitCode = executeSilent(vBoxImage, config.testAliveCmd, config)
        print "."
        sleep(5000)
    }
    println ""
    println "Virtual machine sudah siap"

    // membuat file ZIP untuk di-copy ke host
    println "Membuat file ZIP untuk dipindahkan ke guest"
    def baseDir = griffonSettings.baseDir.path
    Path tempZip = Files.createTempDirectory("griffonvboxtemp").resolve('project.zip')
    ant.zip(destFile: tempZip.toAbsolutePath().toString(), basedir: baseDir)
    tempZip.toFile().deleteOnExit()

    // mencopy file project ke host
    println "Mencopy file ZIP ke guest"
    if (config.os == 'windows') {
        copyTo(vBoxImage, tempZip, config.targetDir, config)
    } else {
        copyTo(vBoxImage, tempZip, config.targetDir + separator + 'project.zip', config)
    }

    // men-extract file ZIP di host
    println "Men-extract file ZIP di guest"
    def targetZip = concat(config.targetDir, 'project.zip', separator)
    def targetExtractZip = concat(config.targetDir, 'project', separator)
    execute(vBoxImage, config.sevenZip, config, ['--', 'x', targetZip, '-y', '-o' + targetExtractZip])

    // mengerjakan test-app
    println "Mengerjakan test-app"
    try {
        def envs = ""GRIFFON_HOME=${config.griffonHome} JAVA_HOME=${config.javaHome}"".toString()
        execute(vBoxImage, config.griffonExec, config, ['--environment', envs, '--',
                                                        '-Dbase.dir=' + targetExtractZip, 'test-app'])
    } catch (RuntimeException ex) {
        println "PENTING: Pengujian untuk proyek ini gagal!"
        tambahGagal(vBoxImage, 1)
    }

    // mengambil hasil pengujian di guest di folder targettest-reportsTESTS-TestSuites.xml
    println "Men-copy file hasil pengujian dari guest"
    def lokasiTestReports = Paths.get(baseDir, 'target', 'vbox-test-reports')
    if (!lokasiTestReports.toFile().exists()) lokasiTestReports.toFile().mkdirs()
    def dest = lokasiTestReports.resolve("TESTS-$vBoxImage-TestSuites-${new Date().format('yyyyMMddhhmm')}.xml")
    def source = concat(targetExtractZip, 'target' + separator + 'test-reports' + separator + 'TESTS-TestSuites.xml', separator)
    copyFrom(vBoxImage, source, dest, config)
    println "File $dest berhasil dibuat"

    // membaca hasil pengujian
    def testsuites = new XmlSlurper().parse(dest.toFile())
    testsuites.testsuite.each { node ->
        if (node.@errors.toInteger() > 0) {
            tambahGagal(vBoxImage, node.@errors.toInteger())
        }
        if (node.@failures.toInteger() > 0) {
            tambahGagal(vBoxImage, node.@failures.toInteger())
        }
    }

}

target(name: 'vboxtest', description: "Run test in VirtualBox", prehook: null, posthook: null) {

    buildConfig.vbox.images.each { k, v ->
        proses(k,v)

        // rollback ke state terakhir
        println "Mengembalikan snapshot seperti semula"
        snapshotRestore(k, v)
    }

    println "nHasil pengujian:"
    println '-'*40
    printf "%-30s %5sn", 'VM Image', 'Gagal'
    println '-'*40
    hasil.each { k,v ->
        printf "%-30s %5dn", k, v
    }
    println '-'*40
    println "nSelesai.n"

}

setDefaultTarget('vboxtest')

Pada script yang saya buat di atas, saya memiliki asumsi bahwa Java, Griffon dan database yang dibutuhkan telah ter-install dimasing-masing image. Saya perlu mendaftarkan setiap pengaturan yang berkaitan dengan masing-masing image dengan menuliskannya di file BuildConfig.groovy. Sebagai contoh, saya menambahkan baris berikut ini pada BuildConfig.groovy:

vbox {

    vboxManage = 'C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe'

    images {
        'Windows 7 Test' {
            os = 'windows'
            username = 'Tester'
            testAliveCmd = 'C:\\Windows\\System32\\ipconfig.exe'
            targetDir = 'C:\\Users\\Tester\\Desktop\\'
            sevenZip = 'C:\\Progra~1\\7-Zip\\7z.exe'
            griffonExec = 'C:\\Progra~1\\Griffon-1.5.0\\bin\\griffon.bat'
            griffonHome = 'C:\\Progra~1\\Griffon-1.5.0'
            javaHome = 'C:\\Progra~1\\Java\\jdk1.7.0_21'
        }
        'Linux Mint Test' {
            os = 'linux'
            username = 'tester'
            password = 'toor'
            testAliveCmd = '/bin/ls'
            targetDir = '/home/tester/Desktop'
            sevenZip = '/usr/bin/7z'
            griffonExec = '/home/tester/griffon-1.5.0/bin/griffon'
            griffonHome = '/home/tester/griffon-1.5.0'
            javaHome = '/usr/lib/jvm/java-7-openjdk-i386'
        }
    }
}

Konfigurasi di atas juga memungkinkan sebuah image yang sama dijalankan untuk diuji lebih dari sekali, misalnya dengan nilai griffonHome dan/atau javaHome berbeda.

Virtual machine akan dijalankan dalam modus headless dimana tidak ada layar UI sehingga pengguna tidak bisa berinteraksi dengannya secara langsung. Untuk mengendalikan virtual machine yang berada dalam keadaan headless seperti ini, saya akan memanggil VBoxManage.exe pada script yang saya buat.

Perintah guestcontrol pada VBoxManage.exe sangat bergantung pada Guest Additions. Salah satu penyebab pesan kesalahan random seperti Session is not in started state adalah versi Guest Additions yang sudah kadaluarsa. Untuk men-install versi Guest Additions terbaru, saya dapat memilih menu Devices, Install Guest Additions CD image…. Versi Guest Additions bisa berbeda karena saat saya men-upgrade VirtualBox ke versi terbaru, masing-masing image tetap memakai Guest Additions versi lama (yang harus di-upgrade secara manual).

Saya tidak bisa langsung memberikan perintah pada virtual machine yang baru dinyalakan. Oleh sebab itu, terdapat nilai testAliveCmd di konfigurasi yang berisi sebuah perintah yang dapat dipakai untuk menguji apakah proses startup sistem operasi virtual sudah selesai atau belum. Bila perintah ini sukses dikerjakan, maka saya bisa segera lanjut mengirim file zip berisi kode program proyek ke virtual machine dan meng-extract file tersebut di dalam virtual machine. Saya mengandaikan bahwa 7zip telah ter-install di guest. 7zip biasanya sudah ada dalam distro Linux populer, tetapi harus di-install terpisah di Windows.

Setelah itu, di dalam virtual machine, saya mengerjakan perintah test-app dari Griffon untuk menguji aplikasi. Setelah pengujian selesai, saya mengambil file XML yang dihasilkan dan meletakkannya di folder target/vbox-test-reports milik proyek di host (proyek yang menjalankan pengujian bukan yang diuji). Tidak lupa saya memberikan perintah agar virtual machine dikembalikan seperti pada snapsnot semula. Disini saya mengasumsikan bahwa virtual machine telah diberi snapshot sebelumnya. Dengan demikian, setiap kali saya menjalankan pengujian, sistem operasi virtual akan selalu berada dalam kondisi yang sama. Ini adalah salah satu kelebihan melakukan pengujian pada virtual machine.

Untuk menjalankan script di atas, saya akan memberikan perintah berikut ini:

C:\proyek> griffon vbox-test
...
Waiting for VM "Windows 7 Test" to power on...
VM "Windows 7 Test" has been successfully started.
Menunggu virtual machine siap  .
Virtual machine sudah siap
Membuat file ZIP untuk dipindahkan ke guest
...
Mencopy file ZIP ke guest
...
Men-extract file ZIP di guest
...
Mengerjakan test-app
...
Men-copy file hasil pengujian dari guest
...
Mengembalikan snapshot seperti semula
...
Waiting for VM "Linux Mint Test" to power on...
VM "Linux Mint Test" has been successfully started.
Menunggu virtual machine siap  .
Virtual machine sudah siap
Membuat file ZIP untuk dipindahkan ke guest
...
Mencopy file ZIP ke guest
...
Men-extract file ZIP di guest
...
Mengerjakan test-app
...
Men-copy file hasil pengujian dari guest
...
Mengembalikan snapshot seperti semula
...

Hasil pengujian:
----------------------------------------
VM Image                       Gagal
----------------------------------------
Windows 7 Test                    12
Linux Mint Test                   12
----------------------------------------

Selesai.

Perintah di atas menunjukkan ada 12 pengujian yang gagal, baik di Windows mapun di Linux. Untuk mendapatkan informasi lebih lanjut, saya dapat membaca file XML hasil pengujian yang terletak di folder target/vbox-test-reports seperti yang terlihat pada gambar berikut ini:

Informasi hasil pengujian di setiap image

Informasi hasil pengujian di setiap image

Belajar Membuat Dialog Login Di Griffon

Pada panduan sederhana ini, saya akan membuat sebuah aplikasi desktop yang menampilkan dialog login. Program baru akan ditampilkan bila dialog di-isi dengan nama pengguna dan password yang benar. Untuk keperluan tersebut, saya akan menggunakan Griffon 1.5 dan JXLoginPane dari SwingX. Untuk melakukan akses database, saya akan memakai simple-jpa 0.6. Karena simple-jpa sudah menyertakan SwingX builder, saya tidak perlu men-install swingx-builder lagi.

Langkah pertama yang saya lakukan adalah membuat sebuah proyek baru dengan perintah berikut ini:

C:\> griffon create-app login
C:\> cd login
C:\login> _

Setelah proyek baru dibuat, saya akan men-install plugin simple-jpa 0.6 dengan menggunakan perintah berikut ini:

C:\login> griffon install-plugin simple-jpa 0.6

Saya perlu menyiapkan koneksi ke sebuah database MySQL di komputer lokal dengan memberikan perintah berikut ini:

C:\login> griffon create-simple-jpa -user=snake -password=12345 -database=latihanLogin -rootPassword=admin12345

Bila ingin memakai database yang sudah ada, saya perlu menambahkan perintah -skip-database pada perintah di atas.

Karena terbiasa memakai IDE, saya akan membuat proyek IntelliJ IDEA dengan memberikan perintah berikut ini:

C:\login> griffon integrate-with --idea

Setelah ini, saya membuka file proyek yang dihasilkan dengan memilih menu File, Open… di IntelliJ IDEA.

Saya kemudian membuka file LoginController.groovy dan mengubahnya menjadi seperti berikut ini:

package login

import domain.DatabaseLoginService
import org.jdesktop.swingx.JXLoginPane

class LoginController {

    def view

    void mvcGroupInit(Map args) {
        DatabaseLoginService loginService = new DatabaseLoginService()
        loginService.buatUserDefault()
        execInsideUISync {
            JXLoginPane panel = new JXLoginPane(loginService)
            JXLoginPane.Status status = JXLoginPane.showLoginDialog(app.windowManager.getStartingWindow(), panel)
            if (status != JXLoginPane.Status.SUCCEEDED) {
                app.shutdown()
            }
        }
    }

}

Pada kode program di atas, saya memakai JXLoginPane dari SwingX. Selain itu, saya menggunakan app.windowManager.getStartingWindow() untuk mendapatkan JFrame yang biasanya ditampilkan pertama kali (sesuai dengan konfigurasi di Application.groovy).

Berikutnya, saya perlu membuat kode program untuk DatabaseLoginService yang akan memeriksa apakah login sukses atau tidak berdasarkan informasi yang tersimpan di database. Ini adalah kanditat yang tepat untuk services. Griffon mendukung application services dengan perintah create-service. Di Griffon, services adalah artifact sama seperti controller. Akan tetapi, agar sederhana, saya tidak akan menggunakan fasilitas dari Griffon tersebut. Saya akan menganggap DatabaseLoginService sebagai domain service (bila mengikuti domain driven design, domain juga memiliki services). Untuk itu, saya membuat file src\main\domain\DatabaseLoginService.groovy yang isinya seperti berikut ini:

package domain

import org.jdesktop.swingx.auth.LoginService
import simplejpa.transaction.Transaction

@Transaction
class DatabaseLoginService extends LoginService {

    @Override
    boolean authenticate(String nama, char[] password, String server) throws Exception {
        return false
    }

    void buatUserDefault() {}

}

Setelah itu, saya menambahkan import domain.DatabaseLoginService pada file LoginController.groovy.

Sampai disini, bila saya menjalankan kode program, saya akan memperoleh tampilan seperti pada gambar berikut ini:

Tampilan Dialog Login

Tampilan Dialog Login

Bila saya mengisi dengan nama pengguna atau password yang saya, saya akan memperoleh tampilan seperti pada gambar berikut ini:

Tampilan Dialog Login Bila Terjadi Kesalahan

Tampilan Dialog Login Bila Terjadi Kesalahan

Apapun yang saya isi, login tidak akan pernah sukses, karena saat ini method DatabaseLoginService.authenticate() selalu mengembalikan nilai false.

Saya akan membuat sebuah domain class yang mewakili pengguna. Class ini akan saya beri nama Pengguna. Untuk itu, saya memilih menu Tools, Griffon, Run Target, lalu mengisinya dengan:

create-domain-class Pengguna

Setelah itu, saya mengubah kode program Pengguna.groovy yang dihasilkan menjadi seperti berikut ini:

package domain

import groovy.transform.*
import simplejpa.DomainClass
import javax.persistence.*
import javax.validation.constraints.*
import org.hibernate.validator.constraints.*
import java.security.MessageDigest

@DomainClass @Entity @Canonical
class Pengguna {

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

    @NotNull
    byte[] passwordHash

    private byte[] getMD5(String plain) {
        MessageDigest digester = MessageDigest.getInstance('MD5')
        digester.update(plain.bytes)
        digester.digest()
    }

    void setPassword(String password) {
        passwordHash = getMD5(password)
    }

    boolean login(String password) {
        if (password.isAllWhitespace()) return false 
        Arrays.equals(passwordHash, getMD5(password))
    }

}

Pada class di atas, saya tidak menyimpan password secara langsung melainkan hanya MD5 hash-nya saja. Walaupun tidak sangat aman, menyimpan dalam bentuk MD5 hash jauh lebih baik daripada menyimpan password apa adanya. Hal ini karena orang yang melihat isi tabel secara langsung tetap tidak bisa mengetahui apa password yang harus diketik. Saya memilih menyimpan passwordHash sebagai byte[]. Secara default, Hibernate JPA akan memetakan byte[] dengan sebuah kolom BLOB di tabel MySQL.

Untuk memastikan tidak ada yang salah pada class di atas, saya akan membuat sebuah unit test dengan memberikan perintah berikut ini:

create-unit-test Pengguna

Saya kemudian mengisi file PenggunaTests yang dihasilkan menjadi seperti berikut ini:

package login

import domain.Pengguna
import griffon.test.*

class PenggunaTests extends GriffonUnitTestCase {

    protected void setUp() {
        super.setUp()
    }

    protected void tearDown() {
        super.tearDown()
    }

    void testLogin() {
        Pengguna pengguna = new Pengguna(nama: 'Solid')
        pengguna.setPassword('Snake')

        // Kasus password benar
        assertTrue(pengguna.login('Snake'))

        // Kasus password salah
        assertFalse(pengguna.login('Liquid Snake'))
        assertFalse(pengguna.login(''))
    }
}

Saya kemudian menghapus file LoginControllerTests.groovy dan LoginModelTests.groovy (yang dihasilkan oleh Griffon) karena mereka tidak berisi test case dan tidak diperlukan pada proyek latihan ini.

Berikutnya saya perlu menjalankan unit test. Untuk itu, saya memberikan perintah berikut ini:

test-app unit:

Bila semuanya sesuai dengan harapan, saya akan harus memperoleh hasil seperti berikut ini:

...
-------------------------------------------------------
Running 1 unit test...
Running test login.PenggunaTests...PASSED
Tests Completed in 562ms ...
-------------------------------------------------------
Tests passed: 1
Tests failed: 0
-------------------------------------------------------

Setelah yakin bahwa kode program Pengguna sudah dapat berfungsi sebagaimana seharusnya, kini saat yang tepat untuk mengubah kode program DatabaseLoginService menjadi seperti berikut ini:

package domain

import org.jdesktop.swingx.auth.LoginService
import simplejpa.transaction.Transaction

@Transaction
class DatabaseLoginService extends LoginService {

    @Override
    boolean authenticate(String nama, char[] password, String server) throws Exception {
        Pengguna pengguna = findPenggunaByNama(nama)
        pengguna?.login(String.valueOf(password))

    }

    void buatUserDefault() {
        if (!findPenggunaByNama('Solid')) {
            Pengguna me = new Pengguna(nama: 'Solid')
            me.password = 'Snake'
            persist(me)
        }
    }

}

Pada kode program di atas, method authenticate() akan dipanggil untuk memeriksa apakah password benar atau salah. Selain itu, juga ada method buatUserDefault() yang akan membuat sebuah pengguna dengan nama dan password default (bila belum ada) sehingga setidaknya saya bisa login ke aplikasi walaupun database masih kosong.

Sekarang, saya dapat mencoba menjalankan aplikasi. Pada proyek latihan ini, saya bisa masuk ke dialog utama hanya bila mengisi nama pengguna dengan Solid dan password berupa Snake.