Antar-layanan microservices dan pipeline streaming, data dikirim jutaan kali per detik dalam payload kecil. JSON mudah dibaca manusia, tetapi setiap pesan mengulang nama kolom sebagai teks dan angka disimpan sebagai deretan karakter — boros untuk komunikasi antar mesin. Protocol Buffers (Protobuf) dari Google menjawab masalah ini dengan pendekatan kontrak dulu: kita mendefinisikan skema di file .proto, lalu data diserialisasi menjadi byte biner yang padat dan cepat diurai. Kafka, gRPC, hingga Confluent Schema Registry menjadikan Protobuf salah satu pilihan utama. Dokumen resminya ada di protobuf.dev .

Skema .proto sebagai Kontrak #

Segalanya dimulai dari file skema. Berikut transaksi.proto untuk dataset transaksi toko kita:

protobuf
syntax = "proto3";

package toko;

message Transaksi {
  int32 id = 1;
  string kasir = 2;
  string produk = 3;
  int32 qty = 4;
  int64 harga = 5;
}

Tiga elemen penting perlu dicermati. syntax = "proto3" memilih generasi sintaks ketiga — field tidak wajib diisi dan tidak ada kata kunci required. package toko mencegah bentrok nama antar file skema. Setiap field dideklarasikan sebagai tipe nama = nomor; — nomor inilah identitas field di dalam byte biner, bukan namanya. Tipe yang sering dipakai antara lain int32, int64, string, bool, bytes, serta float dan double.

Menyusun Schema di Python Tanpa protoc #

Biasanya file .proto dikompilasi oleh protoc menjadi modul Python (transaksi_pb2.py). Untuk pembelajaran — dan untuk kasus skema dinamis seperti schema registry — kita bisa membangun descriptor yang sama secara programatik memakai descriptor_pool dan message_factory dari pustaka google.protobuf:

python
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory

FD = descriptor_pb2.FieldDescriptorProto

file_proto = descriptor_pb2.FileDescriptorProto()
file_proto.name = "transaksi.proto"
file_proto.package = "toko"
file_proto.syntax = "proto3"

pesan = file_proto.message_type.add()
pesan.name = "Transaksi"
for nama, nomor, tipe in [
    ("id", 1, FD.TYPE_INT32),
    ("kasir", 2, FD.TYPE_STRING),
    ("produk", 3, FD.TYPE_STRING),
    ("qty", 4, FD.TYPE_INT32),
    ("harga", 5, FD.TYPE_INT64),
]:
    field = pesan.field.add()
    field.name = nama
    field.number = nomor
    field.type = tipe
    field.label = FD.LABEL_OPTIONAL

pool = descriptor_pool.DescriptorPool()
pool.Add(file_proto)
Transaksi = message_factory.GetMessageClass(
    pool.FindMessageTypeByName("toko.Transaksi")
)

t = Transaksi(id=1, kasir="Budi", produk="Kopi Robusta", qty=2, harga=45000)
buffer = t.SerializeToString()
print("byte protobuf :", buffer)
print("panjang       :", len(buffer), "byte")
print("harga dibaca  :", Transaksi.FromString(buffer).harga)

Output yang Dihasilkan:

text
byte protobuf : b'\x08\x01\x12\x04Budi\x1a\x0cKopi Robusta \x02(\xc8\xdf\x02'
panjang       : 28 byte
harga dibaca  : 45000

Mari kita telaah baris-baris kode di atas:

  1. Baris 5-8 (FileDescriptorProto): membangun representasi file .proto sebagai objek — nama file, package, dan versi sintaks terisi persis seperti isi transaksi.proto.
  2. Baris 10-23 (message_type.add() dan loop): menambahkan message Transaksi beserta lima field; setiap field membawa nama, nomor, tipe, dan label LABEL_OPTIONAL sesuai semantik proto3.
  3. Baris 25-29 (DescriptorPool dan GetMessageClass): pool menyimpan descriptor terkompilasi, lalu GetMessageClass mengembalikan class message — class ini setara dengan hasil protoc sehingga punya API yang sama.
  4. Baris 31-32 (Transaksi(...) dan SerializeToString): membuat pesan lewat keyword argument lalu mengubah pesan menjadi byte; FromString pada baris cetak mengurai byte kembali menjadi pesan utuh.

Format Wire: Isi 28 Byte #

