35. Menyimpan data — Solusi PR
File solusi .go ada di
exercises/35/homework/solutions/. File data dibuat di
folder tempat kamu menjalankannya. Hapus untuk memulai lagi.
Soal 1 — Hewan peliharaan dalam file
Soal. Muat dan simpan slice berisi struct.
Cara berpikir. Ini dua fungsi dari bab tadi dengan tipe yang berbeda. Ada tiga hasil untuk memuat: tidak ada file berarti slice kosong tanpa error, file yang tidak bisa dibaca adalah error, dan file dengan JSON buruk adalah error. Penyimpanan melewati file sementara, supaya crash tidak bisa meninggalkan setengah file.
Solusi yang dikerjakan.
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
)
const petsFile = "pets.json"
type pet struct {
Name string
Age int
}
func loadPets() ([]pet, error) {
data, err := os.ReadFile(petsFile)
if errors.Is(err, os.ErrNotExist) {
return []pet{}, nil
}
if err != nil {
return nil, err
}
var loaded []pet
err = json.Unmarshal(data, &loaded)
if err != nil {
return nil, fmt.Errorf("%s is not valid: %v", petsFile, err)
}
return loaded, nil
}
func savePets(list []pet) error {
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
temporary := petsFile + ".tmp"
err = os.WriteFile(temporary, data, 0644)
if err != nil {
return err
}
return os.Rename(temporary, petsFile)
}
func main() {
pets, err := loadPets()
if err != nil {
fmt.Println("Error:", err)
return
}
pets = append(pets, pet{Name: "Rex", Age: len(pets) + 1})
err = savePets(pets)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Pets:", pets)
}Dua kali jalan, lalu satu kali dengan file yang rusak:
Pets: [{Rex 1}]
Pets: [{Rex 1} {Rex 2}]
Error: pets.json is not valid: invalid character 'b' looking for beginning of value
Kesalahan umum.
- Memberikan
loadedkejson.Unmarshaltanpa&.go vetmelaporkancall of Unmarshal passes non-pointer as second argument, dan ketika dijalankan, program mencetakjson: Unmarshal(non-pointer []main.note)sebagai error-nya, dengan tipemu sebagai gantinote. - Nama field huruf kecil. File lalu hanya berisi
{}, tanpa error. - Memeriksa
err != nilsebelumerrors.Is. File yang tidak ada lalu menghentikan program.
Soal 2 — Penghitung kunjungan
Soal. Hitung request dengan aman.
Cara berpikir. Hitungannya dibagi bersama dan
berubah, jadi butuh kunci. Mengikuti aturan, satu fungsi kecil memegang
kunci dan melakukan semua pekerjaannya, dan handler hanya memanggilnya.
Fungsi itu juga harus membaca nilai baru di dalam kunci: membaca
visits di handler sesudah kunci dilepas bisa mengambil
angka milik request lain.
Solusi yang dikerjakan.
package main
import (
"fmt"
"net/http"
"sync"
)
// mu protects visits.
var mu sync.Mutex
var visits = 0
func nextVisit() int {
mu.Lock()
defer mu.Unlock()
visits = visits + 1
return visits
}
func handleHome(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "You are visitor number %d.\n", nextVisit())
}
func main() {
http.HandleFunc("GET /{$}", handleHome)
fmt.Println("Server running at http://localhost:8080")
fmt.Println("Press Ctrl+C to stop it.")
err := http.ListenAndServe("localhost:8080", nil)
if err != nil {
fmt.Println("Server stopped:", err)
}
}Hasilnya:
curl.exe http://localhost:8080/
You are visitor number 1.
curl.exe http://localhost:8080/
You are visitor number 2.
Pola "GET /{$}" itu penting. Dengan "/",
request browser sendiri untuk /favicon.ico juga akan
dihitung sebagai kunjungan, dan angkanya akan melompat.
Kesalahan umum.
- Tidak memakai mutex karena penghitungnya tampak berjalan. Memang berjalan, dengan satu pengunjung. Di bawah beban, hitungannya hilang.
- Menulis
mu.Unlock()di akhir tanpadefer. Di sini berjalan, tetapi mudah rusak ketika seseorang kelak menambahreturnawal. - Menyalin mutex, misalnya dengan memberikan
musebagai nilai ke fungsi.go vetmelaporkanpasses lock by value. Salinan adalah kunci yang berbeda, dan tidak melindungi apa pun.
Soal 3 — Penghitung yang mengingat
Soal. Simpan hitungan di file sepanjang pemulaian ulang.
Cara berpikir. JSON bisa menyimpan satu angka selain
daftar, jadi filenya hanya berisi 2, misalnya. Urutan di
dalam nextVisit sama seperti di bab tadi: simpan dulu, dan
ubah variabel hanya kalau penyimpanan berhasil. Dengan begitu, error
untuk pengunjung dan datanya selaras. main memuat sebelum
mendaftarkan apa pun, dan berhenti kalau filenya rusak.
Solusi yang dikerjakan.
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"sync"
)
const visitsFile = "visits.json"
// mu protects visits, and the file that holds it.
var mu sync.Mutex
var visits = 0
func loadVisits() (int, error) {
data, err := os.ReadFile(visitsFile)
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, err
}
var loaded int
err = json.Unmarshal(data, &loaded)
if err != nil {
return 0, fmt.Errorf("%s is not valid: %v", visitsFile, err)
}
return loaded, nil
}
func saveVisits(count int) error {
data, err := json.Marshal(count)
if err != nil {
return err
}
temporary := visitsFile + ".tmp"
err = os.WriteFile(temporary, data, 0644)
if err != nil {
return err
}
return os.Rename(temporary, visitsFile)
}
// nextVisit adds 1 and saves. If saving fails, the count stays as it was.
func nextVisit() (int, error) {
mu.Lock()
defer mu.Unlock()
err := saveVisits(visits + 1)
if err != nil {
return 0, err
}
visits = visits + 1
return visits, nil
}
func handleHome(w http.ResponseWriter, r *http.Request) {
number, err := nextVisit()
if err != nil {
fmt.Println("Save error:", err)
http.Error(w, "The visit could not be saved.", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "You are visitor number %d.\n", number)
}
func main() {
loaded, err := loadVisits()
if err != nil {
fmt.Println("Could not load the count:", err)
fmt.Println("Fix or remove the file, then start the server again.")
return
}
visits = loaded
http.HandleFunc("GET /{$}", handleHome)
fmt.Println("Server running at http://localhost:8080")
fmt.Println("Press Ctrl+C to stop it.")
err = http.ListenAndServe("localhost:8080", nil)
if err != nil {
fmt.Println("Server stopped:", err)
}
}Hasilnya, dengan server dimulai ulang di antara request kedua dan ketiga:
You are visitor number 1.
You are visitor number 2.
You are visitor number 3.
Dan dengan xx di dalam file:
Could not load the count: visits.json is not valid: invalid character 'x' looking for beginning of value
Fix or remove the file, then start the server again.
Kesalahan umum.
- Memperlakukan file rusak seperti file yang tidak ada. Hitungannya diam-diam mulai lagi dari 0, dan penyimpanan berikutnya menghancurkan nilai lama.
- Melepas kunci sebelum menyimpan. Dua request lalu bisa menulis file sementara pada waktu yang sama.
- Menambah 1 sebelum menyimpan, dan membiarkannya ketika penyimpanan gagal. Memori dan file lalu tidak selaras.
Tantangan — Daftar tugas
Soal. Tambah dan hapus tugas, dengan semua yang ada di bagian ini.
Cara berpikir. Rencanakan fungsi datanya dulu:
allTasks, addTask, dan
removeTask, masing-masing dimulai dengan dua baris kunci.
Pemeriksaan n berada di dalam kunci pada
removeTask. Kalau handler memeriksanya lebih dulu, request
lain bisa memendekkan daftar di antara pemeriksaan dan penghapusan. Itu
jenis masalah yang sama dengan penghitung tadi.
removeTask harus memberi tahu handler mana dari dua hal
yang salah, karena keduanya mendapat status berbeda.
errNoTask adalah variabel yang menyimpan satu error
tertentu, dan errors.Is memeriksanya, dengan cara yang sama
seperti os.ErrNotExist. Selain itu dari
removeTask adalah kegagalan menyimpan.
Daftar di halaman diberi nomor sesuai indeksnya, dan setiap tugas
punya formulir yang mengirim POST ke alamatnya sendiri, seperti
/delete/2. Menghapus mengubah data, jadi memakai POST dan
bukan tautan.
Solusi yang dikerjakan.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"slices"
"strconv"
"strings"
"sync"
"unicode/utf8"
)
const todoFile = "todo.json"
const maxNameLength = 100
type task struct {
Name string
}
// errNoTask means that no task has the index asked for.
var errNoTask = errors.New("no task has that index")
// mu protects tasks, and the file that holds it.
var mu sync.Mutex
var tasks = []task{}
func loadTasks() ([]task, error) {
data, err := os.ReadFile(todoFile)
if errors.Is(err, os.ErrNotExist) {
return []task{}, nil
}
if err != nil {
return nil, err
}
var loaded []task
err = json.Unmarshal(data, &loaded)
if err != nil {
return nil, fmt.Errorf("%s is not valid: %v", todoFile, err)
}
return loaded, nil
}
func saveTasks(list []task) error {
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
temporary := todoFile + ".tmp"
err = os.WriteFile(temporary, data, 0644)
if err != nil {
return err
}
return os.Rename(temporary, todoFile)
}
func allTasks() []task {
mu.Lock()
defer mu.Unlock()
return slices.Clone(tasks)
}
func addTask(t task) error {
mu.Lock()
defer mu.Unlock()
updated := append(slices.Clone(tasks), t)
err := saveTasks(updated)
if err != nil {
return err
}
tasks = updated
return nil
}
// removeTask removes the task at index n, counting from 0. The check is inside
// the lock, because another request could change the list at any moment.
func removeTask(n int) error {
mu.Lock()
defer mu.Unlock()
if n < 0 || n >= len(tasks) {
return errNoTask
}
updated := slices.Clone(tasks)
updated = append(updated[:n], updated[n+1:]...)
err := saveTasks(updated)
if err != nil {
return err
}
tasks = updated
return nil
}
const todoText = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>To-do</title>
</head>
<body>
<h1>To-do</h1>
<ul>
{{range $i, $task := .}}
<li>
{{$task.Name}}
<form action="/delete/{{$i}}" method="post">
<button type="submit">Delete</button>
</form>
</li>
{{else}}
<li>Nothing to do.</li>
{{end}}
</ul>
<form action="/tasks" method="post">
<p><label>New task: <input type="text" name="name"></label></p>
<p><button type="submit">Add</button></p>
</form>
</body>
</html>
`
var todoTemplate = template.Must(template.New("todo").Parse(todoText))
func handleHome(w http.ResponseWriter, r *http.Request) {
var page bytes.Buffer
err := todoTemplate.Execute(&page, allTasks())
if err != nil {
fmt.Println("Template error:", err)
http.Error(w, "The page could not be built.", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
page.WriteTo(w)
}
func handleAdd(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "The task has no name.", http.StatusBadRequest)
return
}
if utf8.RuneCountInString(name) > maxNameLength {
http.Error(w, "The name is too long.", http.StatusBadRequest)
return
}
err := addTask(task{Name: name})
if err != nil {
fmt.Println("Save error:", err)
http.Error(w, "The task could not be saved.", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleDelete(w http.ResponseWriter, r *http.Request) {
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil {
http.Error(w, "The index must be a whole number.", http.StatusBadRequest)
return
}
err = removeTask(n)
if errors.Is(err, errNoTask) {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if err != nil {
fmt.Println("Save error:", err)
http.Error(w, "The list could not be saved.", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func main() {
loaded, err := loadTasks()
if err != nil {
fmt.Println("Could not load the list:", err)
fmt.Println("Fix or remove the file, then start the server again.")
return
}
tasks = loaded
http.HandleFunc("GET /{$}", handleHome)
http.HandleFunc("POST /tasks", handleAdd)
http.HandleFunc("POST /delete/{n}", handleDelete)
fmt.Println("Server running at http://localhost:8080")
fmt.Println("Press Ctrl+C to stop it.")
err = http.ListenAndServe("localhost:8080", nil)
if err != nil {
fmt.Println("Server stopped:", err)
}
}Hasilnya, sesudah menambah tiga tugas dan menghapus yang pertama:
curl.exe -i -X POST http://localhost:8080/delete/0
HTTP/1.1 303 See Other
Location: /
curl.exe -i -X POST http://localhost:8080/delete/99
HTTP/1.1 404 Not Found
no task has that index
curl.exe -i -X POST http://localhost:8080/delete/abc
HTTP/1.1 400 Bad Request
The index must be a whole number.
curl.exe -i -d "name=" http://localhost:8080/tasks
HTTP/1.1 400 Bad Request
The task has no name.
curl.exe -i http://localhost:8080/delete/0
HTTP/1.1 405 Method Not Allowed
Allow: POST
Method Not Allowed
Nama tugas yang memuat <script> tampil sebagai
teks, karena template meng-escape-nya.
Kesalahan umum.
- Membuat penghapusan sebagai tautan GET. Browser atau program lain bisa mengambil tautan dengan sendirinya, dan satu tugas menghilang.
- Membaca
allTasks()di handler untuk memeriksan, lalu memanggilremoveTask. Daftarnya bisa berubah di antara kedua pemanggilan. - Memanggil
allTasks()dari dalamaddTask. Dia menunggu kunci yang dipegang pemanggilnya sendiri, dan server macet pada request itu. - Lupa bahwa indeks tugas berubah ketika tugas sebelumnya dihapus. Halaman yang dibiarkan terbuka menampilkan nomor lama, jadi formulir lama bisa menghapus tugas lain. Itu batasan yang bisa diterima di sini. Program sungguhan memberi setiap item id yang tetap.