Maintaining Consistency in Codebases with Go vet

March 17, 2020 1175 words

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 in the code.

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.

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.


Written by Jesús Espino - @jesus.espino on community.mattermost.com and @jespino on GitHub

Join us on community.mattermost.com!