Chapter 04

golangci-lint の内部実装を学ぶ

さんぽし
さんぽし
2021.07.12に更新

ここまで読んだあなた、「いや、公式ドキュメント + αくらいのことを日本語で書いただけやないか」と思いましたね。僕も書いていて思いました。

ではこの章では一歩踏み込んで golangci-lint のlinterを実行する様子をコードベースで追ってみましょう。

golangci-lintの開発者向けにArchitectureと言う章が公式ドキュメントに存在します。

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

この記事ではガッツリコードを使って解説していくので、そこのあなたがこの記事を読んでいる現在とはコードに変更が入っている可能性があります。この記事で参考にしているのはv1.41.1時点でのコードです。

各種初期化処理

エントリーポイントは/cmd/golangci-lint/main.goです

func main() {
	e := commands.NewExecutor(version, commit, date)

	if err := e.Execute(); err != nil {
		fmt.Fprintf(os.Stderr, "failed executing command with error %v\n", err)
		os.Exit(exitcodes.Failure)
	}
}

commands.Executorを初期化してそれを実行(Execute)しています。

commands.Executorはこのような定義になっています。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/commands/executor.go#L33
type Executor struct {
	rootCmd    *cobra.Command
	runCmd     *cobra.Command
	lintersCmd *cobra.Command

	exitCode              int
	version, commit, date string

	cfg               *config.Config
	log               logutils.Log
	reportData        report.Data
	DBManager         *lintersdb.Manager
	EnabledLintersSet *lintersdb.EnabledSet
	contextLoader     *lint.ContextLoader
	goenv             *goutil.Env
	fileCache         *fsutils.FileCache
	lineCache         *fsutils.LineCache
	pkgCache          *pkgcache.Cache
	debugf            logutils.DebugFunc
	sw                *timeutils.Stopwatch

	loadGuard *load.Guard
	flock     *flock.Flock
}

では初期化処理NewExecutorをザクっと見てみましょう。基本的にはさまざまな初期化の処理が入っています。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/commands/executor.go#L58

前半の部分はloggerの作成や、出力の際の色の設定などを入れています。

以下の部分で実際に実行されるコマンドを登録しています。golangci-lintはcobraを使用しています。

https://github.com/spf13/cobra
// init of commands must be done before config file reading because
// init sets config with the default values of flags
e.initRoot()
e.initRun()
e.initHelp()
e.initLinters()
e.initConfig()
e.initCompletion()
e.initVersion()
e.initCache()

メインとなるgolangci-lint runの初期化を覗いてみましょうか。e.initRun()で行っています。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/commands/run.go#L275
func (e *Executor) initRun() {
	e.runCmd = &cobra.Command{
		Use:   "run",
		Short: "Run the linters",
		Run:   e.executeRun,
		PreRun: func(_ *cobra.Command, _ []string) {
			if ok := e.acquireFileLock(); !ok {
				e.log.Fatalf("Parallel golangci-lint is running")
			}
		},
		PostRun: func(_ *cobra.Command, _ []string) {
			e.releaseFileLock()
		},
	}
	e.rootCmd.AddCommand(e.runCmd)

	e.runCmd.SetOut(logutils.StdOut) // use custom output to properly color it in Windows terminals
	e.runCmd.SetErr(logutils.StdErr)

	e.initRunConfiguration(e.runCmd)
}

golangci-lint runではe.executeRunが実行されることがわかります。(内容はあとで見ることにします。)

さて、NewExecutorに戻りましょう

// init e.cfg by values from config: flags parse will see these values
// like the default ones. It will overwrite them only if the same option
// is found in command-line: it's ok, command-line has higher priority.

r := config.NewFileReader(e.cfg, commandLineCfg, e.log.Child("config_reader"))
if err = r.Read(); err != nil {
	e.log.Fatalf("Can't read config: %s", err)
}

ここでは見ての通りフラグや設定ファイルから設定を読み込んでいますね。コメントにあるようにcommand lineの設定値が優先されるようになっていることがわかります。

ちなみにr.Read()とかの処理を追っていくと、設定周りの処理のためにviperと言うライブラリが使用されていることがわかります。

https://github.com/spf13/viper
// recreate after getting config
e.DBManager = lintersdb.NewManager(e.cfg, e.log).WithCustomLinters()

ここではDBManagerと言うフィールドを初期化しています。DBManagerは「linterの情報を管理するlinterdb」のための構造体です。
例えば、Manager.GetAllSupportedLinterConfigsからはgolangci-lintで有効にできる全てのlinterを取得できたりします。

