Spectral Contexts in Go
hypirion.com
hypirion.com
type Server struct {
logger *Logger
users *UserService
// etc.
}
Assuming we use a router like Chi or similar: router.Get("/users", srv.HandleUsers)
...where HandleUsers is a method on Server.Some people like to spread handlers in different packages. You can of course declare handlers as functions with no receiver struct, and pass the data as an argument:
router.Get("/users", func(w io.ResponseWriter, r *http.Request) {
userroutes.HandleUsers(srv, w, r)
})
For middlewares, e.g. something that parses a session cookie and fetches the current user, do exactly the same thing.The data you store in the context should be specific to the current request. Think: request ID, user ID, auth info, etc.
I think this is more prevalent in Go though because go forces you to make many more local variables (since error handling prevents you from composing expressions), and when you need lots of useless local variables for intermediate values with no externally imposed meaning people tend to fall back to things that are easy to write.
It’s actually very pragmatic and once you embrace it, it makes writing and reasoning about Go much easier than you’d expect.
The way you write Go isn’t like most languages I’ve encountered. You write a lot of local variables which typically come from well-named structs and functions, so keeping track of what’s what is extremely low effort. Tracing what something is happens very naturally, and arguably easier because there’s less data to parse.
It’s counter intuitive, but I was extremely resistant to it and now I like it (when writing Go) quite a bit. I don’t do this in other languages I write (like TypeScript or Rust); it’s definitely a Go-ism.
i := 0
But a more complex thing might get a fuller name: producer := NewProducer()
In the earlier case with the HTTP handlers, "r" and "w" are conventions. They appear so often that it's counterproductive to give them full names. People who read the code would be confused.Before Go, I did a lot of Java and Ruby, and felt like you did, and went against the conventions. But the code I wrote back when I started with Go now looks foreign to me.
It’s weird. It isn’t a complex problem or solution, but almost every team I’ve worked on hasn’t done this. You get to this point where you’ve seen so much context-stuffing that you half expect to see tools to help you do it in the standard library. You almost wonder if your aversion to it is because you’re the crazy one, haha.
> Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
The advice to use the context as the article proposes is a recipe for a regretted design. I guarantee it. It’s a fun, clever thought experiment but should not be applied to production code.
Also, while I think I know what "unlike Rust, these phantom types appear at runtime in Go" is saying, it's saying it in a really confusing way. These types do not "appear at runtime"; they are exactly like all other types, constructed at compile time and visible when interface-boxed at runtime. They're in no way "spectral".
My only real issue with them is lack of type safety so you have to remember which contest var is what.
For example I'd like to have User context variable that is set somewhere early if user is authenticated but now at every subsequent call there needs to be a bit of fluff to check whether it is right type, else you risk runtime safety.
Not really a problem. If you pass a channel you can close the channel. And cancel isn't magical, still need to handle it from within a goroutine and adding a timeout isn't any more or less complex than handling context cancellation
> Given that, I think you can handwave it a bit and regard context variables as goroutine local.
That's extremely wrong way to handle it, especially if it is in a bigger app. There is nothing stopping from changing the values in it in other goroutines for example, and one of designed uses of ctx is scatter-gather pattern of spawning multiple parallel tasks that need to be done to respond to request, and other is using ctx to pass data between middlewares (as is often used in web frameworks in Go at least). So you have to at least take care about variable naming across the project and parallel access to them might happen so you need to take care about not running 2 goroutines wanting to write into same variable.
https://cs.opensource.google/go/go/+/refs/tags/go1.20.5:src/...
Even passing the single context variable increases clutter a lot.
- Request-scoped parameters like user ID, request ID, etc.
- Dependency injection (database connection and other IO)
- Cancellation
It works great. The one hairy part is when you need to change the context, for example when logging in you take an anonymous context and end up with a logged-in context. We use linters to ensure that stuff is right, but it still takes some thought in the edge cases.
Still, I think those issues are exposing problems in our codebase rather than problems with typed contexts.
The only use of dependency injection for us is testing. Our test fixtures have to provide both request-scoped info (user ID etc.) and mocks for IO. It makes sense to do that all in one object.
I still think these gotchas are indicative of issues in the codebase rather than the struct or the typed context. They only come up in the hairiest parts of the code.
i found this to be a good approach for injecting dependencies, especially when the methods implement an interface
Furthermore, why is that code using string keys for the context? It's recommended in the context package documentation that context keys should always be an unexported type, so that the key can never collide with a different key.[1] It's common to define accessor functions which are type safe
[0] https://google.github.io/styleguide/go/decisions.html#custom...
But, i am finding them useful in building a pipeline composer similar to https://www.reddit.com/r/RedditEng/comments/z137m3/from_serv...