Golang Dasar: Dari Nol hingga CLI Production-Ready8/10

Bab 8 dari 10

File I/O, JSON, dan Persistensi CLI

Tujuan Pembelajaran

Setelah menyelesaikan bab ini, Anda mampu:

  • membaca dan menulis file dengan pustaka standar;
  • mengubah struct menjadi JSON dan sebaliknya;
  • membedakan file tidak ada dari kegagalan I/O lain;
  • menyimpan data CLI secara atomik;
  • menangani path, permission, dan error dengan aman.

1. Membaca dan Menulis File

Untuk file kecil yang harus dimuat seluruhnya, gunakan os.ReadFile dan os.WriteFile.

Code
package main

import (
	"fmt"
	"log"
	"os"
)

func main() {
	const path = "message.txt"
	if err := os.WriteFile(path, []byte("Halo, Go!\n"), 0o600); err != nil {
		log.Fatal(err)
	}

	data, err := os.ReadFile(path)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(string(data))
}

Mode 0o600 memberi akses baca-tulis kepada pemilik. Permission aktual dapat dipersempit oleh umask. Untuk data pengguna yang mungkin sensitif, jangan memakai mode yang dapat dibaca semua pengguna.

Untuk data besar atau streaming, gunakan os.Open, bufio.Scanner, bufio.Reader, atau io.Copy. File yang dibuka harus ditutup.

Code
f, err := os.Open("input.txt")
if err != nil {
	return err
}
defer f.Close()

Pada file output, error Close dapat penting karena flush terakhir bisa gagal. Tangani secara eksplisit jika kehilangan data berisiko.

2. JSON dan Struct Tags

Package encoding/json memetakan field struct yang diekspor. Tag mengatur nama key.

Code
package main

import (
	"encoding/json"
	"fmt"
	"log"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func main() {
	tasks := []Task{{ID: 1, Title: "Belajar JSON", Done: false}}
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}

Untuk membaca JSON yang tidak dipercaya, decoder dapat menolak field asing:

Code
decoder := json.NewDecoder(r)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
	return fmt.Errorf("decode JSON: %w", err)
}

Marshal hanya gagal untuk nilai yang tidak didukung, seperti channel, function, atau siklus pointer. Tetap tangani error.

3. Memuat Data dengan Semantik yang Jelas

File belum ada dapat berarti aplikasi baru, bukan kegagalan.

Code
func load(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return []Task{}, nil
	}
	if err != nil {
		return nil, fmt.Errorf("baca %q: %w", path, err)
	}
	if len(data) == 0 {
		return []Task{}, nil
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, fmt.Errorf("decode %q: %w", path, err)
	}
	return tasks, nil
}

Jangan memperlakukan permission denied atau kerusakan disk sebagai “data kosong”; tindakan tersebut menyembunyikan masalah dan berpotensi menimpa data.

4. Penulisan Atomik

os.WriteFile langsung ke file tujuan dapat meninggalkan file terpotong bila proses berhenti. Pola yang lebih aman adalah menulis file sementara pada direktori yang sama, menyinkronkan, menutup, lalu mengganti nama.

Memuat diagram…

Program lengkap berikut menyediakan penyimpanan JSON sederhana:

Code
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func Load(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return []Task{}, nil
	}
	if err != nil {
		return nil, fmt.Errorf("baca %q: %w", path, err)
	}
	if len(data) == 0 {
		return []Task{}, nil
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, fmt.Errorf("decode %q: %w", path, err)
	}
	return tasks, nil
}

func Save(path string, tasks []Task) (err error) {
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return fmt.Errorf("encode tugas: %w", err)
	}
	data = append(data, '\n')

	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return fmt.Errorf("buat direktori %q: %w", dir, err)
	}

	tmp, err := os.CreateTemp(dir, ".tasks-*.tmp")
	if err != nil {
		return fmt.Errorf("buat file sementara: %w", err)
	}
	tmpName := tmp.Name()
	defer func() {
		tmp.Close()
		os.Remove(tmpName)
	}()

	if err := tmp.Chmod(0o600); err != nil {
		return fmt.Errorf("atur permission: %w", err)
	}
	if _, err := tmp.Write(data); err != nil {
		return fmt.Errorf("tulis file sementara: %w", err)
	}
	if err := tmp.Sync(); err != nil {
		return fmt.Errorf("sinkronkan file sementara: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("tutup file sementara: %w", err)
	}
	if err := os.Rename(tmpName, path); err != nil {
		return fmt.Errorf("ganti %q: %w", path, err)
	}
	return nil
}

