Gob dari data

Rob Pike
24 Maret 2011

Pendahuluan

Untuk mengirim struktur data dalam jaringan atau menyimpannya ke dalam sebuah berkas, ia haruslah ditulis (encode) dan kemudian dibaca (decode) kembali. Ada banyak encoding yang tersedia, seperti: JSON, XML, protocol buffer dari Google, dan banyak lagi. Dan sekarang ada satu lagi, disediakan oleh paket Go gob.

Kenapa mendefinisikan encoding baru? Ia menambah kerja dan tampak mubazir. Kenapa tidak menggunakan format yang ada? Pertama, Go punya semuanya! Go memiliki paket-paket yang mendukung semua encoding yang disebutkan di atas (paket protocol buffer ada pada repositori terpisah namun yang paling sering diunduh). Dan untuk tujuan secara umumnya, termasuk berkomunikasi dengan perkakas dan sistem yang ditulis dengan bahasa yang berbeda, mereka adalah format yang sesuai.

Namun untuk lingkungan yang spesifik dengan Go, seperti berkomunikasi antara dua server yang dibuat dengan Go, ada sebuah kesempatan untuk membuat sesuatu yang lebih mudah digunakan dan mungkin lebih efisien.

Gob bekerja dalam bahasa Go yang mana encoding lain yang terdefinisi secara eksternal dan berdiri sendiri (tidak bergantung pada bahasa pemrograman) tidak mampu. Pada saat yang sama, ada beberapa pelajaran yang dapat diambil dari sistem (encoding) yang sudah ada.

Tujuan

Paket gob dirancang dengan sejumlah tujuan.

Pertama, dan yang paling kentara, ia haruslah mudah digunakan. Pertama, karena Go memiliki fitur refleksi, maka tidak perlu antar muka definisi bahasa yang terpisah atau "protocol compiler". Struktur data saja sudah cukup bagi paket gob untuk mengetahui bagaimana menulis dan membaca sebuah data. Di sisi lain, pendekatan ini berarti gob tidak akan pernah bisa bekerja dengan bahasa pemrograman lain, namun tidak apa-apa: gob adalah Go sentris.

Efisiensi juga penting. Representasi tekstual, dicontohkan pada XML dan JSON, terlalu lambat bila digunakan sebagai pusat komunikasi jaringan yang efisien. Sistem encoding dengan binary memang dibutuhkan.

Stream dari gob haruslah self-describing (berisi informasi yang menjelaskan dirinya sendiri). Artinya, stream gob yang dibaca dari awal, memiliki informasi yang cukup sehingga keseluruhan stream dapat dibaca oleh sebuah agen yang sebelumnya tidak mengetahui isinya. Dengan properti ini berarti kita bisa selalu membaca stream gob yang disimpan dalam berkas, bahkan setelah kita lupa representasi dari datanya.

Ada juga beberapa hal yang kita pelajari dari pengalaman kita dengan protocol buffer dari Google.

Kesalahan protocol buffer

Protocol buffer (PB) berpengaruh besar dalam merancang gob, namun ia memiliki tiga fitur yang secara sengaja dihindari dalam merancang gob. (Kesampingkan bahwa properti dari PB itu sendiri tidaklah self-describing: jika kita tidak mengetahui definisi data yang digunakan untuk menulis sebuah PB, maka kita tidak akan bisa membacanya.)

Pertama, PB hanya bekerja pada tipe data yang kita sebut struct dalam Go. Kita tidak bisa menulis sebuah integer atau array pada level teratas, hanya sebuah struct dengan field-field di dalamnya. Hal ini tampak seperti batasan yang tidak berarti, setidaknya dalam Go. Jika kita hanya ingin mengirim sebuah array dari integer, kenapa harus disimpan dalam sebuah struct terlebih dahulu?

