Analysis in Go

One of Go’s most outstanding advantages is the excellent toolset that comes directly with it. As far as the core functionality is concerned, almost no wish is left unfulfilled, even for static code analysis with tools such as go vet and gopls and many more in the eco-system.

But what can we do when it comes to analysis of framework- or even project-specific code?

With large frameworks, there are often many pitfalls that beginners in particular tend to fall into. But most of them can already be caught by static analysis and thus not only save a lot of debug time, but also make it easier overall to get started with the framework.

At Flamingo, we are always up to create a better developer experience and one little step in that direction is to provide framework-specific static code analysis.

Go Vet and Vettools

Most of Go’s analysis tools rely on the great core analysis library https://pkg.go.dev/golang.org/x/tools/go/analysis.

This library makes it possible to write so-called vettools yourself and to use the already existing functionality for your own checks.

We tried this out and the result is the Flamalyzer.

Flamalyzer

The Flamingo Flamalyzer relies on https://pkg.go.dev/golang.org/x/tools/go/analysis and provides Flamingo-specific static code analysis checks. It can be used as vettool and standalone analyzer.

Usage

Flamalyzer can be installed via go install flamingo.me/flamalyzer@latest

As console tool

We can run flamalyzer as standalone command:

flamalyzer ./...

The exit code will be non-zero when Flamalyzer finds some issues. So it can be easily used in your CI pipeline.

You can even let Flamalyzer auto-fix found issues by providing the --fix flag.

We can also run flamalyzer as vettool within go vet:

go vet -vettool flamalyzer ./...

So it is even easier to integrate in your CI if you have go vet already in place - If not, you should really consider doing it, but that’s another topic ;)

In your IDE

GoLand

Goland supports custom filewatchers and can bind an inspection to it.

Flamalyzer settings in GoLand

Note that we analyze the whole project with the ./... argument on each file change. This is because of the import analysis. But most checks will work when we just run Flamalyzer on the changed file only.

By defining the output parsing $FILE_PATH$:$LINE$:$COLUMN$:$MESSAGE$, we let GoLand highlight the findings in the code.

Flamalyzer issue highlighting in GoLand

VS Code

In VS Code we can add a custom task to the tasks.json: See https://go.microsoft.com/fwlink/?LinkId=733558.

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "Flamalyzer",
            "type": "shell",
            "group": "test",
            "command": "flamalyzer",
            "args": ["./..."],
            "options": {
                "cwd": "${workspaceFolder}"
            },
            "problemMatcher": [
                {
                    "base": "$go",
                    "fileLocation":"absolute"
                }

            ]
        }
    ]
}

We can use the Go problem matcher here, but have to modify it to respect absolute paths and VS Code will highlight all problems in our files.

Flamalyzer issue highlighting in VS Code Flamalyzer problems list in VS Code

Available checks

For the start, there are some basic checks available:

Dingo: Pointer receiver check

The typical Inject method of dingo is used very often in Flamingo projetcs. It must have a pointer-receiver or the dependency-injection will not work.

Flamalyzer checks for it and can even auto-add missing pointers.

Dingo: correct interface binding check

To get interface implementations via dependency injection, we must bind an implementation to the interface, for example by injector.Bind(new(MyInterface)).To(new(MyImplementation))

All Dingo binding-related methods only take interface{} as argument and the magic happens inside using reflection. For that reason, we do not know if the types do match (if the bound implementation really implements the interface).

When dingo tries to resolve the bindings on runtime, it will of course panic, but it would be nice to see the problem already during typing.

Flamalyzer steps in at this point and checks that an instance implements the interface it is bound to.

Dingo: proper inject tags check

In the first days of Flamingo, dependency injection was done by struct tags only and the code looked like this:

type MyStruct struct {
	DependencyA   MyInterface   `inject:""`
	DependencyB   *MyDependency `inject:"annotated"`
	FeatureToggle bool          `inject:"config:feature"`
}

A big disadvatage of this was that all fields had to be exported. To leave this behind we introduced the Inject function. The tags are still working, but we want to discourage from using them in most cases.

The “modern” approach for the example above would be:

type MyStruct struct {
	dependencyA   MyInterface
	dependencyB   *MyDependency
	featureToggle bool
}

func (s *MyStruct) Inject(
	a MyInterface,
	tagged *struct {
		b             *MyDependency `inject:"annotated"`
		FeatureToggle bool          `inject:"config:feature"`
	},
) *MyStruct {
	s.dependencyA = a
	if tagged != nil {
		s.dependencyB = tagged.b
		s.featureToggle = tagged.FeatureToggle
	}

	return s
}

Now, we have all fields unexported. Note that the annotated binding and the config (which is a special form of annotated binding) still need tags. We get this by using an anonymous struct inline here, but we could also define a separate type for it. For these cases, inject tags are still allowed.

Flamalyzer checks if the inject tags are used properly.

This means:

  • No empty inject tags
  • Inject tags can be defined in the Inject-function arguments
  • If they are defined outside, the type must be an argument of an Inject-function
    • Such types must be declared in the same package as the Inject-function that uses them.

Architecture: dependency conventions check

Flamalyzer can check on all import statements below the entry path that the provided Group-Conventions are respected.

At AOE, we love domain driven design, which comes with some dependency restrictions. Flamalyzer tries to generalize this approach and allows a configuration of allowed dependencies.

For domain driven design, it looks like this:

groups:
  entrypaths: [ "src" ]
  infrastructure:
    allowedDependencies: [ "infrastructure", "interfaces", "application", "domain" ]
  interfaces:
    allowedDependencies: [ "interfaces", "application", "domain" ]
  application:
    allowedDependencies: [ "application", "domain" ]
  domain:
    allowedDependencies: [ "domain" ]

Expandability

If you have more ideas for Flamingo-specific checks, feel free to open an issue or pull request on github.

If you have very specific ideas for your own Flamingo project, you can easily set up your own analysis tool with Flamalyzer as core library and benefit from the checks already built in.

All Checks are bundled in dingo.Modules just as you are used to it from your Flamingo project. A main.go for your own Flamalyzer-based tool will look like this:

func main() {
	flamalyzer.Run(
		[]dingo.Module{
			// include the core modules
			new(dingoAnalyzer.Module), 
			new(architecture.Module),
			// here your own modules
			new(myFancyCheck.Module),
		},
	)
}

In your module, you will have to implement the analyzers.Analyzer Interface from flamingo.me/flamalyzer/src/analyzers and register your implementation via multi-bind:

injector.BindMulti(new(analyzers.Analyzer)).To(new(MyAnalyzer))