JSON dan XML adalah format text yang dapat dibaca manusia, tetapi boros ruang. Field name seperti "nama" atau "email" diulang di setiap object, padahal sebenarnya tidak perlu—cukup posisi atau nomor field. ProtoBuf (Protocol Buffers) dari Google dan format binary lain mengganti nama field dengan nomor, dan memakai varint encoding yang menghemat byte untuk angka kecil. Hasilnya: output binary 30-50% lebih kecil dari JSON, parsing lebih cepat, dan schema evolution terjaga. Pada chapter ini kita akan memahami encoding ProtoBuf, melihat implementasi manual binary serializer, dan bagaimana schema evolution memungkinkan data lama dan baru tetap kompatibel.

Encoding ProtoBuf #

Setiap field di ProtoBuf memiliki dua atribut: nomor field (1, 2, 3, dst) dan wire type (0=varint, 2=length-delimited). Tag dikemas dalam satu byte: (field_number << 3) | wire_type. Field number 3 dengan wire type 2 menghasilkan tag (3 << 3) | 2 = 26 = 0x1A.

text Wire types ProtoBuf
0 = Varint            : int, long, bool, enum
1 = 64-bit            : fixed64, double
2 = Length-delimited  : string, bytes, embedded message, repeated
5 = 32-bit            : fixed32, float

Varint encoding memakai 7 bit per byte untuk data, dengan bit ke-8 sebagai continuation flag. Angka 1 hanya butuh 1 byte; angka 300 butuh 2 byte; angka 128 butuh 2 byte (lebih besar dari 1 byte raw). Untuk angka kecil yang umum di ID dan umur, varint menghemat banyak byte.

Implementasi Binary Serializer #

Demo berikut menunjukkan implementasi binary serializer manual yang mengikuti pola ProtoBuf. Karena kotlinx-serialization-protobuf memerlukan library eksternal, demo mensimulasikan encoding dengan BinaryWriter dan BinaryReader buatan sendiri.

kotlin
// binary_formats.kt
// Binary formats: ProtoBuf (simulasi). Compact binary; schema evolution.
// Demo: implementasi binary sederhana dengan field tag + wire type.

import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream

// --- ProtoBuf wire types (subset) ---
// 0 = Varint (int, long, bool, enum)
// 2 = Length-delimited (string, bytes, embedded message)
// Wire type + field number dikemas: (field_number << 3) | wire_type

object WireType {
    const val VARINT: Byte = 0
    const val LENGTH_DELIMITED: Byte = 2
}

data class FieldDef(val number: Int, val name: String, val type: String)

// --- Binary writer (ProtoBuf-like) ---
class BinaryWriter {
    private val baos = ByteArrayOutputStream()
    private val out = DataOutputStream(baos)

    fun writeVarint(fieldNumber: Int, value: Long) {
        val tag = ((fieldNumber shl 3) or WireType.VARINT.toInt()).toLong()
        writeVarintRaw(tag)
        writeVarintRaw(value)
    }

    fun writeString(fieldNumber: Int, value: String) {
        val tag = ((fieldNumber shl 3) or WireType.LENGTH_DELIMITED.toInt()).toLong()
        writeVarintRaw(tag)
        val bytes = value.toByteArray(Charsets.UTF_8)
        writeVarintRaw(bytes.size.toLong())
        out.write(bytes)
    }

    fun writeBytes(fieldNumber: Int, value: ByteArray) {
        val tag = ((fieldNumber shl 3) or WireType.LENGTH_DELIMITED.toInt()).toLong()
        writeVarintRaw(tag)
        writeVarintRaw(value.size.toLong())
        out.write(value)
    }

    private fun writeVarintRaw(value: Long) {
        var v = value
        while (v > 0x7F) {
            out.writeByte(((v and 0x7F) or 0x80).toInt())
            v = v ushr 7
        }
        out.writeByte(v.toInt())
    }

