前書き:DSLに半日悩み、カッとなって作った

goaは、DSLで記述されたデザインをもとに、Web APIホスティングに必要なベース処理(ルーティング、コントローラ、Swaggerなど)を生成するFrameworkです。goaを採用している会社の例は、DMM。goaを使うとコード記述量が減り、APIドキュメントが自動生成される利点があります。

私は、2022年1月からgoaを開発で使用するようになりました。goaは、DSLを覚えるコストが小さくはありません。DSLを書き間違えると、当然goa-designから各種ファイルの生成処理でエラーとなります。

ここでのエラー文章は簡潔(例:「パニックが起きた」)であり、プログラマに優しくありません。例えば、近年のコンパイラはユーザフレンドリーで、実装ミスをしている箇所を表示し、間違いの原因(推測)も出力します。goaはそのような情報を一切出力しません。この仕様は、goa初心者には辛いものです。ドキュメントやgoa-designコードを読み漁る時間が増えます。

この課題を解決するため、goa version1(フォーク版)のlinterとして、goavlの開発を始めました。開発目的は、goaを用いたWeb APIデザイン設計速度を早め、チーム内でのgoa-designの差異を極力小さくする事です。

goavlは、goa version 1 のみをサポートし、現行のversion 3 はサポートしない予定です。その理由は、フォークしたgoa version 1 を私が利用しているからです。何故、フォーク版を使用しているかの背景は、他のサイトで説明しています(記事の作者とgoavl開発者は別人のため注意)

本記事では、「goavlをどのように機能を用いて実装したかの説明(ザックリ説明)」と「goavlの基本機能」について説明します。

GolangはASTを作る標準パッケージが存在

linter(静的解析ツール)を作る場合は、ソースコードの中身を解析する必要があります。

コンパイラを自作した経験がある方は、「字句解析器(lexer、tokenizer)や構文解析器(parser)を作るの大変だな」と考えるかもしれません(私は新卒で入社した会社で、構文解析器を上手く作れなかった苦い経験があります。雑な設計では動きません)。

Golangでは、標準パッケージgo/astがAST(構文解析木)を作成してくれます。そのため、lexerやparserを自作せずにすみます。さらに、ASTを可視化する関数ast.Print() が用意されており、ASTを目視確認しながら実装を進められます。これが本当に便利。

どれぐらい便利かというと、目的の機能をlinterに導入するのに要した時間が10〜12時間ぐらいでした。go/astやast.Print() がなければ、10倍以上の時間が必要だったと思います。

ASTの出力例

以下のコードで、ASTを出力できます。

package main

import (
	"go/ast"
	"go/parser"
	"go/token"
	"os"
)

func main() {
	filepath := os.Args[1] // 今回は、引数でこのファイル自身を指定する
	fset := token.NewFileSet()
	f, _ := parser.ParseFile(fset, filepath, nil, 0)
	ast.Print(fset, f)
}

上記ファイル自体をast.Print()した結果は、以下の通りです(約250行)。

0行目に書かれているast.Fileノードは、goファイル(1個)のASTを表す構造体です。ast.File構造体には、パッケージ名やインポートPATH、変数や関数の宣言/実装が全て代入されています。ここからお目当ての変数や関数を探し出す作業は、以下のast.Print()結果を見ながら行います。

