Generating protobuf-generated Go code without a registry
I’ve recently had to generate Go code from protos, without the aid of a proto registry. This article covers my advice for anyone needing to do the same.
Generating Go code with protoc
Generating code with protoc
is best for protos that have few or no
dependencies.
Simple code generation
Imagine two simple protos:
// protos/user/user.proto
syntax = "proto3";
package user;
option go_package = "github.com/username/myrepo/protos/user";
message User {
string name = 1;
}
// protos/server/server.proto
syntax = "proto3";
package server;
option go_package = "github.com/username/myrepo/protos/server";
import "user/user.proto";
message Server {
repeated user.User users = 1;
}
Generate Go code (.pb.gos) from this simple proto with protoc
:
$ protoc protos/**/*.proto \
--go_out=protos \
-I protos/ \
--go_opt=paths=source_relative
$ tree
.
|____protos
| |____server
| | |____server.pb.go
| | |____server.proto
| |____user
| | |____user.pb.go
| | |____user.proto
Warning: This approach allows others to depend on your generated Go code.
This example signs you up to maintaining generated Go code at github.com/username/myrepo/protos/server
and [...]/protos/user
. Whether or not this is a good idea is discussed in “Storing generated code”. For now suffice it to say that instead of declaring option go_package
you could instead provide --go_opt=M<proto>=<go import>
. And, instead of generating your .pb.go
s into the publicly import-able protos/
, you could generate them into an internal/
directory which is not publicly import-able.
If you need to produce gRPC language bindings, add the gRPC option equivalents --go-grpc_out
and --go_grpc_opt
:
$ protoc protos/**/*.proto \
--go_out=protos \
--go-grpc_out=protos \
-I protos/ \
--go_opt=paths=source_relative \
--go-grpc_opt=paths=source_relative
Foreign protos
Part of your dependency graph may include protos in other repos:
// protos/user/user.proto
syntax = "proto3";
package user;
option go_package = "github.com/username/myrepo/protos/user";
// Not in this repo. Also not repo-rooted, which we'll discuss below. We could
// change this if we owned user.proto, but if this occurred in a foreign proto
// that we can't change then we'd have to work around it.
import "non/repo/rooted/import.proto";
message User {
string name = 1;
}
You’ll need to git clone the foreign protos and include them with -I
during your protoc
invocation.
You’ll may also need to account for quirks, such as:
-
Dealing with imports not rooted at its repo root. Consider your code A importing B, and B importing another proto C, but not at C’s repo root. Instead, B imports C at at
$REPO-ROOT/some/nested/dir
. Since you don’t own B (another team / company does), you can’t change B. Get around this with-I
rooted at the place the import expects, as shown below. -
Dealing with imports that do not define
option go_package
. When protos in your dependency graph don’t defineoption go_package
, you’ll have to tellprotoc
which go_package to use with--go_opt=M<proto>=<go import path>
. You’ll probably have to generate the foreign proto’s.pb.go
into your module and point the aforementioned Go import path there, under the assumption that if they haven’t defined an option go_package then it’s also likely they are not generating and publishing Go bindings for you to use.
TMP=$(mktemp -d)
git clone https://github.com/foreignorg/foreignrepo.git "$TMP/foreign"
mkdir -p internal/protogen
protoc protos/**/*.proto \
non/repo/rooted/import.proto \
--go_out=internal/protogen \
-I protos \
--go_opt=Muser/user.proto=github.com/username/myrepo/internal/protogen/user \
--go_opt=Mserver/server.proto=github.com/username/myrepo/internal/protogen/server \
-I $TMP/path/to/expected/import/root \
--go_opt=Mnon/repo/rooted/import.proto=github.com/foreignorg/foreignrepo/path/to/expected/import/root \
--go_opt=paths=source_relative
Which when run would generate:
$ tree
.
|____internal
| |____protogen
| | |____server
| | | |____server.pb.go
| | |____user
| | | |____user.pb.go
| | |____non
| | | |____repo
| | | | |____rooted
| | | | | |____import.pb.go
Note that all the generated code was generated into internal/
. That involved overwriting user.proto
and server.proto
’s option go_package
with --go_opt=M
. That’s because different generated versions of import.proto
may not exist (directly or transitively) in any Go import graph due to Go’s global proto registry. So, internal/
is used to prevent anybody from depending on these .pb.go
s. Read more on this in “Storing generated code”.
Large dependency graphs
Using protoc
becomes progressively more unwieldy the larger your proto dependency graph becomes, since you have to specify all transitive dependencies at protoc
time.
Let’s now look at a better tool for managing larger dependency graphs.
Generating Go code with buf
Generating code with buf
is better for protos that have more than a few dependencies.
Simple code generation
Imagine two simple protos:
// protos/user/user.proto
syntax = "proto3";
package user;
option go_package = "github.com/username/myrepo/protos/user";
message User {
string name = 1;
}
// protos/server/server.proto
syntax = "proto3";
package server;
option go_package = "github.com/username/myrepo/protos/server";
import "user/user.proto";
message Server {
repeated user.User users = 1;
}
Generate Go code (.pb.gos) from these protos with buf by adding:
-
A
buf.yaml
:# buf.yaml # For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml version: v2 lint: use: - STANDARD breaking: use: - FILE modules: - path: protos/
-
A
buf.gen.yaml
:# buf.gen.yaml # For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-gen-yaml version: v2 plugins: - remote: buf.build/protocolbuffers/go:v1.36.6 out: protos opt: - paths=source_relative
Generate code with buf generate
:
$ buf generate
$ tree
.
|____protos
| |____server
| | |____server.pb.go
| | |____server.proto
| |____user
| | |____user.pb.go
| | |____user.proto
Warning: This approach allows others to depend on your generated Go code.
This example signs you up to maintaining generated Go code at github.com/username/myrepo/protos/server
and [...]/protos/user
. Whether or not this is a good idea is discussed in “Storing generated code”. For now suffice it to say that instead of declaring option go_package
you could instead provide --go_opt=M<proto>=<go import>
. And, instead of generating your .pb.go
s into the publicly import-able protos/
, you could generate them into an internal/
directory which is not publicly import-able.
If you need to produce gRPC language bindings, add the gRPC plugin to buf.gen.yaml
:
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go:v1.36.6
out: protos
opt:
- paths=source_relative
- remote: buf.build/grpc/go:v1.5.1
out: protos
opt:
- paths=source_relative
Foreign protos
Part of your dependency graph may include protos in other repos:
// protos/user/user.proto
syntax = "proto3";
package user;
option go_package = "github.com/username/myrepo/protos/user";
// Not in this repo. Also not repo-rooted, which we'll discuss below. We could
// change this if we owned user.proto, but if this occurred in a foreign proto
// that we can't change then we'd have to work around it.
import "non/repo/rooted/import.proto";
message User {
string name = 1;
}
Foreign protos that are neither in your repo nor the Buf Schema Registry (BSR) can be imported, but in a roundabout way. You’ll need to git clone them yourself and include them as modules in buf.yaml.
Before we do so, let’s quickly recap proto quirks you might run into, described above in “Generating code with protoc: Foreign protos”. We’ll need to contend with the fact that the import.proto
import was not rooted at its repo root, and that import.proto
does not define option go_package
.
# buf.yaml
version: v2
lint:
use:
- STANDARD
breaking:
use:
- FILE
modules:
- path: protos/
- path: tmp/path/to/expected/import/root
The non-repo rooted import is handled by specifying the path at which the import "non/repo/rooted/...";
import works.
To handle the lack of option go_package
, we’ll need to turn on managed mode in buf.gen.yaml
:
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go:v1.36.6
out: internal/protogen
opt:
- paths=source_relative
managed:
enabled: true
override:
- file_option: go_package_prefix
path: non/repo/rooted/
value: github.com/foreignorg/foreignrepo/path/to/expected/import/root
- file_option: go_package_prefix
path: user/user.proto
value: github.com/username/myrepo/internal/protogen
- file_option: go_package_prefix
path: server/server.proto
value: github.com/username/myrepo/internal/protogen
Next, let’s perform the git clone
and tmp/
management:
git clone https://github.com/foreignorg/foreignrepo.git tmp/foreign
buf generate
Which when run generates:
$ tree
.
|____internal
| |____protogen
| | |____server
| | | |____server.pb.go
| | |____user
| | | |____user.pb.go
| | |____non
| | | |____repo
| | | | |____rooted
| | | | | |____import.pb.go
Note that all the generated code was generated into internal/
. That involved overwriting user.proto
and server.proto
’s option go_package
with --go_opt=M
. That’s because different generated versions of import.proto
may not exist (directly or transitively) in any Go import graph due to Go’s global proto registry. So, internal/
is used to prevent anybody from depending on these .pb.go
s. Read more on this in “Storing generated code”.
Concurrent git clones
If you have many git clone
statements, consider using &
and wait
to run them concurrently:
git clone ... &
git clone ... &
git clone ... &
wait
buf generate