    fun toByteArray(): ByteArray = baos.toByteArray()
}

// --- Binary reader (ProtoBuf-like) ---
class BinaryReader(private val data: ByteArray) {
    private var pos = 0

    fun readTag(): Pair<Int, Byte>? {
        if (pos >= data.size) return null
        val tagVarint = readVarintRaw()
        val fieldNumber = (tagVarint ushr 3).toInt()
        val wireType = (tagVarint and 0x07).toByte()
        return fieldNumber to wireType
    }

    fun readVarint(): Long = readVarintRaw()

    fun readString(): String {
        val len = readVarintRaw().toInt()
        val bytes = data.copyOfRange(pos, pos + len)
        pos += len
        return String(bytes, Charsets.UTF_8)
    }

    fun readBytes(): ByteArray {
        val len = readVarintRaw().toInt()
        val bytes = data.copyOfRange(pos, pos + len)
        pos += len
        return bytes
    }

    private fun readVarintRaw(): Long {
        var result = 0L
        var shift = 0
        while (pos < data.size) {
            val b = data[pos].toInt()
            pos++
            result = result or ((b and 0x7F).toLong() shl shift)
            if (b and 0x80 == 0) break
            shift += 7
        }
        return result
    }
}

// --- Model ---
data class PenggunaBinary(
    val id: Long,
    val nama: String,
    val email: String,
    val aktif: Boolean,
    val umur: Int,
    val bio: String? = null  // field opsional, baru di versi 2
)

// --- Serializer (menggunakan field number) ---
object PenggunaBinarySerializer {
    // Definisi field number (stabil untuk schema evolution)
    // Versi 1: 1=id, 2=nama, 3=email, 4=aktif, 5=umur
    // Versi 2: + 6=bio (opsional)
    private val fieldId = 1
    private val fieldNama = 2
    private val fieldEmail = 3
    private val fieldAktif = 4
    private val fieldUmur = 5
    private val fieldBio = 6

    fun serialize(p: PenggunaBinary): ByteArray {
        val w = BinaryWriter()
        w.writeVarint(fieldId, p.id)
        w.writeString(fieldNama, p.nama)
        w.writeString(fieldEmail, p.email)
        w.writeVarint(fieldAktif, if (p.aktif) 1L else 0L)
        w.writeVarint(fieldUmur, p.umur.toLong())
        if (p.bio != null) {
            w.writeString(fieldBio, p.bio)
        }
        return w.toByteArray()
    }

    fun deserialize(bytes: ByteArray): PenggunaBinary {
        val r = BinaryReader(bytes)
        var id = 0L
        var nama = ""
        var email = ""
        var aktif = false
        var umur = 0
        var bio: String? = null

        while (true) {
            val tag = r.readTag() ?: break
            val (fieldNumber, wireType) = tag
            when (fieldNumber) {
                fieldId -> id = r.readVarint()
                fieldNama -> nama = r.readString()
                fieldEmail -> email = r.readString()
                fieldAktif -> aktif = r.readVarint() != 0L
                fieldUmur -> umur = r.readVarint().toInt()
                fieldBio -> bio = r.readString()
                else -> {
                    // Field tidak dikenali - skip sesuai wire type
                    when (wireType) {
                        WireType.VARINT -> r.readVarint()
                        WireType.LENGTH_DELIMITED -> r.readBytes()
                    }
                }
            }
        }
        return PenggunaBinary(id, nama, email, aktif, umur, bio)
    }
}

// --- Bandingkan ukuran dengan JSON ---
object JsonSize {
    fun serialize(p: PenggunaBinary): String {
        val parts = mutableListOf<String>()
        parts.add("\"id\":${p.id}")
        parts.add("\"nama\":\"${p.nama}\"")
        parts.add("\"email\":\"${p.email}\"")
        parts.add("\"aktif\":${p.aktif}")
        parts.add("\"umur\":${p.umur}")
        if (p.bio != null) parts.add("\"bio\":\"${p.bio}\"")
        return "{${parts.joinToString(",")}}"
    }
    fun size(p: PenggunaBinary): Int = serialize(p).toByteArray(Charsets.UTF_8).size
}

