Pairs Engineering

Learn about Pairs’ engineering efforts, product developments and more.

Follow publication

gopls を改造したら開発者体験が向上した

🎅🏻 This post is part of the Pairs Advent Calendar 2024 🎅🏻

こちらはPairs Advent Calendar 2024の20日目の記事です。

こんにちは!Eureka Product Back-end Engineer のぺりーです!

Eureka Back-end Team では Go を採用しており、独自 Analyzer を組み込んだ gopls に改造して2年以上が経ちました。

多数の独自 Analyzer を運用している Back-end Team では改造版 gopls を導入以後、リアルタイムに静的解析結果を検出して自動で修正できるようになったり、チームメンバー全員が静的解析ツールを追加したりメンテナンスしたりできるようになったりして開発者体験が向上しました。

こちらが実際に改造版 gopls を使用している例です。

詳細な説明は省略しますが、ORM からQuery Builderに移行を完了させた際に作成した静的解析ツールを使用して、ワイルドカードでのカラム指定を禁止・警告し、カラムの入力補助を行っています。

ワイルドカードを使用した該当箇所に Warning が表示され、Quick Fix を適用してカラムが自動入力できるのがわかります。

どのような手順で gopls 改造を始めれば良いかわからない方もいらっしゃると思うので、本記事では gopls の改造をどのように始めるか、運用するかに焦点を当てて説明します。

fork してすぐに改造できるように sample repository も用意したので合わせてご覧ください。

1. LSPとは

まず初めに前提知識である Language Server Protocolについて確認します。

https://code.visualstudio.com/api/language-extensions/language-server-extension-guide

LSPとは JSON-RPC ベースのプロトコルでオートコンプリート、定義への移動、ホバー上のドキュメント、参照の検索などの機能を IDE や Editor など複数の開発ツールが共通して利用するための共通規格です。

https://microsoft.github.io/language-server-protocol/overviews/lsp/overview/

従来、各 Editor ごとのプラグインを作成する必要がありましたが、LSP を使用することで言語コミュニティは1つの LSP サーバーの開発に集中でき、エディタコミュニティは1つの LSP クライアントを開発すればどの言語サーバーとも連携できるようになりました。

これによって m x n の複雑さが m + n に軽減されました。

LSP はプログラミング言語と Editor を繋げる共通言語で、開発時になくてはならないものになっています。

https://speakerdeck.com/shohata/gonolanguage-server-protocolshi-zhuang-gopls-nozi-dong-bu-wan-noshi-zu-miwoxue-bu?slide=15

2. 静的解析とその役割

次に、静的解析とその役割についても確認しておきます。

静的解析はソースコードを解析してエラーやセキュリティの脆弱性を検出する手法で、次のような役割があります。

  • バグ、エラー、潜在的なリスクの早期検知による品質向上、修正コストの削減
  • コーディング規約やベストプラクティスの遵守によるレビュー負担の軽減や可読性、保守性、パフォーマンスの向上
  • 入力支援などによる開発者体験の向上
https://youtu.be/RFa_zSrxDK8?si=P1SYmPEavbzZo98_&t=264

このように静的解析を活用することで、開発サイクルの早い段階で問題を検知するシフトレフトが可能になります。

3. 改造版 gopls 導入の背景

冒頭で簡単に紹介しましたが、Eureka Back-end Team では独自の静的解析ツールを多数作成・運用しています。

静的解析ツールが日々増える中でユースケースによって手動で実行するのを手間に感じたり、リアルタイムに静的解析ツールで問題を検出したり自動修正したりしたいという想いがチーム内で高まっていました。

gopls のコントリビューターがチームに何人かいたので自然と改造することになりました。

4. gopls 改造の始め方

4.1. 組み込み戦略

gopls の改造には大きく分けて以下の2つの戦略が考えられました。

