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:
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:
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:
byte protobuf : b'\x08\x01\x12\x04Budi\x1a\x0cKopi Robusta \x02(\xc8\xdf\x02'
panjang : 28 byte
harga dibaca : 45000Mari kita telaah baris-baris kode di atas:
- Baris 5-8 (
FileDescriptorProto): membangun representasi file.protosebagai objek — nama file, package, dan versi sintaks terisi persis seperti isitransaksi.proto. - Baris 10-23 (
message_type.add()dan loop): menambahkan messageTransaksibeserta lima field; setiap field membawa nama, nomor, tipe, dan labelLABEL_OPTIONALsesuai semantik proto3. - Baris 25-29 (
DescriptorPooldanGetMessageClass): pool menyimpan descriptor terkompilasi, laluGetMessageClassmengembalikan class message — class ini setara dengan hasilprotocsehingga punya API yang sama. - Baris 31-32 (
Transaksi(...)danSerializeToString): membuat pesan lewat keyword argument lalu mengubah pesan menjadi byte;FromStringpada 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 byte | Arti |
|---|---|
\x08\x01 | field 1 (id), varint, nilai 1 |
\x12\x04Budi | field 2 (kasir), 4 byte teks Budi |
\x1a\x0cKopi Robusta | field 3 (produk), 12 byte teks |
\x20\x02 | field 4 (qty), varint, nilai 2 |
(\xc8\xdf\x02 | field 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:
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:
total protobuf: 165 byte
total json : 412 byte
rasio : 2.5 x lebih besarUntuk 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.