ast.Print()結果には、Package、Name、Specsなどの変数名が書かれており、変数の内容も合わせて出力されています。この情報を見ながら、ノードを辿る作業を行います。

     0  *ast.File {
     1  .  Package: cmd/ast/main.go:1:1
     2  .  Name: *ast.Ident {
     3  .  .  NamePos: cmd/ast/main.go:1:9
     4  .  .  Name: "main"
     5  .  }
     6  .  Decls: []ast.Decl (len = 2) {
     7  .  .  0: *ast.GenDecl {
     8  .  .  .  TokPos: cmd/ast/main.go:3:1
     9  .  .  .  Tok: import
    10  .  .  .  Lparen: cmd/ast/main.go:3:8
    11  .  .  .  Specs: []ast.Spec (len = 4) {
    12  .  .  .  .  0: *ast.ImportSpec {
    13  .  .  .  .  .  Path: *ast.BasicLit {
    14  .  .  .  .  .  .  ValuePos: cmd/ast/main.go:4:2
    15  .  .  .  .  .  .  Kind: STRING
    16  .  .  .  .  .  .  Value: "\"go/ast\""
    17  .  .  .  .  .  }
    18  .  .  .  .  .  EndPos: -
    19  .  .  .  .  }
    20  .  .  .  .  1: *ast.ImportSpec {
    21  .  .  .  .  .  Path: *ast.BasicLit {
    22  .  .  .  .  .  .  ValuePos: cmd/ast/main.go:5:2
    23  .  .  .  .  .  .  Kind: STRING
    24  .  .  .  .  .  .  Value: "\"go/parser\""
    25  .  .  .  .  .  }
    26  .  .  .  .  .  EndPos: -
    27  .  .  .  .  }
    28  .  .  .  .  2: *ast.ImportSpec {
    29  .  .  .  .  .  Path: *ast.BasicLit {
    30  .  .  .  .  .  .  ValuePos: cmd/ast/main.go:6:2
    31  .  .  .  .  .  .  Kind: STRING
    32  .  .  .  .  .  .  Value: "\"go/token\""
    33  .  .  .  .  .  }
    34  .  .  .  .  .  EndPos: -
    35  .  .  .  .  }
    36  .  .  .  .  3: *ast.ImportSpec {
    37  .  .  .  .  .  Path: *ast.BasicLit {
    38  .  .  .  .  .  .  ValuePos: cmd/ast/main.go:7:2
    39  .  .  .  .  .  .  Kind: STRING
    40  .  .  .  .  .  .  Value: "\"os\""
    41  .  .  .  .  .  }
    42  .  .  .  .  .  EndPos: -
    43  .  .  .  .  }
    44  .  .  .  }
    45  .  .  .  Rparen: cmd/ast/main.go:8:1
    46  .  .  }
    47  .  .  1: *ast.FuncDecl {
    48  .  .  .  Name: *ast.Ident {
    49  .  .  .  .  NamePos: cmd/ast/main.go:10:6
    50  .  .  .  .  Name: "main"
    51  .  .  .  .  Obj: *ast.Object {
    52  .  .  .  .  .  Kind: func
    53  .  .  .  .  .  Name: "main"
    54  .  .  .  .  .  Decl: *(obj @ 47)
    55  .  .  .  .  }
    56  .  .  .  }
    57  .  .  .  Type: *ast.FuncType {
    58  .  .  .  .  Func: cmd/ast/main.go:10:1
    59  .  .  .  .  Params: *ast.FieldList {
    60  .  .  .  .  .  Opening: cmd/ast/main.go:10:10
    61  .  .  .  .  .  Closing: cmd/ast/main.go:10:11
    62  .  .  .  .  }
    63  .  .  .  }
    64  .  .  .  Body: *ast.BlockStmt {
    65  .  .  .  .  Lbrace: cmd/ast/main.go:10:13
    66  .  .  .  .  List: []ast.Stmt (len = 4) {
    67  .  .  .  .  .  0: *ast.AssignStmt {
    68  .  .  .  .  .  .  Lhs: []ast.Expr (len = 1) {
    69  .  .  .  .  .  .  .  0: *ast.Ident {
    70  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:11:2
    71  .  .  .  .  .  .  .  .  Name: "filepath"
    72  .  .  .  .  .  .  .  .  Obj: *ast.Object {
    73  .  .  .  .  .  .  .  .  .  Kind: var
    74  .  .  .  .  .  .  .  .  .  Name: "filepath"
    75  .  .  .  .  .  .  .  .  .  Decl: *(obj @ 67)
    76  .  .  .  .  .  .  .  .  }
    77  .  .  .  .  .  .  .  }
    78  .  .  .  .  .  .  }
    79  .  .  .  .  .  .  TokPos: cmd/ast/main.go:11:11
    80  .  .  .  .  .  .  Tok: :=
    81  .  .  .  .  .  .  Rhs: []ast.Expr (len = 1) {
    82  .  .  .  .  .  .  .  0: *ast.IndexExpr {
    83  .  .  .  .  .  .  .  .  X: *ast.SelectorExpr {
    84  .  .  .  .  .  .  .  .  .  X: *ast.Ident {
    85  .  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:11:14
    86  .  .  .  .  .  .  .  .  .  .  Name: "os"
    87  .  .  .  .  .  .  .  .  .  }
    88  .  .  .  .  .  .  .  .  .  Sel: *ast.Ident {
    89  .  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:11:17
    90  .  .  .  .  .  .  .  .  .  .  Name: "Args"
    91  .  .  .  .  .  .  .  .  .  }
    92  .  .  .  .  .  .  .  .  }
    93  .  .  .  .  .  .  .  .  Lbrack: cmd/ast/main.go:11:21
    94  .  .  .  .  .  .  .  .  Index: *ast.BasicLit {
    95  .  .  .  .  .  .  .  .  .  ValuePos: cmd/ast/main.go:11:22
    96  .  .  .  .  .  .  .  .  .  Kind: INT
    97  .  .  .  .  .  .  .  .  .  Value: "1"
    98  .  .  .  .  .  .  .  .  }
    99  .  .  .  .  .  .  .  .  Rbrack: cmd/ast/main.go:11:23
   100  .  .  .  .  .  .  .  }
   101  .  .  .  .  .  .  }
   102  .  .  .  .  .  }
   103  .  .  .  .  .  1: *ast.AssignStmt {
   104  .  .  .  .  .  .  Lhs: []ast.Expr (len = 1) {
   105  .  .  .  .  .  .  .  0: *ast.Ident {
   106  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:12:2
   107  .  .  .  .  .  .  .  .  Name: "fset"
   108  .  .  .  .  .  .  .  .  Obj: *ast.Object {
   109  .  .  .  .  .  .  .  .  .  Kind: var
   110  .  .  .  .  .  .  .  .  .  Name: "fset"
   111  .  .  .  .  .  .  .  .  .  Decl: *(obj @ 103)
   112  .  .  .  .  .  .  .  .  }
   113  .  .  .  .  .  .  .  }
   114  .  .  .  .  .  .  }
   115  .  .  .  .  .  .  TokPos: cmd/ast/main.go:12:7
   116  .  .  .  .  .  .  Tok: :=
   117  .  .  .  .  .  .  Rhs: []ast.Expr (len = 1) {
   118  .  .  .  .  .  .  .  0: *ast.CallExpr {
   119  .  .  .  .  .  .  .  .  Fun: *ast.SelectorExpr {
   120  .  .  .  .  .  .  .  .  .  X: *ast.Ident {
   121  .  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:12:10
   122  .  .  .  .  .  .  .  .  .  .  Name: "token"
   123  .  .  .  .  .  .  .  .  .  }
   124  .  .  .  .  .  .  .  .  .  Sel: *ast.Ident {
   125  .  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:12:16
   126  .  .  .  .  .  .  .  .  .  .  Name: "NewFileSet"
   127  .  .  .  .  .  .  .  .  .  }
   128  .  .  .  .  .  .  .  .  }
   129  .  .  .  .  .  .  .  .  Lparen: cmd/ast/main.go:12:26
   130  .  .  .  .  .  .  .  .  Ellipsis: -
   131  .  .  .  .  .  .  .  .  Rparen: cmd/ast/main.go:12:27
   132  .  .  .  .  .  .  .  }
   133  .  .  .  .  .  .  }
   134  .  .  .  .  .  }
   135  .  .  .  .  .  2: *ast.AssignStmt {
   136  .  .  .  .  .  .  Lhs: []ast.Expr (len = 2) {
   137  .  .  .  .  .  .  .  0: *ast.Ident {
   138  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:13:2
   139  .  .  .  .  .  .  .  .  Name: "f"
   140  .  .  .  .  .  .  .  .  Obj: *ast.Object {
   141  .  .  .  .  .  .  .  .  .  Kind: var
   142  .  .  .  .  .  .  .  .  .  Name: "f"
   143  .  .  .  .  .  .  .  .  .  Decl: *(obj @ 135)
   144  .  .  .  .  .  .  .  .  }
   145  .  .  .  .  .  .  .  }
   146  .  .  .  .  .  .  .  1: *ast.Ident {
   147  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:13:5
   148  .  .  .  .  .  .  .  .  Name: "_"
   149  .  .  .  .  .  .  .  .  Obj: *ast.Object {
   150  .  .  .  .  .  .  .  .  .  Kind: var
   151  .  .  .  .  .  .  .  .  .  Name: "_"
   152  .  .  .  .  .  .  .  .  .  Decl: *(obj @ 135)
   153  .  .  .  .  .  .  .  .  }
   154  .  .  .  .  .  .  .  }
   155  .  .  .  .  .  .  }
   156  .  .  .  .  .  .  TokPos: cmd/ast/main.go:13:7
   157  .  .  .  .  .  .  Tok: :=
   158  .  .  .  .  .  .  Rhs: []ast.Expr (len = 1) {
   159  .  .  .  .  .  .  .  0: *ast.CallExpr {
   160  .  .  .  .  .  .  .  .  Fun: *ast.SelectorExpr {
   161  .  .  .  .  .  .  .  .  .  X: *ast.Ident {
   162  .  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:13:10
   163  .  .  .  .  .  .  .  .  .  .  Name: "parser"
   164  .  .  .  .  .  .  .  .  .  }
   165  .  .  .  .  .  .  .  .  .  Sel: *ast.Ident {
   166  .  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:13:17
   167  .  .  .  .  .  .  .  .  .  .  Name: "ParseFile"
   168  .  .  .  .  .  .  .  .  .  }
   169  .  .  .  .  .  .  .  .  }
   170  .  .  .  .  .  .  .  .  Lparen: cmd/ast/main.go:13:26
   171  .  .  .  .  .  .  .  .  Args: []ast.Expr (len = 4) {
   172  .  .  .  .  .  .  .  .  .  0: *ast.Ident {
   173  .  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:13:27
   174  .  .  .  .  .  .  .  .  .  .  Name: "fset"
   175  .  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 108)
   176  .  .  .  .  .  .  .  .  .  }
   177  .  .  .  .  .  .  .  .  .  1: *ast.Ident {
   178  .  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:13:33
   179  .  .  .  .  .  .  .  .  .  .  Name: "filepath"
   180  .  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 72)
   181  .  .  .  .  .  .  .  .  .  }
   182  .  .  .  .  .  .  .  .  .  2: *ast.Ident {
   183  .  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:13:43
   184  .  .  .  .  .  .  .  .  .  .  Name: "nil"
   185  .  .  .  .  .  .  .  .  .  }
   186  .  .  .  .  .  .  .  .  .  3: *ast.BasicLit {
   187  .  .  .  .  .  .  .  .  .  .  ValuePos: cmd/ast/main.go:13:48
   188  .  .  .  .  .  .  .  .  .  .  Kind: INT
   189  .  .  .  .  .  .  .  .  .  .  Value: "0"
   190  .  .  .  .  .  .  .  .  .  }
   191  .  .  .  .  .  .  .  .  }
   192  .  .  .  .  .  .  .  .  Ellipsis: -
   193  .  .  .  .  .  .  .  .  Rparen: cmd/ast/main.go:13:49
   194  .  .  .  .  .  .  .  }
   195  .  .  .  .  .  .  }
   196  .  .  .  .  .  }
   197  .  .  .  .  .  3: *ast.ExprStmt {
   198  .  .  .  .  .  .  X: *ast.CallExpr {
   199  .  .  .  .  .  .  .  Fun: *ast.SelectorExpr {
   200  .  .  .  .  .  .  .  .  X: *ast.Ident {
   201  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:14:2
   202  .  .  .  .  .  .  .  .  .  Name: "ast"
   203  .  .  .  .  .  .  .  .  }
   204  .  .  .  .  .  .  .  .  Sel: *ast.Ident {
   205  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:14:6
   206  .  .  .  .  .  .  .  .  .  Name: "Print"
   207  .  .  .  .  .  .  .  .  }
   208  .  .  .  .  .  .  .  }
   209  .  .  .  .  .  .  .  Lparen: cmd/ast/main.go:14:11
   210  .  .  .  .  .  .  .  Args: []ast.Expr (len = 2) {
   211  .  .  .  .  .  .  .  .  0: *ast.Ident {
   212  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:14:12
   213  .  .  .  .  .  .  .  .  .  Name: "fset"
   214  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 108)
   215  .  .  .  .  .  .  .  .  }
   216  .  .  .  .  .  .  .  .  1: *ast.Ident {
   217  .  .  .  .  .  .  .  .  .  NamePos: cmd/ast/main.go:14:18
   218  .  .  .  .  .  .  .  .  .  Name: "f"
   219  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 140)
   220  .  .  .  .  .  .  .  .  }
   221  .  .  .  .  .  .  .  }
   222  .  .  .  .  .  .  .  Ellipsis: -
   223  .  .  .  .  .  .  .  Rparen: cmd/ast/main.go:14:19
   224  .  .  .  .  .  .  }
   225  .  .  .  .  .  }
   226  .  .  .  .  }
   227  .  .  .  .  Rbrace: cmd/ast/main.go:15:1
   228  .  .  .  }
   229  .  .  }
   230  .  }
   231  .  Scope: *ast.Scope {
   232  .  .  Objects: map[string]*ast.Object (len = 1) {
   233  .  .  .  "main": *(obj @ 51)
   234  .  .  }
   235  .  }
   236  .  Imports: []*ast.ImportSpec (len = 4) {
   237  .  .  0: *(obj @ 12)
   238  .  .  1: *(obj @ 20)
   239  .  .  2: *(obj @ 28)
   240  .  .  3: *(obj @ 36)
   241  .  }
   242  .  Unresolved: []*ast.Ident (len = 5) {
   243  .  .  0: *(obj @ 84)
   244  .  .  1: *(obj @ 120)
   245  .  .  2: *(obj @ 161)
   246  .  .  3: *(obj @ 182)
   247  .  .  4: *(obj @ 200)
   248  .  }
   249  }