// --- Demo ---

fun main() {
    println("=== Binary Formats (ProtoBuf simulasi) ===\n")

    val pengguna = PenggunaBinary(
        id = 1001L,
        nama = "Budi Santoso",
        email = "[email protected]",
        aktif = true,
        umur = 28,
        bio = "Pengembang perangkat lunak yang suka belajar teknologi baru."
    )

    println("--- Serialize ke binary ---")
    val binary = PenggunaBinarySerializer.serialize(pengguna)
    println("Object     : $pengguna")
    println("Binary size: ${binary.size} bytes")
    println("Hex (first 30): ${binary.take(30).joinToString(" ") { "%02x".format(it) }}...")
    println()

    println("--- Serialize ke JSON ---")
    val jsonText = JsonSize.serialize(pengguna)
    val jsonBytes = jsonText.toByteArray(Charsets.UTF_8)
    println("JSON       : $jsonText")
    println("JSON size  : ${jsonBytes.size} bytes")
    println()

    println("--- Perbandingan ukuran ---")
    println("Binary     : ${binary.size} bytes")
    println("JSON       : ${jsonBytes.size} bytes")
    println("Rasio      : ${"%.1f".format(jsonBytes.size.toDouble() / binary.size)}x lebih besar dari JSON")
    println()

    println("--- Round-trip binary ---")
    val restored = PenggunaBinarySerializer.deserialize(binary)
    println("Restored   : $restored")
    println("Identik    : ${restored == pengguna}")
    println()

    println("--- Schema evolution: data v1 dibaca kode v2 ---")
    // Tulis data tanpa bio (versi 1 schema)
    val v1 = pengguna.copy(bio = null)
    val v1Bytes = PenggunaBinarySerializer.serialize(v1)
    // Baca dengan kode v2 (yang tahu field bio)
    val v1Restored = PenggunaBinarySerializer.deserialize(v1Bytes)
    println("v1 object  : $v1")
    println("v1 restored: $v1Restored")
    println("bio null   : ${v1Restored.bio == null} (field opsional, tidak ada di v1)")
    println()

    println("--- Schema evolution: data v2 dibaca kode v1 ---")
    // Tulis data dengan bio (versi 2)
    val v2Bytes = PenggunaBinarySerializer.serialize(pengguna)
    // Baca dengan kode v1 (yang TIDAK tahu field bio)
    // Simulasi: hapus fieldBio dari when
    val v1Reader = BinaryReader(v2Bytes)
    var id = 0L
    var nama = ""
    var email = ""
    var aktif = false
    var umur = 0
    while (true) {
        val tag = v1Reader.readTag() ?: break
        val (fieldNumber, wireType) = tag
        when (fieldNumber) {
            1 -> id = v1Reader.readVarint()
            2 -> nama = v1Reader.readString()
            3 -> email = v1Reader.readString()
            4 -> aktif = v1Reader.readVarint() != 0L
            5 -> umur = v1Reader.readVarint().toInt()
            else -> {
                // Field tidak dikenali (bio, #6) - skip
                println("  Field tidak dikenali: #$fieldNumber (wire type $wireType) - dilewati")
                when (wireType) {
                    WireType.VARINT -> v1Reader.readVarint()
                    WireType.LENGTH_DELIMITED -> v1Reader.readBytes()
                }
            }
        }
    }
    val v1RestoredFromV2 = PenggunaBinary(id, nama, email, aktif, umur, null)
    println("v2 -> v1 reader: $v1RestoredFromV2")
    println("Field bio diabaikan tanpa error - forward compatible")
    println()

    println("--- ProtoBuf wire types ---")
    println("0 = Varint            : int, long, bool, enum")
    println("1 = 64-bit            : fixed64, double")
    println("2 = Length-delimited  : string, bytes, embedded message, repeated")
    println("5 = 32-bit            : fixed32, float")
    println()
    println("Tag dikemas: (field_number << 3) | wire_type")
    println("Contoh: field 3, wire 2 -> tag = (3 << 3) | 2 = 26 = 0x1A")
}

