October 15, 2019 in Programming6 minutes
In the previous posts, I covered the basics of connecting to NATS in Go and the different ways subscribers can request information is sent to them.
In this post, I’d like to build on those concepts by exploring how to structure your NATS-powered Go code so that things are clean and DRY. I’ll also show that trying to make things too DRY can be problematic; as with everything, moderation is a good idea.
I have seen a lot of projects use messaging systems like NATS, where each service implements its own connectivity logic from scratch, which is not only unnecessarily repetitive, it also creates a bit of a management nightmare.
Rather, it seems sensible to centralize communications-related functionality in a separate package of your Go applications, rather than have function calls sprinkled throughout your application that call a system like NATS. This will allow you to make changes to the communications layer much more cleanly in the future.
To facilitate this, I created a package called comms
where I created a type for holding not only the NATS connection objects, but also all channels that a microservice
might want to use.
I also created a function that can be called by any of my services which returns a pre-populated instance of Comms
.
The idea here was that I would just need to call this function once from each service, and it will return a pointer to a Comms
instance with channels ready to send and receive data.
This definitely did have a positive effect on the code at the publisher and subscriber - instead of having to do the above code for each service, I just had to call the NewComms()
function.
However, when I actually ran these, I saw some interesting behavior. It appeared as though some of the messages being sent from the publisher were being dropped along the way. Since I was putting a counter variable in each of the messages, this was easy to see:
Since I’ve only ever heard that NATS is super battle-tested and stable, I knew it was something I was doing wrong, and not NATS going on the fritz. So ensued several hours of me looking through the source of the Go client - here’s what I found.
The BindRecvChan()
function we have been calling in our code is located in netchan.go
and is ultimately satisfied
by an unexported function
that declares a handler for incoming messages, and passes it off to the subscribe
function of the connection
object. If you look at how that function handles subscriptions when such a handler is defined, you’ll notice that it
spins up a goroutine
to handle all future incoming messages.
What this means is that simply by binding our Go channel to the NATS client, we’re already subscribing to the indicated channel. This is easy to miss if you’re not even intending on using the subscription logic - if you centralize your code like I did above, even if you only care to use the publishing channel, on binding a receive channel, you’re a subscriber. What was happening in the terminal output above was that NATS wasn’t dropping any messages; rather, I had one more subscriber than I intended, and NATS was load-balancing messages to both.
This seems a bit obvious in retrospect, but this kind of thing can happen often if you get over-zealous about code DRY-ness, neglecting deep understanding about what your code actually does.
Okay now that my silly mistake (which totally didn’t take hours out of my evening) is out of the way - how can we centralize the communication logic to get as DRY as possible without going overboard?
As with anything in programming, there’s probably no single “right” answer. For me, though, I still wanted to use the experience of binding to Go channels, which meant I needed to find a way to allocate the right channels for the right use cases while consolidating as much as possible into a single package and a few functions so that we retain the cleanliness of having this logic centralized.
For communications-related functionality like this I like to think about the various microservices that will be using
NATS and breaking them down by use case. When building a proper comms
package, we really need three things:
I like this layout because:
comms
package, so all services can stay simple.I’m sure it’s not the only way to do this, but it’s a way that makes sense to me. If you know of a better way, especially one that’s more Go-idiomatic - feel free to let me know in the comments below, I’m still learning!
Stay tuned for the next post in this series - I have a bit more NATS work to do. :)