ast.Fileノードを愚直に辿るとどうなるか

ast.Print()の結果を見ながら、愚直にast.File構造体を辿っていくと、あなたのエンジニア人生で見た事がない深さのネストコードが拝めます。そして、この実装方法は間違えています。

愚直にast.File構造体を辿っても、パースしたファイルによってast.Fileの内容は変わります。そのため、愚直に書いたコードは汎用性がありません。

通常は、ast.Inspect()を使用してast.Fileの内容を探索します。ast.Inspect()は、探しているノード位置(型)をswitch-caseに記載しておくと、その位置で探索が止まります(case文に入ります)。ここでの型はプリミティブ型ではなく、go/ast内で定義されている型(ASTを表現するための型)です。

func main() {
	// 解析対象ソースコード
	src := `
package p
const c = 1.0
var X = f(3.14)*2 + c
`
	// srcのASTを作成
	fset := token.NewFileSet() // positions are relative to fset
	f, err := parser.ParseFile(fset, "src.go", src, 0)
	if err != nil {
		panic(err)
	}

	// ASTを再帰的に探索する。case部には、停止したいノード位置の型を書く
	ast.Inspect(f, func(n ast.Node) bool {
		var s string
		switch x := n.(type) {
		case *ast.BasicLit:
			s = x.Value
		case *ast.Ident:
			s = x.Name
		}
		if s != "" {
			fmt.Printf("%s:\t%s\n", fset.Position(n.Pos()), s)
		}
		return true
	})

}

