【Go】公式ツール "eg" を使って効率的にGoのコードをリファクタリングする

# はじめに

こんにちは、ken です。お仕事では Go をよく書きます。
最近、Go の公式パッケージである`golang.org/x/tools`を眺めていたら、なにやら有用そうなパッケージを見つけたので今回はそれについて書こうと思います。
それは**eg**というリファクタリングツールです。

https://pkg.go.dev/golang.org/x/tools/cmd/eg

# eg とは

eg は、**例ベースで Go コードをリファクタリングするためのツール**です。このツールを使用することで、特定のコードパターンを別のコードに置き換えることができ、効率的にリファクタリングが行えます。

先ほど貼った公式ドキュメントに詳しい説明があるかと思いきや

> The eg command performs example-based refactoring. For documentation, run the command, or see Help in golang.org/x/tools/refactor/eg.

としか書かれてませんでした...（なんでや...）
詳しく知りたかったらヘルプを見てねということだったので指示通りヘルプを出力してみました。[^1]

[^1]: `go install`での eg ツールのインストール後、`eg -help`で出力できます。

<details><summary>egツールのヘルプ</summary><div>

> This tool implements example-based refactoring of expressions.
>
> The transformation is specified as a Go file defining two functions,
> 'before' and 'after', of identical types. Each function body consists
> of a single statement: either a return statement with a single
> (possibly multi-valued) expression, or an expression statement. The
> 'before' expression specifies a pattern and the 'after' expression its
> replacement.
>
> package P
>
> import ( "errors"; "fmt" )
> func before(s string) error { return fmt.Errorf("%s", s) }
> func after(s string) error { return errors.New(s) }
>
> The expression statement form is useful when the expression has no
> result, for example:
>
> func before(msg string) { log.Fatalf("%s", msg) }
> func after(msg string) { log.Fatal(msg) }
>
> The parameters of both functions are wildcards that may match any
> expression assignable to that type. If the pattern contains multiple
> occurrences of the same parameter, each must match the same expression
> in the input for the pattern to match. If the replacement contains
> multiple occurrences of the same parameter, the expression will be
> duplicated, possibly changing the side-effects.
>
> The tool analyses all Go code in the packages specified by the
> arguments, replacing all occurrences of the pattern with the
> substitution.
>
> So, the transform above would change this input:
> err := fmt.Errorf("%s", "error: " + msg)
> to this output:
> err := errors.New("error: " + msg)
>
> Identifiers, including qualified identifiers (p.X) are considered to
> match only if they denote the same object. This allows correct
> matching even in the presence of dot imports, named imports and
> locally shadowed package names in the input program.
>
> Matching of type syntax is semantic, not syntactic: type syntax in the
> pattern matches type syntax in the input if the types are identical.
> Thus, func(x int) matches func(y int).
>
> This tool was inspired by other example-based refactoring tools,
> 'gofmt -r' for Go and Refaster for Java.
>
> # LIMITATIONS
>
> EXPRESSIVENESS
>
> Only refactorings that replace one expression with another, regardless
> of the expression's context, may be expressed. Refactoring arbitrary
> statements (or sequences of statements) is a less well-defined problem
> and is less amenable to this approach.
>
> A pattern that contains a function literal (and hence statements)
> never matches.
>
> There is no way to generalize over related types, e.g. to express that
> a wildcard may have any integer type, for example.
>
> It is not possible to replace an expression by one of a different
> type, even in contexts where this is legal, such as x in fmt.Print(x).
>
> The struct literals T{x} and T{K: x} cannot both be matched by a single
> template.
>
> SAFETY
>
> Verifying that a transformation does not introduce type errors is very
> complex in the general case. An innocuous-looking replacement of one
> constant by another (e.g. 1 to 2) may cause type errors relating to
> array types and indices, for example. The tool performs only very
> superficial checks of type preservation.
>
> IMPORTS
>
> Although the matching algorithm is fully aware of scoping rules, the
> replacement algorithm is not, so the replacement code may contain
> incorrect identifier syntax for imported objects if there are dot
> imports, named imports or locally shadowed package names in the input
> program.
>
> Imports are added as needed, but they are not removed as needed.
> Run 'goimports' on the modified file for now.
>
> Dot imports are forbidden in the template.
>
> # TIPS
>
> Sometimes a little creativity is required to implement the desired
> migration. This section lists a few tips and tricks.
>
> To remove the final parameter from a function, temporarily change the
> function signature so that the final parameter is variadic, as this
> allows legal calls both with and without the argument. Then use eg to
> remove the final argument from all callers, and remove the variadic
> parameter by hand. The reverse process can be used to add a final
> parameter.
>
> To add or remove parameters other than the final one, you must do it in
> stages: (1) declare a variant function f' with a different name and the
> desired parameters; (2) use eg to transform calls to f into calls to f',
> changing the arguments as needed; (3) change the declaration of f to
> match f'; (4) use eg to rename f' to f in all calls; (5) delete f'.

