From de64f65984d39a10abc3cb8535ef2971a23df0cf Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 10 Nov 2024 13:45:52 -0500 Subject: [PATCH] adding context --- learn_go_with_tests/my_context/README.md | 24 +++ learn_go_with_tests/my_context/my_context.go | 47 +++++ .../my_context/my_context_test.go | 175 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 learn_go_with_tests/my_context/README.md create mode 100644 learn_go_with_tests/my_context/my_context.go create mode 100644 learn_go_with_tests/my_context/my_context_test.go diff --git a/learn_go_with_tests/my_context/README.md b/learn_go_with_tests/my_context/README.md new file mode 100644 index 0000000..9565c12 --- /dev/null +++ b/learn_go_with_tests/my_context/README.md @@ -0,0 +1,24 @@ +# Using Context + +Remember that a common pattern of signaling events or completion is using a blocking channel that receives and empty struct. An empty struct has zero memory footprint. It is typically used to signal completion or stopping. + +The Context.Done() returns a channel that is closed when this Context is canceled `Done() <-chan struct{}`. Contexts have a root and can form a parent child tree structure. A child is "derived" from a parent context and when a Context is cancelled all derived contexts are also canceled. WithCancel and WithTimeout return derived Context values that can be canceled sooner than the parent Context. + +> Incoming requests to a server should create a Context, and outgoing calls to servers should accept a Context. The chain of function calls between them must propagate the Context, optionally replacing it with a derived Context created using WithCancel, WithDeadline, WithTimeout, or WithValue. When a Context is canceled, all Contexts derived from it are also canceled. + +## What about Context.Value + +The problem with context.Values is that it's just an untyped map so you have no type-safety and you have to handle it not actually containing your value. You have to create a coupling of map keys from one module to another and if someone changes something things start breaking. + +In short, if a function needs some values, put them as typed parameters rather than trying to fetch them from +context.Value +. This makes it statically checked and documented for everyone to see. + +Context.Value is good for things like "A trace ID". It's information that is not needed for by every function in the call stack. + +> Context.Value should inform not control. The content of context.Value is for maintainers not users. It should never be required input for documented or expected results. + +## Readings + +https://go.dev/blog/context +https://go.dev/blog/pipelines \ No newline at end of file diff --git a/learn_go_with_tests/my_context/my_context.go b/learn_go_with_tests/my_context/my_context.go new file mode 100644 index 0000000..e552eef --- /dev/null +++ b/learn_go_with_tests/my_context/my_context.go @@ -0,0 +1,47 @@ +package my_context + +import ( + "context" + "fmt" + "net/http" +) + +type Store interface { + Fetch(ctx context.Context) (string, error) + // instead of calling cancel we want to use context above to cross API boundries + // Cancel() +} + +func Server(store Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + // THIS WAS WHAT WE DID WHEN WE DIDN"T PROPOGATE CONTEXT + // // This is the requests ctx not a derived context + // ctx := r.Context() + + // data := make(chan string, 1) + + // go func() { + // data <- store.Fetch() + // }() + + // // Here we are simply waiting on the which channel + // // will close out first. If the Done() channel on the request + // // does store.Cancel() is called. + + // // Remember select lets use wait on multiple async operations and execute cases + // // based on which one completes first as signaled by data being received on that channel. + // select { + // case d := <-data: + // fmt.Fprint(w, d) + // case <-ctx.Done(): + // store.Cancel() + // } + data, err := store.Fetch(r.Context()) + + if err != nil { + return // todo log the error however you like + } + fmt.Fprint(w, data) + } +} diff --git a/learn_go_with_tests/my_context/my_context_test.go b/learn_go_with_tests/my_context/my_context_test.go new file mode 100644 index 0000000..d359d19 --- /dev/null +++ b/learn_go_with_tests/my_context/my_context_test.go @@ -0,0 +1,175 @@ +package my_context + +import ( + "context" + "errors" + "log" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// THIS WAS WHAT WE DID WHEN WE DIDN"T PROPOGATE CONTEXT +// type SpyStore struct { +// response string +// cancelled bool +// t *testing.T +// } + +// func (s *SpyStore) assertWasCancelled() { +// s.t.Helper() +// if !s.cancelled { +// s.t.Error("store was not told to cancel") +// } +// } + +// func (s *SpyStore) assertWasNotCancelled() { +// s.t.Helper() +// if s.cancelled { +// s.t.Error("store was told to cancel") +// } +// } + +// func (s *SpyStore) Fetch() string { +// time.Sleep(100 * time.Millisecond) +// return s.response +// } + +// func (s *SpyStore) Cancel() { +// s.cancelled = true +// } + +// func TestServer(t *testing.T) { +// data := "Hello, Gopher" +// t.Run("Happy path", func(t *testing.T) { +// store := &SpyStore{response: data, t: t} +// svr := Server(store) + +// request := httptest.NewRequest(http.MethodGet, "/", nil) +// response := httptest.NewRecorder() + +// svr.ServeHTTP(response, request) + +// if response.Body.String() != data { +// t.Errorf(`got "%s" want "%s"`, response.Body.String(), data) +// } + +// store.assertWasNotCancelled() + +// }) +// t.Run("Should cancel", func(t *testing.T) { +// store := &SpyStore{response: data, t: t} +// svr := Server(store) + +// request := httptest.NewRequest(http.MethodGet, "/", nil) +// // we are copying the parent context but with a new Done channel. +// // The returned context's Done channel is closed when the returned cancel +// // function is called or when the parent context's Done channel is closed, +// // whichever happens first. +// cancellingCtx, cancel := context.WithCancel(request.Context()) +// time.AfterFunc(5*time.Millisecond, cancel) +// // changing the current request context to our new cancellingCtx +// request = request.WithContext(cancellingCtx) +// response := httptest.NewRecorder() + +// svr.ServeHTTP(response, request) + +// store.a + +// }) +// } + +type SpyStore struct { + response string + t *testing.T +} + +func (s *SpyStore) Fetch(ctx context.Context) (string, error) { + data := make(chan string, 1) + + go func() { + var result string + for _, c := range s.response { + select { + case <-ctx.Done(): + log.Println("spy store got cancelled") + return + default: + // simulates some network traffic? + time.Sleep(10 * time.Millisecond) + result += string(c) + } + } + data <- result + }() + + select { + case <-ctx.Done(): + return "", ctx.Err() + case res := <-data: + return res, nil + } +} + +// Rolling our own Spy for ResponseWriter because httptest.ResponseRecorder can't be +// used to determine if any response was writing in the error case + +// SpyResponse Writer implements http.ResponseWriter so we can use it in the test. +type SpyResponseWriter struct { + written bool +} + +func (s *SpyResponseWriter) Header() http.Header { + s.written = true + return nil +} + +func (s *SpyResponseWriter) Write([]byte) (int, error) { + s.written = true + return 0, errors.New("not implemented") +} + +func (s *SpyResponseWriter) WriteHeader(statusCode int) { + s.written = true +} + +func TestServer(t *testing.T) { + data := "Hello, Gopher" + + t.Run("Happy Path", func(t *testing.T) { + + store := &SpyStore{response: data, t: t} + svr := Server(store) + + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + + svr.ServeHTTP(response, request) + + if response.Body.String() != data { + t.Errorf(`got "%s", want "%s"`, response.Body.String(), data) + } + }) + + t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) { + store := &SpyStore{response: data, t: t} + svr := Server(store) + + request := httptest.NewRequest(http.MethodGet, "/", nil) + + cancellingCtx, cancel := context.WithCancel(request.Context()) + time.AfterFunc(5*time.Millisecond, cancel) + request = request.WithContext(cancellingCtx) + + response := &SpyResponseWriter{} + + svr.ServeHTTP(response, request) + + if response.written { + t.Errorf("A response should not have been written") + } + + }) + +}