textDocument/diagnostic & textDocument/codeAction の特徴は下記のとおりです。

  • 手に馴染んだ Go の静的解析ツール(のナレッジ)をそのまま使える
  • 独自 Analyzerを Plugin ライクに追加できる
  • コマンドラインから使いやすく GoLand ユーザーに優しい
  • 本家との Conflict が発生しにくくメンテナンスコストが低い

一方、textDocument/completion & snippet support を利用した場合の特徴は下記のとおりです。

  • gopls を拡張する必要がある
  • gopls の機能とバッティングする可能性がある
  • 本家との Conflict が発生しやすくメンテナンスコストが高い
  • コマンドラインから使えないため GoLand で使えるか調査が必要
  • Analyzer を組み込む形にはできない

textDocument/completion & snippet support を使いこなせればかなり強力に見えるものの、PoCとしては初期コストも高く、長期的にはレイテンシーの改善なども必要そうに見えました。

これらを踏まえて、多数の既存の独自 Analyzer の移行コストや改造版 gopls のメンテナンスコスト、gopls を使用しない GoLand への組み込みやすさからtextDocument/diagnostic & textDocument/codeAction を利用して組み込むことにしました。

4.2. リポジトリ構成

独自 Analyzer を取り込んだ gopls を build するイメージで進めます。

gopls の本家である golang/tools を一部改変しながら組み込むため、リモートリポジトリをローカルサブディレクトリに展開できる git subtree を使用して変更を加えていきます。

リポジトリ構成は以下のようにします。

.
├── Makefile
├── README.md
├── cmd
│ └── custom analysis commands...
├── go.mod
├── go.sum
├── golang.org
│ └── x
│ └── tools
│ ├── custom
│ │ ├── analyzer
│ │ │ └── custom analyzers...
│ │ └── astutil
│ └── gopls
│ └── internal
│ └── settings
| ├── analysis.go
│ └── custom.go
└── util
├── gitutil
│ ├── gitutil.go
│ ├── hunk.go
│ ├── hunk_test.go
│ └── testdata
│ └── diff.txt
└── lsputil
├── gopls.go
├── lsputil.go
└── types.go

このリポジトリには本家 golang.org/x/tools ディレクトリが含まれており、その中に独自 Analyzer とそれを組み込むためのコードを配置しています。

詳しくは 4.7. Analyzer の組み込み で説明しますが、独自の Analyzer は golang.org/x/tools/custom/analyzer 配下に、独自Analyzer を組み込むためのコードはgolang.org/x/tools/gopls/internal/settings/custom.go に配置しています。

4.3. インストール方法

通常であれば以下のコマンドで gopls をインストールします。

go install golang.org/x/tools/gopls@latest

改造した gopls には golang.org/x/tools/custom/analyzer が組み込まれているため、下記のコマンドを使用して gopls をインストールするようにします。

go install ./golang.org/x/tools/gopls

このように自前で build することによって本家 gopls の恩恵は受けつつ、自前 Analyzer を組み込めるようになります。

4.4. Analyzer の作成

4.1. 組み込み戦略 で説明したように textDocument/diagnostic & textDocument/codeAction を利用して Analyzer を作成します。

textDocument/diagnostictextDocument/codeAction の違いは修正候補を表示・適用できるかどうかで、リファクタリングやエラー修正など修正内容が明確なユースケースで textDocument/codeAction を使用すると良いです。

analysis package と LSP Methods の対応表は下記のとおりです。

textDocument/diagnostic を利用するには analysis#Pass.Reportf を使用して analysis#Diagnostic を指定します。

// golang.org/x/tools/go/analysis/analysis.go
// Reportf is a helper function that reports a Diagnostic using the
// specified position and formatted error message.
func (pass *Pass) Reportf(pos token.Pos, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
pass.Report(Diagnostic{Pos: pos, Message: msg})
}

textDocument/codeAction を利用するには analysis#Pass.Reportanalysis#Diagnosticanalysis#SuggestedFix を指定します。

// https://github.com/golang/tools/blob/74dea82592ad27cc0a4954831ee4bca305ed5ebd/go/analysis/diagnostic.go
type Diagnostic struct {
...
SuggestedFixes []SuggestedFix // this is an optional field
}

