Part 7 mini-project: Web Scoreboard
This is the last project in the book. It uses a little of everything:
a type that guards its rules as in Chapter 30, errors as in Chapter 21,
sorting with a named function as in Chapter 28, forms as in Chapter 33,
a template as in Chapter 34, and JSON and a mutex as in Chapter 35.
Nothing in it is new, apart from one small piece of
fmt.Errorf, which the hints explain.
What to build
A server at http://localhost:8080 that keeps a board of
players and their scores:
| Route | What it does |
|---|---|
GET / |
Shows the board, highest score first, with the forms. |
POST /players |
Adds a player with a score of 0, from the form value
name. |
POST /points |
Changes a player's score, from the form values name and
points. |
The rules that must always stay true:
- A name is not empty and has at most 30 characters, and no two players share one.
- A score is always from 0 to 1000000.
- One change is from -1000 to 1000, and is not 0.
- Equal scores are shown in order of name.
When a form succeeds, the reply is status 303 and a redirect to
/. When it breaks a rule, the board is shown again with a
message, and with status 400. An unknown player gets status 404. A
failed save gets status 500, and the board stays as it was.
From real runs, with three players, here are the names and scores on the page:
<td>Ada</td>
<td>90</td>
<td>Arif</td>
<td>40</td>
<td>Keiko</td>
<td>40</td>
The responses:
curl.exe -i -d "name=Keiko" http://localhost:8080/players
HTTP/1.1 400 Bad Request
<p class="error">Keiko is already on the board.</p>
curl.exe -i -d "name=Keiko&points=abc" http://localhost:8080/points
HTTP/1.1 400 Bad Request
<p class="error">Points must be a whole number.</p>
curl.exe -i -d "name=Keiko&points=5000" http://localhost:8080/points
HTTP/1.1 400 Bad Request
<p class="error">Points must be from -1000 to 1000, and not 0.</p>
curl.exe -i -d "name=Keiko&points=-50" http://localhost:8080/points
HTTP/1.1 400 Bad Request
<p class="error">Keiko has 40 points, so cannot lose 50.</p>
curl.exe -i -d "name=Nobody&points=5" http://localhost:8080/points
HTTP/1.1 404 Not Found
<p class="error">There is no player with that name.</p>
curl.exe -i -d "name=Keiko&points=-10" http://localhost:8080/points
HTTP/1.1 303 See Other
Location: /
A name such as <i>x</i> is shown as
text:
<td><i>x</i></td>
<input type="hidden" name="name" value="<i>x</i>">
File layout
Both versions are under projects/web-scoreboard/:
starter/
go.mod
main.go
scoreboard/
scoreboard.go
templates/
board.html
finished/
go.mod
main.go
scoreboard/
scoreboard.go
templates/
board.html
To work on the starter, go into its folder and use
go run .:
cd projects/web-scoreboard/starter
go run .
To run the finished version:
cd projects/web-scoreboard/finished
go run .
To go back up to go-work afterwards:
cd ../../..
The program reads templates/board.html, and reads and
writes scores.json, both relative to the folder it runs
from. The data file appears next to main.go the first time
a player is added. Delete it to start with an empty board.
The starter runs as it is. main.go is finished. The page
shows the form and a TODO line, and each form gets status
400 with a line such as TODO: AddPlayer is not written yet.
The TODOs are in scoreboard/scoreboard.go and
templates/board.html.
What to build it from
Here is finished/main.go, which the starter also
has:
// Web Scoreboard -- main (finished).
//
// Run from this folder, because the template and the data file are relative to
// it:
// cd projects/web-scoreboard/finished
// go run .
package main
import (
"bytes"
"errors"
"fmt"
"html/template"
"net/http"
"strconv"
"webscoreboard/scoreboard"
)
const templateFile = "templates/board.html"
const dataFile = "scores.json"
// boardData is everything the template needs.
type boardData struct {
Error string
Players []scoreboard.Player
}
var boardTemplate *template.Template
var board *scoreboard.Board
// showBoard builds the page in memory, and sends it only if that worked.
func showBoard(w http.ResponseWriter, status int, message string) {
data := boardData{Error: message, Players: board.Players()}
var page bytes.Buffer
err := boardTemplate.Execute(&page, data)
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")
w.WriteHeader(status)
page.WriteTo(w)
}
// finish turns the result of a change into a response: a redirect on success,
// and otherwise the status that fits the error.
func finish(w http.ResponseWriter, r *http.Request, err error) {
if err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if errors.Is(err, scoreboard.ErrNotSaved) {
fmt.Println("Save error:", err)
http.Error(w, "The change could not be saved.", http.StatusInternalServerError)
return
}
if errors.Is(err, scoreboard.ErrUnknownPlayer) {
showBoard(w, http.StatusNotFound, err.Error())
return
}
showBoard(w, http.StatusBadRequest, err.Error())
}
func handleHome(w http.ResponseWriter, r *http.Request) {
showBoard(w, http.StatusOK, "")
}
func handleAddPlayer(w http.ResponseWriter, r *http.Request) {
err := board.AddPlayer(r.FormValue("name"))
finish(w, r, err)
}
func handleAddPoints(w http.ResponseWriter, r *http.Request) {
points, err := strconv.Atoi(r.FormValue("points"))
if err != nil {
showBoard(w, http.StatusBadRequest, "Points must be a whole number.")
return
}
err = board.AddPoints(r.FormValue("name"), points)
finish(w, r, err)
}
func main() {
parsed, err := template.ParseFiles(templateFile)
if err != nil {
fmt.Println("Could not read the template:", err)
fmt.Println("Run this program from its own folder, and check the template.")
return
}
boardTemplate = parsed
opened, err := scoreboard.Open(dataFile)
if err != nil {
fmt.Println("Could not load the scores:", err)
fmt.Println("Fix or remove the file, then start the server again.")
return
}
board = opened
http.HandleFunc("GET /{$}", handleHome)
http.HandleFunc("POST /players", handleAddPlayer)
http.HandleFunc("POST /points", handleAddPoints)
fmt.Println("Scoreboard 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 package scoreboard needs:
Player, with the fieldsNameandScore.Board, with async.Mutex, the file name, and a slice of players.Open(filename), which returns a*Boardand an error.byScore(a, b), a named function that compares two players, forslices.SortFunc.(b *Board) Players(), which returns a sorted copy.(b *Board) AddPlayer(name)and(b *Board) AddPoints(name, points), which return an error.ErrUnknownPlayerandErrNotSaved, two errors that the handlers look for.
Hints
- Answer the three questions from Chapter 30 first. What does a board know? What can it do? What must stay true? The third list is already written above.
byScorereturnsb.Score - a.Scorefor the highest first. For equal scores, it returnsstrings.Compare(a.Name, b.Name), as in Chapter 28.- Sort a copy.
Playersmust not reorder the board's own slice for the sake of one page, and the copy can be used after the lock is released. - Check for a duplicate inside the lock. If you checked before locking, two requests with the same name could both pass.
- Make the changes on a copy, with
slices.Clone, and save the copy. Keep it only if saving worked. A helper can do these last steps for both methods. A helper that needs the lock must not take it itself, because the caller already holds it. Say so in its comment. - A form on each row sends the player's name in a hidden input:
<input type="hidden" name="name" value="{{.Name}}">. It is sent like any other value, and the visitor can change it, soAddPointsstill checks that the player exists.
Handlers need to choose a status for each kind of error, and they
can't compare the text. So scoreboard.go keeps two
particular errors in variables, as the to-do list in Chapter 35 did with
errNoTask:
var ErrUnknownPlayer = errors.New("There is no player with that name.")
var ErrNotSaved = errors.New("could not save")errors.Is(err, scoreboard.ErrUnknownPlayer) then finds
out which one it is. ErrNotSaved should carry the reason
too. That is the one new piece, %w in
fmt.Errorf:
err := save(b.filename, updated)
if err != nil {
return fmt.Errorf("%w: %v", ErrNotSaved, err)
}%w works like %v, and also lets
errors.Is find ErrNotSaved inside the new
error. A real save failure prints this in the terminal, and the visitor
gets only a short message with status 500:
Save error: could not save: open scores.json.tmp: permission denied
On Windows, the end of that message is worded differently.
A bigger challenge (optional)
- Remove a player. Add
POST /players/remove, with a small form on each row. - A player page. Add
GET /players/{name}, usingr.PathValue, and a link from the board. To make a link, write<a href="/players/{{.Name}}">, and test it with a name that has a space in it. - Ranks. Show
1,2,3on the rows.{{range $i, $player := .Players}}gives the index, and a field in a struct can hold the rank. - A history. Give each player a list of past changes, and show it on the player page.
None of these are required to finish the project.
Done?
When players can be added and scores changed, the order is right, bad input gets a useful message with the right status, and the board survives a restart, the project is done.
So is the book.
What you have learned
You started with a list of steps on paper. Since then, you have learned:
- To plan a program and trace it by hand, before and after writing it.
- To read an error message, and to go to the line it names.
- Variables, types, strings, numbers, and reading input.
- Decisions and loops, and the patterns made from them.
- Functions that return values and errors, and checking every error.
- Slices, maps, sorting, and splitting a program into packages.
- Structs, methods, pointers, composition and interfaces.
- Designing a type that protects its own rules.
- Servers: handlers, routes, status codes, forms, templates, saved data, and shared data under a lock.
- That input from outside your program is never to be trusted.
The programs you wrote are small, but they are real programs. The same parts make up larger ones.
What to do next
Build something of your own. Nothing teaches as much as a program you want to exist, and that no book has planned for you. A list of books you have read, a tally for a game with friends, or a small quiz would all fit what you know. Start small, and change it when it works.
Learn what a public server needs. Everything in this
book listens only on localhost, and that is on purpose.
Before putting a program on the internet, read about these subjects,
because this book did not cover them:
- HTTPS, which protects a request on its way across a network.
- Logins and passwords, and how to store them safely.
- Cross-site request forgery, where another site makes a visitor's browser send your form.
- Limits on the size and number of requests, and timeouts, which
http.ListenAndServedoes not set.
Learn the next parts of Go. In about this order:
go test, for writing tests of your own functions. The rules inscoreboardare a good place to start.- Goroutines and channels, which is how Go runs things at the same
time. You have seen one side of this, as
net/httpuses goroutines for its requests. - A database, for when a JSON file is no longer enough. The
database/sqlpackage is in the standard library, although a database driver is not.
Read the official material. The Go website at https://go.dev/doc/ has a tour, tutorials
and the documentation for every package in the standard library. You can
read net/http there now, and most of it will make
sense.
This is the end of the book.