27
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Okinawa.go Advent Calendar 2016

Day 7

gorenameをライブラリとして使う #golang

Posted at

はじめに

皆さんはgorenameというツールをご存知でしょうか?
golang.org/x/tools/cmd/gorenameで公開されている、Goのリファクタリングツールです。
ここでは、このツールを自分のコード上でライブラリとして使う方法と使いやすいリファクタリングツールの形について説明できればと思います。
なお、ここで扱う話はVimConf 2016において、エディタの壁を越えるGoの開発ツールの文化と作成法ビデオ)というタイトルで話したものと、一部重複していますので、そちらもよかったら見て下さい。

gorenameを使ってみる

まずは、gorenameをインストールして使ってみましょう。すでに使ったことある方はここは飛ばしてもらって構いません。
もしかすると、お使いのIDEやエディタによってすでにインストールされているかもしれません。
vim-goをお使いの方は、:GoRenameで使うことができます。

さて、インストールしてみましょう。お決まりのgo getでインストールできます。

$ go get -u golang.org/x/tools/cmd/gorename

ヘルプを見て、使い方を調べてみます。

$ gorename -help
gorename: precise type-safe renaming of identifiers in Go source code.

Usage:

 gorename (-from <spec> | -offset <file>:#<byte-offset>) -to <name> [-force]

You must specify the object (named entity) to rename using the -offset
or -from flag.  Exactly one must be specified.

Flags:

-offset    specifies the filename and byte offset of an identifier to rename.
           This form is intended for use by text editors.

-from      specifies the object to rename using a query notation;
           This form is intended for interactive use at the command line.
           A legal -from query has one of the following forms:

  "encoding/json".Decoder.Decode        method of package-level named type
  (*"encoding/json".Decoder).Decode     ditto, alternative syntax
  "encoding/json".Decoder.buf           field of package-level named struct type
  "encoding/json".HTMLEscape            package member (const, func, var, type)
  "encoding/json".Decoder.Decode::x     local object x within a method
  "encoding/json".HTMLEscape::x         local object x within a function
  "encoding/json"::x                    object x anywhere within a package
  json.go::x                            object x within file json.go

           Double-quotes must be escaped when writing a shell command.
           Quotes may be omitted for single-segment import paths such as "fmt".

           For methods, the parens and '*' on the receiver type are both
           optional.

           It is an error if one of the ::x queries matches multiple
           objects.

-to        the new name.

-force     causes the renaming to proceed even if conflicts were reported.
           The resulting program may be ill-formed, or experience a change
           in behaviour.

           WARNING: this flag may even cause the renaming tool to crash.
           (In due course this bug will be fixed by moving certain
           analyses into the type-checker.)

-d         display diffs instead of rewriting files

-v         enables verbose logging.

gorename automatically computes the set of packages that might be
affected.  For a local renaming, this is just the package specified by
-from or -offset, but for a potentially exported name, gorename scans
the workspace ($GOROOT and $GOPATH).

gorename rejects renamings of concrete methods that would change the
assignability relation between types and interfaces.  If the interface
change was intentional, initiate the renaming at the interface method.

gorename rejects any renaming that would create a conflict at the point
of declaration, or a reference conflict (ambiguity or shadowing), or
anything else that could cause the resulting program not to compile.


Examples:

$ gorename -offset file.go:#123 -to foo

  Rename the object whose identifier is at byte offset 123 within file file.go.

$ gorename -from '"bytes".Buffer.Len' -to Size

  Rename the "Len" method of the *bytes.Buffer type to "Size".

---- TODO ----
...

長い!-hをつけるともっと短いヘルプがでます。

$ gorename -h
Usage of gorename:
  -d    display diffs instead of rewriting files
  -diffcmd string
        diff command invoked when using -d (default "diff")
  -force
        proceed, even if conflicts were reported
  -from string
        identifier to be renamed; see -help for formats
  -help
        show usage message
  -offset string
        file and byte offset of identifier to be renamed, e.g. 'file.go:#123'.  For use by editors.
  -tags build tags
        a list of build tags to consider satisfied during the build. For more information about build tags, see the description of build constraints in the documentation for the go/build package (default <tagsFlag>)
  -to string
        new name for identifier
  -v    print verbose information

なるほど、-fromで指定した部分を-toに変更するという感じで使うわけですね。
たとえば、上記のヘルプに出ている例であるように、bytesパッケージのBuffer型のLenメソッドの名前をSizeに変更する場合を考えてみます。
以下のように、-from'"bytes".Buffer.Len'のように変えたい名前を一意に特定できるように指定し、-toで変えたい名前を指定します。

$ gorename -from '"bytes".Buffer.Len' -to Size

-fromで指定した識別子は、現在のGOPATH使って解決されます。

-fromの代わりに、-offsetを使っても変更元を指定できます。
例えば、以下のように指定できます。

$ gorename -offset file.go:#123 -to foo

なるほど、file.go123行目を変更するのか、と思ったかもしれませんが、123は行の指定ではなく、先頭から123バイト目にある識別子の名前を変更するという意味になります。そのため、-offsetという名前のオプションなんですね。また、行が指定できても、その行のどこなのかわからないので、オフセットになっています。

人間の手で何バイト目なのか指定するの面倒ですよね。しかし、このオプションは主にIDEやエディタのプラグインから利用するために用意されているものです。人間が利用する場合は、-fromで指定してやるのをオススメします。

上記のように、gorenameコマンドを走らせてやると、該当の識別子がGOPATH上からくまなく探し出され、-toで指定した文字列にキレイに置換されます。
便利ですね。

当然、IDEに慣れた方にとっては、ごくごく当たり前でプログラミング言語のプラグインとしては当たり前だと思っているでしょう。
gorenameについても特段珍しい機能があるわけではありません。しかし、面白いことにgorenameの本体はライブラリとしても提供されています。
次に、ライブラリとして使う方法について説明したいと思います。

ライブラリとして使う

gorename自体は、リポジトリを見る限り、main.goだけで構成されています。そのmain.goもかなり短いですね。

どうやら、以下の部分が本体のようです。renameというパッケージを使っています。

rename.Main(&build.Default, *offsetFlag, *fromFlag, *toFlag)

renameパッケージは、golang.org/x/tools/refactor/renameで提供されているライブラリで、識別子のリネームの機能を提供するライブラリです。

go getgorenameをインストールした場合には、こちらのライブラリも一緒にインストールされているはずです。
別途インストールするには、やはりgo getを使います。

$ go get -u golang.org/x/tools/refactor/rename

rename.Main関数を見てみましょう。
以下のように定義されています。

func Main(ctxt *build.Context, offsetFlag, fromFlag, to string) error

見たことある名前の引数を取っていますね。
そうです。gorenameの引数と対応していて、offsetFlag-offset-fromFlag-fromto-toに対応しています。
なお、offsetFlagfromFlagについてはどちらかを指定すればOKで、指定する文字列はgorenameで指定するものと同じです。

引数の1つめは、build.Contextです。この型はビルドする際に使用するコンテキストを表す型で、GOPATHGOROOTなどを解決するために用いられます。gorenameでは、build.Defaultというデフォルトのコンテキストを用いています。

build.Contextは以下のように定義された構造体です。

type Context struct {
        GOARCH      string // target architecture
        GOOS        string // target operating system
        GOROOT      string // Go root
        GOPATH      string // Go path
        CgoEnabled  bool   // whether cgo can be used
        UseAllFiles bool   // use files regardless of +build lines, file names
        Compiler    string // compiler to assume when computing target paths

        // The build and release tags specify build constraints
        // that should be considered satisfied when processing +build lines.
        // Clients creating a new context may customize BuildTags, which
        // defaults to empty, but it is usually an error to customize ReleaseTags,
        // which defaults to the list of Go releases the current release is compatible with.
        // In addition to the BuildTags and ReleaseTags, build constraints
        // consider the values of GOARCH and GOOS as satisfied tags.
        BuildTags   []string
        ReleaseTags []string

        // The install suffix specifies a suffix to use in the name of the installation
        // directory. By default it is empty, but custom builds that need to keep
        // their outputs separate can set InstallSuffix to do so. For example, when
        // using the race detector, the go command uses InstallSuffix = "race", so
        // that on a Linux/386 system, packages are written to a directory named
        // "linux_386_race" instead of the usual "linux_386".
        InstallSuffix string

        // JoinPath joins the sequence of path fragments into a single path.
        // If JoinPath is nil, Import uses filepath.Join.
        JoinPath func(elem ...string) string

        // SplitPathList splits the path list into a slice of individual paths.
        // If SplitPathList is nil, Import uses filepath.SplitList.
        SplitPathList func(list string) []string

        // IsAbsPath reports whether path is an absolute path.
        // If IsAbsPath is nil, Import uses filepath.IsAbs.
        IsAbsPath func(path string) bool

        // IsDir reports whether the path names a directory.
        // If IsDir is nil, Import calls os.Stat and uses the result's IsDir method.
        IsDir func(path string) bool

        // HasSubdir reports whether dir is a subdirectory of
        // (perhaps multiple levels below) root.
        // If so, HasSubdir sets rel to a slash-separated path that
        // can be joined to root to produce a path equivalent to dir.
        // If HasSubdir is nil, Import uses an implementation built on
        // filepath.EvalSymlinks.
        HasSubdir func(root, dir string) (rel string, ok bool)

        // ReadDir returns a slice of os.FileInfo, sorted by Name,
        // describing the content of the named directory.
        // If ReadDir is nil, Import uses ioutil.ReadDir.
        ReadDir func(dir string) ([]os.FileInfo, error)

        // OpenFile opens a file (not a directory) for reading.
        // If OpenFile is nil, Import uses os.Open.
        OpenFile func(path string) (io.ReadCloser, error)
}

新しいコンテキストを用意することで、GOPATHをカスタマイズできたり、ReadDirOpenFileをカスタマイズすればioutil.ReadDiros.OpenなどのOSの機能を使ってファイルを読み込むのではなく、自前の機構を使ってファイルを読み込んだりもできます。
たとえば、メモリ上に仮想的なファイルシステムを作って、そこから読み出すということもできます。

さて、このままだとあまりおもしろくありません。また、イマイチ使い所もわからないでしょう。
renameパッケージは、goパッケージと一緒に使うと効果を発揮します。

goパッケージは、Goの構文解析や型チェックなどの機能を提供するパッケージです。renameパッケージでも内部でこれらのパッケージを使って、変数の定義位置や使用位置を調べています。

go/astパッケージが提供するAST(抽象構文木)のノードを表すast.Nodeを使うと、rename.Mainに渡すオフセットを簡単に指定できます。
ast.NodePosメソッドを使えば、そのノードの位置が手に入り、それをオフセットに渡せます。

たとえば、1つ例を見てみましょう。
少し長いですが、指定したパッケージ内のgoファイルを探して、構造体のフィールドがIdで終わっているものをすべてIDにリネームするコードです。

package main

import (
	"fmt"
	"go/ast"
	"go/build"
	"go/parser"
	"go/token"
	"path/filepath"
	"strings"

	"golang.org/x/tools/refactor/rename"
)

func main() {
	pkg, err := build.Import("fuga", ".", build.IgnoreVendor)
	if err != nil {
		fmt.Println("Error:", err)
	}

	for _, filename := range pkg.GoFiles {
		if err := renameID(filepath.Join(pkg.Dir, filename)); err != nil {
			fmt.Println("Error:", err)
		}
	}
}

func renameID(filename string) error {
	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
	if err != nil {
		return err
	}

	type renameParam struct {
		offset string
		to     string
	}

	var renameParams []*renameParam
	for _, decl := range f.Decls {
		genDecl, isGenDecl := decl.(*ast.GenDecl)
		if !isGenDecl {
			continue
		}

		for _, spec := range genDecl.Specs {
			typeSpec, isType := spec.(*ast.TypeSpec)
			if !isType {
				continue
			}

			structType, isStruct := typeSpec.Type.(*ast.StructType)
			if !isStruct {
				continue
			}

			for _, fi := range structType.Fields.List {
				for _, fn := range fi.Names {
					if strings.HasSuffix(fn.Name, "Id") {
						renameParams = append(renameParams, &renameParam{
							offset: fmt.Sprintf("%s:#%d", filename, fn.Pos()),
							to:     fn.Name[:len(fn.Name)-1] + "D", // Id -> ID
						})
					}
				}
			}
		}
	}

	fmt.Println(filename)
	for _, p := range renameParams {
		fmt.Println(p.offset, "->", p.to)
		if err := rename.Main(&build.Default, p.offset, "", p.to); err != nil {
			return err
		}
	}

	return nil
}

実際に動かしてみましょう。ここでは、以下のコードを変換してみます。

$ tree $GOPATH/src/fuga
$GOPATH/src/fuga
└── fuga.go
$ cat $GOPATH/src/fuga/fuga.go
package fuga

type Hoge struct {
        hogeId         int
        fugaId, piyoId int
}

さて、動かしてみましょう。

$ go run rename.go
$GOPATH/fuga.go
$GOPATH/fuga.go:#35 -> hogeID
Renamed 1 occurrence in 1 file in 1 package.
$GOPATH/fuga.go:#55 -> fugaID
Renamed 1 occurrence in 1 file in 1 package.
$GOPATH/fuga.go:#63 -> piyoID
Renamed 1 occurrence in 1 file in 1 package.
$ cat $GOPATH/src/fuga/fuga.go
package fuga

type Hoge struct {
        hogeID         int
        fugaID, piyoID int
}

3つのフィールドが置換されたのがログで表示されていますね。
なお、$GOPATHの部分は実際には、フルパスで表示されていますが、分かりやすさのために$GOPATHとして表記してあります。

上記のコードのざっくりとした手順を以下に示します。

  • build.Importfugaパッケージをインポートする
  • build.Packageからfugaパッケージを構成するgoファイルを取得する
  • goファイルに対して、renameIDをかける
  • renameIDでは、ファイルをパースし、ASTを取得している
  • ast.Fileから宣言部分の解析結果を取得する
  • 宣言部分の解析結果から、GenDecl(generic declaration node)を取得する
  • GenDeclのうち、typeで型定義をしている部分であるast.TypeSpecを取得する
  • ast.TypeSpecのうち、構造体の型を表すast.StructTypeを取得する
  • ast.StructTypeから構造体のフィールドリストを取得する
  • フィールドリストの各ast.Fieldから名前にあたるField.Namesを取得する
  • 1行あたりフィールド名は複数指定できるので、各File.Namesの要素であるast.Identに対して処理をする
  • 識別子の名前を表すIdent.NameIdで終わっていたら、その場所をIdent.Posから取得し、名前と共に記録しておく
  • 変換するフィールド名のリストが揃ったら、各フィールドをrename.Mainにかけてリネームする

要するに、リネームしたい対象のast.Nodeを探し出し、Posから場所を取得し、リネームするという訳です。便利ですね!

リファクタリングツールをライブラリとして提供する利点

上述のように、gorenameのメインの処理は別のライブラリとして切り出されていました。これは非常に賢い方法で、ライブラリに切り出されていることで、第3者が新たなリファクタリングツールを作る際に同じような機能を作る必要なくなります。
また、任意のbuild.Contextを取ることで、GOPATHをカスタマイズしたり、ファイルを開く処理をカスタマイズしたりと、汎用的に使うことができます。

もし、goパッケージを駆使して自作のリファクタリングツールや開発ツールを作る際には、メインの処理をライブラリとして切り出すことで、新たなツールを作る際に再利用がしやすくなるのでオススメです。

おわりに

この記事では、gorename内部で使われているgolang.org/x/tools/refactor/renameをライブラリとして使う方法について説明しました。
また、ツール類を作る際は、メインの処理をうまく切り出してライブラリにすることで、再利用が可能だという話をしました。

ちなみに、renameパッケージは私も業務上のコンテキストにべったりなリファクタリングツールを作る際に使いました。go/typesパッケージを使えば、変数の定義位置や参照位置を取得できるので、自分でできないこともありませんが、renameパッケージを使った方が断然楽ですし、リネーム漏れの心配もありません。

そうそう、go/typesパッケージは、これもまた非常に便利なパッケージですので、また別の機会に説明したいと思います。

27
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?