Maintaining consistency in codebases with Go Vet
Maintaining success in a large open source project is one of the key objectives of Mattermost. We have hundreds of contributors and we want to create a project that could serve as a model in the Go community.
Having said that, following idiomatic Go principles is the thing that we care most about while maintaining our code consistency. For this specific task, we utilized go vet
and with this blog post, I would like to explain how we pushed the limits of this tool by extending it.
The main restriction of go vet
is that there are a limited number of absolute truths about what is right and what is wrong and so the go vet
checks are very general.
Although go vet
has great use in common checks, having domain-specific or even company-specific checks is inevitable to maintain a project at our scale. In Mattermost, we have a way of doing certain things like logging, test assertions, and adding license headers to the source files. We try our best to keep code consistent and work hard to avoid reintroducing old patterns.
Extending go vet
I think the best way to explain it is with an example.
Some time ago, we redesigned our logging implementation and it’s a great example to showcase our work around maintaining consistency. While migrating to the new approach didn’t happen with the snap of a finger, we observed that the old way of logging was making its way into new PRs. Also in such cases, the old pattern was one of the obvious approaches that you can follow to present data in the logs.
Let’s dive deeper into the topic, and I will try my best to explain how we extended the go vet
tool by adding our own specific checks.
A good starting point is the check we added so that we could avoid any string building with fmt.Sprintf
calls as part of the calls to our logging library. With that check implemented we were able to detect all the cases in the code where we were not doing structured logging and replace them with the properly structured logging approach. We then added that check to our CI pipeline to ensure that the pattern was not reintroduced accidentally by us or by any contributor.
Another interesting example is our approach to improve the consistency of test assertions. We use the Testify library to include more semantic assertions, but at the same time, we were using t.Fatalf
calls in certain places. The t.Fatalf
method of failing tests was less semantic because the test’s error itself is not necessarily related to the assertion. We created a check to avoid the use of t.Fatalf
in our tests.
Once we had that, we discovered that we have some incorrectly defined assertions. For example, we were using require.Equal(t, 5, len(x))
which is less semantic than require.Len(t, x, 5)
. We created a check for semantic length assertions, adding a suggestion in the error message to replace it with the correct assertion. We kept digging there, and we discovered that sometimes we were checking require.Len(t, x, 0)
which can be more semantically written as require.Empty(t, x)
, so we wrote the check for that, and included in the check the case for require.Equal(t, 0, len(x))
suggesting in both cases to use require.Empty(t, x)
.
Other checks have been made for other purposes. For example, checking the consistency and existence of the license in the header of our files, or checking for the consistency in the receiver variable name of the methods for the same structure.
Extending go vet
is a really easy task, you only need some knowledge about the Go AST because almost anything else is already handled by the go vet
tool. As an example, let’s implement a go vet
check to find forbidden words in the strings of our code.
Building an Analyzer
The first thing that we need is an Analyzer. An Analyzer is the struct responsible for receiving the AST (and some other things), finding the things that we consider errors, notifying go vet
of those errors, and alerting the user.
Let’s build our Analyzer.
// File: checkwords.go
package main
import (
"go/ast"
"go/token"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/unitchecker"
)
var analyzer = &analysis.Analyzer{
Name: "checkWords",
Doc: "check forbidden words usage in our strings",
Run: run,
}
func run(pass *analysis.Pass) (interface{}, error) {
forbiddenWords := []string{
"bird",
"water",
"candy",
}
for _, file := range pass.Files {
ast.Inspect(file, func(node ast.Node) bool {
switch x := node.(type) {
case *ast.BasicLit:
if x.Kind != token.STRING {
return false
}
words := strings.Fields(x.Value)
for _, word := range words {
for _, forbiddenWord := range forbiddenWords {
if word == forbiddenWord {
pass.Reportf(x.Pos(), "Forbidden word used, please do not use the word %s in your strings", word)
}
}
}
return false
}
return true
})
}
return nil, nil
}
func main() {
unitchecker.Main(
analyzer,
)
}
Our Analyzer is inspecting all the files searching for *ast.BasicLit
of Kind
token.STRING
, which are our literal strings. It splits those strings by spaces, and checks whether any of them match the forbidden words (this is a completely basic approach, and doesn’t catch a lot of cases, but for the sake of simplicity I’ll leave it as is). If it finds any forbidden words, it reports to the user with an error message; go vet
handles printing the filename and location of the error.
Once we have our Analyzer, we only have to register the Analyzer in our main function to connect it with go vet
using the unitchecker.Main
function (we can register multiple analyzers there).
Now we only need to compile it with go build ./checkwords.go
and use it with go vet -vettool=./checkwords -checkWords ./file-or-module-path
.
For example, we can create an example.go
file like this:
package main
import "fmt"
func main() {
fmt.Println("my candy is forbidden!")
fmt.Println("but other strings are not")
}
and run our go vet
tool to check this with go vet -vettool=./checkwords -checkWords example.go
and the resulting output is:
# command-line-arguments
./example.go:6:14: Forbidden word used, please do not use the word candy in your strings
And that is all we need. Now we have an automatic way to detect undesired patterns in our code.
We have been using our version for a number of months and our conclusion is that using the go vet
tool is an excellent opportunity to improve your code.
In addition, extending it allows you to define your own patterns and maintain the consistency of your code. With our open source culture you can find our implementations at our mattermost-govet repository. If you see yourself asking for the same changes in PRs all the time, you can probably consider using go vet
to detect the issues automatically.
Once the patterns are created you can apply them whenever you want, maybe by hand from time to time, maybe as a Git hook, maybe enforced by the CI, maybe you can use it as a one time thing for changing something in your code. The option you decide on is up to you and your use case.
To learn more, download Mattermost for free today and see what’s happening in our contributor community channel.