mediocre-blog/assets/component-oriented-design/v3/main.go
2020-11-28 17:54:17 -07:00

391 lines
10 KiB
Go

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()
}