https://github.com/golangci/golangci-lint/blob/f95b1ed39fb58390dbde896948f3d93435488c76/pkg/lint/lintersdb/manager.go#L101

その後はあまり特筆すべき点がないので割愛しますが、キャッシュのためのフィールドの初期化などを行いNewExecutorの処理は終了します。

golangci-lint runの実行を俯瞰する

では本丸、executeRunを見ていきましょう

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/commands/run.go#L448
func (e *Executor) executeRun(_ *cobra.Command, args []string) {
	needTrackResources := e.cfg.Run.IsVerbose || e.cfg.Run.PrintResourcesUsage
	trackResourcesEndCh := make(chan struct{})
	defer func() { // XXX: this defer must be before ctx.cancel defer
		if needTrackResources { // wait until resource tracking finished to print properly
			<-trackResourcesEndCh
		}
	}()

	e.setTimeoutToDeadlineIfOnlyDeadlineIsSet()
	ctx, cancel := context.WithTimeout(context.Background(), e.cfg.Run.Timeout)
	defer cancel()

	if needTrackResources {
		go watchResources(ctx, trackResourcesEndCh, e.log, e.debugf)
	}

	if err := e.runAndPrint(ctx, args); err != nil {
		e.log.Errorf("Running error: %s", err)
		if e.exitCode == exitcodes.Success {
			if exitErr, ok := errors.Cause(err).(*exitcodes.ExitError); ok {
				e.exitCode = exitErr.Code
			} else {
				e.exitCode = exitcodes.Failure
			}
		}
	}

	e.setupExitCode(ctx)
}

何となく、メインっぽいのはe.runAndPrintですね。こちらを見てみましょう。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/commands/run.go#L384
func (e *Executor) runAndPrint(ctx context.Context, args []string) error {
	if err := e.goenv.Discover(ctx); err != nil {
		e.log.Warnf("Failed to discover go env: %s", err)
	}

	if !logutils.HaveDebugTag("linters_output") {
		// Don't allow linters and loader to print anything
		log.SetOutput(ioutil.Discard)
		savedStdout, savedStderr := e.setOutputToDevNull()
		defer func() {
			os.Stdout, os.Stderr = savedStdout, savedStderr
		}()
	}

	issues, err := e.runAnalysis(ctx, args)
	if err != nil {
		return err // XXX: don't loose type
	}

	p, err := e.createPrinter()
	if err != nil {
		return err
	}

	e.setExitCodeIfIssuesFound(issues)

	if err = p.Print(ctx, issues); err != nil {
		return fmt.Errorf("can't print %d issues: %s", len(issues), err)
	}

	e.fileCache.PrintStats(e.log)

	return nil
}

これもなんとなくメインっぽいのはe.runAnalysisのように見えます。e.runAnalysisからlinterの結果を取得し、これをp.Printで出力しています。設定から出力のフォーマットを切り替えることができるため、そのフォーマットに即した出力を行います。

ではメインっぽい雰囲気を醸し出すe.runAnalysisをみていきましょう。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/commands/run.go#L327

全部処理を追うと大変なことになるので重要な

  1. e.contextLoader.Loadの部分
  2. 実際にlinterを実行しているrunner.Runの部分

を中心に見ていきます。

パッケージの情報をロードする

e.runAnalysisのこの部分ですね。

lintCtx, err := e.contextLoader.Load(ctx, lintersToRun)
if err != nil {
	return nil, errors.Wrap(err, "context loading failed")
}

e.contextLoader.Loadではlinter.Contextを取得しています。これはこの後すごいいろんなところに連れまわされる重要な構造体です。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/lint/linter/context.go#L15

ではe.contextLoader.Loadが何をしているか覗いてみましょう。コードはこちら

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/lint/load.go#L286
func (cl *ContextLoader) Load(ctx context.Context, linters []*linter.Config) (*linter.Context, error) {
	loadMode := cl.findLoadMode(linters)
	pkgs, err := cl.loadPackages(ctx, loadMode)
	if err != nil {
		return nil, errors.Wrap(err, "failed to load packages")
	}

	deduplicatedPkgs := cl.filterDuplicatePackages(pkgs)

	if len(deduplicatedPkgs) == 0 {
		return nil, exitcodes.ErrNoGoFiles
	}

	ret := &linter.Context{
		Packages: deduplicatedPkgs,

		// At least `unused` linters works properly only on original (not deduplicated) packages,
		// see https://github.com/golangci/golangci-lint/pull/585.
		OriginalPackages: pkgs,

		Cfg:       cl.cfg,
		Log:       cl.log,
		FileCache: cl.fileCache,
		LineCache: cl.lineCache,
		PkgCache:  cl.pkgCache,
		LoadGuard: cl.loadGuard,
	}

	return ret, nil
}

