Flamalyzer - Static code analysis for Flamingo projects
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.
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.
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.
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.
- Such types must be declared in the same package as the
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))