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 loaded ke json.Unmarshal tanpa &. go vet melaporkan call of Unmarshal passes non-pointer as second argument, dan ketika dijalankan, program mencetak json: Unmarshal(non-pointer []main.note) sebagai error-nya, dengan tipemu sebagai ganti note.
  • Nama field huruf kecil. File lalu hanya berisi {}, tanpa error.
  • Memeriksa err != nil sebelum errors.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 tanpa defer. Di sini berjalan, tetapi mudah rusak ketika seseorang kelak menambah return awal.
  • Menyalin mutex, misalnya dengan memberikan mu sebagai nilai ke fungsi. go vet melaporkan passes 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 memeriksa n, lalu memanggil removeTask. Daftarnya bisa berubah di antara kedua pemanggilan.
  • Memanggil allTasks() dari dalam addTask. 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.