流れは割とシンプルで、linterが必要とするLoadModeを取得し、それに即してPackageの読み込みをし、linter.Contextを構築して、返却しているようです。

LoadModeの話

cl.loadPackagesでは「実行するlinterの必要とするLoadMode」に即してPackageの読み込みを行います。

「実行するlinterの必要とするLoadMode」の情報は以下のようにlinter.Configというlinterごとの設定を保持する構造体が持っています。

type Config struct {
	Linter           Linter
	EnabledByDefault bool

	LoadMode packages.LoadMode

	InPresets        []string
	AlternativeNames []string

	OriginalURL     string // URL of original (not forked) repo, needed for autogenerated README
	CanAutoFix      bool
	IsSlow          bool
	DoesChangeTypes bool

	Since       string
	Deprecation *Deprecation
}

またpackage.LoadModeとは↓これです。これによりcl.loadPackages内で使用しているpackages.Loadの動作が変化し、取得する情報量が変化します。

https://pkg.go.dev/golang.org/x/tools/go/packages#LoadMode

linterのLoadModeの設定はManager.GetAllSupportedLinterConfigs内で行われています。

https://github.com/golangci/golangci-lint/blob/f95b1ed39fb58390dbde896948f3d93435488c76/pkg/lint/lintersdb/manager.go#L101
linter.NewConfig(golinters.NewGovet(govetCfg)).
	WithSince("v1.0.0").
	WithLoadForGoAnalysis(). // これでLoadModeを設定している
	WithPresets(linter.PresetBugs, linter.PresetMetaLinter).
	WithAlternativeNames("vet", "vetshadow").
	WithURL("https://golang.org/cmd/vet/"),

WithLoadFilesと、WithLoadForGoAnalysisでlinterに設定されるModeが変化します。

func (lc *Config) WithLoadFiles() *Config {
	lc.LoadMode |= packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles
	return lc
}

func (lc *Config) WithLoadForGoAnalysis() *Config {
	lc = lc.WithLoadFiles()
	lc.LoadMode |= packages.NeedImports | packages.NeedDeps | packages.NeedExportsFile | packages.NeedTypesSizes
	lc.IsSlow = true
	return lc
}

WithLoadFilesでLoadModeが設定されているlitnerは、最低限の情報である、ファイルネームとASTのみを必要としており、パッケージの依存情報などは必要としないことがわかります。
このようにLoadModeをlinterごとに切り分け、必要な情報のみをLoadしているので、例えば、WithLoadForGoAnalysisを使用しているlinterを実行しない場合はpackage.Loadが早くなり、実行時間が短くなるように設計されていることがわかりますね。賢い。

linterを実行する

パッケージを読み込むことができました。これでlinterを実行しましょう。runAnalysisのこの部分です。

runner, err := lint.NewRunner(e.cfg, e.log.Child("runner"),
	e.goenv, e.EnabledLintersSet, e.lineCache, e.DBManager, lintCtx.Packages)
if err != nil {
	return nil, err
}

issues, err := runner.Run(ctx, lintersToRun, lintCtx)
if err != nil {
	return nil, err
}

runnerというのをNewRunnerで初期化して、runner.Runを呼び出しています。また、lintCtxというのは先ほど読み込んだパッケージの情報などが入っている変数です。

NewRunnerは色々初期化を行っているぐらいで具体的な処理は行っていないので説明を割愛します。
ちなみに有効にしているlinterが非推奨になっていた場合はこのNewRunner内でその旨の出力がなされています。

では、runner.Runを見ていきましょう

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/lint/runner.go#L191
func (r Runner) Run(ctx context.Context, linters []*linter.Config, lintCtx *linter.Context) ([]result.Issue, error) {
	sw := timeutils.NewStopwatch("linters", r.Log)
	defer sw.Print()

	var issues []result.Issue
	for _, lc := range linters {
		lc := lc
		sw.TrackStage(lc.Name(), func() {
			linterIssues, err := r.runLinterSafe(ctx, lintCtx, lc)
			if err != nil {
				r.Log.Warnf("Can't run linter %s: %v", lc.Linter.Name(), err)
				return
			}
			issues = append(issues, linterIssues...)
		})
	}

	return r.processLintResults(issues), nil
}

