Aplikasi yang selesai dikembangkan berupa ratusan berkas .class di direktori build — bentuk yang tidak bisa dikirim ke produksi begitu saja. Chapter Packaging JAR di section build tools sudah memperkenalkan format arsipnya; sekarang kita fokus pada tujuan deployment: satu berkas yang dijalankan dengan java -jar di mesin mana pun yang memuat JDK. Dua hambatan utamanya: penentu class utama lewat manifest dan cara membawa dependency. Kita menuntaskan keduanya dengan atribut Main-Class+Class-Path, lalu fat jar yang menelan seluruh dependency ke dalam satu arsip.

JAR Eksekusi dan Manifest #

Atribut Main-Class di META-INF/MANIFEST.MF adalah kontrak yang dibaca launcher java -jar: nama class yang memuat method main. Atribut ditulis lewat berkas manifest kustom lalu diserahkan ke opsi --manifest. Skenario kita memakai aplikasi kasir (package toko) dengan library util:

bash
javac -d out-util src/util/Kwitansi.java
javac -d out-toko -cp out-util src/toko/AplikasiKasir.java
jar --create --file dist/toko-app.jar --manifest manifest.txt -C out-toko .

Isi manifest.txt sebelum dikemas:

text
Class-Path: lib/util.jar
Main-Class: toko.AplikasiKasir

Output yang Dihasilkan (isi META-INF/MANIFEST.MF di dalam JAR):

text
Manifest-Version: 1.0
Class-Path: lib/util.jar
Main-Class: toko.AplikasiKasir
Created-By: 25.0.4.1 (Eclipse Adoptium)

Alat jar menambahkan Manifest-Version dan Created-By secara otomatis, sedangkan kedua baris kitalah yang membuat JAR ini layak dieksekusi.

Menjalankan Dependency dengan Class-Path #

Perintah java -jar mengabaikan flag -cp — classpath hanya boleh datang dari isi JAR itu sendiri atau atribut Class-Path di manifest. Entry Class-Path adalah URL relatif terhadap lokasi JAR, bukan path absolut mesin. Karena util.jar kita letakkan di dist/lib/:

bash
jar --create --file dist/lib/util.jar -C out-util .
java -jar dist/toko-app.jar

Output yang Dihasilkan:

text
aplikasi-kasir 2.1.0
Rp250,000.00

Struktur dist/ harus ikut didistribusikan utuh: toko-app.jar di samping folder lib/. Pendekatan ini menjaga dependency sebagai JAR terpisah sehingga tiap library bisa diganti tanpa membangun ulang aplikasi — pola server aplikasi klasik.

Fat Jar dan Shading #

Cara kedua membuang ketergantungan pada struktur folder: fat jar (uber jar) — seluruh class dependency ditarik ke dalam satu arsip. Opsi -C boleh diulang, satu per direktori kompilasi:

bash
jar --create --file dist/toko-fat.jar --main-class toko.AplikasiKasir -C out-toko . -C out-util .
java -jar dist/toko-fat.jar
jar --describe-module --file dist/toko-fat.jar

Output yang Dihasilkan:

text
META-INF/
META-INF/MANIFEST.MF
toko/
toko/AplikasiKasir.class
util/
util/Kwitansi.class
aplikasi-kasir 2.1.0
Rp250,000.00
No module descriptor found. Derived automatic module.

toko.fat automatic
requires java.base mandated
contains toko
contains util
main-class toko.AplikasiKasir

Satu berkas, satu perintah, dependency ikut di dalamnya. Kedua pendekatan pembawa dependency — folder lib/ dengan Class-Path dan fat jar — berkompromi berbeda:

AspekClass-Path + folder lib/Fat jar
Bentuk distribusiJAR aplikasi + folder lib/satu berkas mandiri
Mengganti satu dependencytimpa berkas di lib/bangun ulang seluruh arsip
Konflik class senamaterpisah per JARberisiko saling menimpa
Ukuran unduhan per rilishanya JAR yang berubahseluruh bundle setiap kali

Pada proyek nyata dengan puluhan dependency, penggabungan ini diotomatiskan build tool — Maven memakai maven-shade-plugin:

xml Konfigurasi maven-shade-plugin (cuplikan pom.xml)
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-shade-plugin</artifactId>
  <version>3.6.0</version>
  <executions>
    <execution>
      <phase>package</phase>
      <goals><goal>shade</goal></goals>
      <configuration>
        <transformers>
          <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
            <mainClass>toko.AplikasiKasir</mainClass>
          </transformer>
        </transformers>
      </configuration>
    </execution>
  </executions>
</plugin>

Membaca Manifest dari Program Java #

Kadang aplikasi perlu membaca manifestnya sendiri — misalnya menampilkan versi dari atribut Implementation-Version. Class java.util.jar.Manifest menyediakan baca-tulis atribut yang sama dengan yang dipakai launcher. Program berikut membangun manifest di memori, menserikannya menjadi bytes (persis isi MANIFEST.MF), lalu membacanya kembali dan menginterpretasi Main-Class serta Class-Path:

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.jar.Attributes;
import java.util.jar.Manifest;

/** Membaca manifest seperti launcher java -jar: Main-Class dan Class-Path relatif. */
public class BacaManifest {

    /** Bangun manifest dari pasangan atribut, lalu serikan ke bytes (format MANIFEST.MF). */
    static byte[] buatManifest(String[][] atribut) throws Exception {
        Manifest mf = new Manifest();
        Attributes utama = mf.getMainAttributes();
        utama.put(Attributes.Name.MANIFEST_VERSION, "1.0");
        for (String[] pasangan : atribut) {
            utama.putValue(pasangan[0], pasangan[1]);
        }
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        mf.write(out);
        return out.toByteArray();
    }

    /** Baca kembali bytes manifest — tahap yang dilakukan launcher sebelum menjalankan JAR. */
    static Manifest bacaManifest(byte[] bytes) throws Exception {
        return new Manifest(new ByteArrayInputStream(bytes));
    }

    /** Selesaikan entry Class-Path relatif terhadap lokasi berkas JAR. */
    static String jalurAbsolut(String direktoriJar, String entryRelatif) {
        return direktoriJar + "/" + entryRelatif;
    }

    public static void main(String[] args) throws Exception {
        byte[] bytes = buatManifest(new String[][]{
                {"Main-Class", "toko.AplikasiKasir"},
                {"Class-Path", "lib/util.jar lib/promo.jar"}
        });

        System.out.println("== Isi MANIFEST.MF hasil serialisasi ==");
        // Spesifikasi JAR mewajibkan CRLF sebagai penanda baris pada manifest;
        // dinormalisasi ke \n agar tampilan output rapi di terminal.
        System.out.print(new String(bytes, StandardCharsets.UTF_8).replace("\r\n", "\n"));

        Manifest terbaca = bacaManifest(bytes);
        Attributes utama = terbaca.getMainAttributes();
        System.out.println("== Interpretasi launcher java -jar ==");
        System.out.println("Main-Class : " + utama.getValue(Attributes.Name.MAIN_CLASS));

        String classPath = utama.getValue(Attributes.Name.CLASS_PATH);
        if (classPath == null) {
            System.out.println("Class-Path : (tidak ada) -> hanya isi JAR sendiri");
        } else {
            System.out.println("Class-Path : " + classPath);
            for (String entry : classPath.split(" ")) {
                System.out.println("  " + entry + " -> " + jalurAbsolut("dist", entry));
            }
        }

        System.out.println("== Manifest tanpa Main-Class ==");
        byte[] tanpaMain = buatManifest(new String[][]{
                {"Class-Path", "lib/util.jar"}
        });
        Manifest m2 = bacaManifest(tanpaMain);
        String main = m2.getMainAttributes().getValue(Attributes.Name.MAIN_CLASS);
        System.out.println("Main-Class : " + main);
        System.out.println("hasil      : launcher menolak, JAR bukan aplikasi eksekusi");
    }
}

Output yang Dihasilkan:

text
== Isi MANIFEST.MF hasil serialisasi ==
Manifest-Version: 1.0
Main-Class: toko.AplikasiKasir
Class-Path: lib/util.jar lib/promo.jar

== Interpretasi launcher java -jar ==
Main-Class : toko.AplikasiKasir
Class-Path : lib/util.jar lib/promo.jar
  lib/util.jar -> dist/lib/util.jar
  lib/promo.jar -> dist/lib/promo.jar
== Manifest tanpa Main-Class ==
Main-Class : null
hasil      : launcher menolak, JAR bukan aplikasi eksekusi

