Data Biner dan Serialisasi

Menyimpan objek dengan ObjectOutputStream dan Serializable beserta peringatan pentingnya.

Daftar isi

Kadang data perlu disimpan apa adanya sebagai byte tanpa perantara teks, misalnya hasil pengukuran sensor atau kumpulan nilai numerik yang padat. Topik ini membahas dua tingkatan cara melakukannya. Keluarga DataOutputStream menyimpan nilai primitif satu per satu dengan ukuran yang pasti, sedangkan ObjectOutputStream menyimpan objek utuh lewat mekanisme serialisasi, yakni pengubahan objek menjadi deret byte yang dapat dibaca kembali menjadi objek.

Kelas DataOutputStream menyediakan satu metode tulis untuk setiap tipe primitif, dan ukurannya tidak bergantung pada sistem. Sebuah int selalu menempati empat byte, double selalu delapan byte, sedangkan boolean hanya satu byte. Metode writeUTF menyimpan String dengan panjangnya terlebih dahulu, sehingga pembaca tahu berapa byte yang harus diambil. Berkas hasilnya tidak dapat dibaca editor teks karena isinya memang bukan teks, melainkan byte biner yang ditafsirkan lewat kode.

java DemoDataBiner.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class DemoDataBiner {

    public static void main(String[] args) throws IOException {
        try (DataOutputStream keluar = new DataOutputStream(
                new FileOutputStream("hasil.bin"))) {
            keluar.writeInt(42);
            keluar.writeDouble(87.5);
            keluar.writeBoolean(true);
            keluar.writeUTF("Kuis Aljabar");
        }

        try (DataInputStream masuk = new DataInputStream(
                new FileInputStream("hasil.bin"))) {
            int nomor = masuk.readInt();
            double nilai = masuk.readDouble();
            boolean selesai = masuk.readBoolean();
            String judul = masuk.readUTF();
            System.out.println("nomor   : " + nomor);
            System.out.println("nilai   : " + nilai);
            System.out.println("selesai : " + selesai);
            System.out.println("judul   : " + judul);
        }
    }
}
text Keluaran
nomor   : 42
nilai   : 87.5
selesai : true
judul   : Kuis Aljabar

Urutan pembacaan harus persis mengikuti urutan penulisan, karena berkas biner tidak membawa penanda tipe di antara nilainya. Program pembaca yang memanggil readDouble duluan akan menafsirkan byte milik int sebagai bagian dari double, dan hasilnya kacau tanpa peringatan. Menjalankan programnya dari terminal cukup dengan satu perintah berikut.

bash
# berada di folder tempat DemoDataBiner.java disimpan
java DemoDataBiner.java
text Keluaran
nomor   : 42
nilai   : 87.5
selesai : true
judul   : Kuis Aljabar

Menyimpan objek dengan ObjectOutputStream#

ObjectOutputStream melompat satu tingkat: objek utuh beserta seluruh field-nya ditulis lewat metode writeObject, dan ObjectInputStream membacanya kembali lewat readObject. Syaratnya hanya satu, yaitu kelas objeknya mengimplementasikan Serializable, sebuah marker interface yang tidak punya metode dan hanya menandai kelas tersebut boleh diserialisasi. Tabel berikut membandingkan kedua pendekatan pada topik ini.

AspekDataOutputStreamObjectOutputStream
Yang disimpannilai primitif satu per satuobjek utuh beserta field-nya
Bentuk berkasderet byte yang diatur sendiriformat serialisasi Java
Syarat pada kelastidak adaimplements Serializable

Program berikut menyimpan objek Pengaturan ke berkas pengaturan.dat. Salah satu fieldnya ditandai transient, yakni penanda bahwa field itu tidak ikut diserialisasi dan nilainya tidak disertakan ke dalam berkas.