Selanjutnya, definisi dari sebuah PB bisa menspesifikasi bahwa field T.x dan T.y haruslah ada bila nilai bertipe T ditulis atau dibaca. Walaupun field-field yang dibutuhkan tersebut tampak seperti ide yang bagus, mereka cukup memakan biaya untuk diimplementasikan karena codec harus menjaga supaya struktur data terpisah saat ditulis dan dibaca, supaya dapat melaporkan kesalahan bila field-field yang dibutuhkan tersebut tidak ditemukan nilainya. Dan juga ada permasalahan pemeliharaan. Suatu waktu, kita bisa saja ingin mengubah definisi data, misalnya menghapus field yang dibutuhkan, namun hal ini menyebabkan klien-klien yang mengkonsumsi data yang belum memperbarui definisi datanya menjadi crash. Maka dari itu lebih baik bila kita tidak memiliki field-field yang dibutuhkan tersebut pada saat encoding sama sekali. (PB juga memiliki field-field opsional. Bila kita tidak menentukan field-field yang dibutuhkan, maka semua field adalah opsional dalam PB. Ada beberapa hal lain yang perlu kita jelaskan lagi nantinya.)

Kesalahan ketiga dari PB yaitu nilai default (bawaan). Jika PB mengindahkan nilai dari sebuah field yang memiliki nilai bawaan pada saat ditulis, maka field pada struktur akan di set dengan nilai bawaannya. Ide ini bekerja baik bila kita memiliki method-method getter dan setter untuk mengontrol akses terhadap field, namun akan lebih sulit ditangani secara bersih saat penampung adalah struct polos biasa. Field yang dibutuhkan juga cukup rumit diimplementasikan: kapan nilai bawaan didefinisikan, tipe apa yang dimilikinya (apakah teks berupa UTF-8? atau bytes yang tidak perlu diinterpretasi? berapa banyak bits dalam sebuah float?) dan bahkan dari kesederhanaannya, ada sejumlah komplikasi dalam rancangan dan implementasi PB. Kami memutuskan untuk tidak mengikutkannya pada gob dan kembali ke aturan biasa dari Go dengan aturan bawaan yang efektif: kecuali bila di set dengan nilai lain, ia akan berisi "nilai kosong" untuk tipe tersebut - dan ia tidak perlu dikirim.

Jadi gob akhirnya seperti generalisasi dan penyederhanaan dari protocol buffer. Bagaimana cara bekerjanya?

Nilai

Data gob yang ditulis bukanlah tipe seperti int8 atau uint16. Namun, secara analogi mirip dengan konstan dalam Go, nilai integer-nya yaitu bilangan tanpa ukuran dan abstrak, baik signed maupun unsigned. Saat kita menulis sebuah int8, nilainya dikirim berupa integer dengan panjang tertentu tanpa ukuran. Saat kita menulis sebuah int64, nilainya juga dikirim berupa integer dengan panjang tertentu dan tanpa ukuran. (Signed dan unsigned diperlakukan terpisah, namun perilaku tanpa-ukuran yang sama diterapkan pada nilai unsigned juga.) Jika keduanya bernilai 7, bits yang dikirim akan identik. Saat si penerima membaca nilai tersebut, ia menyimpannya ke dalam variabel, yang bisa bertipe integer apapun. Maka encoder bisa mengirim nilai 7 yang datang dari int8, namun si penerima bisa saja menyimpannya dalam sebuah int64. Hal ini wajar: nilainya adalah integer dan selama dapat disimpan, semua akan bekerja dengan baik (Jika tidak dapat disimpan, akan mengeluarkan eror.) Pemisahan ukuran dari variabel memberikan fleksibilitas pada proses encoding: kita dapat memperbesar tipe dari variabel integer saat program berkembang, namun masih tetap dapat membaca data yang lama.

Fleksibilitas ini juga berlaku pada pointer. Sebelum dikirim, semua pointer akan diratakan. Nilai dari tipe int8, *int8, **int8, ****int8, dan seterusnya, dikirim sebagai nilai integer, yang kemudian dapat disimpan dalam int berukuran apapun, atau *int, atau ****int, dan seterusnya. Sekali lagi, hal ini untuk fleksibilitas.