// A SuggestedFix is a code change associated with a Diagnostic that a user can choose
// to apply to their code. Usually the SuggestedFix is meant to fix the issue flagged
// by the diagnostic.
type SuggestedFix struct {
// A description for this suggested fix to be shown to a user deciding
// whether to accept it.
Message string
TextEdits []TextEdit
}

// A TextEdit represents the replacement of the code between Pos and End with the new text.
type TextEdit struct {
// For a pure insertion, End can either be set to Pos or token.NoPos.
Pos token.Pos
End token.Pos
NewText []byte
}

analysis#SuggestedFix は optional field ですが、指定しない場合 analysis#Pass.Reportf を呼ぶのと変わらないため、textDocument/diagnostic が適用されます。

4.5. textDocument/diagnostic を利用した例

Eureka Back-end Team で使用しているほとんどの Analyzer が textDocument/codeAction を使用して Suggested Fix を使えるようになっており、textDocument/diagnostic を使用していてる例はかなり限られています。

ここでは Go の慣習に従わない package 名を検知する例を挙げます。

package 名に _ が入っているため警告されています。

一見すると修正が明確であるため、 textDocument/codeAction を使用できそうですが、参照元を全て修正する必要があります。

また textDocument/codeAction では複数 package の修正を推奨していないので textDocument/diagnostic を使用しています。

Suggested fixes are allowed to make multiple edits in a file, because some logical changes may affect otherwise unrelated parts of the AST.

4.6. textDocument/codeAction を利用した例

はじめに紹介したワイルドカードでのカラム指定を禁止・警告し、カラムの入力補助を行う例は textDocument/codeAction を利用しています。

VS Code と Vim の例

他にも golden 形式で比較する Controller 単位の endpoint テストを自動生成する例があります。

VS Code と Vim の例

Eureka Back-end Team では Controller 単位の endpoint テスト実装が必須化されており、テストを書いていない場合には警告され、雛形が作成できるようになっています。

いずれも警告に応じて自動で入力・修正できるようになっているのがわかります。

4.7. Analyzer の組み込み

本家では下記箇所で登録された Analyzer が goroutine で実行されます。

// https://github.com/golang/tools/blob/74dea82592ad27cc0a4954831ee4bca305ed5ebd/gopls/internal/golang/diagnostics.go#L46-L64
func Analyze(ctx context.Context, snapshot *cache.Snapshot, pkgIDs map[PackageID]*metadata.Package, tracker *progress.Tracker) (map[protocol.DocumentURI][]*cache.Diagnostic, error) {
// Exit early if the context has been canceled. This also protects us
// from a race on Options, see golang/go#36699.
if ctx.Err() != nil {
return nil, ctx.Err()
}

analyzers := analyzers(snapshot.Options().Staticcheck)
analysisDiagnostics, err := snapshot.Analyze(ctx, pkgIDs, analyzers, tracker)
if err != nil {
return nil, err
}
return moremaps.Group(analysisDiagnostics, byURI), nil
}

// https://github.com/golang/tools/blob/74dea82592ad27cc0a4954831ee4bca305ed5ebd/gopls/internal/cache/analysis.go#L434-L440
func analyzers(staticcheck bool) []*settings.Analyzer {
analyzers := slices.Collect(maps.Values(settings.DefaultAnalyzers))
if staticcheck {
analyzers = slices.AppendSeq(analyzers, maps.Values(settings.StaticcheckAnalyzers))
}
return analyzers
}

Analyzer は DefaultAnalyzer に登録されています。

// https://github.com/golang/tools/blob/74dea82592ad27cc0a4954831ee4bca305ed5ebd/gopls/internal/settings/analysis.go#L103-L191
// DefaultAnalyzers holds the set of Analyzers available to all gopls sessions,
// independent of build version, keyed by analyzer name.
//
// It is the source from which gopls/doc/analyzers.md is generated.
var DefaultAnalyzers = make(map[string]*Analyzer) // initialized below