java DemoSimpanPengaturan.java
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class DemoSimpanPengaturan {

    public static void main(String[] args) throws IOException {
        Pengaturan p = new Pengaturan();
        p.namaPengguna = "budi";
        p.modeGelap = true;
        p.ukuranHuruf = 14;
        p.tokenSesi = "abc-123";

        try (ObjectOutputStream keluar = new ObjectOutputStream(
                new FileOutputStream("pengaturan.dat"))) {
            keluar.writeObject(p);
        }
        System.out.println("Objek Pengaturan tersimpan ke pengaturan.dat");
    }
}

class Pengaturan implements Serializable {
    private static final long serialVersionUID = 1L;

    String namaPengguna;
    boolean modeGelap;
    int ukuranHuruf;
    transient String tokenSesi;
}
text Keluaran
Objek Pengaturan tersimpan ke pengaturan.dat

Program kedua membaca berkas yang baru dibuat. Metode readObject mengembalikan Object, sehingga hasilnya di-cast ke kelas asalnya, dan metode itu juga melempar ClassNotFoundException karena definisi kelas dimuat saat pembacaan berlangsung.

java DemoBacaPengaturan.java
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class DemoBacaPengaturan {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        try (ObjectInputStream masuk = new ObjectInputStream(
                new FileInputStream("pengaturan.dat"))) {
            Pengaturan p = (Pengaturan) masuk.readObject();
            System.out.println("namaPengguna : " + p.namaPengguna);
            System.out.println("modeGelap    : " + p.modeGelap);
            System.out.println("ukuranHuruf  : " + p.ukuranHuruf);
            System.out.println("tokenSesi    : " + p.tokenSesi);
        }
    }
}

class Pengaturan implements Serializable {
    private static final long serialVersionUID = 1L;

    String namaPengguna;
    boolean modeGelap;
    int ukuranHuruf;
    transient String tokenSesi;
}
text Keluaran
namaPengguna : budi
modeGelap    : true
ukuranHuruf  : 14
tokenSesi    : null

Kedua program dijalankan berurutan, karena program kedua membaca berkas yang dibuat program pertama.

bash
# program pertama membuat berkasnya, program kedua membacanya
java DemoSimpanPengaturan.java
java DemoBacaPengaturan.java
text Keluaran
Objek Pengaturan tersimpan ke pengaturan.dat
namaPengguna : budi
modeGelap    : true
ukuranHuruf  : 14
tokenSesi    : null

Objek yang kembali adalah salinan dengan isi field yang sama, sedangkan tokenSesi yang transient kembali sebagai null. Pola ini biasa dipakai untuk field yang dapat dibangun ulang, seperti data cache atau sesi masuk yang tidak perlu dipertahankan antar eksekusi. Alur penyimpanan dan pembacaannya digambarkan berikut ini.

objek Pengaturanhidup di heapwriteObject()ObjectOutputStreammenyusun deret bytepengaturan.datderet byte di diskpengaturan.datdibaca byte demi bytereadObject()ObjectInputStreammemuat definisi kelasobjek salinanPengaturan yang barufield transient (tokenSesi) tidak ikut: nilainya kembali sebagai default
Serialisasi mengubah objek menjadi byte lalu membacanya kembali

serialVersionUID dan kelas yang berubah#

Deret byte hasil serialisasi menyimpan nama kelas beserta serialVersionUID, yaitu nomor versi kelas yang menemani datanya. Saat membaca, JVM membandingkan nomor versi di berkas dengan nomor versi kelas yang ada di kode, dan ketidakcocokannya melempar InvalidClassException. Bila nomor ini tidak dinyatakan eksplisit, JVM menghitungnya sendiri dari bentuk kelas, sehingga perubahan sekecil apa pun pada field mengubah nomornya dan berkas lama tak terbaca. Program berikut meniru kelas yang sudah berubah, dengan nomor versi 2, lalu mencoba membaca berkas dari versi 1.