Fleksibilitas juga terjadi karena saat membaca sebuah struct, hanya field-field yang dikirim oleh encoder yang disimpan dalam tujuan. Misalnya diberikan nilai

type T struct{ X, Y, Z int } // Hanya field yang diekspor yang ditulis dan dibaca
var t = T{X: 7, Y: 0, Z: 8}

encoding dari t hanya mengirim 7 dan 8. Karena nilai Y adalah kosong, ia tidak dikirim; paket gob tidak perlu mengirim nilai yang kosong.

Si penerima bisa membaca nilai tersebut ke dalam struktur berikut:

type U struct{ X, Y *int8 } // Catatan: pointer ke int8
var u U

dan mendapatkan nilai u dengan hanya X yang diset (yang menunjuk alamat variabel int8 yang diset dengan 7); field Z diindahkan - mau disimpan di mana? Saat membaca struct, field dicocokan berdasarkan nama dan tipe yang kompatibel, dan hanya field-field yang ada dikeduanya yang terpengaruh. Pendekatan sederhana ini mengatasi permasalahan "field opsional": saat tipe T berkembang dengan menambahkan field baru, penerima yang ketinggalan masih dapat berfungsi dengan bagian tipe yang dikenali. Oleh karena itu gob menyediakan solusi dari permasalahan dari field opsional - ekstensibilitas - tanpa adanya mekanisme atau notasi tambahan.

Dari perilaku tipe dan nilai integer di atas kita dapat membangun semua tipe lainnya: byte, string, array, slice, map, bahkan float. Nilai floating-point direpresentasikan dengan pola bit floating-point IEEE 754, disimpan sebagai integer, yang bekerja baik selama kita tahu tipenya. Nilai integer tersebut dikirim dalam urutan byte-dibalik karena nilai bersama dari bilangan floating-point, seperti integer yang kecil, memiliki banyak nilai nol pada akhirannya yang dapat diindahkan saat pengiriman.

Salah satu fitur yang bagus dari gob ialah Go membolehkan kita mendifiniskan encoding sendiri dengan memenuhi interface GobEncoder dan GobDecoder, dengan cara yang sama seperti Marshaler dan Unmarshaler pada paket JSON dan juga seperti pada interface Stringer dari paket fmt. Fasilitas ini memungkinkan merepresentasikan fitur khusus, membuat batasan-batasan, atau merahasiakan sesuatu saat data ditransmisikan. Lihat dokumentasi dari paket gob untuk lebih rincinya.

Bentuk tipe saat ditransmisi

Pertama kali kita mengirim sebuah tipe tertentu, paket gob mengikutkan deskripsi dari tipe tersebut dalam stream data. Yang terjadi adalah encoder menulis struktur internal, dalam format standar encoding gob, yang menjelaskan tipe dan memberinya sebuah angka unik. (Tipe-tipe dasar, berikut dengan deskripsi tipe dari struct, didefinisikan terlebih dahulu oleh perangkat lunak untuk bootstrapping.) Setelah tipe dideskripsikan, ia bisa diacu dengan angka.

Maka saat kita mengirim tipe pertama kita T, encoder gob mengirim deskripsi dari T dan men-tag nya dengan angka, katakanlah 127. Semua nilai, termasuk yang pertama, diberi prefiks dengan angka tersebut, sehingga stream dari nilai T berbentuk seperti berikut:

("define type id" 127, definisi dari tipe T)(127, nilai T)(127, nilai T), ...

Angka-angka tersebut membuat kita bisa mendeskripsikan tipe rekursif dan mengirim nilainya. Sehingga gob dapat menulis tipe seperti tree berikut:

type Node struct {
	Value       int
	Left, Right *Node
}

(Latihan bagi pembaca untuk mengetahui bagaimana aturan nilai kosong bawaan bekerja, walaupun gob tidak merepresentasikan pointer.)

Dengan informasi tipe, sebuah stream dari gob secara penuh self-describing kecuali untuk sekumpulan tipe bootstrap, yang mana telah terdefinisi pada saat awal.

