35. Menyimpan data

Server-server sejauh ini tidak mengingat apa pun. Setiap request dimulai dari awal, dan nilai formulir hilang ketika handler berakhir. Bab ini menyimpan data di memori, menemui masalah nyata dengan cara itu, lalu menyimpan data ke file supaya bertahan setelah server dimulai ulang.

Di mana data bisa tinggal

Variabel di dalam handler bertahan selama satu request, seperti yang dijelaskan Bab 21. Untuk menyimpan data di antara request, data harus tinggal di luar handler:

var notes = []note{}

Setiap handler sekarang bisa melihat slice yang sama. Ini memunculkan pertanyaan yang belum pernah perlu ditanyakan oleh program-program sebelumnya di buku ini.

Request terjadi pada waktu yang sama

Program-programmu sebelumnya mengerjakan satu hal dalam satu waktu. Server tidak begitu. net/http menjalankan setiap request pada waktu yang sama dengan yang lain. Dua pengunjung yang mengklik pada saat yang sama dilayani pada saat yang sama, oleh fungsi handler yang sama, memakai variabel tingkat package yang sama.

Variabel lokal aman, karena setiap pemanggilan punya miliknya sendiri. Variabel bersama tidak aman. Ambil contoh sebuah penghitung:

visits = visits + 1

Itu tampak seperti satu langkah, tetapi komputer membaca nilainya, menambah 1, lalu menyimpannya. Ini penelusuran, seperti di Bab 04, dari dua request ketika visits dimulai dari 10:

Request A Request B visits
membaca 10 10
membaca 10 10
menyimpan 11 11
menyimpan 11 11

Dua kunjungan, dan hitungannya hanya naik satu. Ini disebut data race. Masalah ini sulit ditemukan, karena hanya terjadi ketika waktunya sedang sial. Dengan satu pengunjung yang mengklik, kamu tidak akan pernah melihatnya. Sebuah uji dengan perlindungan di bawah ini dibuang, mengirim 300 request dengan 16 sekaligus, memberikan You are visitor number 297. padahal jawaban yang benar adalah 301, dan angkanya berbeda dari satu kali jalan ke kali berikutnya. Slice lebih parah daripada penghitung: dua pemanggilan append bisa menghilangkan satu pesan, atau hasilnya bisa berupa sesuatu yang tidak ditulis oleh keduanya.

Bab-bab sebelumnya tidak membutuhkan semua ini, karena handler-nya hanya membaca nilai yang tidak pernah berubah. Berbagi itu aman kalau tidak ada yang berubah. Mulai bab ini, handler mengubah data, jadi data itu perlu dilindungi.

Aturan mutex

Mutex dari package sync adalah kunci yang hanya bisa dipegang satu request dalam satu waktu. Yang lain yang memintanya menunggu sampai kunci itu bebas:

// mu protects visits.
var mu sync.Mutex
var visits = 0

func nextVisit() int {
    mu.Lock()
    defer mu.Unlock()

    visits = visits + 1
    return visits
}

Sekarang penelusuran di atas tidak bisa terjadi, karena request B tidak bisa membaca sampai request A selesai. Inilah aturan yang diikuti di buku ini:

  1. Taruh mutex di sebelah data yang dilindunginya, dengan komentar yang mengatakan begitu.
  2. Sentuh data hanya di fungsi-fungsi kecil yang dimulai dengan dua baris ini: mu.Lock() dan defer mu.Unlock().
  3. Handler memanggil fungsi-fungsi itu, dan tidak pernah menyentuh data atau mutex sendiri.

Aturan ini berlaku untuk membaca maupun menulis, karena pembacaan bisa bertumpang tindih dengan penulisan oleh request lain. Masih banyak yang bisa dibahas tentang menjalankan banyak hal pada waktu yang sama, dan itu satu bidang tersendiri di luar buku ini. Aturan kecil ini cukup untuk semua program di sini.

Fungsi yang memegang kunci tidak boleh memanggil fungsi lain yang mengunci mutex yang sama. Yang kedua menunggu yang pertama, dan yang pertama menunggu yang kedua, selamanya. Tidak ada pesan error. Request itu tidak pernah selesai, begitu juga request berikutnya yang butuh kunci itu. Browser menampilkan halaman yang terus memuat.

defer

defer menunda sebuah pemanggilan sampai fungsi di sekitarnya berakhir, bagaimanapun cara berakhirnya. Ini exercises/35/01-defer.go:

// Chapter 35 example: defer runs a call when the function ends.
// Run with: go run exercises/35/01-defer.go

package main

import "fmt"

