Aplikasi hidup bertahun-tahun, tetapi data yang sudah tersimpan tidak ikut menulis ulang dirinya setiap kali struct berubah. Berkas transaksi tahun lalu harus tetap terbaca oleh versi program hari ini, dan output program hari ini harus masih dipahami rekan lama yang belum sempat upgrade. Untungnya encoding/json dirancang toleran terhadap perubahan bentuk, dan toleransi tersebut bisa kita susun menjadi strategi. Pada chapter ini kita akan membuktikan dua arah kompatibilitas dengan program nyata, lalu menyusun daftar kebiasaan yang menjaga format tetap hidup.
Dua Arah Toleransi encoding/json #
Toleransi json.Unmarshal bekerja pada dua arah yang berbeda. Pertama, data lama yang tidak memuat field baru tetap bisa didecode: field yang hilang dibiarkan bernilai zero tanpa galat. Kedua, data baru yang memuat field asing tetap bisa didecode oleh struct lama: field asing diabaikan diam-diam. Kombinasi keduanya membuat penambahan field menjadi perubahan yang aman untuk pembaca lama maupun baru.
Prinsip Perubahan Additive #
Aturan intinya sederhana: tambah, jangan ubah dan jangan hapus. Menambah field baru tidak merusak pembaca lama karena field asing diabaikan; pembaca baru terhadap data lama juga aman karena field baru cukup bernilai zero. Sebaliknya, mengubah nama field atau mengubah tipe field mematahkan kedua arah sekaligus: pembaca lama kehilangan data, pembaca baru menerima galat UnmarshalTypeError. Penghapusan field lebih lunak — data lama tetap terbaca, fieldnya hanya diabaikan — tetapi bagian data itu hilang dari proses.
Program Pembuktian #
Program kompatibilitas-demo.go berikut memperagakan tiga generasi kontrak data dalam satu berkas:
package main
import (
"encoding/json"
"fmt"
)
// KontrakV1: bentuk data yang dirilis paling awal.
type KontrakV1 struct {
ID string `json:"id"`
Pelanggan string `json:"pelanggan"`
Total float64 `json:"total"`
}
// KontrakV2: menambah field metode dan versi tanpa mengubah field lama.
type KontrakV2 struct {
ID string `json:"id"`
Pelanggan string `json:"pelanggan"`
Total float64 `json:"total"`
Metode string `json:"metode"`
Versi int `json:"versi"`
}
// KontrakV3: field total tidak dipakai lagi, tetapi kontrak tetap bisa
// membaca data lama maupun baru.
type KontrakV3 struct {
ID string `json:"id"`
Pelanggan string `json:"pelanggan"`
Metode string `json:"metode"`
Versi int `json:"versi"`
}
func main() {
// Data lama dibaca struct baru: field baru bernilai zero, tanpa galat.
dataLama := `{"id":"P-100","pelanggan":"Budi","total":250000}`
var v2 KontrakV2
if err := json.Unmarshal([]byte(dataLama), &v2); err != nil {
fmt.Println("decode gagal:", err)
return
}
fmt.Printf("V2 baca data lama : %+v\n", v2)
// Data baru dibaca struct lama: field tambahan diabaikan, tanpa galat.
dataBaru := `{"id":"P-101","pelanggan":"Siti","total":99000,"metode":"transfer","versi":2}`
var v1 KontrakV1
if err := json.Unmarshal([]byte(dataBaru), &v1); err != nil {
fmt.Println("decode gagal:", err)
return
}
fmt.Printf("V1 baca data baru : %+v\n", v1)
// Data lama tanpa field metode dibaca struct V3: metode kosong.
var v3 KontrakV3
if err := json.Unmarshal([]byte(dataLama), &v3); err != nil {
fmt.Println("decode gagal:", err)
return
}
fmt.Printf("V3 baca data lama : %+v\n", v3)
// Field versi untuk percabangan penanganan data.
var v3b KontrakV3
if err := json.Unmarshal([]byte(dataBaru), &v3b); err != nil {
fmt.Println("decode gagal:", err)
return
}
switch v3b.Versi {
case 0:
fmt.Println("format tanpa nomor versi: perlakukan sebagai V1")
case 2:
fmt.Printf("format versi %d: metode %q tersedia\n", v3b.Versi, v3b.Metode)
}
// Menulis data: struct lama tetap menghasilkan JSON yang dikenali
// pembaca lama maupun baru.
keluaran, _ := json.Marshal(KontrakV2{ID: "P-102", Pelanggan: "Rina", Total: 15000, Metode: "tunai", Versi: 2})
fmt.Printf("JSON keluaran V2 : %s\n", keluaran)
}Output yang Dihasilkan:
V2 baca data lama : {ID:P-100 Pelanggan:Budi Total:250000 Metode: Versi:0}
V1 baca data baru : {ID:P-101 Pelanggan:Siti Total:99000}
V3 baca data lama : {ID:P-100 Pelanggan:Budi Metode: Versi:0}
format versi 2: metode "transfer" tersedia
JSON keluaran V2 : {"id":"P-102","pelanggan":"Rina","total":15000,"metode":"tunai","versi":2}Mari kita telaah baris demi baris program di atas:
- Baris 9-13 (KontrakV1): kontrak generasi pertama dengan tiga field.
- Baris 16-22 (KontrakV2): kontrak generasi kedua menambah field
metodedanversitanpa mengubah maupun menghapus field lama — perubahan additive murni. - Baris 26-31 (KontrakV3): kontrak generasi ketiga berhenti memakai
total; field tersebut hilang dari struct tetapi data lama yang memuatnya tetap terbaca. - Baris 35-41: data lama (tanpa
metodedanversi) didecode ke struct V2; hasilnyaMetodekosong danVersibernilai nol tanpa galat. - Baris 44-50: data baru didecode ke struct V1; field
metodedanversidiabaikan, ketiga field lama terisi normal. - Baris 53-58: data lama didecode ke struct V3; field
totalpada data diabaikan karena struct tidak lagi mengenalnya. - Baris 61-71: field
versimenjadi penanda format — nilai nol berarti data era V1, nilai dua berarti era V2; percabangan menyesuaikan penanganan. - Baris 75-76: struct V2 menghasilkan JSON yang mengandung field lama dan baru sekaligus, sehingga pembaca dari generasi mana pun tetap menemukan bagian yang dikenalnya.
Kontrak Publik Bukan Struct Internal #
Struct internal bebas berubah mengikuti kebutuhan kode; kontrak data publik tidak. Kesalahan yang sering terjadi adalah memakai satu struct yang sama untuk tabel basis data, response API, dan input handler — setiap perubahan internal otomatis menjadi perubahan kontrak. Kebiasaan yang sehat: pisahkan struct kontrak publik (punya tag, divalidasi, dijaga stabil) dari struct internal (bebas berbentuk), lalu salin nilainya secara eksplisit di lapisan batas. Tabel berikut merangkum kebiasaan tersebut:
| Lakukan | Hindari |
|---|---|
| Menambah field baru dengan nilai zero yang wajar | Mengganti nama atau tipe field |
| Menandai versi format pada data | Mengandalkan tanggal berkas sebagai penanda versi |
| Mempertahankan field usang beberapa rilis sebelum benar-benar dihapus | Menghapus field begitu kode internal berhenti memakainya |
| Struct kontrak publik terpisah dari struct internal | Satu struct dipakai lintas lapisan sekaligus |
| Validasi hasil decode di lapisan batas | Memercayai bentuk data dari luar begitu decode berhasil |
Menandai Versi Data #
Field versi pada program adalah bentuk paling ringan dari versioning data: satu angka yang disimpan bersama data itu sendiri. Ketika perbedaan antar generasi makin besar, strategi naik ke versi di tingkat API — /v2/pesanan yang memuat kontrak berbeda dari /v1/pesanan — sebagaimana dibahas pada chapter API Versioning
. Prinsipnya sama: penanda versi membuat pembaca tahu aturan mana yang sedang dihadapi sebelum mulai mengurai.
Referensi #
- Dokumentasi package encoding/json — aturan resmi perilaku field tak dikenal dan nilai zero.
- Go 1 Compatibility Promise — jaminan stabilitas API standard library.
- Dokumentasi package encoding — gambaran umum keluarga format encoding.
Kita sudah menempuh seluruh perjalanan encoding: JSON sebagai bahasa utama, XML dan CSV untuk dunia lama dan data tabular, gob untuk kanal internal Go, lalu custom marshaler, struct tag, validasi, dan strategi kompatibilitas. Dengan bekal tersebut, materi HTTP dan REST API pada section berikutnya menjadi tempat alami bagi seluruh teknik ini.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
kompatibilitas-demo.go
Unduh berkaspackage main
import (
"encoding/json"
"fmt"
)
// KontrakV1: bentuk data yang dirilis paling awal.
type KontrakV1 struct {
ID string `json:"id"`
Pelanggan string `json:"pelanggan"`
Total float64 `json:"total"`
}
// KontrakV2: menambah field metode dan versi tanpa mengubah field lama.
type KontrakV2 struct {
ID string `json:"id"`
Pelanggan string `json:"pelanggan"`
Total float64 `json:"total"`
Metode string `json:"metode"`
Versi int `json:"versi"`
}
// KontrakV3: field total tidak dipakai lagi, tetapi kontrak tetap bisa
// membaca data lama maupun baru.
type KontrakV3 struct {
ID string `json:"id"`
Pelanggan string `json:"pelanggan"`
Metode string `json:"metode"`
Versi int `json:"versi"`
}
func main() {
// Data lama dibaca struct baru: field baru bernilai zero, tanpa galat.
dataLama := `{"id":"P-100","pelanggan":"Budi","total":250000}`
var v2 KontrakV2
if err := json.Unmarshal([]byte(dataLama), &v2); err != nil {
fmt.Println("decode gagal:", err)
return
}
fmt.Printf("V2 baca data lama : %+v\n", v2)
// Data baru dibaca struct lama: field tambahan diabaikan, tanpa galat.
dataBaru := `{"id":"P-101","pelanggan":"Siti","total":99000,"metode":"transfer","versi":2}`
var v1 KontrakV1
if err := json.Unmarshal([]byte(dataBaru), &v1); err != nil {
fmt.Println("decode gagal:", err)
return
}
fmt.Printf("V1 baca data baru : %+v\n", v1)
// Data lama tanpa field metode dibaca struct V3: metode kosong.
var v3 KontrakV3
if err := json.Unmarshal([]byte(dataLama), &v3); err != nil {
fmt.Println("decode gagal:", err)
return
}
fmt.Printf("V3 baca data lama : %+v\n", v3)
// Field versi untuk percabangan penanganan data.
var v3b KontrakV3
if err := json.Unmarshal([]byte(dataBaru), &v3b); err != nil {
fmt.Println("decode gagal:", err)
return
}
switch v3b.Versi {
case 0:
fmt.Println("format tanpa nomor versi: perlakukan sebagai V1")
case 2:
fmt.Printf("format versi %d: metode %q tersedia\n", v3b.Versi, v3b.Metode)
}
// Menulis data: struct lama tetap menghasilkan JSON yang dikenali
// pembaca lama maupun baru.
keluaran, _ := json.Marshal(KontrakV2{ID: "P-102", Pelanggan: "Rina", Total: 15000, Metode: "tunai", Versi: 2})
fmt.Printf("JSON keluaran V2 : %s\n", keluaran)
}