golangci-lintのアーキテクチャの学び
Golangci-lintは以下のような流れで各種処理が実行されている。
Initに関して
エントリーポイントは/cmd/golangci-lint/main.go
commands.Executorを初期化してそれを実行するような形になっている。
func main() {
info := createBuildInfo()
if err := commands.Execute(info); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Failed executing command with error: %v\n", err)
os.Exit(exitcodes.Failure)
}
}
Executeの中身:
RootCommandをbuildInfoを元に生成して、Executeをするような形になっている。(Cobra使っている)
func newRootCommand(info BuildInfo) *rootCommand {
c := &rootCommand{}
rootCmd := &cobra.Command{
Use: "golangci-lint",
Short: "golangci-lint is a smart linters runner.",
Long: `Smart, fast linters runner.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if c.opts.PrintVersion {
_ = printVersion(logutils.StdOut, info)
return nil
}
return cmd.Help()
},
}
fs := rootCmd.Flags()
fs.BoolVar(&c.opts.PrintVersion, "version", false, color.GreenString("Print version"))
setupRootPersistentFlags(rootCmd.PersistentFlags(), &c.opts)
log := logutils.NewStderrLog(logutils.DebugKeyEmpty)
// Each command uses a dedicated configuration structure to avoid side effects of bindings.
rootCmd.AddCommand(
newLintersCommand(log).cmd,
newRunCommand(log, info).cmd,
newCacheCommand().cmd,
newConfigCommand(log, info).cmd,
newVersionCommand(info).cmd,
newCustomCommand(log).cmd,
)
rootCmd.SetHelpCommand(newHelpCommand(log).cmd)
c.log = log
c.cmd = rootCmd
return c
}
この中でrootCmdにCommandを追加する際にnewRunCommandの内部でpreRunE
というcobraの実装だと事前処理に当たるところをやっている部分。linterdb.Managerはlinterdb.LinterBuilder, linterdb.PluginModuleBuilder, linterdb.PluginGoBuilderから作られている。
NewLinterBuilderでは、Build時に使われる各種Linterの設定をするところが定義されている。
func (c *runCommand) preRunE(_ *cobra.Command, args []string) error {
dbManager, err := lintersdb.NewManager(c.log.Child(logutils.DebugKeyLintersDB), c.cfg,
lintersdb.NewLinterBuilder(), lintersdb.NewPluginModuleBuilder(c.log), lintersdb.NewPluginGoBuilder(c.log))
if err != nil {
return err
}
c.dbManager = dbManager
printer, err := printers.NewPrinter(c.log, &c.cfg.Output, c.reportData)
if err != nil {
return err
}
...
}
また、persistentPreRunEというコマンドをnewRunCommandで登録している。
traceの開始や、config情報のロード等をしている
func (c *runCommand) persistentPreRunE(cmd *cobra.Command, args []string) error {
if err := c.startTracing(); err != nil {
return err
}
c.log.Infof("%s", c.buildInfo.String())
loader := config.NewLoader(c.log.Child(logutils.DebugKeyConfigReader), c.viper, cmd.Flags(), c.opts.LoaderOptions, c.cfg, args)
err := loader.Load(config.LoadOptions{CheckDeprecation: true, Validation: true})
if err != nil {
return fmt.Errorf("can't load config: %w", err)
}
if c.cfg.Run.Concurrency == 0 {
backup := runtime.GOMAXPROCS(0)
// Automatically set GOMAXPROCS to match Linux container CPU quota.
_, err := maxprocs.Set(maxprocs.Logger(c.log.Infof))
if err != nil {
runtime.GOMAXPROCS(backup)
}
} else {
runtime.GOMAXPROCS(c.cfg.Run.Concurrency)
}
return nil
}
Load Packagesに関して
パッケージの読み込みでは、すべてのパッケージとその再帰的な依存関係をリストアップして分析を行っている。また、有効になっているlinterに対しては、この段階でソースコードの解析が実行される場合もある。
path: pkg/lint/package.go
func (l *PackageLoader) Load(ctx context.Context, linters []*linter.Config) (pkgs, deduplicatedPkgs []*packages.Package, err error) {
loadMode := findLoadMode(linters)
pkgs, err = l.loadPackages(ctx, loadMode)
if err != nil {
return nil, nil, fmt.Errorf("failed to load packages: %w", err)
}
return pkgs, l.filterDuplicatePackages(pkgs), nil
}
まず、有効化されているすべてのlinterのload modeを統合してload modeを見つける。パッケージの読み込みの際には go/packages を使用し、その中のEnum型 packages.Need*
をload modeとして使用します。load modeは、linterが実行に必要とするデータを設定する。
ASTのみを扱うlinterは、最小限の情報しか必要としないので、必要なのはファイル名とASTだけで、パッケージの依存関係や型情報は不要。ASTは、メモリ使用量を削減するために、go/analysis
の実行中に構築される。このようなASTベースのlinterは、以下のコードで設定:
path: pkg/lint/linter/config.go
func (lc *Config) WithLoadFiles() *Config {
lc.LoadMode |= packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedModule
return lc
}
linterがgo/analysisを使う場合は型情報が必要になってくるので、Loadの処理が少し違ってくる。
path: pkg/lint/linter/config.go
func (lc *Config) WithLoadForGoAnalysis() *Config {
lc = lc.WithLoadFiles()
lc.LoadMode |= packages.NeedImports | packages.NeedDeps | packages.NeedExportFile | packages.NeedTypesSizes
lc.IsSlow = true
return lc
}
Run Lintersに関して
linterを実行する際には、登録されていて有効化されているlinterを見つけないといけない。
NewLinterBuilderでは、Build時に使われる各種Linterの設定をするところが定義されている。
path: pkg/lint/lintersdb/builder_linter.go
func (LinterBuilder) Build(cfg *config.Config) ([]*linter.Config, error) {
if cfg == nil {
return nil, nil
}
const megacheckName = "megacheck"
// The linters are sorted in the alphabetical order (case-insensitive).
// When a new linter is added the version in `WithSince(...)` must be the next minor version of golangci-lint.
return []*linter.Config{
linter.NewConfig(asasalint.New(&cfg.LintersSettings.Asasalint)).
WithSince("v1.47.0").
WithPresets(linter.PresetBugs).
WithLoadForGoAnalysis().
WithURL("https://github.com/alingse/asasalint"),
linter.NewConfig(asciicheck.New()).
WithSince("v1.26.0").
WithPresets(linter.PresetBugs, linter.PresetStyle).
WithURL("https://github.com/tdakkota/asciicheck"),
linter.NewConfig(bidichk.New(&cfg.LintersSettings.BiDiChk)).
WithSince("v1.43.0").
WithPresets(linter.PresetBugs).
WithURL("https://github.com/breml/bidichk"),
....
configで定義されているlinterのみを取得したいので、以下の処理でfilter処理がされている。
path: pkg/lint/lintersdb/manager.go
func (m *Manager) GetEnabledLintersMap() (map[string]*linter.Config, error) {
enabledLinters := m.build(m.GetAllEnabledByDefaultLinters())
if os.Getenv(logutils.EnvTestRun) == "1" {
m.verbosePrintLintersStatus(enabledLinters)
}
return enabledLinters, nil
}
また有効化されているlinterはMetaLinterにマージして、実行時間を最適化するようにされている。(データの再利用等)
path: pkg/lint/lintersdb/manager.go
// GetOptimizedLinters returns enabled linters after optimization (merging) of multiple linters into a fewer number of linters.
// E.g. some go/analysis linters can be optimized into one metalinter for data reuse and speed up.
func (m *Manager) GetOptimizedLinters() ([]*linter.Config, error) {
resultLintersSet := m.build(m.GetAllEnabledByDefaultLinters())
m.verbosePrintLintersStatus(resultLintersSet)
m.combineGoAnalysisLinters(resultLintersSet)
resultLinters := maps.Values(resultLintersSet)
// Make order of execution of linters (go/analysis metalinter and unused) stable.
sort.Slice(resultLinters, func(i, j int) bool {
a, b := resultLinters[i], resultLinters[j]
if b.Name() == linter.LastLinter {
return true
}
if a.Name() == linter.LastLinter {
return false
}
if a.DoesChangeTypes != b.DoesChangeTypes {
return b.DoesChangeTypes // move type-changing linters to the end to optimize speed
}
return a.Name() < b.Name()
})
return resultLinters, nil
}
MetaLinterは単にmergeされたlinterを格納して、一回で動かすようにしているだけ。
path: pkg/goanalysis/metalinter.go
type MetaLinter struct {
linters []*Linter
analyzerToLinterName map[*analysis.Analyzer]string
}
func NewMetaLinter(linters []*Linter) *MetaLinter {
ml := &MetaLinter{linters: linters}
ml.analyzerToLinterName = ml.getAnalyzerToLinterNameMapping()
return ml
}
func (ml MetaLinter) Run(_ context.Context, lintCtx *linter.Context) ([]result.Issue, error) {
for _, l := range ml.linters {
if err := l.preRun(lintCtx); err != nil {
return nil, fmt.Errorf("failed to pre-run %s: %w", l.Name(), err)
}
}
return runAnalyzers(ml, lintCtx)
}
現在、unused
を除くすべてのlinterは、このmetalinterに統合することが可能です。unused
が統合されていない理由としては、メモリ使用量が高いためらしい。
linterの実行は runAnalyzers
で開始される。カスタムの go/analysis ランナーを使用していて、このrunnerは以下のように動作します:
- 可能な限り並列実行を行う。
- 可能な限り遅延ロードを行い、メモリ使用量を削減する。
- 不要になった重いデータはすべて
nil
に設定し、メモリを節約する。
既存の multichecker を使用していない理由としては、そのpkgではキャッシングを行わず、パフォーマンス最適化が欠けているため。
すべてのlinterによって見つかった問題は、result.Issue
構造体で表現されます:
path: pkg/result/issue.go
type Issue struct {
FromLinter string
Text string
Severity string
// Source lines of a code with the issue to show
SourceLines []string
// Pkg is needed for proper caching of linting results
Pkg *packages.Package `json:"-"`
Pos token.Position
LineRange *Range `json:",omitempty"`
// HunkPos is used only when golangci-lint is run over a diff
HunkPos int `json:",omitempty"`
// If we know how to fix the issue we can provide replacement lines
SuggestedFixes []analysis.SuggestedFix `json:",omitempty"`
// If we are expecting a nolint (because this is from nolintlint), record the expected linter
ExpectNoLint bool
ExpectedNoLintLinter string
}
この設計により、メモリ効率を最大化しつつ、複数のlinterを並列的に実行してパフォーマンスを向上させている。
Postprocess Issuesに関して
We have an abstraction of result.Processor
to postprocess found issues:
result.Processerの抽象が以下で、検知されてissueに対してpostprocessされるためにある。
./pkg/result/processors/
├── autogenerated_exclude.go
├── autogenerated_exclude_test.go
├── base_rule.go
├── cgo.go
├── diff.go
├── exclude.go
├── exclude_rules.go
├── exclude_rules_test.go
├── exclude_test.go
├── filename_unadjuster.go
├── fixer.go
├── identifier_marker.go
├── identifier_marker_test.go
├── invalid_issue.go
├── invalid_issue_test.go
├── issues.go
├── max_from_linter.go
├── max_from_linter_test.go
├── max_per_file_from_linter.go
├── max_per_file_from_linter_test.go
├── max_same_issues.go
├── max_same_issues_test.go
├── nolint.go
├── nolint_test.go
├── path_prefixer.go
├── path_prefixer_test.go
├── path_prettifier.go
├── path_shortener.go
├── processor.go
├── processor_test.go
├── severity.go
├── severity_test.go
├── skip_dirs.go
├── skip_dirs_test.go
├── skip_files.go
├── skip_files_test.go
├── sort_results.go
├── sort_results_test.go
├── source_code.go
├── testdata
├── uniq_by_line.go
└── uniq_by_line_test.go
ProcessorのIF:
path: pkg/result/processors/processor.go
type Processor interface {
Process(issues []result.Issue) ([]result.Issue, error)
Name() string
Finish()
}
Processorではnolintやexcludeのissueを隠したり、path_shortener等のissueを変更したりしている。
Print Issuesに関して
見つかった問題を出力するための抽象化が用意されています。
path: pkg/printers/printer.go
type issuePrinter interface {
Print(issues []result.Issue) error
}
以下が対応しているファイル:
./pkg/printers/
├── checkstyle.go
├── checkstyle_test.go
├── codeclimate.go
├── codeclimate_test.go
├── githubaction.go
├── githubaction_test.go
├── html.go
├── html_test.go
├── json.go
├── json_test.go
├── junitxml.go
├── junitxml_test.go
├── printer.go
├── printer_test.go
├── sarif.go
├── sarif_test.go
├── tab.go
├── tab_test.go
├── teamcity.go
├── teamcity_test.go
├── testdata
├── text.go
└── text_test.go
必要なプリンターは、コマンドラインオプション --out-format
で選択されます。
参考記事:
Discussion