ここでは以下の流れで大きく分けて二つの処理を行なっています。

  1. forループの中で実行するlinterをループで回して、実行(r.runLinterSafe)し、報告を集める
  2. 集まった報告をr.processLintResultsして結果を返却

それぞれ見ていきましょう

1. linterを実行

r.runLinterSafeの処理を追っていきましょう。
内部ではlc.Linter.Runを呼び出して、linterの報告を受け取っています。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/lint/runner.go#L103

では、Linter.Runを見てみましょうか。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/golinters/goanalysis/linter.go#L58
func (lnt *Linter) Run(_ context.Context, lintCtx *linter.Context) ([]result.Issue, error) {
	if err := lnt.preRun(lintCtx); err != nil {
		return nil, err
	}

	return runAnalyzers(lnt, lintCtx)
}

linterを実行しているrunAnalyzersをみてみることにしましょう。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/golinters/goanalysis/runners.go#L30

ここでやっとlinterの実行の本体のようなものが見えました。
runAnalyzersの実行は以下のような流れで行われています。

  1. キャッシュを読み込む
  2. キャッシュになかったパッケージのみlinterを実行
  3. linterからの報告を整形
  4. 結果をキャッシュする

キャッシュ周りとlinterの実行に関してもう少し覗いてみましょう(3は割愛します)

golangci-lint におけるキャッシュ管理

golangci-lintを使用している方は、「あれ、初回の実行遅いけど、2回目以降の実行早いな」と気がついている方もいるかもしれません。これはgolangci-lintがキャッシュを頑張っている恩恵です。

runhAnalyzers内ではキャッシュの取得はloadIssuesFromCacheから、キャッシュの保存はsaveIssuesToCacheから行われます。

saveIssuesToCacheを見て、概要を掴みましょう。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/golinters/goanalysis/runners.go#L125

流れはパッケージごとにlinterからの報告をまとめたものを引数で受け取り、それをキャッシュに対してlintCtx.PkgCache.Putしているという形です。シンプルですね。

workerCount分だけgoroutineが走るようになっています。
workerCountは runtime.GOMAXPROCS(-1)から受け取るようになっているため、デフォルトではworkerCount = {CPUの数}になります。

lintCtx.PkgCache.Putの内部では、引数として渡したpackage.Packageのハッシュ値を用いてキャッシュの保存に用いるKeyが計算がされます。

これによって「package.Packageに変更が加わった際にはキャッシュがヒットしない」という動作を実現しています。

そういえばキャッシュってどこに置かれてるん?

キャッシュの置かれる場所の処理はcache.DefaultDir()に存在しています。

https://github.com/golangci/golangci-lint/blob/v1.41.1/internal/cache/default.go#L61
// DefaultDir returns the effective GOLANGCI_LINT_CACHE setting.
func DefaultDir() string {
	// Save the result of the first call to DefaultDir for later use in
	// initDefaultCache. cmd/go/main.go explicitly sets GOCACHE so that
	// subprocesses will inherit it, but that means initDefaultCache can't
	// otherwise distinguish between an explicit "off" and a UserCacheDir error.

	defaultDirOnce.Do(func() {
		defaultDir = os.Getenv("GOLANGCI_LINT_CACHE")
		if filepath.IsAbs(defaultDir) {
			return
		}
		if defaultDir != "" {
			defaultDirErr = fmt.Errorf("GOLANGCI_LINT_CACHE is not an absolute path")
			return
		}

		// Compute default location.
		dir, err := os.UserCacheDir()
		if err != nil {
			defaultDirErr = fmt.Errorf("GOLANGCI_LINT_CACHE is not defined and %v", err)
			return
		}
		defaultDir = filepath.Join(dir, "golangci-lint")
	})

	return defaultDir
}

デフォルトではos.UserCacheDirを使用し、os.UserCacheDir() + "golangci-lint"の位置に置かれます。

例えばMacだとos.UserCacheDir()が返すのは/Users/username/Library/Cachesなので/Users/username/Library/Caches/golangci-lintにキャッシュが置かれることになります。

自分で設定することもでき、環境変数GOLANGCI_LINT_CACHEを設定することでキャッシュの位置を変更することが可能であることがコードから見て取れます。

linterの実行