Mari kita bedah bagian-bagiannya:

  1. buatManifest(...): method ini mengisi Attributes utama melalui putValue, lalu mf.write(out) menuliskan format manifest standar: setiap atribut satu baris Nama: nilai diakhiri baris kosong.
  2. bacaManifest(...): konstruktor Manifest(InputStream) mem-parsing bytes kembali menjadi struktur data — tahap yang dilakukan launcher sebelum mengeksekusi JAR.
  3. Attributes.Name.MAIN_CLASS dan CLASS_PATH: konstanta nama atribut baku; memakainya mencegah salah ketik yang membuat manifest terbaca kosong.
  4. jalurAbsolut("dist", entry): resolusi entry Class-Path relatif terhadap direktori JAR — mengapa lib/util.jar menunjuk ke dist/lib/util.jar.
  5. Skenario ketiga: manifest tanpa Main-Class menghasilkan null, dan launcher memang menolak menjalankan JAR seperti ini — sumber error no main manifest attribute pada JAR yang dikemas terburu-buru.

Fat jar menuntaskan masalah cara membawa kode, tetapi masih menyisakan satu kebutuhan: mesin target harus terpasang JDK versi yang tepat. Chapter berikutnya membalik pendekatannya — jangan pasang runtime di mesin, bawa runtime bersama aplikasi lewat jlink.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

BacaManifest.java

Unduh berkas
java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.jar.Attributes;
import java.util.jar.Manifest;

/** Membaca manifest seperti launcher java -jar: Main-Class dan Class-Path relatif. */
public class BacaManifest {

    /** Bangun manifest dari pasangan atribut, lalu serikan ke bytes (format MANIFEST.MF). */
    static byte[] buatManifest(String[][] atribut) throws Exception {
        Manifest mf = new Manifest();
        Attributes utama = mf.getMainAttributes();
        utama.put(Attributes.Name.MANIFEST_VERSION, "1.0");
        for (String[] pasangan : atribut) {
            utama.putValue(pasangan[0], pasangan[1]);
        }
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        mf.write(out);
        return out.toByteArray();
    }

    /** Baca kembali bytes manifest — tahap yang dilakukan launcher sebelum menjalankan JAR. */
    static Manifest bacaManifest(byte[] bytes) throws Exception {
        return new Manifest(new ByteArrayInputStream(bytes));
    }

    /** Selesaikan entry Class-Path relatif terhadap lokasi berkas JAR. */
    static String jalurAbsolut(String direktoriJar, String entryRelatif) {
        return direktoriJar + "/" + entryRelatif;
    }

    public static void main(String[] args) throws Exception {
        byte[] bytes = buatManifest(new String[][]{
                {"Main-Class", "toko.AplikasiKasir"},
                {"Class-Path", "lib/util.jar lib/promo.jar"}
        });

        System.out.println("== Isi MANIFEST.MF hasil serialisasi ==");
        // Spesifikasi JAR mewajibkan CRLF sebagai penanda baris pada manifest;
        // dinormalisasi ke \n agar tampilan output rapi di terminal.
        System.out.print(new String(bytes, StandardCharsets.UTF_8).replace("\r\n", "\n"));

        Manifest terbaca = bacaManifest(bytes);
        Attributes utama = terbaca.getMainAttributes();
        System.out.println("== Interpretasi launcher java -jar ==");
        System.out.println("Main-Class : " + utama.getValue(Attributes.Name.MAIN_CLASS));

        String classPath = utama.getValue(Attributes.Name.CLASS_PATH);
        if (classPath == null) {
            System.out.println("Class-Path : (tidak ada) -> hanya isi JAR sendiri");
        } else {
            System.out.println("Class-Path : " + classPath);
            for (String entry : classPath.split(" ")) {
                System.out.println("  " + entry + " -> " + jalurAbsolut("dist", entry));
            }
        }

        System.out.println("== Manifest tanpa Main-Class ==");
        byte[] tanpaMain = buatManifest(new String[][]{
                {"Class-Path", "lib/util.jar"}
        });
        Manifest m2 = bacaManifest(tanpaMain);
        String main = m2.getMainAttributes().getValue(Attributes.Name.MAIN_CLASS);
        System.out.println("Main-Class : " + main);
        System.out.println("hasil      : launcher menolak, JAR bukan aplikasi eksekusi");
    }
}