func init() {
analyzers := []*Analyzer{
{analyzer: appends.Analyzer, enabled: true},
// ...
}
for _, analyzer := range analyzers {
DefaultAnalyzers[analyzer.analyzer.Name] = analyzer
}
}

DefaultAnalyzer の map に独自の Analyzer を追加します。

// golang.org/x/tools/gopls/internal/settings/analysis.go
func init() {
// ...
- analyzers := []*Analyzer{
+ analyzers := addCustomAnalyzers([]*Analyzer{
// The traditional vet suite:
{analyzer: appends.Analyzer, enabled: true},
// ...
{analyzer: unusedvariable.Analyzer, enabled: false},
+ })
- }
for _, analyzer := range analyzers {
DefaultAnalyzers[analyzer.analyzer.Name] = analyzer
}
}

本家のコードに改変を加えるため、型や変数名などが変更された場合に Conflict が発生することがあります。
Conflict をなるべく避け、解消しやすいようになるべく本家との差分が少なくなるようにします。

そうは言っても Analyzer を追加するにとどまっているのでほとんど Conflict は発生しません。

// golang.org/x/tools/gopls/internal/settings/custom.go
package settings

import (
"golang.org/x/tools/gopls/internal/protocol"
)

func addCustomAnalyzers(a []*Analyzer) []*Analyzer {
return append(a, []*Analyzer{{}}...)
}

このようにすることで built-in の Analyzer の恩恵を受けつつ、任意の Analyzer を追加することができます。

不要になった Analyzer は append から外して build しなおせば捨てることができます。

4.2. リポジトリ構成 を今回の変更に焦点を当てて簡略化すると以下です。

.
├── cmd
│ └── custom commands...
└── golang.org
└── x
└── tools
├── custom
│ └── analyzer
│ └── custom analyzers...
└── gopls
└── internal
└── settings
├── analysis.go
└── custom.go

実際に analysis#Analyzer を追加する際は以下の手順を踏みます。

  1. golang.org/x/tools/custom/analyzer に Analyzer を実装する
  2. golang.org/x/tools/gopls/internal/settings/custom.go に実装した Analyzer を追加する
  3. cmd 配下に実装した Analyzer を追加する

本記事では Analyzer の実装は完了しているとして説明を進めます。

2 では Analyzer を DefaultAnalyzer に組み込めるようにgolang.org/x/tools/gopls/internal/settings/custom.go に追加します。

// golang.org/x/tools/gopls/internal/settings/custom.go
package settings

import (
+ "golang.org/x/tools/custom/analyzer/custom"
"golang.org/x/tools/gopls/internal/protocol"
)

func addCustomAnalyzers(a []*Analyzer) []*Analyzer {
return append(a, []*Analyzer{
// ...
+ {
+ analyzer: custom.Analyzer,
+ enabled: true,
+ actionKinds: []protocol.CodeActionKind{
+ protocol.SourceFixAll,
+ protocol.QuickFix,
+ },
},
}...)
}

次に cmd 配下に実装した Analyzer を追加します。

// cmd/custom.go
package main

import (
"golang.org/x/tools/custom/analyzer/custom"
"golang.org/x/tools/go/analysis/singlechecker"
)

func main() { singlechecker.Main(custom.Analyzer) }

Eureka Back-end Team では複数の Analyzer で構成された静的解析ツールを運用しているため multichecker にも追加するようにします。

// cmd/custom-lint.go
package main

import (
+ "golang.org/x/tools/custom/analyzer/custom"
"golang.org/x/tools/go/analysis/multichecker"
)

func main() {
multichecker.Main(
// ...
+ custom.Analyzer,
)
}

4.8. Editor / IDE の連携

Vim / VS Codeでは改造版 gopls が build され使用されていることを確認するだけですが、 GoLand では gopls を使用しないため別途 integration が必要です。

コマンドライン経由で発火させたり、 Kotlin で Plugin を書いたりして設定を行います。

5. 改造版 gopls の運用方法

