Goのコードを書いていると、以下のような構造体のタグを、
type Resource struct {
ID int64 `json:"id" xml:"id"`
Data []string `json:"data,omitempty" xml:"data"`
}
次のように整形したくなることがあるかもしれません。
type Resource struct {
ID int64 `json:"id" xml:"id"`
Data []string `json:"data,omitempty" xml:"data"`
}
しかし、標準付属の gofmt
は構造体のタグを整形してくれないため、手でスペースを入力する必要があります。最初はそれでも良いかもしれませんが、段々と大変になるので、ツールなどに頼らないと継続的なメンテナンスが困難になってしまうでしょう。
幸いGoにはソースコードを静的解析したり、生成するためのパッケージがあるため、それらを使用することで構造体のタグを整形することができます。
本コードラボではgo/astをはじめとする、go/配下のパッケージの使い方を学び、実際に構造体のタグを整形するツールを作成します。
なお、静的解析の基礎については深く解説しないため、こちらのコードラボで学習しておくことをおすすめします。
Goのソースコードから構造体のタグを取得するには、まずソースコードを抽象構文木(ast)に変換し、目的の要素を探索していく必要があります。
コマンドラインツールとして実装する場合、対象のソースコードを指定する方法として、以下の3つが考えられます。
ファイルから取得するには go/parser
パッケージのParseFile関数を使用します。
第一引数の fset
には解析したファイルの情報を記録するためのものです。こちらには go/token
パッケージのNewFileSet関数で作成した*FileSetを指定します。
第二引数の filename
と第三引数の src
でファイルを指定します。 src
が nil
の場合、 filename
のファイルが解析されます。 src
が string
や []byte
、 io.Reader
の場合は src
が解析され、 filename
は fset
に記録される名前として使用されるだけです。そのため、標準入力やネットワーク越しにファイルを指定する際などは src
を設定することになります。
このコードラボでは src
として、コード中に以下のソースコードを埋め込んだものを使用して解説していきます。
package a
type Resource struct {
ID int64 `json:"id" xml:"id"`
Data []string `json:"data,omitempty" xml:"data"`
}
Go Playgroundでコードを見る。
第四引数の mode
はModeです。こちらは 0
でも問題ありませんが、少なくとも ParseComments
は指定し、コメントを取得するようにしておくのが良いでしょう。
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, filename, src, parser.ParseComments)
if err != nil {
log.Fatal("Error:", err)
}
Go Playgroundでコードを見る。
ディレクトリから取得するには、配下にあるファイルごとに ParseFile
を呼んでいっても構いませんが、同じく go/parser
パッケージにあるParseDir関数を使用するのが簡単です。こちらは ".go"
で終わるファイルのみが対象になり、第三引数の filter
で追加のフィルタ条件を指定することもできます。
fset := token.NewFileSet()
files, err := parser.ParseDir(fset, path, nil, parser.ParseComments)
if err != nil {
log.Fatal("Error:", err)
}
パッケージから取得する場合、ファイルやディレクトリのような関数が用意されていないため、まずはパッケージ名から対象のファイルやディレクトリを取得する必要があります。
パッケージ名からパッケージの情報を取得するには go/build
パッケージを使用します。 GOOS
や GOARCH
などが実行環境のもので良ければDefault.Import、変更する必要がある場合はContextを作成した上でImportメソッドを呼ぶと*Packageが取得できます。
通常のソースは Package.GoFiles
に格納されていますが、CGO用のソースやテスト用のファイルはそれぞれ CgoFiles
、 TestGoFiles
と異なるフィールドに格納されているので必要に応じて組み合わせて使ってください。また、ディレクトリは Package.Dir
です。
p, err := build.Default.Import(name, "", 0)
if err != nil {
log.Fatal("Error:", err)
}
var files []string
files = append(files, p.GoFiles...)
files = append(files, p.TestGoFiles...)
それでは取得した抽象構文木を出力してみましょう。抽象構文木は go/ast
パッケージのPrint関数で出力できます。
ast.Print(fset, file)
Go Playgroundでコードを見る。
実行すると次のようになります。
0 *ast.File {
1 . Package: a.go:1:1
2 . Name: *ast.Ident {
3 . . NamePos: a.go:1:9
4 . . Name: "a"
5 . }
6 . Decls: []ast.Decl (len = 1) {
7 . . 0: *ast.GenDecl {
8 . . . TokPos: a.go:3:1
9 . . . Tok: type
10 . . . Lparen: -
11 . . . Specs: []ast.Spec (len = 1) {
12 . . . . 0: *ast.TypeSpec {
13 . . . . . Name: *ast.Ident {
14 . . . . . . NamePos: a.go:3:6
15 . . . . . . Name: "Resource"
16 . . . . . . Obj: *ast.Object {
17 . . . . . . . Kind: type
18 . . . . . . . Name: "Resource"
19 . . . . . . . Decl: *(obj @ 12)
20 . . . . . . }
21 . . . . . }
22 . . . . . Assign: -
23 . . . . . Type: *ast.StructType {
24 . . . . . . Struct: a.go:3:15
25 . . . . . . Fields: *ast.FieldList {
26 . . . . . . . Opening: a.go:3:22
27 . . . . . . . List: []*ast.Field (len = 2) {
28 . . . . . . . . 0: *ast.Field {
29 . . . . . . . . . Names: []*ast.Ident (len = 1) {
30 . . . . . . . . . . 0: *ast.Ident {
31 . . . . . . . . . . . NamePos: a.go:4:2
32 . . . . . . . . . . . Name: "ID"
33 . . . . . . . . . . . Obj: *ast.Object {
34 . . . . . . . . . . . . Kind: var
35 . . . . . . . . . . . . Name: "ID"
36 . . . . . . . . . . . . Decl: *(obj @ 28)
37 . . . . . . . . . . . }
38 . . . . . . . . . . }
39 . . . . . . . . . }
40 . . . . . . . . . Type: *ast.Ident {
41 . . . . . . . . . . NamePos: a.go:4:7
42 . . . . . . . . . . Name: "int64"
43 . . . . . . . . . }
44 . . . . . . . . . Tag: *ast.BasicLit {
45 . . . . . . . . . . ValuePos: a.go:4:16
46 . . . . . . . . . . Kind: STRING
47 . . . . . . . . . . Value: "`json:\"id\" xml:\"id\"`"
48 . . . . . . . . . }
49 . . . . . . . . }
50 . . . . . . . . 1: *ast.Field {
51 . . . . . . . . . Names: []*ast.Ident (len = 1) {
52 . . . . . . . . . . 0: *ast.Ident {
53 . . . . . . . . . . . NamePos: a.go:5:2
54 . . . . . . . . . . . Name: "Data"
55 . . . . . . . . . . . Obj: *ast.Object {
56 . . . . . . . . . . . . Kind: var
57 . . . . . . . . . . . . Name: "Data"
58 . . . . . . . . . . . . Decl: *(obj @ 50)
59 . . . . . . . . . . . }
60 . . . . . . . . . . }
61 . . . . . . . . . }
62 . . . . . . . . . Type: *ast.ArrayType {
63 . . . . . . . . . . Lbrack: a.go:5:7
64 . . . . . . . . . . Elt: *ast.Ident {
65 . . . . . . . . . . . NamePos: a.go:5:9
66 . . . . . . . . . . . Name: "string"
67 . . . . . . . . . . }
68 . . . . . . . . . }
69 . . . . . . . . . Tag: *ast.BasicLit {
70 . . . . . . . . . . ValuePos: a.go:5:16
71 . . . . . . . . . . Kind: STRING
72 . . . . . . . . . . Value: "`json:\"data,omitempty\" xml:\"data\"`"
73 . . . . . . . . . }
74 . . . . . . . . }
75 . . . . . . . }
76 . . . . . . . Closing: a.go:6:1
77 . . . . . . }
78 . . . . . . Incomplete: false
79 . . . . . }
80 . . . . }
81 . . . }
82 . . . Rparen: -
83 . . }
84 . }
85 . Scope: *ast.Scope {
86 . . Objects: map[string]*ast.Object (len = 1) {
87 . . . "Resource": *(obj @ 16)
88 . . }
89 . }
90 . Unresolved: []*ast.Ident (len = 2) {
91 . . 0: *(obj @ 40)
92 . . 1: *(obj @ 64)
93 . }
94 }
抽象構文木を探索するには、 go/ast
パッケージのInspect関数を使用します。探索する動作をより詳細に指定したい場合はVisitorを実装した上でWalk関数を使用することもできます。
Inspect
関数は false
が返ってくるまで探索を継続します。以下のコードは ast.Node
として渡された file
を出力したらそこで探索が終了するため、先ほどと同じ実行結果になります。
ast.Inspect(file, func(node ast.Node) bool {
ast.Print(fset, node)
return false
})
Go Playgroundでコードを見る。
今回は構造体の定義からタグを取得したいので node
が *ast.StructType
のものを探索していきます。ast.Nodeは interface
なので型アサーションで実際の型をチェックしていきます。
ast.Inspect(file, func(node ast.Node) bool {
s, ok := node.(*ast.StructType)
if !ok {
return true
}
ast.Print(fset, s)
return true
})
Go Playgroundでコードを見る。
これで構造体の抽象構文木のみが出力されるようになりました。
0 *ast.StructType {
1 . Struct: a.go:3:15
2 . Fields: *ast.FieldList {
3 . . Opening: a.go:3:22
4 . . List: []*ast.Field (len = 2) {
5 . . . 0: *ast.Field {
6 . . . . Names: []*ast.Ident (len = 1) {
7 . . . . . 0: *ast.Ident {
8 . . . . . . NamePos: a.go:4:2
9 . . . . . . Name: "ID"
10 . . . . . . Obj: *ast.Object {
11 . . . . . . . Kind: var
12 . . . . . . . Name: "ID"
13 . . . . . . . Decl: *(obj @ 5)
14 . . . . . . }
15 . . . . . }
16 . . . . }
17 . . . . Type: *ast.Ident {
18 . . . . . NamePos: a.go:4:7
19 . . . . . Name: "int64"
20 . . . . }
21 . . . . Tag: *ast.BasicLit {
22 . . . . . ValuePos: a.go:4:16
23 . . . . . Kind: STRING
24 . . . . . Value: "`json:\"id\" xml:\"id\"`"
25 . . . . }
26 . . . }
27 . . . 1: *ast.Field {
28 . . . . Names: []*ast.Ident (len = 1) {
29 . . . . . 0: *ast.Ident {
30 . . . . . . NamePos: a.go:5:2
31 . . . . . . Name: "Data"
32 . . . . . . Obj: *ast.Object {
33 . . . . . . . Kind: var
34 . . . . . . . Name: "Data"
35 . . . . . . . Decl: *(obj @ 27)
36 . . . . . . }
37 . . . . . }
38 . . . . }
39 . . . . Type: *ast.ArrayType {
40 . . . . . Lbrack: a.go:5:7
41 . . . . . Elt: *ast.Ident {
42 . . . . . . NamePos: a.go:5:9
43 . . . . . . Name: "string"
44 . . . . . }
45 . . . . }
46 . . . . Tag: *ast.BasicLit {
47 . . . . . ValuePos: a.go:5:16
48 . . . . . Kind: STRING
49 . . . . . Value: "`json:\"data,omitempty\" xml:\"data\"`"
50 . . . . }
51 . . . }
52 . . }
53 . . Closing: a.go:6:1
54 . }
55 . Incomplete: false
56 }
次はast.StructTypeからタグの値を取得します。タグは StructType.Fields.List[].Tag
にast.BasicLitとして格納されており、 BasicLit
の Value
からその文字列を取得できます。
ast.Inspect(file, func(node ast.Node) bool {
s, ok := node.(*ast.StructType)
if !ok {
return true
}
for _, f := range s.Fields.List {
if f.Tag == nil {
continue
}
fmt.Println(f.Tag.Value)
}
return true
})
Go Playgroundでコードを見る。
以下のタグが取得できました。
`json:"id" xml:"id"`
`json:"data,omitempty" xml:"data"`
目的の要素を取得できるようになったので、次はコードの生成です。抽象構文木から実際のコードを出力するには、 go/printer
のFprint関数、または go/format
のNode関数を使用します。どちらも一見同じような出力に見えますが、 printer.Fprint
は空白がタブではなく、スペースになるため、 gofmt
とは異なる出力になります。そのため、そのまま使えるコードを生成するには format.Node
を使用します。
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, filename, src, parser.ParseComments)
if err != nil {
log.Fatal("Error:", err)
}
format.Node(os.Stdout, fset, file)
Go Playgroundでコードを見る。
こちらを実行すると、元のコードがそのまま出力されます。
そして、 format.Node
に渡す node
を書き換えることで生成されるコードも書き換わります。試しに全てのフィールドを出力しないようにする、"-"
に書き換えてみましょう。
ast.Inspect(file, func(node ast.Node) bool {
s, ok := node.(*ast.StructType)
if !ok {
return true
}
for _, f := range s.Fields.List {
if f.Tag == nil {
continue
}
f.Tag.Value = "`" + `json:"-" xml:"-"` + "`"
}
return true
})
format.Node(os.Stdout, fset, file)
Go Playgroundでコードを見る。
出力されるコートが以下のように書き換わります。
package a
type Resource struct {
ID int64 `json:"-" xml:"-"`
Data []string `json:"-" xml:"-"`
}
それでは実際にタグを整形する処理を実装してみましょう。内容としては、構造体のタグを以下のような、長さがフィールド数のスライスとして受け取って整形する、といったものです。
var tags = []string{
`json:"id" xml:"id"`,
`json:"data,omitempty" xml:"data"`,
}
なお、 Tag.Value
には `
が含まれた文字列が格納されているため、以下のような関数を通して取り除いたものを前提とします。
func unquote(tag string) string {
s, err := strconv.Unquote(tag)
if err != nil {
panic(err) // 不正なタグはParseFileでエラーになる
}
return s
}
このコードラボでは、タグ名は次の正規表現で取得する実装にします。
var reTagName = regexp.MustCompile(`(\w+):`)
Go Playgroundでコードを見る。
また、タグの値はreflect.StructTag型からGetやLookupで取得するようにします。
reflect.StructTag(tag).Get(tagname)
Go Playgroundでコードを見る。
さて、整形のルールですが、今回は次のように実装します。
そのため、以下の structTags
型に必要な情報を保持するようにします。
type structTags struct {
tags []reflect.StructTag // 各フィールドのタグ
length map[string]int // キーのタグ名の最も長いタグの長さ
order []string // タグ名の順序
}
そして aligned
メソッドにフィールド番号を渡すことで、整形済みのタグを得られるようにします。
func (st *structTags) aligned(index int) string {
b := new(bytes.Buffer)
for _, tagname := range st.order {
var t string
if value, ok := st.tags[index].Lookup(tagname); ok {
t = tagstr(tagname, value)
}
b.WriteString(t)
// 一番後ろに少なくとも1つはスペースを入れる
b.WriteString(strings.Repeat(" ", st.length[tagname]-len(t)+1))
}
return strings.TrimRight(b.String(), " ")
}
func tagstr(tagname, value string) string {
return tagname + `:"` + value + `"`
}
ただ、 structTags
のフィールドは簡単に作ることができないため、コンストラクタを用意しておきます。
func newStructTags(tags []string) *structTags {
st := structTags{
tags: make([]reflect.StructTag, len(tags)),
length: map[string]int{},
}
for i, tag := range tags {
if tag == "" {
continue
}
rst := reflect.StructTag(tag)
for _, match := range reTagName.FindAllStringSubmatch(tag, -1) {
tagname := match[1]
length, ok := st.length[tagname]
if !ok {
// 初めて出現したタグ名を登録
st.order = append(st.order, tagname)
}
if l := len(tagstr(tagname, rst.Get(tagname))); l > length {
// 最も長いタグの長さを更新
st.length[tagname] = l
}
}
st.tags[i] = rst
}
return &st
}
これを以下のようにして使うことで、タグを整形します。
st := newStructTags(tags)
for i := range tags {
if tags[i] != "" {
tags[i] = st.aligned(i)
}
}
Go Playgroundでコードを見る。
実行すると、tagsの内容が以下のようになります。
json:"id" xml:"id"
json:"data,omitempty" xml:"data"
これでタグを整形する処理が実装できました。こちらを Align
関数として定義し、以下のようにして Tag.Value
を書き換えます。
ast.Inspect(file, func(node ast.Node) bool {
s, ok := node.(*ast.StructType)
if !ok {
return true
}
tags := make([]string, len(s.Fields.List))
for i, f := range s.Fields.List {
if f.Tag == nil {
continue
}
tags[i] = unquote(f.Tag.Value)
}
for i, tag := range Align(tags) {
if s.Fields.List[i].Tag == nil {
continue
}
s.Fields.List[i].Tag.Value = quote(tag)
}
return true
})
format.Node(os.Stdout, fset, file)
Go Playgroundでコードを見る。
こちらのコードで使用している quote
関数は unquote
の対となる、次のような関数です。
func quote(tag string) string {
return "`" + tag + "`"
}
実行すると構造体のタグが整形された状態で、完全なソースが出力されます。
package a
type Resource struct {
ID int64 `json:"id" xml:"id"`
Data []string `json:"data,omitempty" xml:"data"`
}
念のため他のパターンも確認してみましょう。まずはテストコードの雛形を準備します。
func TestAlign(t *testing.T) {
type args struct {
tags []string
}
tests := []struct {
name string
args args
want []string
}{
// TODO: パターンを書いていく
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tags := Align(tt.args.tags)
got := strings.Join(tags, "\n")
want := strings.Join(tt.want, "\n")
if got != want {
t.Errorf("Align()\n[got]\n%v\n[want]\n%v", got, want)
}
})
}
}
確認するのは以下の3パターンです。
{
name: "single tag",
args: args{tags: []string{
`json:"id"`,
`json:"data,omitempty"`,
}},
want: []string{
`json:"id"`,
`json:"data,omitempty"`,
},
},
{
name: "multiple tag",
args: args{tags: []string{
`json:"id" xml:"id"`,
`json:"data,omitempty" xml:"data"`,
}},
want: []string{
`json:"id" xml:"id"`,
`json:"data,omitempty" xml:"data"`,
},
},
{
name: "goon with json",
args: args{tags: []string{
`goon:"kind,Kind"`,
`datastore:"-" goon:"id" json:"id"`,
`datastore:"data" json:"data"`,
}},
want: []string{
`goon:"kind,Kind"`,
`goon:"id" datastore:"-" json:"id"`,
` datastore:"data" json:"data"`,
},
},
Go Playgroundでコードを見る。
全てPASSしました!
=== RUN TestAlign
=== RUN TestAlign/single_tag
=== RUN TestAlign/multiple_tag
=== RUN TestAlign/goon_with_json
--- PASS: TestAlign (0.00s)
--- PASS: TestAlign/single_tag (0.00s)
--- PASS: TestAlign/multiple_tag (0.00s)
--- PASS: TestAlign/goon_with_json (0.00s)
PASS
All tests passed.
今回はGo Playgroundで実行可能なコードにしていたので実際のコードは紹介していませんが、もし gofmt
の -w
フラグのように、ファイルを直接上書きしたい場合、 format.Node
の出力先をos.Fileに変更することで実現することができます。
構造体のタグを整形する方法について学びました!
学習した内容を main.go
を含むコマンドラインツールとして実装し、github.comで公開しましょう。
Args