parent
0450e50258
commit
e346068f58
@ -0,0 +1,10 @@ |
||||
--- |
||||
layout: default |
||||
--- |
||||
|
||||
{% capture body %}```{{ page.lang | default: "go" }} |
||||
{% include_relative {{ page.include }} %}```{% endcapture %} |
||||
|
||||
<br/><a href="{{ page.include }}">Raw source file</a> |
||||
|
||||
{{ body | markdownify }} |
@ -0,0 +1,314 @@ |
||||
package main |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"io/ioutil" |
||||
"log" |
||||
"math/rand" |
||||
"net" |
||||
"net/http" |
||||
"os" |
||||
"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 |
||||
|
||||
// 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) (*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, |
||||
saveLoopWaitCh: make(chan struct{}), |
||||
} |
||||
|
||||
go scoreboard.saveLoop(saveTicker, logger) |
||||
|
||||
return scoreboard, nil |
||||
} |
||||
|
||||
func (s *scoreboard) guessedCorrect(name string) int { |
||||
s.scoresLock.Lock() |
||||
defer s.scoresLock.Unlock() |
||||
|
||||
s.scoresM[name] += 1000 |
||||
return s.scoresM[name] |
||||
} |
||||
|
||||
func (s *scoreboard) guessedIncorrect(name string) int { |
||||
s.scoresLock.Lock() |
||||
defer s.scoresLock.Unlock() |
||||
|
||||
s.scoresM[name] -= 1 |
||||
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.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 |
||||
} |
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// main
|
||||
|
||||
const ( |
||||
saveFilePath = "./save.json" |
||||
listenAddr = ":8888" |
||||
saveInterval = 5 * time.Second |
||||
) |
||||
|
||||
func main() { |
||||
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) |
||||
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") |
||||
newHTTPServer(listener, httpHandlers, logger) |
||||
|
||||
logger.Printf("initialization done") |
||||
select {} // block forever
|
||||
} |
@ -0,0 +1,4 @@ |
||||
--- |
||||
layout: code |
||||
include: main.go |
||||
--- |
@ -0,0 +1,157 @@ |
||||
package main |
||||
|
||||
import ( |
||||
"bytes" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"reflect" |
||||
"testing" |
||||
"time" |
||||
) |
||||
|
||||
type nullLogger struct{} |
||||
|
||||
func (nullLogger) Printf(string, ...interface{}) {} |
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Test scoreboard component
|
||||
|
||||
type fileStub struct { |
||||
*bytes.Buffer |
||||
} |
||||
|
||||
func newFileStub(init string) *fileStub { |
||||
return &fileStub{Buffer: bytes.NewBufferString(init)} |
||||
} |
||||
|
||||
func (fs *fileStub) Truncate(i int64) error { |
||||
fs.Buffer.Truncate(int(i)) |
||||
return nil |
||||
} |
||||
|
||||
func (fs *fileStub) Seek(i int64, whence int) (int64, error) { |
||||
return i, nil |
||||
} |
||||
|
||||
func TestScoreboard(t *testing.T) { |
||||
newScoreboard := func(t *testing.T, fileStub *fileStub, saveTicker <-chan time.Time) *scoreboard { |
||||
t.Helper() |
||||
scoreboard, err := newScoreboard(fileStub, saveTicker, nullLogger{}) |
||||
if err != nil { |
||||
t.Errorf("unexpected error checking saved scored: %v", err) |
||||
} |
||||
return scoreboard |
||||
} |
||||
|
||||
assertScores := func(t *testing.T, expScores, gotScores map[string]int) { |
||||
t.Helper() |
||||
if !reflect.DeepEqual(expScores, gotScores) { |
||||
t.Errorf("expected scores of %+v, but instead got %+v", expScores, gotScores) |
||||
} |
||||
} |
||||
|
||||
assertSavedScores := func(t *testing.T, expScores map[string]int, fileStub *fileStub) { |
||||
t.Helper() |
||||
fileStubCp := newFileStub(fileStub.String()) |
||||
tmpScoreboard := newScoreboard(t, fileStubCp, nil) |
||||
assertScores(t, expScores, tmpScoreboard.scores()) |
||||
} |
||||
|
||||
t.Run("loading", func(t *testing.T) { |
||||
// make sure loading scoreboards with various file contents works
|
||||
assertSavedScores(t, map[string]int{}, newFileStub("")) |
||||
assertSavedScores(t, map[string]int{"foo": 1}, newFileStub(`{"foo":1}`)) |
||||
assertSavedScores(t, map[string]int{"foo": 1, "bar": -2}, newFileStub(`{"foo":1,"bar":-2}`)) |
||||
}) |
||||
|
||||
t.Run("tracking", func(t *testing.T) { |
||||
scoreboard := newScoreboard(t, newFileStub(""), nil) |
||||
assertScores(t, map[string]int{}, scoreboard.scores()) // sanity check
|
||||
|
||||
scoreboard.guessedCorrect("foo") |
||||
assertScores(t, map[string]int{"foo": 1000}, scoreboard.scores()) |
||||
|
||||
scoreboard.guessedIncorrect("bar") |
||||
assertScores(t, map[string]int{"foo": 1000, "bar": -1}, scoreboard.scores()) |
||||
|
||||
scoreboard.guessedIncorrect("foo") |
||||
assertScores(t, map[string]int{"foo": 999, "bar": -1}, scoreboard.scores()) |
||||
}) |
||||
|
||||
t.Run("saving", func(t *testing.T) { |
||||
// this test tests scoreboard's periodic save feature using a ticker
|
||||
// channel which will be written to manually. The saveLoopWaitCh is used
|
||||
// here to ensure that each ticker has been fully processed.
|
||||
ticker := make(chan time.Time) |
||||
fileStub := newFileStub("") |
||||
scoreboard := newScoreboard(t, fileStub, ticker) |
||||
|
||||
tick := func() { |
||||
ticker <- time.Time{} |
||||
scoreboard.saveLoopWaitCh <- struct{}{} |
||||
} |
||||
|
||||
// this should not effect the save file at first
|
||||
scoreboard.guessedCorrect("foo") |
||||
assertSavedScores(t, map[string]int{}, fileStub) |
||||
|
||||
// after the ticker the new score should get saved
|
||||
tick() |
||||
assertSavedScores(t, map[string]int{"foo": 1000}, fileStub) |
||||
|
||||
// ticker again after no changes should save the same thing as before
|
||||
tick() |
||||
assertSavedScores(t, map[string]int{"foo": 1000}, fileStub) |
||||
|
||||
// buffer a bunch of changes, shouldn't get saved till after tick
|
||||
scoreboard.guessedCorrect("foo") |
||||
scoreboard.guessedCorrect("bar") |
||||
scoreboard.guessedCorrect("bar") |
||||
assertSavedScores(t, map[string]int{"foo": 1000}, fileStub) |
||||
tick() |
||||
assertSavedScores(t, map[string]int{"foo": 2000, "bar": 2000}, fileStub) |
||||
}) |
||||
} |
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Test httpHandler component
|
||||
|
||||
type mockScoreboard map[string]int |
||||
|
||||
func (mockScoreboard) guessedCorrect(name string) int { return 1 } |
||||
|
||||
func (mockScoreboard) guessedIncorrect(name string) int { return -1 } |
||||
|
||||
func (m mockScoreboard) scores() map[string]int { return m } |
||||
|
||||
type mockRandSrc struct{} |
||||
|
||||
func (m mockRandSrc) Int() int { return 666 } |
||||
|
||||
func TestHTTPHandlers(t *testing.T) { |
||||
mockScoreboard := mockScoreboard{"foo": 1, "bar": 2} |
||||
httpHandlers := newHTTPHandlers(mockScoreboard, mockRandSrc{}, nullLogger{}) |
||||
|
||||
assertRequest := func(t *testing.T, expCode int, expBody string, r *http.Request) { |
||||
t.Helper() |
||||
rw := httptest.NewRecorder() |
||||
httpHandlers.ServeHTTP(rw, r) |
||||
if rw.Code != expCode { |
||||
t.Errorf("expected HTTP response code %d, got %d", expCode, rw.Code) |
||||
} else if rw.Body.String() != expBody { |
||||
t.Errorf("expected HTTP response body %q, got %q", expBody, rw.Body.String()) |
||||
} |
||||
} |
||||
|
||||
r := httptest.NewRequest("GET", "/guess?name=foo&n=665", nil) |
||||
assertRequest(t, 400, "Try higher. Your score is now -1\n", r) |
||||
|
||||
r = httptest.NewRequest("GET", "/guess?name=foo&n=667", nil) |
||||
assertRequest(t, 400, "Try lower. Your score is now -1\n", r) |
||||
|
||||
r = httptest.NewRequest("GET", "/guess?name=foo&n=666", nil) |
||||
assertRequest(t, 200, "Correct! Your score is now 1\n", r) |
||||
|
||||
r = httptest.NewRequest("GET", "/scores", nil) |
||||
assertRequest(t, 200, "bar: 2\nfoo: 1\n", r) |
||||
} |
@ -0,0 +1,4 @@ |
||||
--- |
||||
layout: code |
||||
include: main_test.go |
||||
--- |
@ -0,0 +1,384 @@ |
||||
package main |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"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 |
||||
|
||||
// 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) (*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, |
||||
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] += 1000 |
||||
return s.scoresM[name] |
||||
} |
||||
|
||||
func (s *scoreboard) guessedIncorrect(name string) int { |
||||
s.scoresLock.Lock() |
||||
defer s.scoresLock.Unlock() |
||||
|
||||
s.scoresM[name] -= 1 |
||||
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
|
||||
|
||||
const ( |
||||
saveFilePath = "./save.json" |
||||
listenAddr = ":8888" |
||||
saveInterval = 5 * time.Second |
||||
) |
||||
|
||||
func main() { |
||||
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) |
||||
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