Output yang Dihasilkan:

text
=== Binary Formats (ProtoBuf simulasi) ===

--- Serialize ke binary ---
Object     : PenggunaBinary(id=1001, nama=Budi Santoso, [email protected], aktif=true, umur=28, bio=Pengembang perangkat lunak yang suka belajar teknologi baru.)
Binary size: 101 bytes
Hex (first 30): 08 e9 07 12 0c 42 75 64 69 20 53 61 6e 74 6f 73 6f 1a 10 62 75 64 69 40 65 78 61 6d 70 6c...

--- Serialize ke JSON ---
JSON       : {"id":1001,"nama":"Budi Santoso","email":"[email protected]","aktif":true,"umur":28,"bio":"Pengembang perangkat lunak yang suka belajar teknologi baru."}
JSON size  : 152 bytes

--- Perbandingan ukuran ---
Binary     : 101 bytes
JSON       : 152 bytes
Rasio      : 1.5x lebih besar dari JSON

--- Round-trip binary ---
Restored   : PenggunaBinary(id=1001, nama=Budi Santoso, [email protected], aktif=true, umur=28, bio=Pengembang perangkat lunak yang suka belajar teknologi baru.)
Identik    : true

--- Schema evolution: data v1 dibaca kode v2 ---
v1 object  : PenggunaBinary(id=1001, nama=Budi Santoso, [email protected], aktif=true, umur=28, bio=null)
v1 restored: PenggunaBinary(id=1001, nama=Budi Santoso, [email protected], aktif=true, umur=28, bio=null)
bio null   : true (field opsional, tidak ada di v1)

--- Schema evolution: data v2 dibaca kode v1 ---
  Field tidak dikenali: #6 (wire type 2) - dilewati
v2 -> v1 reader: PenggunaBinary(id=1001, nama=Budi Santoso, [email protected], aktif=true, umur=28, bio=null)
Field bio diabaikan tanpa error - forward compatible

--- ProtoBuf wire types ---
0 = Varint            : int, long, bool, enum
1 = 64-bit            : fixed64, double
2 = Length-delimited  : string, bytes, embedded message, repeated
5 = 32-bit            : fixed32, float

Tag dikemas: (field_number << 3) | wire_type
Contoh: field 3, wire 2 -> tag = (3 << 3) | 2 = 26 = 0x1A

Mari kita bedah bagian kunci dari kode di atas:

  1. object WireType: konstanta untuk wire types—VARINT (0) untuk angka dan bool, LENGTH_DELIMITED (2) untuk string dan bytes. Wire type menentukan cara parser membaca value setelah tag.
  2. BinaryWriter.writeVarint(fieldNumber, value): menulis tag (fieldNumber << 3) | wireType lalu value sebagai varint. Setiap byte memuat 7 bit data; bit ke-8 set bila masih ada byte lanjutan.
  3. BinaryWriter.writeString(fieldNumber, value): menulis tag, panjang string sebagai varint, lalu byte UTF-8. Pola length-delimited memungkinkan parser melewati field tanpa mengetahui struktur isinya.
  4. BinaryReader.readTag(): membaca varint tag, lalu memisahkan field number (tag >> 3) dan wire type (tag & 0x07). Bila sudah di akhir data, mengembalikan null untuk menghentikan loop.
  5. PenggunaBinarySerializer.serialize/deserialize: serializer untuk model PenggunaBinary. Field number ditetapkan sebagai konstanta—fieldId = 1, fieldNama = 2, dst. Saat deserialize, loop membaca tag hingga akhir data; field tidak dikenali dilewati sesuai wire type.
  6. Schema evolution v1 → v2: data v1 (tanpa field bio) dibaca dengan kode v2. Field bio tidak ada di data, sehingga bio = null (default). Backward compatible.
  7. Schema evolution v2 → v1: data v2 (dengan field bio) dibaca dengan kode v1 yang tidak tahu field bio. Saat menemui tag field 6 yang tidak dikenali, parser memanggil readBytes() untuk melewati field tanpa error. Forward compatible.
  8. Perbandingan ukuran: binary 101 byte vs JSON 152 byte—binary 33% lebih kecil. Rasio bervariasi tergantung data; field name panjang di JSON akan membuat rasio lebih besar.