func main() {
	path := filepath.Join(os.TempDir(), "taskcli-example", "tasks.json")
	tasks := []Task{{ID: 1, Title: "Simpan atomik"}}
	if err := Save(path, tasks); err != nil {
		panic(err)
	}
	loaded, err := Load(path)
	if err != nil {
		panic(err)
	}
	fmt.Println(loaded[0].Title)
}

Rename atomik secara umum jika sumber dan tujuan berada pada filesystem yang sama; karena itu file sementara dibuat di direktori tujuan. Jaminan detail tetap bergantung pada sistem operasi dan filesystem. Untuk ketahanan terhadap kehilangan daya yang lebih ketat, sinkronisasi direktori setelah rename dapat diperlukan.

5. Menentukan Lokasi Data CLI

Hindari menulis data tetap di current working directory karena direktori tersebut berubah sesuai cara program dijalankan. Gunakan direktori konfigurasi pengguna:

Code
func defaultPath() (string, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("tentukan direktori konfigurasi: %w", err)
	}
	return filepath.Join(dir, "taskcli", "tasks.json"), nil
}

Izinkan flag seperti -file agar pengujian dan otomasi dapat memilih lokasi lain. Jangan membangun path dengan penggabungan string; gunakan filepath.Join.

6. Batas Persistensi JSON

JSON sesuai untuk CLI pengguna tunggal dengan data kecil. Ia bukan solusi ideal untuk banyak proses yang menulis bersamaan, query kompleks, atau dataset besar. Penulisan atomik mencegah file setengah jadi, tetapi tidak mencegah lost update antara dua proses. Pada kebutuhan tersebut, tambahkan locking yang sesuai platform atau beralih ke database transaksional.

Kesalahan Umum

  1. Mengabaikan error I/O atau JSON. Selalu beri konteks dengan %w.
  2. Menimpa file ketika decode gagal. Hentikan operasi dan laporkan kerusakan data.
  3. Membuat temp file di filesystem lain. Rename dapat gagal dan kehilangan sifat atomik.
  4. Permission terlalu longgar. Gunakan 0o600 untuk file data pribadi dan 0o700 untuk direktori.
  5. Lupa menutup file. Gunakan defer untuk input; tangani error close pada output penting.
  6. Mengandalkan current working directory. Gunakan direktori pengguna atau flag eksplisit.
  7. Menganggap JSON sebagai database multiwriter. Tambahkan koordinasi atau gunakan penyimpanan transaksional.

Latihan

  1. Tambahkan field CreatedAt time.Time dan amati representasi JSON-nya.
  2. Gunakan json.Decoder dengan DisallowUnknownFields untuk memuat file.
  3. Tulis fungsi Backup(path string) yang menyalin data lama sebelum perubahan.
  4. Uji Load untuk file tidak ada, kosong, JSON rusak, dan JSON valid.
  5. Tambahkan flag -file ke CLI kecil dan gunakan t.TempDir() dalam pengujian.

Ringkasan

  • Pilih API file sesuai ukuran dan pola akses data.
  • Struct tag mengontrol format JSON; semua kegagalan encode/decode harus ditangani.
  • Bedakan file tidak ada dari error I/O lain.
  • Temp-file, Sync, Close, dan Rename mengurangi risiko korupsi saat menyimpan.
  • JSON cocok untuk persistensi sederhana, bukan konkurensi penulis tanpa koordinasi.
File I/O, JSON, dan Persistensi CLI · Golang Dasar: Dari Nol hingga CLI Production-Ready