Golang uses code generation heavily. Here-and-there developers generate gRPC stubs, parts of source code, documentation, commit messages, etc. Go even has a special //go:generate directive. Bazel moves code generation to the next level. In this post, I’ll show you how to produce and build Go code with Bazel and with generators written in Go.
genrule + cat
Our process will look like this:
- Generate a
hello.go
file withcat
utility. - Build a binary out of
hello.go
file. - Run it.
It looks simple enough. Let’s see how it could be done. I’m going to generate
and then execute the following Hello World
application.
package main
import "fmt"
func main() {
fmt.Printf("Hello World!")
}
As we know from my previous
post, to
build a binary, we need to use go_binary
Bazel target:
go_binary(
name = "hello-world",
embed = [":hello-world_lib"],
importpath = "github.com/ekhabarov/hello-world",
)
It embeds another target, go_library:
go_library(
name = "hello-world_lib",
srcs = ["hello.go"],
importpath = "github.com/ekhabarov/hello-world",
)
Which in turn accepts a source file hello.go
. Usually, we have such source
files, but not today. So, now we have to generate it. For this, we will be
using built-in Bazel
genrule. As per
documentation:
A genrule generates one or more files using a user-defined Bash command.
A-ha, that’s what we need. Here is how it can be used:
genrule(
name = "generate_hello_go",
outs = ["hello.go"],
cmd = """
cat << EOF >> $@
package main
import "fmt"
func main() {
fmt.Printf("Hello World!")
}
EOF
""",
)
This rule creates the file hello.go
, by running cat
command and redirecting
output to $@
, which is a file defined in the outs
attribute. More about
Bazel Make variables
here.
Ok, let’s build it (link to the repo under the post description):
% bazel build //cat:generate_hello_go
INFO: Analyzed target //cat:generate_hello_go (1 packages loaded, 1 target configured).
INFO: Found 1 target...
Target //cat:generate_hello_go up-to-date:
bazel-bin/cat/hello.go <<< HERE IS OUR GENERATED FILE
INFO: Elapsed time: 0.125s, Critical Path: 0.02s
INFO: 2 processes: 1 internal, 1 darwin-sandbox.
INFO: Build completed successfully, 2 total actions
% cat bazel-bin/cat/hello.go
package main
import "fmt"
func main() {
fmt.Printf("Hello World!")
}
As you can see, the generated file is available a) after the build only and
b) in the Bazel cache only. Now, we can use this file as a source for
go_library
. But let’s look at genrule
docs again to see
how these rules could be linked:
When referencing the genrule in other rules, you can use either the genrule’s label or the labels of individual output files. Sometimes the one approach is more readable, sometimes the other: referencing outputs by name in a consuming rule’s srcs will avoid unintentionally picking up other outputs of the genrule, but can be tedious if the genrule produces many outputs.
i.e., we can use go_library
“as is”, or we can modify it this way:
go_library(
...
srcs = [":generate_hello_go"],
...
)
And the final test, let’s run our generated binary:
% bazel run //cat:hello-world
INFO: Analyzed target //cat:hello-world (1 packages loaded, 4 targets configured).
INFO: Found 1 target...
Target //cat:hello-world up-to-date:
bazel-bin/cat/hello-world_/hello-world <<< GENERATED BINARY
INFO: Elapsed time: 0.121s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/cat/hello-world_/hello-world
Hello World!% <<< HERE IS AN OUTPUT
Woohoo, it works! Now, let’s look at a bit more complex example.
genrule + Go binary
What if, instead of the cat
command, we would have a Go binary to generate
our code? How will our process change?
- Build a generator: the first Go binary.
- Generate a
hello.go
file with the built generator. - Build a binary out of
hello.go
file. - Run it.
We need one more additional step, generator build. All other steps are the same.
For this example, I’ll be using hello-world-generator, which prints out the following Go code:
// hello-world-generator
package main
import "fmt"
var tpl = `package main
import (
"flag"
"fmt"
)
var name = flag.String("name", "World", "Just a name")
func main() {
flag.Parse()
fmt.Printf("Hello, %s!\n", *name)
}`
func main() {
fmt.Println(tpl)
}
To utilize it, we need the following:
- Add
hello-world-generator
as a dependency to our project. - Modify the
genrule
that produceshello.go
.
Add a generator dependency
hello-world-generator
is a Go binary, so we can add it as any other Go
dependency by calling go_repository
rule. This call can be added to the
WORKSPACE
file, or, as I did, we can add it to
go_deps.bzl.
# go_geps.bzl
load("@bazel_gazelle//:deps.bzl", "go_repository")
def go_deps():
go_repository(
name = "com_github_ekhabarov_helloworld_generator",
importpath = "github.com/ekhabarov/helloworld-generator",
sum = "h1:MrREQgX6I0/4cstUhbuqfALzUF3W2Nz8kVZRq6A4q+E=",
version = "v0.0.1",
)
It’s a Go binary, i.e., we can run it right away on its own:
% bazel run @com_github_ekhabarov_helloworld_generator//:helloworld-generator
...skipped...
package main
import (
"flag"
"fmt"
)
var name = flag.String("name", "World", "Just a name")
func main() {
flag.Parse()
fmt.Printf("Hello, %s!\n", *name)
}
And here ^ is the source code we have to build. We have a generator. Now it’s
time to modify our genrule
:
genrule(
name = "generate_hello_go",
outs = ["hello.go"],
cmd = "$(execpath @com_github_ekhabarov_helloworld_generator//:helloworld-generator) > $@",
tools = [
"@com_github_ekhabarov_helloworld_generator//:helloworld-generator",
],
)
There are two main changes:
tools
attribute, which contains a list of tools that must be built before Bazel will rungenrule
.cmd
attribute that has a call for our generator.execpath
denotes the path beneath the execroot where Bazel runs build actions (c).
go_library
and go_binary
targets remain unchanged.
% bazel run //go:hello-world -- -name Go
...bazel info output skipped...
Hello, Go!
For a generator, technically, we can use any other binary or a script we can build with Bazel.
Have fun!