</div></details>

長いので折りたたんでおきますが、気になる人は読んでみてください。
一応ここから先はこのヘルプを読んでいないという前提で eg を紹介するので、面倒な方は読まなくても OK です。

# 実際につかってみる

eg ツールを使用するには、まず以下のコマンドでインストールします

```
go install golang.org/x/tools/cmd/eg@latest
```

次に、変換のルールを定義するテンプレートファイルを作成します。例えば、`fmt.Errorf`を`errors.New`に置き換える場合、以下のような`template.go`を作成します。`before`と`after`という名前が直感的でいいですね。

```go:template.go
package template1

import (
	"errors"
	"fmt"
)

func before(s string) error { return fmt.Errorf("%s", s) }
func after(s string)  error { return errors.New(s) }
```

そして、以下のコマンドを実行します。

```
eg -t {テンプレートのファイルのパス} -w {変換対象となるコードへのパス}
```

すると、プロジェクト内の`fmt.Errorf`が自動的に`errors.New`へと更新されます。
例えば以下のようなファイルがあったとすれば、それは次のように変換されます。単にステートメントが置き換わるだけでなく、もともとなかった`errors`パッケージの import も追加されています...！

```go:sample1.go(Before)
package samples

import (
	"fmt"
)

func sample() {
    msg := "something went wrong"
    err := fmt.Errorf("%s", "error: " + msg)
    fmt.Println(err)
}
```

```go:sample1.go(After)
package samples

import (
	"errors"
	"fmt"
)

func sample() {
	msg := "something went wrong"
	err := errors.New("error: " + msg)
	fmt.Println(err)
}
```

実際に実行している様子を映した GIF 画像も置いておきます。

![eg.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649218/d8ed201d-17b8-6fb1-ae22-952560549de7.gif)

ちなみに eg ツールにはいくつかのオプションがあります。先ほどつけていた`-w`は任意のオプションで、これをつけるとファイルを直接書き換えることになります。このオプションを使用しない場合、変更は標準出力に表示されるだけになります。

```
Usage: eg -t template.go [-w] [-transitive] <packages>

-help            show detailed help message
-t template.go   specifies the template file (use -help to see explanation)
-w               causes files to be re-written in place.
-transitive      causes all dependencies to be refactored too.
-v               show verbose matcher diagnostics
-beforeedit cmd  a command to exec before each file is modified.
                 "{}" represents the name of the file.
```

# 具体的なユースケース

## API の更新

eg ツールは deprecated となったライブラリ内の関数を置き換える際に便利そうです。例えば、Go1.16 で`ioutil`パッケージが非推奨となり、多くの機能が`os`パッケージに移行されましたが、その移行も eg ツールを使えば楽にできます。

```go:template2
package template2

import (
    "io/ioutil"
    "os"
)

func before(filename string) ([]byte, error) {
    return ioutil.ReadFile(filename)
}
func after(filename string) ([]byte, error) {
    return os.ReadFile(filename)
}
```

また、社内で使っているライブラリを新調したいときにも使えそうです。特にロギングライブラリはプロジェクト内の至るところで使われていると思うので、手動で行うよりも eg ツールを使って一括で行ったほうが効率良く置換できそうです。

```go
package templates

import (
    "original/logger"
    "original/newLogger"
)

func before(msg string) {
    return logger.Info(msg)
}

func after(msg string) {
    return newLogger.Info(msg)
}
```

## コード品質の統一

不適切なコードをプロジェクト内から一掃するときにも eg ツールは有用です。例えば、`time.Time`型のゼロ値判定について考えてみましょう。

与えられた`time.Time`型の変数がゼロ値かどうかを判定する際には、一般的には`IsZero()`メソッドを使用するのが推奨されていますが、このメソッドの存在を知らない開発者が`t != time.Time{}`という比較を行うことはあり得なくもないです。