func check(n int) {
    fmt.Println("Start")
    defer fmt.Println("End")

    if n < 0 {
        fmt.Println("Negative, so return early")
        return
    }
    fmt.Println("Not negative")
}

func main() {
    check(5)
    check(-5)
}

Keluaran:

Start
Not negative
End
Start
Negative, so return early
End

End tercetak terakhir pada kedua pemanggilan, bahkan pada return awal. Itulah sebabnya defer cocok untuk kunci. defer mu.Unlock() berada tepat sesudah mu.Lock(), dan kuncinya dilepas di setiap jalur keluar dari fungsi. Tanpa itu, satu Unlock yang terlupa sebelum satu return awal akan membekukan server.

Catatan di memori

Ini halaman catatan yang berjalan, exercises/35/02-notes-in-memory.go. Bagian yang mengurus datanya:

// mu protects notes. Any code that reads or changes notes must hold mu.
var mu sync.Mutex
var notes = []note{}

// allNotes returns a copy, so that the caller does not need mu to read it.
func allNotes() []note {
    mu.Lock()
    defer mu.Unlock()

    return slices.Clone(notes)
}

func addNote(n note) {
    mu.Lock()
    defer mu.Unlock()

    notes = append(notes, n)
}

slices.Clone membuat slice baru dengan item yang sama. Handler lalu bisa membangun halaman dengan santai, memakai salinannya sendiri, sesudah kuncinya dilepas.

Handler-nya mirip dengan yang ada di Bab 33 dan Bab 34: POST /notes membaca, memeriksa, memanggil addNote, dan melakukan redirect. GET / memberikan allNotes() ke template. Teks dari pengunjung ditampilkan oleh html/template, jadi di-escape. Cobalah:

curl.exe -i -d "text=Buy rice" http://localhost:8080/notes
HTTP/1.1 303 See Other
Location: /

curl.exe http://localhost:8080/
<li>Buy rice</li>

curl.exe -i -d "text=" http://localhost:8080/notes
HTTP/1.1 400 Bad Request
The note is empty.

Sekarang hentikan server dengan Ctrl + C, jalankan lagi, dan muat ulang:

curl.exe http://localhost:8080/
<li>No notes yet.</li>

Catatannya hilang. Variabel tinggal di memori program yang sedang berjalan, dan ketika program berakhir, memorinya juga berakhir. Untuk menyimpan data, data harus masuk ke file.

Jalankan exercises/35/02-notes-in-memory.go, tambahkan dua catatan di browser, dan muat ulang. Lalu mulai ulang server dan muat ulang lagi.

JSON

File menyimpan teks atau byte, bukan struct. JSON adalah format teks yang umum untuk data, dan encoding/json mengubahnya ke dua arah. Ini exercises/35/03-json-basics.go, yang tidak punya server dan tidak menulis file:

// Chapter 35 example: turning a slice of structs into JSON and back.
// This program has no server, and writes no file.
// Run with: go run exercises/35/03-json-basics.go

package main

import (
    "encoding/json"
    "fmt"
)

type note struct {
    Text string
}

func main() {
    notes := []note{{Text: "Buy rice"}, {Text: "Call Arif"}}

    data, err := json.MarshalIndent(notes, "", "  ")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println(string(data))

    var loaded []note
    err = json.Unmarshal(data, &loaded)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println(loaded)

    err = json.Unmarshal([]byte("this is not JSON"), &loaded)
    if err != nil {
        fmt.Println("Error:", err)
    }
}

Keluaran:

[
  {
    "Text": "Buy rice"
  },
  {
    "Text": "Call Arif"
  }
]
[{Buy rice} {Call Arif}]
Error: invalid character 'h' in literal true (expecting 'r')
  • json.MarshalIndent mengubah nilai menjadi JSON, dengan pindah baris dan indentasi. Fungsi ini mengembalikan []byte, slice berisi byte mentah, dan error. string(data) membuatnya bisa dicetak.
  • json.Unmarshal berjalan ke arah sebaliknya. Fungsi ini menerima byte, dan pointer ke variabel yang akan diisi, karena dia harus mengubah variabelmu, seperti di Bab 27.
  • Fungsi itu memberikan error untuk teks yang bukan JSON. Kata-katanya yang aneh berasal dari t pada this: dia berharap menemukan kata true.

Field juga harus diekspor di sini. Dengan field bernama text, package itu tidak bisa melihatnya, dan tidak memberikan error. Dia menulis {} untuk setiap catatan, dan datamu hilang tanpa peringatan. Jadi pakai huruf kapital pada struct apa pun yang kamu simpan.

Membaca dan menulis file

