Goのcode generatorの作り方: ast(dst)を解析して書き換える
Goのcode generatorの作り方についてまとめる
Go
のcode generationについてまとめようと思います。
前段の記事の
-
Goのcode generatorの作り方: 諸注意とtext/templateの使い方で
- Rationale: なぜGoでcode generationが必要なのか
- code generatorを実装する際の注意点など
-
io.Writer
に書き出すシンプルな方法 -
text/template
を使う方法-
text/template
のcode generationにかかわりそうな機能性。 - 実際に
text/template
を使ったcode generatorのexample。
-
-
Goのcode generatorの作り方: jenniferの使い方で
- github.com/dave/jenniferの各機能
-
text/template
で実装したcode generatorのexampleをjennifer
で再実装
についてそれぞれ述べました。
この記事では
- astutilおよびgithub.com/dave/dstを用いてgo source codeをrewriteする方法
について述べます
- astのパーズ方法
-
go/parser
を用いる方法 - golang.org/x/tools/go/packagesを用いる方法
-
- 軽いastの解析方法やデバッグ方法
- ast構造のprint: ast.Print
- directive commentの解析
- astのtraverse方法
-
astutil.Apply
でgo source codeのrewriteを実装します -
astutil.Apply
ではコメントオフセットの狂いによってコメントの順序がおかしくなる問題について述べ -
github.com/dave/dstによってこの問題を起さずにast rewriteができることを述べます。
-
dst
の紹介 - astと
dst
の相互変換 -
dst
でのコメントの取り扱い方法について -
dstutil.Apply
を使ったrewrite
-
についてそれぞれ述べます。
前提知識
- The Go programming languageの基本的文法、プロジェクト構成などある程度Goを書けるだけの知識
環境
Go
のstdに関するドキュメントおよびソースコードはすべてGo1.22.6
のものを参照します。
golang.org/x/toolsに関してはすべてv0.24.0
を参照します。
コードを実行する環境は1.22.0
です。
# go version
go version go1.22.0 linux/amd64
書いてる途中で1.23.0
がリリースされちゃったんですがでたばっかりなんで1.22.6
を参照したままです。マニアワナカッタ。。。
ast(dst)-rewrite
1からastをくみ上げることでコードを生成することもできますが、それをやるならば上記のtext/template
かgithub.com/dave/jennifer
を用いるほうが楽なはずなので、ここでは深く紹介しません。
その代わり、astをもとにそれをrewriteする方法のみを取り扱います。
利点と欠点
- 利点
- 既存のgo source codeを入力とできる。
- 欠点
- astの変更や、1からastをくみ上げるのは手間がかかる
-
Go
のソースコードを直接書きに行くほかの方法に比べてたった1つのトークンを書くだけでも何倍もの文字を打つ必要があってかなり面倒です。 - そのためこの記事ではrewriteする方法しか想定しません。
text/template
やgithub.com/dave/jenniferを使う方法に比べてずいぶん面倒です。
ではなぜこんなことをわざわざするのかというと
- editorのextensionを実装して、code actionとしてsource codeを書き換えたい
- code generatorの出力結果をさらに修正したい
- ユーザーの体験のため;
- ある
Go
のtypeに対して何かの生成を行いたいとき、生成元はGo
で書くのが最も一直線です。 -
text/template
などを使う方法であげたYAMLやJSONのメタデータを書く方法では、メタデータから生成結果の想像がつかないとやや書きづらくなります。
- ある
仕事でcode generatorを実装する際には筆者的に正当化しずらい費用対効果なので(メタデータをYAMLなどで書かせる方法のコスパがよすぎるため)、なかなか実装する機会がありませんが、体験はいいので慣れておきたいと筆者的には思っていました。
Go source codeの解析
source codeを解析してastをえる方法について述べます。
- 文字列・単一のファイルに対しては
go/parser
のparser.ParseFileを利用します - 複数のファイル・パッケージを解析するにはgolang.org/x/tools/go/packagesを利用します。
go/parser
astはgo/token
, go/parser
を用いて解析します。
Go
のastはastと言いながら各tokenの位置情報が記録されています。これはlinter・その他で構文エラーの位置を表示するため、さらにgo/printer
による逆変換ができるようにするためなどの理由があるのだと思います。
1ファイルのみを読み込むには以下のようにします。
出力は少々長くなるので省略しました。なので、以下のplaygroundで実行するか、ソースをコピーしてローカルで実行してみてください。
package main
import (
"go/ast"
"go/parser"
"go/token"
)
const src = `package target
import "fmt"
type Foo string
const (
FooFoo Foo = "foo"
FooBar Foo = "bar"
FooBaz Foo = "baz"
)
func Bar(x, y string) string {
if len(x) == 0 {
return y + y
}
return fmt.Sprintf("%q%q", x, y)
}
type Some[T, U any] struct {
Foo string
Bar T
Baz U
}
func (s Some[T, U]) Method1() {
// ...nothing...
}
// comment slash slash
/*
comment slash star
*/
`
func main() {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "./target/foo.go", src, parser.AllErrors|parser.ParseComments)
if err != nil {
panic(err)
}
_ = ast.Print(fset, f)
}
token.NewFileSet
で*token.FileSetをallocateして、parser.ParseFileで第3引数を解析します。ドキュメントにある通り、第3引数はnil, []byte, string, io.Readerのいずれかを受け付け, nilの場合第二引数のfilenameを読み込みます。
ast.Print
解析された*ast.Fileをast.Printもしくはast.Fprintに渡すことで内部の構造をプリントすることができます。
これは要するにreflect
によってgo structをwalkしながらprintする関数です。
前述通り上記サンプルコードの出力結果は長いので省略しますが、抜粋して一部を以下に例示します。
type Some[T, U any] struct {
//...
}
は以下のようなastになります。
// ...
287 . . 4: *ast.GenDecl {
288 . . . TokPos: ./target/foo.go:20:1
289 . . . Tok: type
290 . . . Lparen: -
291 . . . Specs: []ast.Spec (len = 1) {
292 . . . . 0: *ast.TypeSpec {
293 . . . . . Name: *ast.Ident {
294 . . . . . . NamePos: ./target/foo.go:20:6
295 . . . . . . Name: "Some"
296 . . . . . . Obj: *ast.Object {
297 . . . . . . . Kind: type
298 . . . . . . . Name: "Some"
299 . . . . . . . Decl: *(obj @ 292)
300 . . . . . . }
301 . . . . . }
302 . . . . . TypeParams: *ast.FieldList {
303 . . . . . . Opening: ./target/foo.go:20:10
304 . . . . . . List: []*ast.Field (len = 1) {
305 . . . . . . . 0: *ast.Field {
306 . . . . . . . . Names: []*ast.Ident (len = 2) {
307 . . . . . . . . . 0: *ast.Ident {
308 . . . . . . . . . . NamePos: ./target/foo.go:20:11
309 . . . . . . . . . . Name: "T"
310 . . . . . . . . . . Obj: *ast.Object {
311 . . . . . . . . . . . Kind: type
312 . . . . . . . . . . . Name: "T"
313 . . . . . . . . . . . Decl: *(obj @ 305)
314 . . . . . . . . . . }
315 . . . . . . . . . }
// ...
A GenDecl node (generic declaration node) represents an import, constant, type or variable declaration. A valid Lparen position (Lparen.IsValid()) indicates a parenthesized declaration.
であるので、*ast.File
のトップレベルにあるものは関数宣言以外はすべてこれになります。var
やtype
はvar()
でグループを持てるため、Spec
フィールドは[]ast.Spec
というsliceになっています。()
によるグルーピングがかかっていない場合はparenthesis((
)がないわけですからこのast nodeにはLparen
とRparen
(省略されて表示されていないが)にはemptyな値が収められています。
TypeParam
のindex([]
)の中身はstructの1つのfieldと同じ構文ルールが適用できるのでast.FieldList
が使われていますね。ここはちょっと筆者的には驚きでした。
とまあそういった感じです。
golang.org/x/tools/go/packages
サンプルは以下でもホストされます
少し前ではあるパッケージ、つまりディレクトリの中にあるsource fileを一気に解析するにはast.ParseDirを使えばよかったのですが、これが返す*ast.PackageがGo1.22からdeprecatedになっているため、ディレクトリの中身を一気にパーズしたいとき何使えばいいんだよってなりますよね。
困ったのでgithub.com/golang/exampleを見ていると、このコミットでgolang.org/x/tools/go/packagesを勧める文章に変わっていました。
ということで複数パッケージを一気にパーズするにはgolang.org/x/tools/go/packagesを使えばよさそうですね。
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"golang.org/x/tools/go/packages"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedImports |
packages.NeedDeps |
packages.NeedExportFile |
packages.NeedTypes |
packages.NeedSyntax |
packages.NeedTypesInfo |
packages.NeedTypesSizes |
packages.NeedModule |
packages.NeedEmbedFiles |
packages.NeedEmbedPatterns,
Context: ctx,
Logf: func(format string, args ...interface{}) {
fmt.Printf("log: "+format, args...)
fmt.Println()
},
}
pkgs, err := packages.Load(cfg, "io", "./ast/parse-by-packages/target")
if err != nil {
panic(err)
}
}
こんな感じでモジュールをロードします。
*packages.Configをいろいろ設定し、packages.Loadの第一引数として渡します。第二引数はvariadicなパターンで、go
コマンドに渡すようなpackage patternを渡して読み込みたいパッケージを指定できます。
pattern
packages.Loadの第二引数にはvairadicなpatternを渡し、これによってロードするパッケージを指定します。
https://pkg.go.dev/golang.org/x/tools@v0.24.0/go/packages#Package
Load passes most patterns directly to the underlying build tool. The default build tool is the go command. Its supported patterns are described at https://pkg.go.dev/cmd/go#hdr-Package_lists_and_patterns. Other build systems may be supported by providing a "driver"; see [The driver protocol].
All patterns with the prefix "query=", where query is a non-empty string of letters from [a-z], are reserved and may be interpreted as query operators.
Two query operators are currently supported: "file" and "pattern".
The query "file=path/to/file.go" matches the package or packages enclosing the Go source file path/to/file.go. For example "file=~/go/src/fmt/print.go" might return the packages "fmt" and "fmt [fmt.test]".
The query "pattern=string" causes "string" to be passed directly to the underlying build tool. In most cases this is unnecessary, but an application can use Load("pattern=" + x) as an escaping mechanism to ensure that x is not interpreted as a query operator if it contains '='.
デフォルトでこれらを引数にgo listを呼び出すので、それに渡すことができるパターンを指定することができます。具体的に言うと./...
でcwd
以下のすべてのパッケージにマッチさせることができます。
*package.Config
packages.Loadの第一引数には*packages.Configを渡します。
ちょっとわかりにくいところがあるのでそこだけ説明します。
-
Mode
: ビットフラグで何をロードするのか制御します。- 前述のサンプルが現時点(
v0.24.0
)でexportされている全てです。 - LoadModeのdoc commentにある通り現時点ではバグがあるみたいです。
- 前述のサンプルが現時点(
-
Dir
:go list
などのcwd
を指定できます。-
Dir
が指定するパッケージのgo.mod
に、Load
に渡すパターンに一致するモジュールがないと以下のようなエラーを吐きます。
-
github.com/hack-pad/hackpadfs: packages.Error{Pos:"", Msg:"no required module provides package github.com/hack-pad/hackpadfs; to add it:\n\tgo get github.com/hack-pad/hackpadfs", Kind:1}
-
Overlay
- コードを追ってみる限り内容をtemp fileとしてそれぞれ書き出し、
-overlay
オプションに渡すことができるjsonファイルを書き出してから-overlay
オプションに書き出したjsonファイルのパスを渡します。 -
-overlay
オプションそのものは今回の話題に対して重要ではないので、ここでは説明を避けgo Command Documentationを読むようにとだけ書いておきます。
- コードを追ってみる限り内容をtemp fileとしてそれぞれ書き出し、
packages.Visit
packages.Visitで、ロードされたパッケージをインポートグラフ順にvisitできます。
pkgs, err := packages.Load(cfg, "io", "./ast/parse-by-packages/target")
if err != nil {
panic(err)
}
packages.Visit(
pkgs,
func(p *packages.Package) bool {
for _, err := range p.Errors {
fmt.Printf("pkg %s: %#v\n", p.PkgPath, err)
}
if len(p.Errors) > 0 {
return true
}
fmt.Printf("package path: %s\n", p.PkgPath)
return true
},
nil,
)
/*
package path: io
package path: errors
package path: internal/reflectlite
package path: internal/abi
package path: internal/goarch
package path: unsafe
package path: internal/unsafeheader
// 中略
package path: internal/race
package path: sync/atomic
package path: github.com/ngicks/go-example-code-generation/ast/parse-by-packages/target
package path: fmt
package path: internal/fmtsort
package path: reflect
package path: internal/itoa
// 中略
package path: internal/testlog
package path: io/fs
package path: path
*/
pkgs[i].Syntax: []*ast.File
packages.Loadの返り値は[]*packages.Package
です。
*packages.Packageの各フィールドが解析結果です。
Load
時、LoadMode
にpackages.NeedSyntax
をつけると
-
Fset
フィールド:*token.FileSet
-
Syntax
フィールド:[]*ast.File
が読み込まれます。これによりパッケージ内のファイルすべてをparser.ParseFile
するのと同等の挙動が得られます。
当然ast.Print
などast情報を使った処理ができます。
pkgs, err := packages.Load(cfg, "io", "./ast/parse-by-packages/target")
if err != nil {
panic(err)
}
targetPkg := pkgs[1]
for _, f := range targetPkg.Syntax {
ast.Print(targetPkg.Fset, f)
fmt.Printf("\n\n")
}
pkgs[i].Types: *types.Package
Load
時、LoadMode
にpackages.NeedTypes
をつけるとTypes
フィールドに*types.Package
が解析結果として代入されます。
これにより型情報を使った処理を行うことができます。
pkgs, err := packages.Load(cfg, "io", "./ast/parse-by-packages/target")
if err != nil {
panic(err)
}
ioPkg := pkgs[0]
targetPkg := pkgs[1]
foo := targetPkg.Types.Scope().Lookup("Foo")
fmt.Printf("foo: %#v\n", foo)
// foo: &types.TypeName{object:types.object{parent:(*types.Scope)(0xc004758660), pos:4034135, pkg:(*types.Package)(0xc0047586c0), name:"Foo", typ:(*types.Named)(0xc0067f0930), order_:0x2, color_:0x1, scopePos_:0}}
r := ioPkg.Types.Scope().Lookup("Reader")
pfoo := types.NewPointer(foo.Type())
if types.AssignableTo(pfoo, r.Type()) {
fmt.Printf("%s satisfies %s\n", pfoo, r)
// *github.com/ngicks/go-example-code-generation/ast/parse-by-packages/target.Foo satisfies type io.Reader interface{Read(p []byte) (n int, err error)}
}
mset := types.NewMethodSet(pfoo)
for i := 0; i < mset.Len(); i++ {
meth := mset.At(i).Obj()
sig := meth.Type().Underlying().(*types.Signature)
fmt.Printf(
"%d: func (receiver=%s name=*%s)(func-name=%s)(params=%s) (results=%s)\n",
i, sig.Recv().Name(), foo.Name(), meth.Name(), sig.Params(), sig.Results(),
)
// 0: func (receiver=f name=*Foo)(func-name=Read)(params=(p []byte)) (results=(int, error))
}
types
の話はちょっとしたおまけなのでこれ以上詳しくしません。
directive commentとその解析方法
Go
はマジックコメントでCompiler directiveを書貸せるようになっています。
このdirective commentはdoc commentに出現しないようです。なので、directive commentでcode generatorに対して指示を出せるとdoc commentを邪魔せず、メタデータを別ファイルに分けることなくGo
のsource codeに追加できるため便利かもしれません。
ただし、directive commentの解析をastから行うには若干の工夫がいるので以下でその方法を述べます。
directive comment
通常のコメントは下記のように、コメントの開始に半角スペースなどを1つ以上入れますが、directive commentは半角などを入れません。
// non-directive comment
/* non-directive comment */
//directive comment
compiler directiveの項目では説明されていませんが、//go:embed
のように他にも色々なマジックコメントが存在します。
staticcheckの//lint:ignore directiveや、golangci-lintのnolint directiveなどサードパーティのツール、特にlinterなどがこのdirective commentを利用して挙動の調節が行えるようになっています。
*ast.CommentGroupの解析方法
directive commentは*ast.CommentGroupのTextメソッドから除外される挙動があるため、これを解析したい場合はList
を直接走査する必要があります。
package main
import (
"fmt"
"go/parser"
"go/token"
)
const src = `package target
// non-directive:comment
/*non-directive:comment*/
//directive:comment
/*line foo: 10 */
`
func main() {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "./target/foo.go", src, parser.AllErrors|parser.ParseComments)
if err != nil {
panic(err)
}
for _, cg := range f.Comments {
fmt.Println(fset.Position(cg.Pos()), cg.Text())
}
/*
./target/foo.go:3:1 non-directive:comment
./target/foo.go:5:1 non-directive:comment
./target/foo.go:7:1
*/
fmt.Println("---")
for _, cg := range f.Comments {
fmt.Print(fset.Position(cg.Pos()))
for _, comment := range cg.List {
fmt.Println(comment.Text)
}
}
// ./target/foo.go:3:1// non-directive:comment
// ./target/foo.go:5:1/*non-directive:comment*/
// ./target/foo.go:7:1//directive:comment
}
(*CommentGroup).Textのこの行でdirective commentが除外されますが、/**/
スタイルのコメントには全く機能しないので、上記のCompiler directiveのドキュメントにも反した挙動のように思えます。なるだけ/**/
スタイルのコメントは使わないほうが混乱が少なくていいのかもしれません。
astのtraverse方法
astを解析して得ることができても、その中から特定の探したいパターンを探せなければ意味のある処理を行うことができません。
そこでGo
は以下の関数などでastをトラバースする方法を提供しています。すべてdepth-first orderです。
-
ast.Inspect
- astをwalkします
- 関数を渡して各nodeを受けとります。
- 渡した関数で
true
を返すと子要素に向けて進みます。
-
ast.Walk
- astをwalkしますが、
Inspect
と違い、関数の代わりにVisitor interfaceを受けります - この
Visitor
はVisit
メソッドでVisitor
を返します。受けたnodeによって返すVisitor
実装を切り替えることでステートマシン的にふるまわせることができます。
- astをwalkしますが、
-
*inspector.Inspector
- 型(
*ast.TypeSpec
など)によるフィルターをかけたnodeの探索を行います。 - WithStackでマッチしたnodeに到達するまでのrootからのast nodeのstackを取得できるので、上位エレメントの構造がこうなら、みたいな条件付けでマッチできるのだと思います。
- この記事を書くための調査で知った機能なので、あまり使ったことがありません。そのため詳しくはわかりません。
- 型(
-
astutil.Apply
- astをwalkしますが、あるast nodeにstep inする前に呼び出されるコールバック(
pre
)と、walkしきあったとに呼び出されるコールバック(post
)を渡して処理を行えます。 -
ApplyFunc(
pre
とpost
)には*Cursorが渡され、これによってast nodeをDelete
,Replace
などができます。- また、*ast.Fileの
Decls
フィールドや、*ast.FieldListのList
フィールドのようなsliceにnodeを挿入するInsertBefore
,InsertAfter
があります。
- また、*ast.Fileの
- astをwalkしますが、あるast nodeにstep inする前に呼び出されるコールバック(
astutil.Applyを使ったrewrite
コードサンプルは以下でホストされます
astutil.Applyを使ったrewriteのサンプルを示します。
今までのサンプルと違い、新しいファイルに書き出すよりも既存のsource codeをrewriteするものを想定します。
この理由は
- そのほうが後述の問題が噴出しやすい
- 記事の目的から微妙にそれるが、editorのcode actionでコードを書き換える物を作りたいのでその前段として
です。
仕様
ざっくり以下のような仕様のものを作ります。
-
//enum:variants=
がdoc commentとしてつけられた型を対象とする- string-based typeのみとする
-
//enum:variants=
の後にspaceなどなしでcomma separatedなvariantsを記述し、これを各enumのvalueとする。- directive commentであってもなくてもよい。
- このvariantsを元に、変数名を
型名+variant
、値がvariantのstring literalをconst ()
で列挙する - 生成された
*ast.GenDecl
ブロックはdoc commentに//enum:generated_for=型名
を持つ。- これによって生成済みかを検査できる
- すでに生成済みのconstの
*ast.GenDecl
がある場合はそれをReplace
し、ない場合は追加します。
ターゲット
生成のターゲットを以下とします。
package target
//enum:variants=foo,bar,baz
type Enum string
//enum:variants=foo,bar,baz
type Enum2 string
//enum:generated_for=Enum2
const (
Enum2Foo = "foo"
)
parse
前述のgolang.org/x/tools/go/packagesを使ってロードするものとします。
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedImports |
packages.NeedTypes |
packages.NeedSyntax,
}
pkgs, err := packages.Load(cfg, "./ast/rewrite/target")
if err != nil {
panic(err)
}
pkg := pkgs[0]
astutil.Apply
astをrewriteするのでSyntaxをfor-rangeします。
astutil.Apply
では、あるast nodeにstep inする前に呼び出されるコールバック(pre
)と、walkしきあったとに呼び出されるコールバック(post
)を渡して処理を行えます。
今回はpre
のみを用います。
今回は*ast.GenDecl
を探索するので*ast.File
をastutil.Apply
に渡します。
その際のステップ順序はpackage comment, package name, Declsの順であるのでdefault branchでreturn true
しないとうまいこと進んでくれません。
for _, f := range pkg.Syntax {
astutil.Apply(
f,
func(c *astutil.Cursor) bool {
n := c.Node()
switch x := n.(type) {
default:
return true
case *ast.FuncDecl:
case *ast.GenDecl:
// ...
}
return false
},
nil,
)
}
対象タイプの探索
ますこのApply
の中で//enum:variants=
のマジックコメントがついたtype specを探します。
今回はめんどくさいので簡易化のため、type ()
で複数のtype specを書くパターンを禁止し、type Foo string
な単体のGenDecl
のみを対象とします。
astutil.Apply(
f,
func(c *astutil.Cursor) bool {
n := c.Node()
switch x := n.(type) {
default:
return true
case *ast.FuncDecl:
case *ast.GenDecl:
if x.Tok != token.TYPE {
break
}
if len(x.Specs) == 1 {
name, ok := isStringBasedType(x.Specs[0])
if !ok {
break
}
param, ok := parseDirective(x.Doc)
if !ok {
break
}
param.Name = name
addOrReplaceEnum(c, param)
}
}
return false
},
nil,
)
時々忘れちゃいますが、defaultでfallthroughが起きないだけでGo
のswitch-case文はbreakが使えます。
type Foo string
なstring-based typeかどうかは以下のように判定します。
こういった判定は、astをast.Print
して確かめた通りに実装するとよいです。
func isStringBasedType(spec ast.Spec) (string, bool) {
typ, ok := spec.(*ast.TypeSpec)
if !ok {
return "", false
}
id, ok := typ.Type.(*ast.Ident)
if !ok {
return "", false
}
return typ.Name.Name, id.Name == "string"
}
前述のとおり、*ast.CommentGroup
のText
メソッドではdirective commentが除外されてしまうのでList
を走査します。
type EnumParam struct {
Name string
Variants []string
}
func parseDirective(cg *ast.CommentGroup) (EnumParam, bool) {
for _, comment := range cg.List {
c := strings.TrimLeftFunc(stripMarker(comment.Text), unicode.IsSpace)
c, isDirection := strings.CutPrefix(c, "enum:variants=")
if !isDirection {
continue
}
return EnumParam{Variants: strings.Split(c, ",")}, true
}
return EnumParam{}, false
}
func stripMarker(text string) string {
if len(text) < 2 {
return text
}
switch text[1] {
case '/':
return text[2:]
case '*':
return text[2 : len(text)-2]
}
return text
}
Replace or Insert
replaceする部分です。
仕様で説明した通り、特定のコメントがついたconst ()
を探して、あれば置き換え、なければ追加します。
追加する際には(*Cursor).InsertAfter
で、対象タイプの直後にコードを挿入したいため、対象となるGenDecl
を指した状態の*Cursor
をそのまま受け取れると都合がよいのでそうします。
既に作成されたconst ()
ブロックを探すには、もう1度Parent
=*ast.File
をApply
で探索します。
func addOrReplaceEnum(c *astutil.Cursor, param EnumParam) {
found := false
astutil.Apply(
c.Parent(),
func(c *astutil.Cursor) bool {
node := c.Node()
switch x := node.(type) {
default:
return true
case *ast.FuncDecl:
case *ast.GenDecl:
if x.Tok != token.CONST {
break
}
if !isGeneratedFor(x.Doc, param.Name) {
break
}
found = true
newDecl := astVariants(param, x.TokPos)
c.Replace(newDecl)
}
return false
},
nil,
)
if !found {
newDecl := astVariants(param, c.Node().(*ast.GenDecl).Specs[0].Pos()+30)
c.InsertAfter(newDecl)
}
}
func isGeneratedFor(cg *ast.CommentGroup, fotTy string) bool {
for _, comment := range cg.List {
c := strings.TrimLeftFunc(stripMarker(comment.Text), unicode.IsSpace)
s, ok := strings.CutPrefix(c, "enum:generated_for=")
if !ok {
return false
}
if s == fotTy {
return true
}
}
return false
}
以下のようなブロックは
const (
EnumFoo = "foo"
EnumBar = "bar"
)
astでは以下のように構成できます。
func astVariants(param EnumParam, pos token.Pos) *ast.GenDecl {
return &ast.GenDecl{
Doc: &ast.CommentGroup{
List: []*ast.Comment{
{
Slash: pos,
Text: "//enum:generated_for=" + param.Name,
},
},
},
TokPos: token.Pos(int(pos) + len("//enum:generated_for="+param.Name) + 1),
Tok: token.CONST,
Lparen: 1,
Specs: mapParamToSpec(param),
Rparen: 2,
}
}
func mapParamToSpec(param EnumParam) []ast.Spec {
specs := make([]ast.Spec, len(param.Variants))
for i, variant := range param.Variants {
specs[i] = &ast.ValueSpec{
Names: []*ast.Ident{{Name: param.Name + capitalize(variant)}},
Type: &ast.Ident{Name: param.Name},
Values: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: strconv.Quote(variant)}},
}
}
return specs
}
func capitalize(s string) string {
if len(s) == 0 {
return s
}
if len(s) == 1 {
return strings.ToUpper(s)
}
return strings.ToUpper(s[:1]) + s[1:]
}
node移動時にコメントの整合性を保つ: *ast.CommentMap
https://pkg.go.dev/go/ast@go1.22.6#File
For correct printing of source code containing comments (using packages go/format and go/printer), special care must be taken to update comments when a File's syntax tree is modified: For printing, comments are interspersed between tokens based on their position. If syntax tree nodes are removed or moved, relevant comments in their vicinity must also be removed (from the [File.Comments] list) or moved accordingly (by updating their positions). A CommentMap may be used to facilitate some of these operations.
とある通り、nodeの移動や削除を行う場合*ast.CommentMap
を使います。
上記にはnodeの追加に対しては何も言及がありません。おそらく、追加はこれを使ってもうまく動きません(後述)。
うまくいかないのであんまり紹介する意義がないかもですが、お作法として*ast.CommentMap
を使うようにこのサンプルを書き換えます。
for _, f := range pkg.Syntax {
+ cm := ast.NewCommentMap(pkg.Fset, f, f.Comments)
astutil.Apply(
f,
func(c *astutil.Cursor) bool {
n := c.Node()
switch x := n.(type) {
default:
return true
case *ast.FuncDecl:
case *ast.GenDecl:
if x.Tok != token.TYPE {
break
}
if len(x.Specs) == 1 {
name, ok := isStringBasedType(x.Specs[0])
if !ok {
break
}
param, ok := parseDirective(x.Doc)
if !ok {
break
}
param.Name = name
- addOrReplaceEnum(c, param)
+ addOrReplaceEnum(c, param, cm)
}
}
return false
},
nil,
)
+ f.Comments = cm.Comments()
}
-func addOrReplaceEnum(c *astutil.Cursor, param EnumParam) {
+func addOrReplaceEnum(c *astutil.Cursor, param EnumParam, cm ast.CommentMap) {
found := false
astutil.Apply(
c.Parent(),
func(c *astutil.Cursor) bool {
node := c.Node()
switch x := node.(type) {
default:
return true
case *ast.FuncDecl:
case *ast.GenDecl:
if x.Tok != token.CONST {
break
}
if !isGeneratedFor(x.Doc, param.Name) {
break
}
found = true
newDecl := astVariants(param, x.TokPos)
+ delete(cm, x)
+ cm[newDecl] = append(cm[newDecl], newDecl.Doc)
c.Replace(newDecl)
}
return false
},
nil,
)
if !found {
newDecl := astVariants(param, c.Node().(*ast.GenDecl).Specs[0].Pos()+30)
+ cm[newDecl] = append(cm[newDecl], newDecl.Doc)
c.InsertAfter(newDecl)
}
}
生成
printer.Fprintで結果をプリントできます。
for _, f := range pkg.Syntax {
filename := filepath.Base(pkg.Fset.Position(f.FileStart).Filename)
astutil.Apply(
f,
func(c *astutil.Cursor) bool {
// ...
},
nil,
)
out, err := os.Create(filepath.Join(generatedDir, filename))
if err != nil {
panic(err)
}
err = printer.Fprint(out, pkg.Fset, f)
if err != nil {
panic(err)
}
}
結果
以下の入力をもとに
package target
//enum:variants=foo,bar,baz
type Enum string
//enum:variants=foo,bar,baz
type Enum2 string
//enum:generated_for=Enum2
const (
Enum2Foo = "foo"
)
以下を出力します。
package target
//enum:variants=foo,bar,baz
type Enum string
//enum:variants=foo,bar,baz
//enum:generated_for=Enum
const (
EnumFoo Enum = "foo"
EnumBar Enum = "bar"
EnumBaz Enum = "baz"
)
type Enum2 string
//enum:generated_for=Enum2
const (
Enum2Foo Enum2 = "foo"
Enum2Bar Enum2 = "bar"
Enum2Baz Enum2 = "baz"
)
なんかおかしいですね。
astutil.Applyの問題点
astutil.Apply
はdoc commentに特に触れられていないですが、うまくいかないパターンがあります。
問題のあるパターン
例えば、下記のサンプルを前節のcode replacerにかけてみます。
package target
// free floating comment 1
func Foo() {
// nothing
}
//enum:variants=foo,bar,baz,qux,quux,corge
type EnumWithComments string
// free floating comment 2
func Bar() {
// nothing
}
//enum:variants=foo,bar,baz
type EnumWithComments2 string
// free floating comment 3
//enum:generated_for=EnumWithComments2
const (
EnumWithComments2Foo EnumWithComments2 = "foo"
)
/* free floating comment 4
*/
以下を出力します。コメントの位置関係が破綻しています!
package target
// free floating comment 1
func Foo() {
// nothing
}
//enum:variants=foo,bar,baz,qux,quux,corge
type EnumWithComments string
// free floating comment 2
//enum:generated_for=EnumWithComments
// nothing
const (
EnumWithCommentsFoo EnumWithComments = "foo"
EnumWithCommentsBar EnumWithComments = "bar"
EnumWithCommentsBaz EnumWithComments = "baz"
EnumWithCommentsQux EnumWithComments = "qux"
EnumWithCommentsQuux EnumWithComments =
//enum:variants=foo,bar,baz
"quux"
EnumWithCommentsCorge EnumWithComments = "corge"
)
func Bar() {
}
type EnumWithComments2 string
//enum:generated_for=EnumWithComments2
const (
EnumWithComments2Foo EnumWithComments2 = "foo"
EnumWithComments2Bar EnumWithComments2 = "bar"
EnumWithComments2Baz EnumWithComments2 = "baz"
)
/* free floating comment 4
*/
Commentはバイトオフセットで管理され、nodeの追加は想定されていない
実はast
パッケージにおけるコメントの表現はすべてバイトオフセットでしかなく、別段、前後のnodeに対する関連性が定義されていません。
parser.ParseFile
やprinter.Fprint
がtoken.FileSet
を引数にとることかわかる通り、ファイルのオフセット関係はFileSet
の中に記録されます。この中で、パッケージ内のファイルをパッケージ内での絶対値オフセットに変更しています。
そのため、nodeを追加してしまうとオフセットの関係が狂って容易におかしな結果を出力してしまいます。
*ast.GenDecl
などについているdoc commentとしてのコメントがついて回りますが、これは単に解析時にコメントのオフセットとトークン(var
やtype
)のオフセットを比較して間に改行がない場合に関連しているとしてフィールドにセットしているだけのようです。
下記のstackoverflowでworkaround方法が述べられていますが、
parser.ParseFile
で解析する前に追加する分のバイトサイズだけをソース末尾をover-allocateしておき、nodeを追加するときに追加分だけAddLineで行を追加するとうまくいくようです。
あらかじめutf-8で何バイト追加するか判明していないと成立しないため、この方法でうまくやっていくビジョンが見えませんね。
しかしこのstackoverflowのaccepted answerにある通り、質問者自身がこの問題を解決するためのパッケージを作っており、これがうまく動作するので以降でその紹介をします。(よく見るとこの質問者はjennifer
の作者のdaveです!)
github.com/dave/dst
github.com/dave/dstはgithub.com/dave/jenniferと同作者が作ったコメントのオフセットを正しくキープしながらastの操作ができるライブラリです。
ast
から変更が加わっており、コメントは前後のNodeに関連づくようになり、free floating commentの概念がなくなっています。
astからdstへの変換
dstを利用するためには*ast.File
をまず用意します。これは今まで通りparser.ParseFile
を呼び出したり、golang.org/x/tools/go/packagesを利用します。
以下のようにdecorator
パッケージを利用して*ast.File
を*dst.File
にdecorateします。
package main
import (
"go/parser"
"go/token"
"github.com/dave/dst"
"github.com/dave/dst/decorator"
)
const src = `package target
import "fmt"
type Foo string
const (
FooFoo Foo = "foo"
FooBar Foo = "bar"
FooBaz Foo = "baz"
)
func Bar(x, y string) string {
if len(x) == 0 {
return y + y
}
return fmt.Sprintf("%q%q", x, y)
}
type Some[T, U any] struct {
Foo string
Bar T
Baz U
}
func (s Some[T, U]) Method1() {
// ...nothing...
}
// comment slash slash
/*
comment slash star
*/
`
func main() {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "./target/foo.go", src, parser.AllErrors|parser.ParseComments)
if err != nil {
panic(err)
}
dec := decorator.NewDecorator(fset)
df, err := dec.DecorateFile(f)
if err != nil {
panic(err)
}
_ = dst.Print(df)
}
ast.Nodeとdst.Nodeの相互変換
*decorator.Decoratorおよび*decorator.RestorerにはMapフィールドがあり、DecorateFile
やRestoreFile
を呼び出し後にnodeの対応関係がマップに記録されるため、これによって相互変換ができます。
dec := decorator.NewDecorator(fset)
_, err = dec.DecorateFile(f)
if err != nil {
panic(err)
}
// ast.Nodeと対応するdst.Nodeを取り出す。
dn := dec.Dst.Nodes[f.Decls[0]]
restorer := decorator.NewRestorer()
_, err = restorer.RestoreFile(df)
if err != nil {
panic(err)
}
// dst.Nodeと対応するast.Nodeを取り出す。
an := restorer.Ast.Nodes[dn]
fmt.Println()
err = printer.Fprint(os.Stdout, restorer.Fset, an) // import "fmt"
if err != nil {
panic(err)
}
fmt.Println()
dstutil.Applyを使った書き換え
github.com/dave/dstにはastutil
に対応するdstutil
パッケージがあり、ほぼ同じ使用感で実装されています。
前述のastutil.Apply
のcode rewriterをdstutil.Apply
を使って実装しなおします。
dstutil.Apply
golang.org/x/tools/go/packagesを用いたロードまでは全く一緒です。
dstutil.Applyの前にdecorator.DecorateFile
を呼び出して*dst.File
を得ます。
for _, f := range pkg.Syntax {
+ df, err := decorator.DecorateFile(pkg.Fset, f)
+ if err != nil {
+ panic(err)
+ }
- astutil.Apply(
+ dstutil.Apply(
- f,
+ df,
- func(c *astutil.Cursor) bool {
+ func(c *dstutil.Cursor) bool {
n := c.Node()
switch x := n.(type) {
default:
return true
- case *ast.FuncDecl:
+ case *dst.FuncDecl:
- case *ast.GenDecl:
+ case *dst.GenDecl:
// ...
}
return false
},
nil,
)
}
対象タイプの探索
isStringBasedType
は引数の型をdst.Spec
に変えただけで、他はastutil
版と全く変わりありません。
func isStringBasedType(spec dst.Spec) (string, bool) {
typ, ok := spec.(*dst.TypeSpec)
if !ok {
return "", false
}
id, ok := typ.Type.(*dst.Ident)
if !ok {
return "", false
}
return typ.Name.Name, id.Name == "string"
}
コメントのパーズ部分(parseDirective
)はけっこうな変更になります。
type EnumParam struct {
Name string
Variants []string
}
func parseDirective(decorations dst.GenDeclDecorations) (EnumParam, bool) {
for i := len(decorations.Start) - 1; i >= 0; i-- {
line := decorations.Start[i]
if len(strings.TrimSpace(line)) == 0 {
// start of comments groups that is not associated to gen decl.
break
}
c := stripMarker(line)
c, isDirection := strings.CutPrefix(c, "enum:variants=")
if !isDirection {
continue
}
return EnumParam{Variants: strings.Split(c, ",")}, true
}
return EnumParam{}, false
}
dst
におけるコメントはdst.GenDeclDecorationsなどの構造体で表現されます。
dst
のdoc commentで述べられる通り、Go
のコメントは思いのほか自由な位置にそれぞれ書くことができます。
dst
はそれらの各部の関係性を保ったまま、dst.GenDeclDecorations
の対応するフィールドにそれぞれ[]string
で格納します。
/*Start*/
const /*Tok*/ ( /*Lparen*/
a, b = 1, 2
c = 3
) /*End*/
/*Start*/
const /*Tok*/ d = 1 /*End*/
doc commentに当たるのはdst.NodeDecsのStart
ですのでこれだけを解析します。
例えば以下のようなnodeとdst.Print
すると以下のようになります。
// free floating comment
// doc comment
func (s Some[T, U]) Method1() {
// ...nothing...
}
// comment slash slash
/*
comment slash star
*/
// 925 . . . Decs: dst.FuncDeclDecorations {
// 926 . . . . NodeDecs: dst.NodeDecs {
// 927 . . . . . Before: EmptyLine
// 928 . . . . . Start: dst.Decorations (len = 3) {
// 929 . . . . . . 0: "// free floating comment"
// 930 . . . . . . 1: "\n"
// 931 . . . . . . 2: "// doc comment"
// 932 . . . . . }
// 933 . . . . . End: dst.Decorations (len = 6) {
// 934 . . . . . . 0: "\n"
// 935 . . . . . . 1: "\n"
// 936 . . . . . . 2: "// comment slash slash"
// 937 . . . . . . 3: "\n"
// 938 . . . . . . 4: "\n"
// 939 . . . . . . 5: "/* \n\ncomment slash star\n\n*/"
// 940 . . . . . }
// 941 . . . . . After: None
// 942 . . . . }
// 943 . . . }
// 944 . . }
// 945 . }
free floating commentもStart
にひとまとめに入れれらます。
つまり、Start
は末尾から最初の\n
のみを含む行、もしくは行頭までの範囲を解析するとast
版と同等ということになります。
上記のparseDirective
実装では単に末尾から探索していますが、ast
版は先頭から探索なので、挙動差が生じています。これは単なる手抜きですので実際にはこういった実装はしないほうがよいでしょう。
doc commentに当たるindexの範囲を探索し、その範囲を先頭から走査すると挙動差が生じません。
Replace or Insert
この部分もastutil
版の型表記をdst
に変えただけでほとんど変更はありません。
CommentMap
やトークンオフセットは不要なので消えます。
-func addOrReplaceEnum(c *astutil.Cursor, param EnumParam, cm ast.CommentMap) {
+func addOrReplaceEnum(c *dstutil.Cursor, param EnumParam) {
found := false
- astutil.Apply(
+ dstutil.Apply(
c.Parent(),
- func(c *astutil.Cursor) bool {
+ func(c *dstutil.Cursor) bool {
node := c.Node()
switch x := node.(type) {
default:
return true
- case *ast.FuncDecl:
+ case *dst.FuncDecl:
- case *ast.GenDecl:
+ case *dst.GenDecl:
if x.Tok != token.CONST {
break
}
if !isGeneratedFor(x.Doc, param.Name) {
break
}
found = true
- newDecl := astVariants(param, x.TokPos)
- delete(cm, x)
- cm[newDecl] = append(cm[newDecl], newDecl.Doc)
- c.Replace(newDecl)
+ c.Replace(astVariants(param, x.Decs))
}
return false
},
nil,
)
if !found {
- newDecl := astVariants(param, c.Node().(*ast.GenDecl).Specs[0].Pos()+30)
- cm[newDecl] = append(cm[newDecl], newDecl.Doc)
- c.InsertAfter(newDecl)
+ c.InsertAfter(astVariants(param, dst.GenDeclDecorations{}))
}
}
-func isGeneratedFor(cg *ast.CommentGroup, fotTy string) bool {
+func isGeneratedFor(decorations dst.GenDeclDecorations, fotTy string) bool {
- for _, comment := range cg.List {
+ for i := len(decorations.Start) - 1; i >= 0; i-- {
+ line := decorations.Start[i]
+ if len(strings.TrimSpace(line)) == 0 {
+ break
+ }
- c := strings.TrimLeftFunc(stripMarker(comment.Text), unicode.IsSpace)
+ c := stripMarker(line)
s, ok := strings.CutPrefix(c, "enum:generated_for=")
if !ok {
return false
}
if s == fotTy {
return true
}
}
return false
}
const ()
ブロックを生成する部分は丸っと変わります。
前述のとおり、dst.GenDeclDecorations
のStart
がdoc commentに当たりますが、nodeのdoc commentの直前にfree floating commnetがあった場合はStart
に一緒くたに入ってしまうため、末尾から探索して\n
が見つかる場合にはそのindex以降にdoc commentを追記する形にします。こうすることで書き換えたいコメント以外を保つことができます。
他の部分は型名のast
の部分をdst
に変える以外の変更はありません。
func astVariants(param EnumParam, targetDecoration dst.GenDeclDecorations) *dst.GenDecl {
if len(targetDecoration.Start) > 0 && targetDecoration.Start[len(targetDecoration.Start)-1] != "//enum:generated_for="+param.Name {
var i int
for i = len(targetDecoration.Start) - 1; i >= 0; i-- {
if targetDecoration.Start[i] == "\n" {
break
}
}
if i < 0 {
i = len(targetDecoration.Start) - 1
}
targetDecoration.Start = append(slices.Clone(targetDecoration.Start[:i]), "\n", "//enum:generated_for="+param.Name)
} else {
targetDecoration.Start = []string{"//enum:generated_for=" + param.Name}
}
return &dst.GenDecl{
Decs: targetDecoration,
Tok: token.CONST,
Lparen: true,
Specs: mapParamToSpec(param),
Rparen: true,
}
}
生成
*decorator.Restorerで書き換えた*dst.File
を*ast.File
に逆変換し、printer.Fprintで結果をプリントできます。
for _, f := range pkg.Syntax {
filename := filepath.Base(pkg.Fset.Position(f.FileStart).Filename)
+ df, err := decorator.DecorateFile(pkg.Fset, f)
+ if err != nil {
+ panic(err)
+ }
- astutil.Apply(
+ dstutil.Apply(
- f,
+ df,
- func(c *astutil.Cursor) bool {
+ func(c *dstutil.Cursor) bool {
// ...
},
nil,
)
out, err := os.Create(filepath.Join(generatedDir, filename))
if err != nil {
panic(err)
}
+ restorer := decorator.NewRestorer()
+ af, err := restorer.RestoreFile(df)
+ if err != nil {
+ panic(err)
+ }
- err = printer.Fprint(out, pkg.Fset, f)
+ err = printer.Fprint(out, pkg.Fset, af)
if err != nil {
panic(err)
}
}
結果
ast
版ではうまくいかなかったのに対し、
package target
// free floating comment 1
func Foo() {
// nothing
}
//enum:variants=foo,bar,baz,qux,quux,corge
type EnumWithComments string
// free floating comment 2
func Bar() {
// nothing
}
//enum:variants=foo,bar,baz
type EnumWithComments2 string
// free floating comment 3
//enum:generated_for=EnumWithComments2
const (
EnumWithComments2Foo EnumWithComments2 = "foo"
)
/* free floating comment 4
*/
以下を生成します。コメントの位置関係が完全に保たれていることがわかります。
package target
// free floating comment 1
func Foo() {
// nothing
}
//enum:variants=foo,bar,baz,qux,quux,corge
type EnumWithComments string
//enum:generated_for=EnumWithComments
const (
EnumWithCommentsFoo EnumWithComments = "foo"
EnumWithCommentsBar EnumWithComments = "bar"
EnumWithCommentsBaz EnumWithComments = "baz"
EnumWithCommentsQux EnumWithComments = "qux"
EnumWithCommentsQuux EnumWithComments = "quux"
EnumWithCommentsCorge EnumWithComments = "corge"
)
// free floating comment 2
func Bar() {
// nothing
}
//enum:variants=foo,bar,baz
type EnumWithComments2 string
// free floating comment 3
//enum:generated_for=EnumWithComments2
const (
EnumWithComments2Foo EnumWithComments2 = "foo"
EnumWithComments2Bar EnumWithComments2 = "bar"
EnumWithComments2Baz EnumWithComments2 = "baz"
)
/* free floating comment 4
*/
dstを使用し続けるリスク
ast
と違い、github.com/dave/dstはサードパーティ、かつ個人メンテのライブラリですからGo
の進化についていけないリスクは常に抱えています。
例えばGo1.18でIndexListExprが追加されました。1.18
と言えばgenericsが追加されたアップデートです。genericsのために構文が拡張されたので(instantiation時に複数の型がある場合の表記, e.g. [int, string, *bytes.Buffer]
がそれまでのastでは表現できなかった)、このexprが追加されたわけです。
現状のdst
は上記には対応済みであるので現状のあらゆるコードにうまく機能するはずです。今後構文の追加があれば、同様にexprが追加されてそれについていけなくなるという可能性があるわけです。
Go1.23ではexprの追加はありません。逆に言って1.0.0
から追加されたast nodeは上記のみです。
ast
は非常にstableであり、おそらくGo
teamもなるだけtokenもexprも追加したくはないでしょうから今後の追加の可能性も少ないでしょう。
ですのでおそらく今後数年はまずもって使い続けられると筆者は見積もっています。
exprが追加されてなおかつモジュールオーナーが非活発的な場合、筆者も頑張って貢献して直します。
おわりに
前段の記事で
-
Goのcode generatorの作り方: 諸注意とtext/templateの使い方で
- Rationale: なぜGoでcode generationが必要なのか
- code generatorを実装する際の注意点など
-
io.Writer
に書き出すシンプルな方法 -
text/template
を使う方法-
text/template
のcode generationにかかわりそうな機能性。 - 実際に
text/template
を使ったcode generatorのexample。
-
-
Goのcode generatorの作り方: jenniferの使い方で
- github.com/dave/jenniferの各機能
-
text/template
で実装したcode generatorのexampleをjennifer
で再実装
についてそれぞれ述べました。
この記事では、
- astのパーズ方法
-
go/parser
を用いる方法 - golang.org/x/tools/go/packagesを用いる方法
-
- 軽いastの解析方法やデバッグ方法
- ast構造のprint: ast.Print
- directive commentの解析
- astのtraverse方法
-
astutil.Apply
でgo source codeのrewriteを実装します -
astutil.Apply
ではコメントオフセットの狂いによってコメントの順序がおかしくなる問題について述べ -
github.com/dave/dstによってこの問題を起さずにast rewriteができることを述べます。
-
dst
の紹介 - astと
dst
の相互変換 -
dst
でのコメントの取り扱い方法について -
dstutil.Apply
を使ったrewrite
-
を述べました。
Go
のsource codeを解析してastをえて、それを解析してrewriteを行うためにかかわりそうな要素について紹介しました。
もう少し凝ったexampleを乗せてもいいかなと思いましたが、それは別の記事に分離しようかと思います(書くかはわかりませんが)。
text/template
やgithub.com/dave/jenniferを用いてコードを生成したほうがはるかに簡単なので、この方法を利用することは少ないと思います。
linterのcode actionのようなものを実装したいときや、code generatorの生成結果をさらに変更するなどのケースで便利かなと思います。
実装する機会は少ないかもしれない・・・少なくとも筆者的に仕事でやるには正当化しずらい手間です・・ですが、やれると体験がよいので覚えておくとよいかもしれません。
Discussion