Byte hasil serialisasi tadi bisa kita baca potongan demi potongan. Kunci pembacaannya: setiap field diawali satu byte tag yang dihitung (nomor_field << 3) | wire_type — wire type 0 berarti varint (angka), wire type 2 berarti blok berpanjang (teks):

Potongan byteArti
\x08\x01field 1 (id), varint, nilai 1
\x12\x04Budifield 2 (kasir), 4 byte teks Budi
\x1a\x0cKopi Robustafield 3 (produk), 12 byte teks
\x20\x02field 4 (qty), varint, nilai 2
(\xc8\xdf\x02field 5 (harga), varint 3 byte, nilai 45000

Perhatikan bahwa nama kolom tidak pernah dikirim — hanya nomor field. Inilah sumber kehematan Protobuf: kasir (5 karakter) cukup dikirim sebagai byte tag tunggal \x12. Sebagai gantinya, pembaca harus memiliki skema yang sama; byte Protobuf tidak bisa dibaca tanpanya, berbeda dengan JSON yang self-describing.

Ukuran Biner vs JSON #

Ukuran adalah klaim yang perlu dibuktikan. Kita serialisasi enam transaksi toko yang sama dua kali — sekali sebagai pesan Protobuf, sekali sebagai JSON paling padat (tanpa spasi) — lalu jumlahkan panjangnya:

python
import json
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory

FD = descriptor_pb2.FieldDescriptorProto

file_proto = descriptor_pb2.FileDescriptorProto()
file_proto.name = "transaksi.proto"
file_proto.package = "toko"
file_proto.syntax = "proto3"
pesan = file_proto.message_type.add()
pesan.name = "Transaksi"
for nama, nomor, tipe in [
    ("id", 1, FD.TYPE_INT32),
    ("kasir", 2, FD.TYPE_STRING),
    ("produk", 3, FD.TYPE_STRING),
    ("qty", 4, FD.TYPE_INT32),
    ("harga", 5, FD.TYPE_INT64),
]:
    field = pesan.field.add()
    field.name = nama
    field.number = nomor
    field.type = tipe
    field.label = FD.LABEL_OPTIONAL

pool = descriptor_pool.DescriptorPool()
pool.Add(file_proto)
Transaksi = message_factory.GetMessageClass(
    pool.FindMessageTypeByName("toko.Transaksi")
)

catatan = [
    {"id": 1, "kasir": "Budi", "produk": "Kopi Robusta", "qty": 2, "harga": 45000},
    {"id": 2, "kasir": "Siti", "produk": "Teh Melati", "qty": 3, "harga": 15000},
    {"id": 3, "kasir": "Budi", "produk": "Gula Aren 500g", "qty": 1, "harga": 25000},
    {"id": 4, "kasir": "Dewi", "produk": "Susu UHT 1L", "qty": 2, "harga": 18000},
    {"id": 5, "kasir": "Dewi", "produk": "Kopi Robusta", "qty": 8, "harga": 45000},
    {"id": 6, "kasir": "Siti", "produk": "Susu UHT 1L", "qty": 4, "harga": 18000},
]

total_pb = sum(len(Transaksi(**c).SerializeToString()) for c in catatan)
total_json = sum(len(json.dumps(c, separators=(",", ":"))) for c in catatan)
print("total protobuf:", total_pb, "byte")
print("total json    :", total_json, "byte")
print("rasio         :", round(total_json / total_pb, 2), "x lebih besar")

Output yang Dihasilkan:

text
total protobuf: 165 byte
total json    : 412 byte
rasio         : 2.5 x lebih besar

Untuk enam pesan sederhana ini, JSON memakai 2,5 kali lipat byte Protobuf. Selisihnya bertambah besar ketika nama kolom panjang dan angka banyak digit, karena JSON menuliskan setiap digit sebagai karakter sementara varint mengemas byte penuh. Sebaliknya, selisih menyempit bila teks mendominasi — isi teks dikirim apa adanya di kedua format. Keputusan memakai Protobuf tetap bukan sekadar soal ukuran: kecepatan parsing biner dan penegakan skema yang ketat sama pentingnya.

Skema yang kita tulis di .proto memuat tipe eksplisit — int32, string, int64. Mengapa tipe begitu sentral di semua format data? Chapter berikutnya membahas schema dan tipe data sebagai fondasi kontrak data: Schema dan Data Types.