Dua fungsi dari os melakukan pekerjaannya. os.ReadFile mengembalikan seluruh file sebagai byte, dan error. os.WriteFile menulis byte ke file, menggantikan isi sebelumnya. Ini exercises/35/04-notes-file.go, lagi-lagi tanpa server. Setiap kali jalan, dia menambah satu catatan:

// Chapter 35 example: loading and saving a JSON file, with no server. Each run
// adds one note. It reads and writes notes.json in the folder you run it from.
// Run with: go run exercises/35/04-notes-file.go

package main

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

const notesFile = "notes.json"

type note struct {
    Text string
}

// loadNotes reads the file. A file that does not exist yet means no notes, which
// is normal on the first run. Any other problem is an error.
func loadNotes() ([]note, error) {
    data, err := os.ReadFile(notesFile)
    if errors.Is(err, os.ErrNotExist) {
        return []note{}, nil
    }
    if err != nil {
        return nil, err
    }

    var loaded []note
    err = json.Unmarshal(data, &loaded)
    if err != nil {
        return nil, fmt.Errorf("%s is not valid: %v", notesFile, err)
    }
    return loaded, nil
}

// saveNotes writes a temporary file, then renames it over the real one.
func saveNotes(list []note) error {
    data, err := json.MarshalIndent(list, "", "  ")
    if err != nil {
        return err
    }

    temporary := notesFile + ".tmp"
    err = os.WriteFile(temporary, data, 0644)
    if err != nil {
        return err
    }
    return os.Rename(temporary, notesFile)
}

func main() {
    notes, err := loadNotes()
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Notes found:", len(notes))

    notes = append(notes, note{Text: "Another note"})

    err = saveNotes(notes)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Notes saved:", len(notes))
}

Program ini membaca dan menulis notes.json di folder tempat kamu menjalankannya, yaitu go-work kalau kamu mengikuti perintah yang biasa. Dua kali jalan:

go run exercises/35/04-notes-file.go
Notes found: 0
Notes saved: 1

go run exercises/35/04-notes-file.go
Notes found: 1
Notes saved: 2

Tiga bagian dari ini perlu diperhatikan.

File yang tidak ada itu normal. Pada jalan pertama belum ada file, dan itu bukan kegagalan. errors.Is(err, os.ErrNotExist) bertanya apakah error ini berarti "file tidak ada". Satu kasus itu dianggap sebagai daftar kosong. Error lain, seperti tidak punya izin membaca file, tetap dilaporkan.

File yang rusak itu tidak normal. Kalau file rusak, program tidak boleh melanjutkan dengan daftar kosong, karena penyimpanan berikutnya akan mengganti semua datamu dengan kekosongan. Jadi dia melaporkan masalahnya dan berhenti:

Error: notes.json is not valid: invalid character 'o' looking for beginning of value

Penyimpanan melewati file sementara. os.WriteFile mengganti isi lama sambil menulis. Kalau program crash di tengah jalan, atau disk penuh, file aslinya akan tertinggal terpotong. Jadi saveNotes menulis semuanya ke notes.json.tmp lebih dulu, lalu os.Rename memindahkannya menimpa nama aslinya, dalam satu langkah. Pada saat apa pun, file aslinya adalah seluruh versi lama atau seluruh versi baru.

Terus terang tentang apa yang tidak dilakukan cara ini: cara ini melindungi dari crash programmu di tengah penyimpanan. Cara ini bukan jaminan penuh terhadap mati listrik, dan tidak mencegah program lain menulis file yang sama. Cara ini menulis ulang seluruh file setiap kali menyimpan, yang menjadi lambat kalau datanya banyak. Sistem sungguhan memakai database untuk ini. Untuk program kecil dengan satu server, ini cukup.

Angka 0644 adalah izin file, yang berpengaruh di Linux dan macOS. Tulis apa adanya. Di Windows pengaruhnya kecil.

Catatan yang bertahan setelah dimulai ulang

exercises/35/05-notes-saved.go menggabungkan semuanya. Ini 02-notes-in-memory.go dengan fungsi file dari 04-notes-file.go, dan addNote sekarang menyimpan:

// addNote saves first, and changes the slice only if saving worked. That way the
// slice and the file never disagree.
func addNote(n note) error {
    mu.Lock()
    defer mu.Unlock()

    updated := append(slices.Clone(notes), n)
    err := saveNotes(updated)
    if err != nil {
        return err
    }
    notes = updated
    return nil
}

File juga dibagi bersama, jadi penyimpanan terjadi selagi kunci dipegang. Hanya satu request dalam satu waktu yang bisa menulisnya, dan nama sementaranya tidak bisa bentrok. Handler mengubah kegagalan menyimpan menjadi status 500, karena ini masalah server dan bukan masalah pengunjung:

