parent
e346068f58
commit
d40fe10213
@ -0,0 +1,390 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"flag" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"io/ioutil" |
||||||
|
"log" |
||||||
|
"math/rand" |
||||||
|
"net" |
||||||
|
"net/http" |
||||||
|
"os" |
||||||
|
"os/signal" |
||||||
|
"sort" |
||||||
|
"strconv" |
||||||
|
"sync" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
// Logger describes a simple component used for printing log lines.
|
||||||
|
type Logger interface { |
||||||
|
Printf(string, ...interface{}) |
||||||
|
} |
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// The scoreboard component
|
||||||
|
|
||||||
|
// File wraps the standard os.File type.
|
||||||
|
type File interface { |
||||||
|
io.ReadWriter |
||||||
|
Truncate(int64) error |
||||||
|
Seek(int64, int) (int64, error) |
||||||
|
} |
||||||
|
|
||||||
|
// scoreboard loads player scores from a save file, tracks score updates, and
|
||||||
|
// periodically saves those scores back to the save file.
|
||||||
|
type scoreboard struct { |
||||||
|
file File |
||||||
|
scoresM map[string]int |
||||||
|
scoresLock sync.Mutex |
||||||
|
|
||||||
|
pointsOnCorrect, pointsOnIncorrect int |
||||||
|
|
||||||
|
// The cleanup method closes cleanupCh to signal to all scoreboard's running
|
||||||
|
// go-routines to clean themselves up, and cleanupWG is then used to wait
|
||||||
|
// for those goroutines to do so.
|
||||||
|
cleanupCh chan struct{} |
||||||
|
cleanupWG sync.WaitGroup |
||||||
|
|
||||||
|
// this field will only be set in tests, and is used to synchronize with the
|
||||||
|
// the for-select loop in saveLoop.
|
||||||
|
saveLoopWaitCh chan struct{} |
||||||
|
} |
||||||
|
|
||||||
|
// newScoreboard initializes a scoreboard using scores saved in the given File
|
||||||
|
// (which may be empty). The scoreboard will rewrite the save file with the
|
||||||
|
// latest scores everytime saveTicker is written to.
|
||||||
|
func newScoreboard(file File, saveTicker <-chan time.Time, logger Logger, pointsOnCorrect, pointsOnIncorrect int) (*scoreboard, error) { |
||||||
|
fileBody, err := ioutil.ReadAll(file) |
||||||
|
if err != nil { |
||||||
|
return nil, fmt.Errorf("reading saved scored: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
scoresM := map[string]int{} |
||||||
|
if len(fileBody) > 0 { |
||||||
|
if err := json.Unmarshal(fileBody, &scoresM); err != nil { |
||||||
|
return nil, fmt.Errorf("decoding saved scores: %w", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
scoreboard := &scoreboard{ |
||||||
|
file: file, |
||||||
|
scoresM: scoresM, |
||||||
|
pointsOnCorrect: pointsOnCorrect, |
||||||
|
pointsOnIncorrect: pointsOnIncorrect, |
||||||
|
cleanupCh: make(chan struct{}), |
||||||
|
saveLoopWaitCh: make(chan struct{}), |
||||||
|
} |
||||||
|
|
||||||
|
scoreboard.cleanupWG.Add(1) |
||||||
|
go func() { |
||||||
|
scoreboard.saveLoop(saveTicker, logger) |
||||||
|
scoreboard.cleanupWG.Done() |
||||||
|
}() |
||||||
|
|
||||||
|
return scoreboard, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *scoreboard) cleanup() error { |
||||||
|
close(s.cleanupCh) |
||||||
|
s.cleanupWG.Wait() |
||||||
|
|
||||||
|
if err := s.save(); err != nil { |
||||||
|
return fmt.Errorf("saving scores during cleanup: %w", err) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *scoreboard) guessedCorrect(name string) int { |
||||||
|
s.scoresLock.Lock() |
||||||
|
defer s.scoresLock.Unlock() |
||||||
|
|
||||||
|
s.scoresM[name] += s.pointsOnCorrect |
||||||
|
return s.scoresM[name] |
||||||
|
} |
||||||
|
|
||||||
|
func (s *scoreboard) guessedIncorrect(name string) int { |
||||||
|
s.scoresLock.Lock() |
||||||
|
defer s.scoresLock.Unlock() |
||||||
|
|
||||||
|
s.scoresM[name] += s.pointsOnIncorrect |
||||||
|
return s.scoresM[name] |
||||||
|
} |
||||||
|
|
||||||
|
func (s *scoreboard) scores() map[string]int { |
||||||
|
s.scoresLock.Lock() |
||||||
|
defer s.scoresLock.Unlock() |
||||||
|
|
||||||
|
scoresCp := map[string]int{} |
||||||
|
for name, score := range s.scoresM { |
||||||
|
scoresCp[name] = score |
||||||
|
} |
||||||
|
|
||||||
|
return scoresCp |
||||||
|
} |
||||||
|
|
||||||
|
func (s *scoreboard) save() error { |
||||||
|
scores := s.scores() |
||||||
|
if _, err := s.file.Seek(0, 0); err != nil { |
||||||
|
return fmt.Errorf("seeking to start of save file: %w", err) |
||||||
|
} else if err := s.file.Truncate(0); err != nil { |
||||||
|
return fmt.Errorf("truncating save file: %w", err) |
||||||
|
} else if err := json.NewEncoder(s.file).Encode(scores); err != nil { |
||||||
|
return fmt.Errorf("encoding scores to save file: %w", err) |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *scoreboard) saveLoop(ticker <-chan time.Time, logger Logger) { |
||||||
|
for { |
||||||
|
select { |
||||||
|
case <-ticker: |
||||||
|
if err := s.save(); err != nil { |
||||||
|
logger.Printf("error saving scoreboard to file: %v", err) |
||||||
|
} |
||||||
|
case <-s.cleanupCh: |
||||||
|
return |
||||||
|
case <-s.saveLoopWaitCh: |
||||||
|
// test will unblock, nothing to do here.
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// The httpHandlers component
|
||||||
|
|
||||||
|
// Scoreboard describes the scoreboard component from the point of view of the
|
||||||
|
// httpHandler component (which only needs a subset of scoreboard's methods).
|
||||||
|
type Scoreboard interface { |
||||||
|
guessedCorrect(name string) int |
||||||
|
guessedIncorrect(name string) int |
||||||
|
scores() map[string]int |
||||||
|
} |
||||||
|
|
||||||
|
// RandSrc describes a randomness component which can produce random integers.
|
||||||
|
type RandSrc interface { |
||||||
|
Int() int |
||||||
|
} |
||||||
|
|
||||||
|
// httpHandlers implements the http.HandlerFuncs used by the httpServer.
|
||||||
|
type httpHandlers struct { |
||||||
|
scoreboard Scoreboard |
||||||
|
randSrc RandSrc |
||||||
|
logger Logger |
||||||
|
|
||||||
|
mux *http.ServeMux |
||||||
|
n int |
||||||
|
nLock sync.Mutex |
||||||
|
} |
||||||
|
|
||||||
|
func newHTTPHandlers(scoreboard Scoreboard, randSrc RandSrc, logger Logger) *httpHandlers { |
||||||
|
n := randSrc.Int() |
||||||
|
logger.Printf("first n is %v", n) |
||||||
|
|
||||||
|
httpHandlers := &httpHandlers{ |
||||||
|
scoreboard: scoreboard, |
||||||
|
randSrc: randSrc, |
||||||
|
logger: logger, |
||||||
|
mux: http.NewServeMux(), |
||||||
|
n: n, |
||||||
|
} |
||||||
|
|
||||||
|
httpHandlers.mux.HandleFunc("/guess", httpHandlers.handleGuess) |
||||||
|
httpHandlers.mux.HandleFunc("/scores", httpHandlers.handleScores) |
||||||
|
|
||||||
|
return httpHandlers |
||||||
|
} |
||||||
|
|
||||||
|
func (h *httpHandlers) ServeHTTP(rw http.ResponseWriter, r *http.Request) { |
||||||
|
h.mux.ServeHTTP(rw, r) |
||||||
|
} |
||||||
|
|
||||||
|
func (h *httpHandlers) handleGuess(rw http.ResponseWriter, r *http.Request) { |
||||||
|
r.Header.Set("Content-Type", "text/plain") |
||||||
|
|
||||||
|
name := r.FormValue("name") |
||||||
|
nStr := r.FormValue("n") |
||||||
|
if name == "" || nStr == "" { |
||||||
|
http.Error(rw, `"name" and "n" GET args are required`, http.StatusBadRequest) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
n, err := strconv.Atoi(nStr) |
||||||
|
if err != nil { |
||||||
|
http.Error(rw, err.Error(), http.StatusBadRequest) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
h.nLock.Lock() |
||||||
|
defer h.nLock.Unlock() |
||||||
|
|
||||||
|
if h.n == n { |
||||||
|
newScore := h.scoreboard.guessedCorrect(name) |
||||||
|
h.n = h.randSrc.Int() |
||||||
|
h.logger.Printf("new n is %v", h.n) |
||||||
|
rw.WriteHeader(http.StatusOK) |
||||||
|
fmt.Fprintf(rw, "Correct! Your score is now %d\n", newScore) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
hint := "higher" |
||||||
|
if h.n < n { |
||||||
|
hint = "lower" |
||||||
|
} |
||||||
|
|
||||||
|
newScore := h.scoreboard.guessedIncorrect(name) |
||||||
|
rw.WriteHeader(http.StatusBadRequest) |
||||||
|
fmt.Fprintf(rw, "Try %s. Your score is now %d\n", hint, newScore) |
||||||
|
} |
||||||
|
|
||||||
|
func (h *httpHandlers) handleScores(rw http.ResponseWriter, r *http.Request) { |
||||||
|
r.Header.Set("Content-Type", "text/plain") |
||||||
|
|
||||||
|
h.nLock.Lock() |
||||||
|
defer h.nLock.Unlock() |
||||||
|
|
||||||
|
type scoreTup struct { |
||||||
|
name string |
||||||
|
score int |
||||||
|
} |
||||||
|
|
||||||
|
scores := h.scoreboard.scores() |
||||||
|
scoresTups := make([]scoreTup, 0, len(scores)) |
||||||
|
for name, score := range scores { |
||||||
|
scoresTups = append(scoresTups, scoreTup{name, score}) |
||||||
|
} |
||||||
|
|
||||||
|
sort.Slice(scoresTups, func(i, j int) bool { |
||||||
|
return scoresTups[i].score > scoresTups[j].score |
||||||
|
}) |
||||||
|
|
||||||
|
for _, scoresTup := range scoresTups { |
||||||
|
fmt.Fprintf(rw, "%s: %d\n", scoresTup.name, scoresTup.score) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// The httpServer component.
|
||||||
|
|
||||||
|
type httpServer struct { |
||||||
|
httpServer *http.Server |
||||||
|
errCh chan error |
||||||
|
} |
||||||
|
|
||||||
|
func newHTTPServer(listener net.Listener, httpHandlers *httpHandlers, logger Logger) *httpServer { |
||||||
|
loggingHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { |
||||||
|
ip, _, _ := net.SplitHostPort(r.RemoteAddr) |
||||||
|
logger.Printf("HTTP request -> %s %s %s", ip, r.Method, r.URL.String()) |
||||||
|
httpHandlers.ServeHTTP(rw, r) |
||||||
|
}) |
||||||
|
|
||||||
|
server := &httpServer{ |
||||||
|
httpServer: &http.Server{ |
||||||
|
Handler: loggingHandler, |
||||||
|
}, |
||||||
|
errCh: make(chan error, 1), |
||||||
|
} |
||||||
|
|
||||||
|
go func() { |
||||||
|
err := server.httpServer.Serve(listener) |
||||||
|
if errors.Is(err, http.ErrServerClosed) { |
||||||
|
err = nil |
||||||
|
} |
||||||
|
server.errCh <- err |
||||||
|
}() |
||||||
|
|
||||||
|
return server |
||||||
|
} |
||||||
|
|
||||||
|
func (s *httpServer) cleanup() error { |
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
||||||
|
defer cancel() |
||||||
|
if err := s.httpServer.Shutdown(ctx); err != nil { |
||||||
|
return fmt.Errorf("shutting down http server: %w", err) |
||||||
|
} |
||||||
|
return <-s.errCh |
||||||
|
} |
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// main
|
||||||
|
|
||||||
|
func main() { |
||||||
|
saveFilePath := flag.String("save-file", "./save.json", "File used to save scores") |
||||||
|
listenAddr := flag.String("listen-addr", ":8888", "Address to listen for HTTP requests on") |
||||||
|
saveInterval := flag.Duration("save-interval", 5*time.Second, "How often to resave scores") |
||||||
|
pointsOnCorrect := flag.Int("points-on-correct", 1000, "Amount to change a user's score by upon a correct score") |
||||||
|
pointsOnIncorrect := flag.Int("points-on-incorrect", -1, "Amount to change a user's score by upon an incorrect score") |
||||||
|
flag.Parse() |
||||||
|
|
||||||
|
logger := log.New(os.Stdout, "", log.LstdFlags) |
||||||
|
|
||||||
|
logger.Printf("opening scoreboard save file %q", *saveFilePath) |
||||||
|
file, err := os.OpenFile(*saveFilePath, os.O_RDWR|os.O_CREATE, 0644) |
||||||
|
if err != nil { |
||||||
|
logger.Fatalf("failed to open file %q: %v", *saveFilePath, err) |
||||||
|
} |
||||||
|
|
||||||
|
saveTicker := time.NewTicker(*saveInterval) |
||||||
|
randSrc := rand.New(rand.NewSource(time.Now().UnixNano())) |
||||||
|
|
||||||
|
logger.Printf("initializing scoreboard") |
||||||
|
scoreboard, err := newScoreboard(file, saveTicker.C, logger, *pointsOnCorrect, *pointsOnIncorrect) |
||||||
|
if err != nil { |
||||||
|
logger.Fatalf("failed to initialize scoreboard: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
logger.Printf("listening on %q", *listenAddr) |
||||||
|
listener, err := net.Listen("tcp", *listenAddr) |
||||||
|
if err != nil { |
||||||
|
logger.Fatalf("failed to listen on %q: %v", *listenAddr, err) |
||||||
|
} |
||||||
|
|
||||||
|
logger.Printf("setting up HTTP handlers") |
||||||
|
httpHandlers := newHTTPHandlers(scoreboard, randSrc, logger) |
||||||
|
|
||||||
|
logger.Printf("serving HTTP requests") |
||||||
|
httpServer := newHTTPServer(listener, httpHandlers, logger) |
||||||
|
|
||||||
|
logger.Printf("initialization done, waiting for interrupt signal") |
||||||
|
sigCh := make(chan os.Signal) |
||||||
|
signal.Notify(sigCh, os.Interrupt) |
||||||
|
<-sigCh |
||||||
|
logger.Printf("interrupt signal received, cleaning up") |
||||||
|
go func() { |
||||||
|
<-sigCh |
||||||
|
log.Fatalf("interrupt signal received again, forcing shutdown") |
||||||
|
}() |
||||||
|
|
||||||
|
if err := httpServer.cleanup(); err != nil { |
||||||
|
logger.Fatalf("cleaning up http server: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// NOTE go's builtin http server does not follow component property 5a, and
|
||||||
|
// instead closes the net.Listener given to it as a parameter when Shutdown
|
||||||
|
// is called. Because of that inconsistency this Close would error if it
|
||||||
|
// were called.
|
||||||
|
//
|
||||||
|
// While there are ways to work around this, it's instead highlighted in
|
||||||
|
// this example as an instance of a language making the component-oriented
|
||||||
|
// pattern more difficult.
|
||||||
|
//
|
||||||
|
//if err := listener.Close(); err != nil {
|
||||||
|
// logger.Fatalf("closing listener %q: %v", listenAddr, err)
|
||||||
|
//}
|
||||||
|
|
||||||
|
if err := scoreboard.cleanup(); err != nil { |
||||||
|
logger.Fatalf("cleaning up scoreboard: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
saveTicker.Stop() |
||||||
|
|
||||||
|
if err := file.Close(); err != nil { |
||||||
|
logger.Fatalf("closing file %q: %v", *saveFilePath, err) |
||||||
|
} |
||||||
|
|
||||||
|
os.Stdout.Sync() |
||||||
|
} |
@ -0,0 +1,4 @@ |
|||||||
|
--- |
||||||
|
layout: code |
||||||
|
include: main.go |
||||||
|
--- |
Loading…
Reference in new issue