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.
0 = Varint : int, long, bool, enum
1 = 64-bit : fixed64, double
2 = Length-delimited : string, bytes, embedded message, repeated
5 = 32-bit : fixed32, floatVarint 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.
// 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:
=== 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 = 0x1AMari kita bedah bagian kunci dari kode di atas:
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.BinaryWriter.writeVarint(fieldNumber, value): menulis tag(fieldNumber << 3) | wireTypelalu value sebagai varint. Setiap byte memuat 7 bit data; bit ke-8 set bila masih ada byte lanjutan.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.BinaryReader.readTag(): membaca varint tag, lalu memisahkan field number (tag >> 3) dan wire type (tag & 0x07). Bila sudah di akhir data, mengembalikannulluntuk menghentikan loop.PenggunaBinarySerializer.serialize/deserialize: serializer untuk modelPenggunaBinary. 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.- Schema evolution v1 → v2: data v1 (tanpa field
bio) dibaca dengan kode v2. Fieldbiotidak ada di data, sehinggabio = null(default). Backward compatible. - Schema evolution v2 → v1: data v2 (dengan field
bio) dibaca dengan kode v1 yang tidak tahu fieldbio. Saat menemui tag field 6 yang tidak dikenali, parser memanggilreadBytes()untuk melewati field tanpa error. Forward compatible. - 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.
// 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:
| Library | Format | Fitur |
|---|---|---|
kotlinx-serialization-protobuf | ProtoBuf 2/3 | KMP-compatible, schema via anotasi |
protobuf-java (Google) | ProtoBuf 3 | Schema dari file .proto |
protostuff | ProtoBuf + JSON | Lebih cepat dari protobuf-java |
FlatBuffers (Google) | FlatBuffers | Zero-copy, untuk game/embedded |
MessagePack | MsgPack | Mirip JSON tetapi binary |
kotlinx-serialization-protobuf mengikuti pola yang sama dengan kotlinx-serialization-json—anotasi @ProtoNumber(N) untuk field number, @ProtoPacked untuk koleksi terkompresi.
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.serializationuntuk 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.