以下、「愚直にコードを書いた場合」と「ast.Inspect()を使った場合」の例です。後者はもっとネストを浅く書けますが、この時の実力では以下が限界でした。

より詳細なAST情報が欲しい方への参考文献(日本語)

goavlの設計

goavlは限定的な用途のツールと言っても差し支えないので、簡単な作りにしました。より正確に言うと、既存のLinterのプラグインとして開発してもマージされない可能性が高かったので、自分で管理できる範囲のLinterとして設計しました。

goavlは、以下の2つを機能として持ちます。

  • Task(チェック項目)のランナー
  • ASTを用いたTask(チェック項目)

Taskのランナーは、非常にシンプルな作りです。まず、Task構造体の中に関数ポインタ(Check)を持ち、このポインタに文法チェック関数を登録します。ランナーは関数ポインタを順番にコールするだけです。優先度も何もありません。

type check func(filepath string)

type Task struct {
	Name string
	Check check // 関数ポインタ:一つの観点で文法チェックを行う
}

func Run(files []string) {
	// ここで複数のタスクがセットされる
	tasks := task.Setup()

	// goa-designファイルのみを抽出し、Task.Checkを順次実行する
	for _, f := range fileutils.ExtractDesignPackageFile(files) {
		for _, v := range tasks {
			v.Check(f)
		}
	}
}

