summaryrefslogtreecommitdiff
path: root/assets/component-oriented-design
diff options
context:
space:
mode:
Diffstat (limited to 'assets/component-oriented-design')
-rw-r--r--assets/component-oriented-design/v1/main_test.go12
-rw-r--r--assets/component-oriented-design/v2/main.go122
-rw-r--r--assets/component-oriented-design/v3/main.go390
-rw-r--r--assets/component-oriented-design/v3/main.md4
4 files changed, 434 insertions, 94 deletions
diff --git a/assets/component-oriented-design/v1/main_test.go b/assets/component-oriented-design/v1/main_test.go
index 6976690..6cfd9fb 100644
--- a/assets/component-oriented-design/v1/main_test.go
+++ b/assets/component-oriented-design/v1/main_test.go
@@ -114,7 +114,7 @@ func TestScoreboard(t *testing.T) {
}
////////////////////////////////////////////////////////////////////////////////
-// Test httpHandler component
+// Test httpHandlers component
type mockScoreboard map[string]int
@@ -155,3 +155,13 @@ func TestHTTPHandlers(t *testing.T) {
r = httptest.NewRequest("GET", "/scores", nil)
assertRequest(t, 200, "bar: 2\nfoo: 1\n", r)
}
+
+////////////////////////////////////////////////////////////////////////////////
+//
+// httpServer is NOT tested, for the following reasons:
+// * It depends on a `net.Listener`, which is not trivial to mock.
+// * It does very little besides passing an httpHandlers along to an http.Server
+// and managing cleanup.
+// * It isn't likely to be changed often.
+// * If it were to break it would be very apparent in subsequent testing stages.
+//
diff --git a/assets/component-oriented-design/v2/main.go b/assets/component-oriented-design/v2/main.go
index e9a1eae..fb5773c 100644
--- a/assets/component-oriented-design/v2/main.go
+++ b/assets/component-oriented-design/v2/main.go
@@ -1,9 +1,9 @@
package main
import (
- "context"
"encoding/json"
"errors"
+ "flag"
"fmt"
"io"
"io/ioutil"
@@ -12,7 +12,6 @@ import (
"net"
"net/http"
"os"
- "os/signal"
"sort"
"strconv"
"sync"
@@ -41,11 +40,7 @@ type scoreboard struct {
scoresM map[string]int
scoresLock sync.Mutex
- // 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
+ pointsOnCorrect, pointsOnIncorrect int
// this field will only be set in tests, and is used to synchronize with the
// the for-select loop in saveLoop.
@@ -55,7 +50,7 @@ type scoreboard 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) (*scoreboard, error) {
+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)
@@ -69,36 +64,23 @@ func newScoreboard(file File, saveTicker <-chan time.Time, logger Logger) (*scor
}
scoreboard := &scoreboard{
- file: file,
- scoresM: scoresM,
- cleanupCh: make(chan struct{}),
- saveLoopWaitCh: make(chan struct{}),
+ file: file,
+ scoresM: scoresM,
+ pointsOnCorrect: pointsOnCorrect,
+ pointsOnIncorrect: pointsOnIncorrect,
+ saveLoopWaitCh: make(chan struct{}),
}
- scoreboard.cleanupWG.Add(1)
- go func() {
- scoreboard.saveLoop(saveTicker, logger)
- scoreboard.cleanupWG.Done()
- }()
+ go scoreboard.saveLoop(saveTicker, logger)
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] += 1000
+ s.scoresM[name] += s.pointsOnCorrect
return s.scoresM[name]
}
@@ -106,7 +88,7 @@ func (s *scoreboard) guessedIncorrect(name string) int {
s.scoresLock.Lock()
defer s.scoresLock.Unlock()
- s.scoresM[name] -= 1
+ s.scoresM[name] += s.pointsOnIncorrect
return s.scoresM[name]
}
@@ -141,8 +123,6 @@ func (s *scoreboard) saveLoop(ticker <-chan time.Time, logger Logger) {
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.
}
@@ -295,90 +275,46 @@ func newHTTPServer(listener net.Listener, httpHandlers *httpHandlers, logger Log
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
-const (
- saveFilePath = "./save.json"
- listenAddr = ":8888"
- saveInterval = 5 * time.Second
-)
-
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)
+ 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)
+ logger.Fatalf("failed to open file %q: %v", *saveFilePath, err)
}
- saveTicker := time.NewTicker(saveInterval)
+ saveTicker := time.NewTicker(*saveInterval)
randSrc := rand.New(rand.NewSource(time.Now().UnixNano()))
logger.Printf("initializing scoreboard")
- scoreboard, err := newScoreboard(file, saveTicker.C, logger)
+ 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)
+ 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.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)
- }
+ newHTTPServer(listener, httpHandlers, logger)
- os.Stdout.Sync()
+ logger.Printf("initialization done")
+ select {} // block forever
}
diff --git a/assets/component-oriented-design/v3/main.go b/assets/component-oriented-design/v3/main.go
new file mode 100644
index 0000000..afe8bab
--- /dev/null
+++ b/assets/component-oriented-design/v3/main.go
@@ -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()
+}
diff --git a/assets/component-oriented-design/v3/main.md b/assets/component-oriented-design/v3/main.md
new file mode 100644
index 0000000..37346c6
--- /dev/null
+++ b/assets/component-oriented-design/v3/main.md
@@ -0,0 +1,4 @@
+---
+layout: code
+include: main.go
+---