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 + 1Itu 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:
- Taruh mutex di sebelah data yang dilindunginya, dengan komentar yang mengatakan begitu.
- Sentuh data hanya di fungsi-fungsi kecil yang dimulai dengan dua
baris ini:
mu.Lock()dandefer mu.Unlock(). - 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.MarshalIndentmengubah nilai menjadi JSON, dengan pindah baris dan indentasi. Fungsi ini mengembalikan[]byte, slice berisi byte mentah, dan error.string(data)membuatnya bisa dicetak.json.Unmarshalberjalan 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
tpadathis: dia berharap menemukan katatrue.
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 = loadedJalankan 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.