working out how to use longer code examples
This commit is contained in:
parent
0450e50258
commit
e346068f58
@ -15,6 +15,8 @@ plugins:
|
|||||||
- jekyll-feed
|
- jekyll-feed
|
||||||
- jekyll-seo-tag
|
- jekyll-seo-tag
|
||||||
- jekyll-paginate
|
- jekyll-paginate
|
||||||
|
- jekyll-relative-links
|
||||||
|
|
||||||
|
|
||||||
date_format: "%b %-d, %Y"
|
date_format: "%b %-d, %Y"
|
||||||
|
|
||||||
|
10
_layouts/code.html
Normal file
10
_layouts/code.html
Normal file
@ -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 }}
|
@ -6,7 +6,7 @@ description: >-
|
|||||||
---
|
---
|
||||||
|
|
||||||
[A previous post in this
|
[A previous post in this
|
||||||
blog](2019-08-02-program-structure-and-composability.html) focused on a
|
blog](/2019/08/02/program-structure-and-composability.html) focused on a
|
||||||
framework developed to make designing component-based programs easier. In
|
framework developed to make designing component-based programs easier. In
|
||||||
retrospect, the pattern/framework proposed was over-engineered. This post
|
retrospect, the pattern/framework proposed was over-engineered. This post
|
||||||
attempts to present the same ideas in a more distilled form, as a simple
|
attempts to present the same ideas in a more distilled form, as a simple
|
||||||
|
314
assets/component-oriented-design/v1/main.go
Normal file
314
assets/component-oriented-design/v1/main.go
Normal file
@ -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
|
||||||
|
}
|
4
assets/component-oriented-design/v1/main.md
Normal file
4
assets/component-oriented-design/v1/main.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
layout: code
|
||||||
|
include: main.go
|
||||||
|
---
|
157
assets/component-oriented-design/v1/main_test.go
Normal file
157
assets/component-oriented-design/v1/main_test.go
Normal file
@ -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)
|
||||||
|
}
|
4
assets/component-oriented-design/v1/main_test.md
Normal file
4
assets/component-oriented-design/v1/main_test.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
layout: code
|
||||||
|
include: main_test.go
|
||||||
|
---
|
384
assets/component-oriented-design/v2/main.go
Normal file
384
assets/component-oriented-design/v2/main.go
Normal file
@ -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()
|
||||||
|
}
|
4
assets/component-oriented-design/v2/main.md
Normal file
4
assets/component-oriented-design/v2/main.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
layout: code
|
||||||
|
include: main.go
|
||||||
|
---
|
Loading…
Reference in New Issue
Block a user