Mengkompilasi mesin

Pada saat pertama kali kita menulis sebuah nilai dari tipe tertentu, paket gob membuat sebuah interpretasi mesin khusus untuk tipe data tersebut. Ia menggunakan refleksi pada tipe untuk mengkonstruksi mesin tersebut, namun setelah mesin tersebut telah dibuat ia tidak bergantung lagi pada refleksi. Mesin tersebut menggunakan package unsafe dan beberapa trik untuk mengkonversi data menjadi byte dengan cepat. Ia bisa saja menggunakan refleksi dan mengindahkan unsafe, namun akan lebih lambat. (Pendekatan yang sama, yang juga cepat, digunakan oleh dukungan terhadap protocol buffer pada Go, yang rancangannya dipengaruhi oleh implementasi dari gob.) Nilai selanjutnya dari tipe yang sama menggunakan mesin yang telah dikompilasi, sehingga bisa langsung ditulis.

(Pembaruan: Pada Go 1.4, paket unsafe tidak lagi digunakan oleh paket gob, dengan penurunan performansi yang ringan.)

Proses decoding caranya sama namun lebih sukar. Saat membaca sebuah nilai, paket gob menyimpan slice byte yang merepresentasikan sebuah nilai yang merepresentasikan tipe yang didefinisikan oleh encoder untuk dibaca, ditambah dengan nilai di mana ia akan disimpan. Paket gob kemudian membuat sebuah mesin untuk pasangan tersebut: tipe gob yang dikirim disilangkan dengan dengan tipe Go yang disediakan untuk decoding. Setelah mesin decoding tersebut dibuat, ia tidak lagi menggunakan refleksi (yang menggunakan method-method pada unsafe) supaya lebih cepat.

Penggunaan

Ada banyak hal yang terjadi dibelakang penulisan dan pembacaan data dengan gob, namun hasilnya adalah sebuah sistem encoding yang efisien dan mudah digunakan untuk mengirim data. Berikut contoh komplit yang memperlihatkan perbedaan penulisan dan pembacaan dari beberapa tipe. Lihatlah bagaimana mudahnya mengirim dan menerima nilai; apa yang harus kita lakukan hanyalah memberi nilai dan variabel ke paket gob dan ia akan melakukan semuanya.

package main

import (
	"bytes"
	"encoding/gob"
	"fmt"
	"log"
)

type P struct {
	X, Y, Z int
	Name    string
}

type Q struct {
	X, Y *int32
	Name string
}

func main() {
	// Inisialiasi encoder dan decoder.  Biasanya enc dan dec akan terikat
	// dengan koneksi jaringan dan berjalan pada proses yang berbeda.
	var network bytes.Buffer        // Penampung koneksi jaringan
	enc := gob.NewEncoder(&network) // Akan menulis ke jaringan.
	dec := gob.NewDecoder(&network) // Akan membaca dari jaringan.

	// Encode (kirim) nilai.
	err := enc.Encode(P{3, 4, 5, "Pythagoras"})
	if err != nil {
		log.Fatal("encode error:", err)
	}

	// Decode (terima) nilainya.
	var q Q
	err = dec.Decode(&q)
	if err != nil {
		log.Fatal("decode error:", err)
	}
	fmt.Printf("%q: {%d,%d}\n", q.Name, *q.X, *q.Y)
}

Anda bisa meng-compile dan menjalankan contoh kode ini dalam Playground Go.

Paket rpc dibangun dari gob untuk mengubah otomatisasi tulis/baca seperti di atas ke dalam sebuah transpor pemanggilan method dalam jaringan.

Rincian

Dokumentasi paket gob, terutama berkas doc.go, menjelaskan lebih rinci dari apa yang dibahas di sini dan mengikutkan contoh lengkap yang memperlihatkan bagaimana encoding merepresentasikan data. Jika tertarik dengan dalaman dari implementasi gob, berkas tersebut adalah tempat yang bagus untuk memulai.