Update on Go 1.24 tool dependencies
Go 1.24 introduced the -tool
flag to go get
and the tool
stanza to go.mod
. This makes tool dependencies an excellent way to manage protobuf plugins. I have
rewritten this post with that in mind.
Background
When working with Protobuf and gRPC, one of the first things that you as a user has to figure out is how to generate code from your Protobuf definitions. When working with Go, this means understanding how to install the Go Protobuf and gRPC plugins. That might seem simple, but anyone who has had to manage Go Protobuf and gRPC generation within an organization knows that a few issues pop up immediately:
-
How do we ensure that users are using the same versions of plugins?
The Go Protobuf Plugins generally produce slightly different code from version to version, so unless everyone is on the same version, you can end up with small changes every time a user reruns generation. This can be confusing, as it’s unclear why so many files change, and it makes reviewers job harder, as they have to constantly skim over files changes that are unrelated to the code being reviewed. More alarmingly, generated code behavior may differ from developer to developer!
-
How do we ensure that plugins and the associated libraries are compatible?
Most popular Go Protobuf plugins have some sort of library dependency for much of their functionality, including Go, Go-gRPC and the gRPC-Gateway. The generated files produced by the plugins are generally compatible with a range of versions from their respective runtime libraries, but to be safe it is recommended that the version of the plugin and the runtime library is the same.
The state of the art
There are a number of ways that I have seen teams approach this problem, including:
-
Using a Docker image with pre-installed versions of all plugins
This doesn’t solve the problem of ensuring that the plugins and library versions are the same, since generally the
Dockerfile
will include a number of lines along lines ofRUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.20.0 ...
Sometimes this
Dockerfile
also lives in a different repo from where it is used, and may be used across multiple repositories, so updating versions becomes a dance of hoping-it-doesn’t-break-anyone. -
Using “tool dependencies”
The tool dependencies pattern is a somewhat “blessed” model of versioning tools in Go modules land, but it can come with downsides. The reason for this is that adding a tool dependency this way adds all of its dependencies to your dependency closure, meaning that you might end up choosing versions of dependencies that are pinned by the tool, or worse, building the tool with a set of dependencies that it has not been tested with. This causes real problems in the wild, such as panics and unexpected behavior.
-
Using Buf’s remote plugin execution
The Buf Schema Registry remote plugins feature is great for simplifying plugin versioning in general (disclaimer: I worked on this). However, it still doesn’t solve the issue of enforcing that plugin versions and library versions are the same, as it requires you to version the plugin in your
buf.gen.yaml
while versioning your library dependency in yourgo.mod
(or directly in ago install
command). Short of using dynamic generation templates with plugin version information populated from some other single source of truth, this doesn’t quite solve our problem either, and even if we wanted to go down that path, using remote plugins may not be possible in every environment. -
Using the Buf Schema Registry Generated SDKs
This does solve both plugin versioning and runtime library versioning, as its all handled on the BSR for you, and the module zip returned from the BSR declares the runtime library version that it needs to run with. However, it requires you to keep all of your Protobuf definitions on the BSR, which is not yet possible for everyone.
Having listed all of the known ways to solve this, and their drawbacks, I today want to discuss a less known way of solving this problem using Go’s modern version installation tooling.
Go 1.24 tool dependencies
With Go 1.24, we can version the tool dependencies using Go modules and still execute the tools at the right version using the Go tool. Let me illustrate with an example.
If you just want to see the code, I’ve put together an example repo.
An example project
Say you want to use protoc-gen-go
, protoc-gen-go-grpc
and
protoc-gen-grpc-gateway
to build an application in Go that uses gRPC and the
gRPC-gateway. The first thing we’ll do is figure out the versions we want to
use. This is a greenfield project, so we’ll just use the (currently) latest
versions:
Plugin name | Version |
---|---|
protoc-gen-go |
v1.36.5 |
protoc-gen-go-grpc |
v1.5.1 |
protoc-gen-grpc-gateway |
v2.26.1 |
Now, remembering the second point from above, we need to ensure (as much as we
can) that the version of the plugin we use generates code that is compatible
with the version of its runtime library in our go.mod
file. The best way to
do this is via the use of a so-called “tool dependency”. It is the easiest way to ensure that code generator and runtime library are the same version.
Improving tool dependencies
As a quick primer, adding a “tool dependency” means executing go get -tool <tool module path/path to tool main.go>
. In our case, it looks like this (for protoc-gen-go
):
$ go get -tool google.golang.org/protobuf/cmd/protoc-gen-go
After running this, the Go tool will add this package to your dependency closure (as an indirect dependency), and add a new tool
stanza to your go.mod
file. It will look like this:
tool (
google.golang.org/protobuf/cmd/protoc-gen-go
)
This will be the same version of the google.golang.org/protobuf
library used by the generated code, avoiding any version differences. We can run
the tool using
$ go tool protoc-gen-go
This will download, build and run the version of protoc-gen-go
that is
specified in our go.mod
file. Of course, it will also cache any build
artifacts so this only takes any significant time the first time you run it
for each version.
We can now add the other tools we want:
$ go get -tool google.golang.org/grpc/cmd/protoc-gen-go-grpc
$ go get -tool github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway
Generating using Buf
To use our tools with buf
, use a buf.gen.yaml
similar to this:
version: v2
plugins:
- local: ["go", "tool", "protoc-gen-go"]
out: gen/go
opt:
- paths=source_relative
- local: ["go", "tool", "protoc-gen-go-grpc"]
out: gen/go
opt:
- paths=source_relative
- local: ["go", "tool", "protoc-gen-grpc-gateway"]
out: gen/go
opt:
- paths=source_relative
And run
$ buf generate
This tells buf
to use the Go tool to execute the tools managed in your Go module.
Generating using protoc
Generating using protoc
is a little trickier, since it expects a single executable. How do we get
it to run go tool protoc-gen-go
when it expects to just execute an executable? Bash to the rescue!
#!/usr/bin/env bash
exec go tool protoc-gen-go
Once we mark this file as executable, we can use it as a Protoc plugin, as
bash will pass standard in and standard out to the command we execute, which will
run the plugin at the desired version. We can name this file protoc-gen-go
and put it in a directory in our repository (e.g., /bin
).
To generate with protoc
:
$ protoc \
--plugin=protoc-gen-go=$(pwd)/bin/protoc-gen-go --go_out=./gen/go --go_opt=paths=source_relative \
--plugin=protoc-gen-go-grpc=$(pwd)/bin/protoc-gen-go-grpc --go-grpc_out=./gen/go --go=grpc_opt=paths=source_relative \
--plugin=protoc-gen-grpc-gateway=$(pwd)/bin/protoc-gen-grpc-gateway --grpc-gateway_out=./gen/go --grpc-gateway_opt=paths=source_relative \
<path to proto files>
Or, if you prefer using $PATH
overrides:
$ PATH=$(pwd)/bin/:$PATH protoc \
--go_out=./gen/go --go_opt=paths=source_relative \
--go-grpc_out=./gen/go --go=grpc_opt=paths=source_relative \
--grpc-gateway_out=./gen/go --grpc-gateway_opt=paths=source_relative \
<path to proto files>
Encore
Now, the astute reader will know that buf
is also a Go tool and can be
versioned in the same way. Instead of requiring the user to install buf
at
a specific version, we could write a go generate
directive that runs it like
we do protoc-gen-go
:
package main
//go:generate go run github.com/bufbuild/buf/cmd/buf@v1.50.0 generate
That means the only thing we have to run to download buf
and all the plugins
we use is:
$ go generate ./...
How neat is that?
Conclusion
We’ve learned why Go Protobuf plugin management is important, and explored a few of the most common solutions people use today. We’ve dived deeper into the use of tool dependencies for Go protobuf plugin management.
See the example repo for a complete example of using this method for managing your Go Protobuf plugin versions.
If you enjoyed this blog post, have any questions or input, don’t hesitate to
contact me on @jbrandhorst.com or
under jbrandhorst
on the Gophers Slack. I’d love to hear your thoughts!