err := addNote(note{Text: text})
if err != nil {
    fmt.Println("Save error:", err)
    http.Error(w, "The note could not be saved.", http.StatusInternalServerError)
    return
}

main memuat catatan sebelum memulai server:

func main() {
    loaded, err := loadNotes()
    if err != nil {
        fmt.Println("Could not load the notes:", err)
        fmt.Println("Fix or remove the file, then start the server again.")
        return
    }
    notes = loaded

Jalankan dari go-work, dan tambahkan dua catatan. Hentikan, lalu jalankan lagi. Catatannya masih ada, dan notes.json ada di folder go-work-mu:

[
  {
    "Text": "Buy rice"
  },
  {
    "Text": "Call Arif"
  }
]

Hasilnya ketika filenya rusak, berisi this is not JSON, dan ketika kosong:

Could not load the notes: notes.json is not valid: invalid character 'h' in literal true (expecting 'r')
Fix or remove the file, then start the server again.

Could not load the notes: notes.json is not valid: unexpected end of JSON input
Fix or remove the file, then start the server again.

Hapus notes.json selagi server berhenti, dan server dimulai dengan daftar kosong.

Catatan yang memuat <script> juga aman di dalam file. Isinya disimpan sebagai teks, dan hanya sampai ke halaman lewat template. encoding/json juga menulis < di file sebagai \u003c, dan membacanya kembali dengan benar, jadi jangan heran kalau kamu membuka file dan melihat itu.

Go bisa memeriksa data race. go run -race <file> menjalankan program dengan pendeteksi, yang mencetak WARNING: DATA RACE ketika dua request menyentuh data yang sama tanpa kunci. Di Windows, ini butuh compiler C, yang tidak dipasang oleh buku ini, jadi kamu mungkin mendapat error go: -race requires cgo; enable cgo by setting CGO_ENABLED=1. Kalau begitu, ikuti aturan di atas. Semua program di bagian ini yang berbagi data yang berubah sudah diuji dengan pendeteksi itu di Linux, dengan banyak request sekaligus, dan tidak ada race yang dilaporkan.

Jalankan exercises/35/05-notes-saved.go, tambahkan satu catatan, lalu hentikan. Buka notes.json di VS Code, tambahkan satu huruf nyasar di awal, dan coba jalankan server. Perbaiki filenya lalu jalankan lagi.

PR

Starter PR ada di exercises/35/homework/. Masing-masing bisa langsung dijalankan, dengan sebuah pengganti. File data apa pun dibuat di folder tempat kamu menjalankannya, yaitu go-work. Hapus untuk memulai lagi.

Soal 1 — Hewan peliharaan dalam file

Buka exercises/35/homework/01-pets-file.go. Program ini tidak punya server. Tulis loadPets dan savePets untuk file pets.json. File yang tidak ada berarti slice kosong, dan file yang rusak adalah error. main sudah selesai: setiap kali jalan menambah satu hewan, dan menyimpan.

Soal 2 — Penghitung kunjungan

Buka exercises/35/homework/02-visit-counter.go. GET / menjawab dengan You are visitor number N., dan setiap request menambah 1. Lindungi hitungannya dengan sync.Mutex. Tulis fungsi nextVisit() int yang melakukan penguncian.

Soal 3 — Penghitung yang mengingat

Buka exercises/35/homework/03-saved-counter.go. Simpan hitungan ke visits.json pada setiap kunjungan, dan muat ketika server dimulai. File yang tidak ada berarti hitungan 0. File yang rusak menghentikan server di awal, dengan pesan yang jelas. Kalau penyimpanan gagal, pengunjung mendapat status 500, dan hitungannya tetap seperti semula.

Tantangan — Daftar tugas

Buka exercises/35/homework/04-todo-list.go. Tulis rute-rute ini:

Rute Yang dilakukan
GET / Menampilkan daftar dan formulir.
POST /tasks Menambah tugas dari nilai formulir name.
POST /delete/{n} Menghapus tugas pada indeks n, dihitung dari 0.

Kedua rute POST melakukan redirect ke / dengan status 303. Nama yang kosong atau panjang menjawab dengan status 400. n yang bukan bilangan bulat menjawab dengan 400, dan n yang tidak punya tugas yang cocok menjawab dengan 404. Simpan daftar ke todo.json, muat di awal, dan lindungi dengan mutex. Setiap tugas di halaman punya formulir kecilnya sendiri, dengan action-nya sendiri, untuk menghapusnya. Di template, {{range $i, $task := .}} memberimu indeks sekaligus itemnya.

Stuck atau sudah selesai? Buka halaman solusi PR.