Schema Evolution: Backward dan Forward Compatibility #

Schema evolution adalah kemampuan kode versi baru membaca data versi lama (backward) dan kode versi lama membaca data versi baru (forward). ProtoBuf mendukung keduanya dengan tiga aturan. Pertama, field number tidak boleh diubah—field 1 selalu menjadi field 1, bahkan setelah rename. Kedua, field baru harus opsional atau punya default value. Ketiga, field lama dapat dihapus tetapi nomornya tidak boleh dipakai ulang.

protobuf Contoh schema ProtoBuf
// Versi 1
message Pengguna {
  int64 id = 1;
  string nama = 2;
  string email = 3;
}

// Versi 2: tambah field opsional (tidak ada di v1)
message Pengguna {
  int64 id = 1;
  string nama = 2;
  string email = 3;
  optional string bio = 6;  // nomor baru, jangan pakai 4/5
}

Aturan praktis: gunakan nomor field baru untuk field baru (jangan isi celah nomor yang sudah dihapus). Tandai field yang dihapus sebagai reserved agar tidak dipakai ulang.

Library Produksi #

Untuk produksi, library berikut populer:

LibraryFormatFitur
kotlinx-serialization-protobufProtoBuf 2/3KMP-compatible, schema via anotasi
protobuf-java (Google)ProtoBuf 3Schema dari file .proto
protostuffProtoBuf + JSONLebih cepat dari protobuf-java
FlatBuffers (Google)FlatBuffersZero-copy, untuk game/embedded
MessagePackMsgPackMirip JSON tetapi binary

kotlinx-serialization-protobuf mengikuti pola yang sama dengan kotlinx-serialization-json—anotasi @ProtoNumber(N) untuk field number, @ProtoPacked untuk koleksi terkompresi.

kotlin kotlinx-serialization-protobuf (library nyata)
import kotlinx.serialization.*
import kotlinx.serialization.protobuf.*

@Serializable
data class Pengguna(
    @ProtoNumber(1) val id: Long,
    @ProtoNumber(2) val nama: String,
    @ProtoNumber(3) val email: String,
    @ProtoNumber(4) val aktif: Boolean
)

fun main() {
    val u = Pengguna(1, "Budi", "[email protected]", true)
    val bytes = ProtoBuf.encodeToByteArray(u)
    // bytes: 0x08 0x01 0x12 0x04 "Budi" 0x1A ...
    val loaded = ProtoBuf.decodeFromByteArray<Pengguna>(bytes)
}

Referensi Lanjutan #

  • kotlinx.serialization — halaman GitHub kotlinx.serialization untuk format biner seperti ProtoBuf dan CBOR

Penutup #

Binary format seperti ProtoBuf menggabungkan kompresi dan schema evolution. Encoding varint dan penggantian nama field dengan nomor menghemat 30-50% ukuran dibanding JSON. Schema evolution—field number stabil, field baru opsional, field lama reserved—memungkinkan backward dan forward compatibility. Chapter berikutnya akan masuk ke topik kritis: serialization security, mengapa input dari sumber tidak terpercaya berbahaya, dan pola defensif.