Workspaces are a feature introduced in March 2022 with Go 1.18. They did not really get a lot of publicity, and I have not had the chance to experiment with them until recently. However I am really glad I did because they improve a major aspect of my workflow: dealing with multiple modules.
Go modules are not ideal
Go modules were a big help when they were introduced in 2018, but they were always limited. Larger products are often split into multiple projects: one or more applications and several libraries and tools, meaning multiple modules.
While module versioning makes sure that components use the right version of
each dependency, it is also really annoying during development. If your
application foo
depends on the go-bar
library, you will often have to work
on go-bar
while testing the changes in foo
. This means updating go-bar
,
commiting changes, pushing them, and updating the foo
module with go get
.
Quite cumbersome.
A first improvement is the module replacement system. In the example above, we
could instruct Go to use the local copy of go-bar
when building foo
.
Assuming that foo
and go-bar
are at the same level in the filesystem:
go mod edit -replace example.com/myproject/go-bar=../go-bar
With this simple change, you have made your life easier: you can work on
changes in go-bar
, and immediately build and run foo
without any
additional operations. When you are done, you can still commit and push
normally in go-bar
.
But it is still not perfect. Module replacements are stored in the go.mod
file, meaning that these changes will be pushed to your central repository
circumventing dependency versioning and causing issues for other developers
and CI processes. While module replacement works fine to point a module to a
fork, it is not really a solution for our problem.
Using workspaces
Go workspaces let you create environments where you control the source of the modules you use, without having to modify these modules.
Going back to our example, we can solve our problem by creating a workspace
for foo
in which go-bar
refers to the local copy. In the directory of foo
:
go work init
go work use .
go work use ../go-bar
Nothing complicated here. The init
subcommand creates the go.work
file
which will contain the configuration of the workspace. Then the use
subcommand is called to add two modules to the workspace: the foo
module in
the current repository, i.e. .
, and the one in the go-bar
sibling directory.
At this point, building foo
will correctly use the local copy of go-bar
without having to modify any of the modules. Problem solved.
Even better, you can include replace
declarations in the go.work
file the
exact same way as in go.mod
file. These declarations will override those in
module files, giving you total control on the environment, again without
having to alter modules.
Committing the workspace file
Whether you commit the workspace file or not depends on your situation. When working in a mono-repository with other developers, committing a workspace file allows everyone to build the project the exact same way, using dependencies in the repository. It can also be practical with multiple repositories as long as you expect everyone to organize their local copies the same way.
But you can also keep your own workspace files without committing them. This
gives you the ability to quickly switch to a local copy for a dependency or to
replace a module by another one during development. For example this what I do
for my go-raft library project. The
program in the cmd/kvstore
directory is based on the go-service
library. During development, I
sometimes have to add code to go-service. Therefore I have a workspace file in
go-raft which references the local copy of go-service. But I do not commit it,
to avoid affecting anyone trying to build go-raft.
This flexibility is really practical, and I’m quite satisfied with workspaces at this point.
What could be better
There is a small issue to keep in mind. If you get used to work with workspaces, committing your local dependencies and having your program use them automatically, you may still have to maintain module dependencies. It is not necessarily a problem if you commit the workspace file and always use it, but it makes sense to keep dependencies up-to-date in your module files.
The go work
command has a sync
subcommand whose description let me think
that it would update modules included in the workspace to the right dependency
versions, but it turns out not do to so when tracking
pseudo-versions (i.e. when you are
tracking a branch and not a specific tag).
Therefore I have to continue to update non-tagged dependencies with this usual (and quite excessive) command, here for go-service:
GOPROXY=direct go get github.com/galdor/go-service@latest
go mod tidy
This way I make sure to bypass the Go proxy,
fetch the last version of the master
branch and clean up the dependency
list.
Despite this small inconvenience, Go workspaces have made my daily work much easier. Still a win!