ASTを用いたTaskは、例えば「命名規則が正しいか」といった観点(1個単位)で作ります。多くのLinterがこの方針を採用していると思われます。今回実装したTaskは、goa-designに関する知識がある方でないと理解できないので、説明を省略します。

goavlの使い方

インストール方法は、README(日本語)を参照してください。goavlは、引数指定が無い場合はカレントディレクトリ以下のgoa-designファイルをチェックし、–fileオプションをつけた場合は任意のgoa-designファイルをチェックします。

以下、実行例です。

$ goavl
[WARN] test/sample/action.go:7    Resource("operandsNG") is not snake case ('operands_ng')
[WARN] test/sample/action.go:9    Action("add-ng") is not snake case ('add_ng')
[WARN] test/sample/action.go:11   Not exist Description() in Action().
[WARN] test/sample/attribute.go:17   Not exist Example() in Attribute().
[WARN] test/sample/attribute.go:27   NoExample() is not user(client) friendly
[WARN] test/sample/attribute.go:9    Resource() has Attribute(). Attribute() can be used in View(), Type(), Attribute(), Attributes(), MediaType()
[WARN] test/sample/description.go:11   Not exist Example() in Attribute().
[WARN] test/sample/goa.go:40   Attributes() has View(). View() can be used in MediaType() or Response()
[WARN] test/sample/goa.go:46   Type() has View(). View() can be used in MediaType() or Response()
[WARN] test/sample/resource.go:11   Resource("operands-Ng_case") is not snake case ('operands_ng_case')
[WARN] test/sample/routing.go:12   Routing(DELETE("delete_ng/:left-ng/:right")) is not chain case ('delete-ng/:left-ng/:right')
[WARN] test/sample/routing.go:15   Routing(POST("/postNg/abc.php")) is not chain case ('/post-ng/abc.php')
[WARN] test/sample/view.go:12   Attributes() has View(). View() can be used in MediaType() or Response()
[WARN] test/sample/view.go:18   Type() has View(). View() can be used in MediaType() or Response()

現状は、「命名規則チェック」や「関数の使用条件チェック」、「APIや属性の説明が書かれているかのチェック」を行います。指摘箇所(ファイル、行数)と直し方を併記しているため、goavlを使えばgoa-designファイルの修正が容易にできる筈です。

最後に:goavlの由来

初期名称は"goalinter-v1"でした。

末尾の"v1"がlinterのVersion 1に見えるため、“goav1linter"に改名。その後、“1"と"l"が似ていたため、それらを統合する事を思いつきました。

その結果、 “goavl"が誕生しました。goavlは「ゴアブル」と呼んでいます。