この二つの方法は同じ結果をもたらしますが、プロジェクト内でコードの記述方法が統一されていないことに対して良く思わない方もいるでしょう。
eg ツールを使用すれば、このような問題も簡単に解決できます。繰り返しになりますが、以下のようなテンプレートファイルを作成し、eg コマンドを実行するだけで良いのです。

```go:template3
package template3

import "time"

func before(t time.Time) bool {
    return t != time.Time{}
}
func after(t time.Time) bool {
    return !t.IsZero()
}
```

# エディタの一括置換より優れている点

「エディタの一括置換を使えばこんなの使わなくていいんじゃないの？」と思った方もいると思います。しかし、eg ツールにはテキストエディタで行うような単純な置換に比べていくつかの利点があります。

## Go の文法を考慮した変換が可能

eg は Go の文法を理解するため、単純な文字列置換では難しい、コンテキストに応じた置換が可能です。これにより同じ名前の変数や関数であっても、それぞれが使用されるコンテキストに基づいて適切に置換できます。例えば、次の例では変数名が違っていても同じ処理であれば置換をしてくれています。

```go:template4
package template4

func before(x int) int { return x + x }
func after(x int) int { return 2 * x }
```

```go:sample4.go(Before)
package samples

func double(x int, y int) (int, int) {
    doubledX := x + x
    doubledY := y + y
    return doubledX, doubledY
}
```

```go:sample4.go(After)
package samples

func double(x int, y int) (int, int) {
    doubledX := 2 * x
    doubledY := 2 * y // 変数名がyであっても変換されている
    return doubledX, doubledY
}
```

## 型の考慮

変数や関数の型を考慮した置換ができるため、より安全なリファクタリングが可能です。eg は型情報を利用して必要な置換のみを行います。これにより、見た目上は同じ式であっても引数の型が異なる場合は置換が行われません。

```go:template5
package template5

func before(x int) int { return x + x }
func after(x int) int { return 2 * x }
```

```go:sample5.go(Before)
package samples

func doubleInt(x int) int { return x + x }
func doubleFloat(x float64) float64 { return x + x }
```

```go:sample5.go(After)
package samples

func doubleInt(x int) int { return 2 * x } // 変更あり
func doubleFloat(x float64) float64 { return x + x } // 変更なし（型が異なるため）
```

## インポートの自動管理

先ほどの`fmt.Errorf`を`errors.New`に置き換えるケースでも見ましたが、eg ツールは必要なインポートを自動的に追加してくれます。
ただし、不要になったインポートの削除は行わないことに注意が必要です。公式からは修正されたファイルに対して`goimports`を実行することが推奨されています。（最初に貼ったヘルプ参照）

```go:template6
package template6

import (
    "fmt"
    "strconv"
)

func before(x int) { fmt.Println(x) }
func after(x int) { fmt.Println(strconv.Itoa(x)) }
```

```go:sample6.go(Before)
package samples

import "fmt"

func main() {
    fmt.Println(0)
}
```

```go:sample6.go(After)
package samples

import (
    "fmt"
    "strconv" // 自動的に追加されている
)

func main() {
    fmt.Println(strconv.Itoa(0))
}
```

## 再現性

テンプレートファイルを用いることで、同じ変換を他のプロジェクトや開発者と共有しやすくなります。これにより、チーム全体で一貫したリファクタリングを行うことが可能になります。

```go:shared_template.go
package main

import "time"

func before(t time.Time) bool { return t != time.Time{} }
func after(t time.Time) bool { return !t.IsZero() }

// 使用例
// $ eg -t shared_template.go -w ./...
// これにより、プロジェクト全体で time.Time がゼロ値かどうかを判定する方法が統一されます
```

# さいごに

今回は`golang.org/x/tools`内にある eg ツールについて紹介してみました。
このツールはあまり知られていないようで執筆時点では紹介記事がほとんどありませんでしたが、かなり便利な使い方ができそうなので頭の片隅に入れておいても良いかなと思いました。
今回の記事で書いたコードは GitHub にあげているので、良かったらご自身でも動かしてみてください。

https://github.com/ken-hashimoto/TechBlogSamples/tree/main/go-eg-tool

ここまで読んでいただきありがとうございました。間違いなどあればコメントにてご指摘ください。