Using Advanced Go Features to Detect Stale Code
Service Weaver is a programming framework for writing distributed systems in Go.
Service Weaver programs are composed of actor-like entities called
components, which are defined using native Go constructs. For
example, we can define the interface and implementation of a Calculator
component using an interface and struct respectively:
// Calculator component interface.
type Calculator interface {
Add(context.Context, int, int) (int, error)
}
// Calculator component implementation.
type calc struct {
weaver.Implements[Calculator]
}
func (*calc) Add(_ context.Context, x, y int) (int, error) {
return x + y, nil
}
One component can call another component's methods, even if the two components
are running on different machines. With Service Weaver, you don't have to
implement these remote procedure calls yourself. Instead, Service Weaver
provides a code generator, weaver generate, that generates the code needed
to execute method calls as remote procedure calls. weaver generate writes the
generated code to a file called weaver_gen.go, which is compiled along with
the rest of your application.
$ weaver generate . # writes weaver_gen.go
$ go build . # builds everything, including weaver_gen.go
Whenever you make a significant change to a Service Weaver app, you have to
re-run weaver generate. For example, if we add a Subtract method to the
Calculator component, we need to re-run weaver generate to generate the code
that executes Subtract calls as remote procedure calls.
Unfortunately, it's easy to forget to run weaver generate and to try and build
an application with a stale weaver_gen.go. We want to detect this as early as
possible, preferably at compile time rather than runtime. In this blog post, we
describe some of advanced ways we use Go to detect, at compile time, when you
forget to run weaver generate.
Component Interfaces
You may forget to run weaver generate after adding, removing, or changing a
method in a component's interface (e.g., Calculator).
type Calculator interface {
Add(context.Context, int, int) (int, error)
+ Subtract(context.Context, int, int) (int, error)
}
For every component interface I, weaver generate generates a pair of structs
to execute I's methods as remote procedure calls. One of the structs acts as a
client, and the other acts as a server. For the Calculator component, for
example, weaver generate generates
- a client struct called
calc_client_stuband - a server struct called
calc_server_stub.
The client struct implements interface I, and weaver generate generates code
to check this at compile time:
// Check that calc_client_stub implements the Calculator interface.
var _ Calculator = (*calc_client_stub)(nil)
If you add or change a method in a component's interface but forget to re-run
weaver generate, this check will fail at compile time, as weaver generate
hasn't had an opportunity to generate an implementation of the new or changed
method.
The server stub receives remote procedure calls and dispatches them to a local
instance of the component implementation (calc in this example). Thus, if you
remove a method from a component's interface, the server struct will be calling
a method that no longer exists, and your code will fail to build.
Component Implementations
You may forget to run weaver generate after adding, removing, or changing a
weaver.Implements[T] embedded inside a component implementation struct (e.g.,
calc).
type calc struct {
- weaver.Implements[Calculator]
}
To detect this, weaver.Implements[T] implements an unexported implements(T)
method:
type Implements[T any] struct { ... }
func (Implements[T]) implements(T) {}
Any struct that embeds weaver.Implements[T] inherits this implements(T)
method:
type calc struct {
weaver.Implements[Calculator]
}
// calc inherits the implements method from the embedded weaver.Implements.
var _ func(Calculator) = calc{}.implements
We then introduce an InstanceOf[T] interface:
type InstanceOf[T any] interface {
implements(T)
}
Because implements(T) is unexported, only structs that embed
weaver.Implements[T] will implement the weaver.InstanceOf[T] interface.
weaver generate generates code to check this at compile time:
// Check that calc embeds weaver.InstanceOf[Calculator].
var _ weaver.InstanceOf[Calculator] = (*calc)(nil)
If you remove or change a weaver.Implements[T] embedded inside a component
implementation struct, the previous check will fail to compile. Unfortunately,
we do not currently have a way to detect when you add weaver.Implements[T]
to a struct but forget to re-run weaver generate. In this case, the
application will panic immediately when run.
Serializable Types
With Service Weaver, basic types like ints, bools, strings, pointers, slices,
and maps are serializable by default, but structs are not.
However, they can trivially be made serializable by embedding
weaver.AutoMarshal.
type Pair struct {
weaver.AutoMarshal
x int
y int
}
When weaver generate encounters a struct with an embedded
weaver.AutoMarshal, like Pair above, it generates methods to encode and
decode instances of the struct.
func (p *Pair) WeaverMarshal(enc *codegen.Encoder) {
enc.Int64(p.x)
enc.Int64(p.y)
}
func (p *Pair) WeaverUnmarshal(dec *codegen.Decoder) {
p.x = dec.Int64()
p.y = dec.Int64()
}
You may forget to run weaver generate after adding, removing, or changing
fields inside a struct that embeds weaver.AutoMarshal.
type Pair struct {
weaver.AutoMarshal
- x int
y int
+ z int
}
To detect this, weaver generate copies the definition of the struct S into
weaver_gen.go, instantiates it, and assigns it to a variable of type S. For
Pair, that looks like this:
var _ Pair = struct{
weaver.AutoMarshal
x int
y int
}{}
If you change the definition of Pair in any way but forget re-run weaver generate, this assignment fails to build.
Routers
Components are replicated, and by default, method calls are routed to random component replicas. Service Weaver allows you to override this behavior with a router that specifies exactly how to route method calls. Consider a key-value cache component as an example:
// Cache component interface.
type Cache interface {
Get(ctx context.Context, key string) (string, error)
Put(ctx context.Context, key, value string) error
}
// Cache component implementation.
type cache struct {
weaver.Implements[Cache]
...
}
func (*cache) Get(ctx context.Context, key string) (string, error) { ... }
func (*cache) Put(ctx context.Context, key, value string) error { ... }
To route method calls to the Cache component, we define a router struct that
returns a routing key for every Cache method. Here, the router uses the key
argument passed to the Get and Put methods:
type router struct{}
func (router) Get(_ context.Context, key string) string { return key }
func (router) Put(_ context.Context, key, value string) string { return key }
Next, we embed weaver.WithRouter[router] in the cache struct:
type cache struct {
weaver.Implements[Cache]
weaver.WithRouter[router]
...
}
With all this in place, Cache method calls with the same key will be routed to
the same replica of the Cache component.
You may forget to run weaver generate after adding, removing, or changing an
embedded weaver.WithRouter[T].
type cache struct {
weaver.Implements[Cache]
- weaver.WithRouter[router]
}
To detect this, we introduce a weaver.RoutedBy[T] interface and
weaver.Unrouted interface. If a component struct embeds
weaver.WithRouter[T], then it implements weaver.RoutedBy[T]. All other
component structs implement weaver.Unrouted.
// Component A is routed.
type a struct {
weaver.Implements[A]
weaver.WithRouter[router]
}
// Component B is not routed.
type b struct {
weaver.Implements[B]
}
var _ weaver.RoutedBy[router] = (*a)(nil)
var _ weaver.Unrouted = (*b)(nil)
First, we implement weaver.RoutedBy[T] by adding an unexported routedBy(T)
method to weaver.WithRouter[T]:
type WithRouter[T any] struct{}
func (WithRouter[T]) routedBy(T) {}
Then, we define the weaver.RoutedBy[T] interface.
type RoutedBy[T any] interface {
routedBy(T)
}
Similar to weaver.InstanceOf[T], because routedBy(T) is unexported, only
structs that embed weaver.WithRouter[T] will implement the
weaver.RoutedBy[T] interface.
Next, we implement the weaver.Unrouted interface. This is a bit tricky because
we want a component that doesn't embed weaver.WithRouter to implement
weaver.Unrouted. How do we make a struct implement an interface based on the
absence of something? We start by defining an implementsImpl struct that
implements the weaver.RoutedBy[private] interface for an empty unexported
private type:
type private struct{}
type implementsImpl struct{}
func (implementsImpl) routedBy(private) {}
Next, we embed implementsImpl in the weaver.Implements[T] struct:
type Implements[T any] struct {
implementsImpl
}
With this in place, any component implementation struct that embeds
weaver.Implements[T] will inherit the routedBy(private) method and therefore
implement weaver.RoutedBy[private].
type cache struct {
weaver.Implements[Cache]
...
}
// cache inherits the routedBy(private) method from the embedded
// weaver.Implements[Cache].
var _ func(private) = cache{}.routedBy
We can then define weaver.Unrouted as an alias of weaver.RoutedBy[private]:
type Unrouted interface {
routedBy(private)
}
Now, every component struct that embeds weaver.Implements implements the
weaver.Unrouted interface. Furthermore, if a struct embeds
weaver.WithRouter[T], the routedBy(T) method defined on
weaver.WithRouter[T] overrides the routedBy(private) method defined on the
implementsImpl embedded in weaver.Implements. This causes the struct to
implement the weaver.RoutedBy[T] interface instead of the weaver.Unrouted
interface.
type cache struct {
weaver.Implements[Cache]
weaver.RoutedBy[router]
}
// Both weaver.Implements.implementsImpl and weaver.RoutedBy have a routedBy
// method, but cache inherits the weaver.RoutedBy method because it is less
// embedded.
var _ func(private) = cache.Implements.implementsImpl.routedBy
var _ func(router) = cache.RoutedBy.routedBy
var _ func(router) = cache.routedBy
Finally, weaver generate generates code to check, at compile time, whether
every component is routed or not:
var _ weaver.RoutedBy[router] = (*calc)(nil)
If you add, remove, or change a weaver.RoutedBy[T] embedded in a component
struct, these checks will fail to build.
And finally for good measure, we can rename the private struct to
if_youre_seeing_this_you_probably_forgot_to_run_weaver_generate to make it
easier to understand why your code doesn't build:
$ go build .
# github.com/ServiceWeaver/weaver/examples/calculator
./weaver_gen.go:74:33: cannot use (*calc)(nil) (value of type *calc) as
RoutedBy[router] value in variable declaration: *calc does not implement
RoutedBy[router] (wrong type for method routedBy)
have routedBy(if_youre_seeing_this_you_probably_forgot_to_run_weaver_generate)
want routedBy(router)
Codegen Versioning
We release a new version of the Service Weaver module and command line tool
(including weaver generate) every two weeks. Every time we change
how code is generated, we assign the release a new codegen version. For
example, the latest codegen version as of writing this blog post is 0.17.0,
meaning that weaver module v0.17.0 has the most recent change to how we generate
code. You may update to the latest version of Service Weaver but forget to
re-run weaver generate.
$ go get github.com/ServiceWeaver/weaver@latest
$ go install github.com/ServiceWeaver/weaver/cmd/weaver@latest
$ go build .
# Oops! Should have run "weaver generate" first.
To detect this, we first introduce a new Version type with a phantom type
parameter.
type Version[_ any] string
Next, note that we can encode version numbers as types using multidimensional
arrays. For example, version 0.17.0 is represented as [0][17][0]struct{}.
We define a type alias LatestVersion that instantiates Version with the
current codegen version using this encoding:
type LatestVersion = Version[[CodegenMajor][CodegenMinor][CodegenPatch]struct{}]
CodegenMajor, CodegenMinor, and CodegenPatch are constants in the weaver
module that reflect the latest codegen version:
const (
CodegenMajor = 0
CodegenMinor = 17
CodegenPatch = 0
)
Finally, weaver generate generates the following code:
var _ LatestVersion = Version[[0][17][0]struct{}]("...")
Note that weaver generate embeds the literal values of CodegenMajor,
CodegenMinor, and CodegenPatch in the assignment. This assignment only
succeeds if codegen.Version[[0][17][0]struct{}]("...") has type
LatestVersion, which is true only when 0.17.0 is equal to
CodegenMajor.CodegenMinor.CodegenPatch.
Imagine you update the weaver module to a version where
CodegenMajor.CodegenMinor.CodegenPatch is 0.42.0 and you forget to re-run
weaver generate. The assignment above will simplify to the following:
var _ Version[[0][42][0]] = Version[[0][17][0]struct{}]("...")
This assignment fails to build because of the mismatched versions.
Finally note that we make Version an alias of string to include an error
message that is shown when the build fails:
var _ codegen.LatestVersion = codegen.Version[[0][42]struct{}](`
ERROR: You generated this file with 'weaver generate' v0.42.0 (codegen
version v0.42.0). The generated code is incompatible with the version of the
github.com/ServiceWeaver/weaver module that you're using. The weaver module
version can be found in your go.mod file or by running the following command.
go list -m github.com/ServiceWeaver/weaver
We recommend updating the weaver module and the 'weaver generate' command by
running the following.
go get github.com/ServiceWeaver/weaver@latest
go install github.com/ServiceWeaver/weaver/cmd/weaver@latest
Then, re-run 'weaver generate' and re-build your code. If the problem persists,
please file an issue at https://github.com/ServiceWeaver/weaver/issues.
`)
Conclusion
In this blog post, we described some of the advanced ways we use Go to detect
when you forget to run weaver generate. For a similar article, we recommend
reading about how Service Weaver uses generics to implement strongly typed
metric labels. If you'd like to learn more about Service Weaver,
we recommend you read the documentation.