5.1. 本体との同期とメンテナンス

git subtree と GitHub Actions を使って本家との同期を行います。

以下は Makefile の抜粋です。
golang.org/x/tools 配下で upstream を merge するようにしています。

.PHONY: sync
sync:
git subtree pull --prefix=golang.org/x/tools \
https://go.googlesource.com/tools master --squash -m 'make sync'
go mod tidy
@if ! git diff --quiet; then \
git add -u; \
git commit -m 'update go.mod'; \
fi

GitHub Actions で毎日更新されるようにしています。

name: Sync

on:
workflow_dispatch:
schedule: # JST 7:30 AM
- cron: '30 22 * * *'

jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
check-latest: true
go-version-file: go.mod
cache: true
cache-dependency-path: golang.org/x/tools/go.sum
- run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- run: make sync
- run: git push origin HEAD

https://github.com/satorunooshie/go-tools/blob/efa4c5f2fcafabef0933f2fddb883a10179f76c9/.github/workflows/sync.yaml

稀に Conflict が発生して失敗するので手動で修正しています。

5.2. Linter と reviewdog の連携

4.7. Analyzer の組み込み で少し触れましたが、CI でも gopls 同様の Analyzer によるチェックを行うために複数の Analyzer を1つの静的解析ツールにもまとめて Linter として使用しています。

gopls コマンドを使用して同様のチェックを行うこともできますが、下記の点で multichecker を使用するようにしています。

  • package 単位で実行できる
  • Analyzer 追加時に改造版 gopls のみの変更に留めることができる
    (CI は複数のリポジトリに分かれていて改造版 gopls のリポジトリとは異なるので)
// cmd/custom-lint.go
package main

import (
+ "golang.org/x/tools/custom/analyzer/custom"
"golang.org/x/tools/go/analysis/multichecker"
)

func main() {
multichecker.Main(
// ...
+ custom.Analyzer,
)
}

Linter の結果は reviewdog/reviewdogreviewdog/action-suggester を使用して PR のコメントに表示させています。

はじめに紹介した SQL Column の自動入力では下記のような PR Comment が表示され、そのまま Suggestion を commit することができます。

ここでは reviewdog を取り上げましたが、 LSP の仕様に準拠しているためそのほかの各種ツールとの連携も楽に行えるのも良い点です。

6. 改造版 gopls を導入・運用してみて

これまでみてきたように、実装中に独自の Analyzer による Warning, Error に気づき、リアルタイムで自動修正できるようになったことで実装速度(PR作成までのリードタイムなど)が向上し、手戻りを削減することができるなど大幅に開発者体験がよくなりました。

また、独自の Analyzer に改善の余地があれば、チームメンバーが修正しPRを出すことも珍しくありません。

独自 Analyzer は社内のみの利用であるため、特定のユースケースに合わせて作成し、ある程度不完全でも利用しながら改善を繰り返し、不要になったら append から外して build するだけで gopls から外すことができるなど取り回しがしやすいです。

その結果、 Back-end Team メンバー全員が静的解析ツールを作成したり、メンテナンスしたりする文化が醸成され、ポストモーテムの恒久対応として静的解析ツールの作成が選ばれるようになりました。

https://speakerdeck.com/rennnosuke/zhang-hai-zai-fa-fang-zhi-notamenojing-de-jie-xi-turuwoshi-zhuang-suru-golang-dot-tokyo-number-37

開発者体験が向上するだけでなく、チーム内で静的解析ツールの作成がより身近になる gopls の改造を 2025 年に検討してみてはいかがでしょうか。

独自 Analyzer を組み込む gopls の改造は簡単に始められますが、go vet-vettool オプションのような拡張が今後公式にサポートされることを期待しています。

最後まで読んでいただきありがとうございました。
それではみなさま良いお年を!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Published in Pairs Engineering

Learn about Pairs’ engineering efforts, product developments and more.

Written by ぺりー

Satoru Kitaguchi. Backend Engineer at eureka, Inc.

No responses yet

Write a response