java DemoKelasBerubah.java
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class DemoKelasBerubah {

    public static void main(String[] args) {
        try (ObjectInputStream masuk = new ObjectInputStream(
                new FileInputStream("pengaturan.dat"))) {
            Pengaturan p = (Pengaturan) masuk.readObject();
            System.out.println("namaPengguna: " + p.namaPengguna);
        } catch (InvalidClassException e) {
            System.out.println("Kelas tidak cocok: " + e.getMessage());
        } catch (IOException | ClassNotFoundException e) {
            System.out.println("Gagal membaca: " + e.getMessage());
        }
    }
}

class Pengaturan implements Serializable {
    private static final long serialVersionUID = 2L;

    String namaPengguna;
    boolean modeGelap;
    int ukuranHuruf;
    transient String tokenSesi;
}
text Keluaran
Kelas tidak cocok: Pengaturan; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2
bash
# simpan ulang dengan kelas versi 1, lalu baca dengan kelas versi 2
java DemoSimpanPengaturan.java
java DemoKelasBerubah.java
text Keluaran
Objek Pengaturan tersimpan ke pengaturan.dat
Kelas tidak cocok: Pengaturan; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2

Pesan errornya menyebut kedua nomor versi, yaitu 1 dari berkas dan 2 dari kelas lokal, sehingga penyebabnya langsung terlihat. Dengan serialVersionUID yang dinyatakan eksplisit, perubahan field yang kompatibel masih dapat dibaca, dan keputusan kapan data lama perlu dibuang berada di tangan pengembang, bukan menjadi kebetulan hasil perhitungan otomatis.

Batasan yang perlu diwaspadai#

Serialisasi bawaan menautkan berkas pada kode Java, baik nama kelas, isi field, maupun format internalnya. Berkas yang ditulis Java sulit dibaca bahasa lain tanpa alat tambahan, dan setiap perubahan kelas harus dijaga lewat nomor versinya, sehingga format ini cocok untuk berkas sementara dan data yang umurnya seumur aplikasinya. Untuk pertukaran data antar sistem yang terdiri atas bahasa berbeda, format teks seperti JSON lazim dipilih karena tidak menuntut definisi kelas di sisi pembaca.

Alasan yang lebih serius adalah keamanan. readObject tidak sekadar mengisi field, tetapi menjalankan kode yang melekat pada kelas yang dibacanya, dan deret byte dari sumber tak dikenal dapat dirancang untuk memicu eksekusi kode saat proses itu berjalan. Field yang bertipe objek ikut terserialisasi bersama induknya, sehingga satu panggilan writeObject dapat membawa seluruh graf objek tanpa terlihat. Data rahasia seperti kata sandi juga sebaiknya tidak diserialisasi sama sekali, sebab byte yang tersimpan dapat dibaca dan disalin apa pun tandanya.

Kesalahan umum#

  • Melupakan implements Serializable pada kelas yang disimpan. Metode writeObject melempar NotSerializableException begitu bertemu objek yang tidak boleh diserialisasi.
  • Mengubah urutan readInt dan readDouble pada DataInputStream. Berkas biner tidak membawa penanda tipe, sehingga nilai terbaca kacau tanpa pesan error.
  • Menyunting kelas tanpa menetapkan serialVersionUID. Nomor versi yang dihitung otomatis ikut berubah, dan berkas lama gagal dibaca dengan InvalidClassException.
  • Mendeserialisasi berkas yang asal-usulnya tidak jelas. Risiko keamanannya nyata, sehingga data yang tidak dipercaya ditolak di depan pintu.

Ringkasan#

  • DataOutputStream menyimpan nilai primitif dengan ukuran pasti, dan urutan pembacaannya harus persis mengikuti urutan penulisannya.
  • ObjectOutputStream menulis objek Serializable utuh, sedangkan ObjectInputStream membacanya kembali dengan cast dan siap menampung ClassNotFoundException.
  • transient menjaga field agar tidak ikut terserialisasi, dan nilainya kembali sebagai default.
  • serialVersionUID menandai versi kelas, dan ketidakcocokan versi memunculkan InvalidClassException.
  • Serialisasi bawaan bukan format pertukaran data lintas bahasa, dan data tak terpercaya tidak boleh dideserialisasi.

Referensi#