golangci-lintのアーキテクチャの学び

2025/01/18に公開

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 で選択されます。

参考記事:
https://zenn.dev/sanpo_shiho/books/61bc1e1a30bf27/viewer/4b9872

https://golangci-lint.run/contributing/new-linters/

https://golangci-lint.run/plugins/module-plugins/

https://golangci-lint.run/contributing/architecture/

Discussion