linterの実行はrunnner.runで行われます。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/golinters/goanalysis/runner.go#L81
func (r *runner) run(analyzers []*analysis.Analyzer, initialPackages []*packages.Package) ([]Diagnostic,
	[]error, map[*analysis.Pass]*packages.Package) {
	debugf("Analyzing %d packages on load mode %s", len(initialPackages), r.loadMode)
	defer r.pkgCache.Trim()

	roots := r.analyze(initialPackages, analyzers)

	diags, errs := extractDiagnostics(roots)

	return diags, errs, r.passToPkg
}

流れはr.analyzeを実行して結果を受け取り、それを整形したものを返却しているようです。
ではr.analyzeを見てみましょう。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/golinters/goanalysis/runner.go#L225

r.analyzeも少し長いですね重要なのは以下の後半に近い部分です

var wg sync.WaitGroup
debugf("There are %d initial and %d total packages", len(initialPkgs), len(loadingPackages))
for _, lp := range loadingPackages {
	if lp.isInitial {
		wg.Add(1)
		go func(lp *loadingPackage) {
			lp.analyzeRecursive(r.loadMode, loadSem)
			wg.Done()
		}(lp)
	}
}
wg.Wait()

WaitGroupを使用してlp.analyzeRecursiveを並行に実行してます。ここまできてやっとパッケージ毎にlinterが並行に実行されていることがわかりました。

また、ここのlp.analyzeRecursiveの中をふか〜く追っていくと実際のlinterの実行にたどり着きます(長くなるので割愛します。)
具体的にはloadingPackage.analyzeRecursive内のloadingPackage.analyze内のaction.analyzeSafe内のaction.analyzeで実行されています。(長くなるのでこの記事では、そこまでは追わないことにします)

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/golinters/goanalysis/runner_action.go#L109

2. 集まった報告をr.processLintResultsして結果を返却

linterの実行の話が終わり、一気にrunner.Runまで戻ってきました。
r.runLinterSafeでlinterを並行に実行し、linterからの報告を取得しました。

r.processLintResultsでその報告を処理してrunner.Runは終了します。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/lint/runner.go#L148

この処理の中で報告の処理という意味で本質的なものは

outIssues = r.processIssues(inIssues, sw, statPerProcessor)

こちらのr.processIssuesです。中身を見ていきます。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/lint/runner.go#L211
func (r *Runner) processIssues(issues []result.Issue, sw *timeutils.Stopwatch, statPerProcessor map[string]processorStat) []result.Issue {
	for _, p := range r.Processors {
		var newIssues []result.Issue
		var err error
		p := p
		sw.TrackStage(p.Name(), func() {
			newIssues, err = p.Process(issues)
		})

		if err != nil {
			r.Log.Warnf("Can't process result by %s processor: %s", p.Name(), err)
		} else {
			stat := statPerProcessor[p.Name()]
			stat.inCount += len(issues)
			stat.outCount += len(newIssues)
			statPerProcessor[p.Name()] = stat
			issues = newIssues
		}

		if issues == nil {
			issues = []result.Issue{}
		}
	}

	return issues
}

r.Processorsをループしてp.Processを呼び出して処理をしていってますね。

まず、Processorというのはlinterの報告をlinterの実行後に処理するものです。

例えば、linterの報告の無視を行うNolintのコメントなどはProcessorとして実装されており、nolintコメントのついている箇所の報告はProcess(issue)を通すことで全て弾かれます。

Processorはinterfaceを満たす形で全てが実装されています。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/result/processors/processor.go#L7
type Processor interface {
	Process(issues []result.Issue) ([]result.Issue, error)
	Name() string
	Finish()
}

Runnerの初期化処理時(NewRunner)にProcessorsフィールドにgolangci-lintに実装されているProcessorが初期化され、配列の形で代入されます。

Processor達はpkg/result/processors/内に実装されているので、他にどのようなProcessorが存在するのかを確認したい方はそちらを見てみると良さそうです。

https://github.com/golangci/golangci-lint/tree/v1.41.1/pkg/result/processors

さて、このようにして全てのProcessorを通して加工されたlinterの報告が出力され、golangci-lint runの実行は終了します。

[ちなみに] じゃあ--fixってどうやってるの

e.runAnalysisを見直してみましょう。

fixer := processors.NewFixer(e.cfg, e.log, e.fileCache)
return fixer.Process(issues), nil

--fixが指定されている場合は、この最後の2行部分で--fixに対応したlinterの報告に関しては修正が行われます。

https://github.com/golangci/golangci-lint/blob/v1.41.1/pkg/commands/run.go#L327

終わりに

処理を細かくコードを交えて追っていきました。golangci-lintが行なっていることが内部実装を通して理解が深まったのではないでしょうか。