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 でファイルを指定します。 srcnil の場合、 filename のファイルが解析されます。 srcstring[]byteio.Reader の場合は src が解析され、 filenamefset に記録される名前として使用されるだけです。そのため、標準入力やネットワーク越しにファイルを指定する際などは src を設定することになります。

このコードラボでは src として、コード中に以下のソースコードを埋め込んだものを使用して解説していきます。

package a

type Resource struct {
        ID   int64    `json:"id" xml:"id"`
        Data []string `json:"data,omitempty" xml:"data"`
}

Go Playgroundでコードを見る。

第四引数の modeModeです。こちらは 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 パッケージを使用します。 GOOSGOARCH などが実行環境のもので良ければDefault.Import、変更する必要がある場合はContextを作成した上でImportメソッドを呼ぶと*Packageが取得できます。

通常のソースは Package.GoFiles に格納されていますが、CGO用のソースやテスト用のファイルはそれぞれ CgoFilesTestGoFiles と異なるフィールドに格納されているので必要に応じて組み合わせて使ってください。また、ディレクトリは 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.Nodeinterface なので型アサーションで実際の型をチェックしていきます。

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[].Tagast.BasicLitとして格納されており、 BasicLitValue からその文字列を取得できます。

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/printerFprint関数、または go/formatNode関数を使用します。どちらも一見同じような出力に見えますが、 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型からGetLookupで取得するようにします。

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で公開しましょう。

参考