35. Saving data — Homework solutions
The .go solution files are in
exercises/35/homework/solutions/. Data files are created in
the folder you run from. Delete them to start again.
Problem 1 — Pets in a file
Problem. Load and save a slice of structs.
How to think about it. These are the two functions from the chapter with a different type. Three outcomes for loading: no file means an empty slice and no error, an unreadable file is an error, and a file with bad JSON is an error. Saving goes through a temporary file, so that a crash cannot leave half a file.
Worked solution.
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)
}Two runs, then a run with a broken file:
Pets: [{Rex 1}]
Pets: [{Rex 1} {Rex 2}]
Error: pets.json is not valid: invalid character 'b' looking for beginning of value
Common mistakes.
- Passing
loadedtojson.Unmarshalwithout the&.go vetreportscall of Unmarshal passes non-pointer as second argument, and when run, the program printsjson: Unmarshal(non-pointer []main.note)as its error, with your type in place ofnote. - Lower case field names. The file then holds only
{}, with no error. - Checking
err != nilbeforeerrors.Is. A missing file then stops the program.
Problem 2 — A visit counter
Problem. Count requests safely.
How to think about it. The count is shared and it
changes, so it needs a lock. Following the rule, one small function
holds the lock and does all the work, and the handler only calls it. It
must read the new value inside the lock too: reading visits
in the handler after the unlock could pick up another request's
number.
Worked solution.
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)
}
}The results:
curl.exe http://localhost:8080/
You are visitor number 1.
curl.exe http://localhost:8080/
You are visitor number 2.
The pattern "GET /{$}" matters. With "/", a
browser's own request for /favicon.ico would count as a
visit too, and the number would jump.
Common mistakes.
- Leaving out the mutex because the counter seems to work. It does work, with one visitor. Under load, it loses counts.
- Writing
mu.Unlock()at the end with nodefer. It works here, but it is easy to break when someone adds an earlyreturnlater. - Copying the mutex, for example by passing
muas a value to a function.go vetreportspasses lock by value. A copy is a different lock, and protects nothing.
Problem 3 — A counter that remembers
Problem. Keep the count in a file across restarts.
How to think about it. JSON can hold a single number
as well as a list, so the file holds just 2, for example.
The order inside nextVisit is the same as in the chapter:
save first, and change the variable only if saving worked. That way the
visitor's error and the data agree. main loads before it
registers anything, and stops if the file is broken.
Worked solution.
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)
}
}The results, with a restart between the second and third requests:
You are visitor number 1.
You are visitor number 2.
You are visitor number 3.
And with xx in the 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.
Common mistakes.
- Treating a broken file like a missing one. The count silently restarts from 0, and the next save destroys the old value.
- Releasing the lock before saving. Two requests could then write the temporary file at the same time.
- Adding 1 before saving, and leaving it there when saving fails. The memory and the file then disagree.
Challenge — A to-do list
Problem. Add and remove tasks, with everything from this part.
How to think about it. Plan the data functions
first: allTasks, addTask and
removeTask, each starting with the two lock lines. The
check on n belongs inside the lock in
removeTask. If the handler checked it first, another
request could shorten the list between the check and the removal. That
is the same kind of problem as the counter.
removeTask must tell the handler which of two things
went wrong, because they get different statuses. errNoTask
is a variable holding one particular error, and errors.Is
checks for it, the same way as os.ErrNotExist. Anything
else from removeTask is a save failure.
The list on the page is numbered by its index, and each task has a
form that posts to its own address, such as /delete/2.
Deleting changes data, so it is a POST and not a link.
Worked solution.
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)
}
}The results, after adding three tasks and deleting the first:
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
Task names with <script> in them show as text,
because the template escapes them.
Common mistakes.
- Making delete a GET link. A browser or another program may fetch a link by itself, and a task disappears.
- Reading
allTasks()in the handler to checkn, then callingremoveTask. The list can change between the two calls. - Calling
allTasks()from insideaddTask. It waits for a lock that its own caller holds, and the server hangs on that request. - Forgetting that a task's index changes when an earlier task is deleted. A page left open shows old numbers, so a stale form can delete a different task. That is an acceptable limit here. A real program gives each item a fixed id.