💾 Archived View for mediocregopher.com › assets › component-oriented-design.tgz › v3 › main.go captured on 2024-05-12 at 15:27:13.

View Raw

More Information

⬅️ Previous capture (2023-09-08)

-=-=-=-=-=-=-

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