go-app-builderのソースコードを読む #golang #gae

December 14, 2016

この記事はQiitaの記事をエクスポートしたものです。内容が古くなっている可能性があります。

はじめに

みなさんはGAE/GoのSDKがどうやってコードをビルドしているのか知っていますか? 私は正直ふわっとしか理解できておらず、きっと小人さんがビルドしてるんだろうなくらいに思ってました。 当然、小人さんじゃなくて、どこかにgoapp buildとかを呼んでいるコードがあるんだろうとは思っていましたが、どうやらgo-app-builderというツールがそれをやっていることを知ったので、調べてみることにしました。 今回は、ソースコードを読んでいく過程で分かったことをまとめたいと思います。

なお、ここではSDKのバージョンはappengine-1.9.48を使っています。

ソースコードはどこにあるのか

さて、go-app-builderはどこにあるんでしょうか。きっとSDKのディレクトリのどこかです。 実はSDK以下にあるgorootの中に入っています。

$ ls $(goapp env GOROOT)/src/cmd/go-app-builder
flags.go       gab.go         parser.go      synthesizer.go

ちなみに、goapp env GOROOTgoappで使われているGOROOTを取得するコマンドです。goapp env GOPATHとするとGOPATHが返ってきます。OSによらず同じように実行できるので、ハンズオンとかでGOPATH確認するのに便利です。

さて、少し脱線しましたが、上記のコードを見ていきます。

ソースコードを読む

まずはmain関数があるファイルから読むのが常套手段ですね。 main関数があるのは、gab.goですね。

main関数を読んでいくと、193行目に以下のような行があります。

193     err = buildApp(app)

なるほど、buildAppできっとビルドしてるんでしょうね。

それにしても、引数で渡しているappってなんでしょうね。 buildAppを読む前に、こちらを先に調べましょう。

appは、gab.goの166行目で作られた変数です。

166     app, err := ParseFiles(baseDir, flag.Args(), ignoreReleaseTags)

どうやら、ソースコードをパースしているようです。 この関数を探しましょう。 parser.goという名前のファイルがあるので、あやしいですね。

やはり、parser.goParseFilesがありました。

129 func ParseFiles(baseDir string, filenames []string, ignoreReleaseTags bool) (*App, error) {

では、ParseFiles関数を読んでいきます。 まずは、戻り値のAppが何者か知らないといけません。 parser.goの28-36行目に定義を見つけました。

 28 // App represents an entire Go App Engine app.
 29 type App struct {
 30     Files        []*File    // the complete set of source files for this app
 31     Packages     []*Package // the packages
 32     RootPackages []*Package // the subset of packages with init functions
 33     HasMain      bool       // whether the app has a user-defined main package
 34
 35     PackageIndex map[string]*Package // index from import path to package object
 36 }

なるほど、アプリケーションを構成するファイルやパッケージの情報を持つみたいですね。 HasMainが気になりますね。

App.HasMainを代入しているところを探してみましょう。

186             // Skip any main.main that isn't at the application root.
187             if file.HasMain && dir == "." {
188                 app.HasMain = true
189             }

186-189行目で代入していますね。 file.HasMainという値がtrueだったら入れてるっぽいですね。 fileはきっと上記のApp構造体のFilesの要素でしょうね。 ついでなので、File型の定義を見てみましょう。

65 type File struct {
66     Name        string   // the file name
67     PackageName string   // the package this file declares itself to be
68     ImportPaths []string // import paths
69     HasInit     bool     // whether the file has an init function
70     HasMain     bool     // whether the file defines a main.main function
71     callsAEMain bool     // whether the main function, if it exists, calls appengine.Main
72 }

なるほど、init関数を持っているのか、main関数を持っているのかという情報を持っているようですね。 おや?callsAEMainというフィールドがありますね。コメントを読む限り、appengine.Mainというものを呼んでいるかどうかのようです。

appengine.Mainとは何でしょう?ドキュメントを読んでみます。

On App Engine Standard it ensures the server has started and is prepared to receive requests.

なるほど、main関数の中で呼び出すことで、init関数で使わずにGAEのエントリーポイントを実行できるんですね。 init関数があったら、HasInittrueになり、main関数があったらHasMaintrueになるわけですね。 そして、callsAEMaintrueの場合はmain関数でappengine.Mainを呼んでいるというわけですね。

appengine.Mainを呼んでいるかどうかは、どの辺でチェックしているんでしょうね。 parser.goの中を探します。

どうやら、parseFile関数の中でやっているようです。

488             if file.Name.Name == "main" && isMain(funcDecl) {
489                 hasMain = true
490                 callsAEMain = callsAppEngineMain(fset, funcDecl)
491             }

おぉ。funcDeclとか出てきましたね。go/astパッケージで定義されている、*ast.FuncDeclのようです。

ast.FuncDeclは関数定義を表した型で、以下のように定義されています。

type FuncDecl struct {
        Doc  *CommentGroup // associated documentation; or nil
        Recv *FieldList    // receiver (methods); or nil (functions)
        Name *Ident        // function/method name
        Type *FuncType     // function signature: parameters, results, and position of "func" keyword
        Body *BlockStmt    // function body; or nil (forward declaration)
}

きっと、Identフィールドとかに、関数名の情報が入ってて、そこからmain関数だったりの情報を取得しているわけですね。 そして、main関数を見つけたら、中身を見てappengine.Mainを呼んでいるか調べているのでしょう。

callsAppEngineMain関数を見てやれば、appengine.Mainを呼んでいるかどうかを調べている処理が見つかりそうです。

412 // callsAppEngineMain returns whether or not the given function calls
413 // appengine.Main(). This is required in user-provided main() functions.
414 func callsAppEngineMain(fset *token.FileSet, f *ast.FuncDecl) bool {
415     for _, expr := range f.Body.List {
416         expStmt, ok := expr.(*ast.ExprStmt)
417         if !ok {
418             continue
419         }
420         callExpr, ok := expStmt.X.(*ast.CallExpr)
421         if !ok {
422             continue
423         }
424         selExpr, ok := callExpr.Fun.(*ast.SelectorExpr)
425         if !ok {
426             continue
427         }
428         selX, ok := selExpr.X.(*ast.Ident)
429         if !ok {
430             continue
431         }
432         if selX.Name == "appengine" && selExpr.Sel.Name == "Main" {
433             return true
434         }
435     }
436     return false
437 }

なるほど。ast.FuncDecl構造体のBodyフィールドから関数の本体を表すast.BlockStmtが取れ、そのListフィールドから文を表すast.Stmtのスライスが取得できるようです。

ast.BlockStmtの定義は以下のようなっています。

type BlockStmt struct {
        Lbrace token.Pos // position of "{"
        List   []Stmt
        Rbrace token.Pos // position of "}"
}

上記のast.Stmtを一つずつ見ていき、関数呼び出しを見つけて、その関数呼び出しがappengine.Mainを呼び出していればOKという感じでしょうか。 インポートパスを見ておらず、appengineというパッケージ名とMainという関数名しか見ていないのは、goapp env GOROOTにあるappengineパッケージかgoogle.golang.org/appengineなのか区別を付けないためにあるんでしょうか。 適当にappengineパッケージを作ったらどうなるのかなぁという衝動を抑えつつ、コードリーディングに戻りましょう。

さて、init関数を使った場合、どこかでappengine.Mainを呼ぶコードを足しているはずですね。 その部分を探してみましょう。

synthesizer.goというファイルに、MakeMainという怪しい関数を見つけました。 きっとここでmain関数を作ってるに違いありません。

15     if err := mainTemplate.Execute(buf, app); err != nil {

おぉ!テンプレートに埋め込んでますね!これはgo testでも取ってる手段ですね。 テンプレートを見てみましょう。

37 var mainTemplate = template.Must(template.New("main").Parse(
38     `package main
39
40 import (
41     internal "appengine_internal"
42
43     // Top-level app packages
44     {{range .RootPackages}}
45     _ "{{.ImportPath}}"
46     {{end}}
47 )
48
49 func main() {
50     internal.Main()
51 }
52 `))

おや?appengine.Mainじゃなくて、internal.Mainを呼んでますね。 何が違うんでしょうか?appengine.Main実装を見てましょう。

func Main() {
	internal.Main()
}

(´・ω・`)同じでした。

予想通り、init関数の場合は、テンプレートを使ってmain.main関数を作成し、それをエントリーポイントにしているようです。

さぁ、次はビルドしているところを探しましょう。 gab.goに戻ります。

ビルドをしているところは、きっと外部コマンドを実行する必要があるので、os/exec.Cmdを使っているところを探してみましょう。

parser.goに、runという関数を見つけました。

676 func run(args []string, env []string) error {
677     if *verbose {
678         log.Printf("run %v", args)
679     }
680     tool := filepath.Base(args[0])
681     if *trampoline != "" {
682         // Add trampoline binary, its flags, and -- to the start.
683         newArgs := []string{*trampoline}
684         if *trampolineFlags != "" {
685             newArgs = append(newArgs, strings.Split(*trampolineFlags, ",")...)
686         }
687         newArgs = append(newArgs, "--")
688         args = append(newArgs, args...)
689     }
690     cmd := &exec.Cmd{
691         Path:   args[0],
692         Args:   args,
693         Env:    env,
694         Stdout: os.Stdout,
695         Stderr: os.Stderr,
696     }
697     if err := cmd.Run(); err != nil {
698         return fmt.Errorf("failed running %v: %v", tool, err)
699     }
700     return nil
701 }

今度はrun関数を呼んでいる部分を探してみましょう。

543 func (t *timer) run(args, env []string) error {
544     start := time.Now()
545     err := run(args, env)
546
547     t.mu.Lock()
548     t.n++
549     t.total += time.Since(start)
550     t.mu.Unlock()
551
552     return err
553 }

timerという型のメソッドで呼ばれているようです。 なるほど、次はこのtimer型を使っている場所を探しましょう。

204 // Timers that are manipulated in buildApp.
205 var gTimer, lTimer, sTimer timer // manipulated in buildApp

なるほど!3つあるようですね。timer型の定義をみると、nameというフィールドがあるようですね。

535 type timer struct {
536     name string
537
538     mu    sync.Mutex
539     n     int
540     total time.Duration
541 }

nameフィールドを初期化している場所を探してみると、以下のようなコードを見つけました。

189     gTimer.name = "compile"
190     lTimer.name = "link"
191     sTimer.name = "skip"

ほうほう。なるほど、compilelinkとか怪しいですね。 gTimerlTimerの行方を追ってみましょう。

505     // Run the actual compilation.
506     if err := gTimer.run(args, c.env); err != nil {
507         return err
508     }

どうやら、gTimer.runcompilerという型のcompileメソッドで呼ばれています。 compiler型の初期化を行っているところ探してみましょう。

270     // Compile phase.
271     c := &compiler{
272         app:              app,
273         goRootSearchPath: goRootSearchPath,
274         compiler:         toolPath("compile"),
275         env:              env,
276     }

なるほど、toolPath関数が怪しいですね。

655 func toolPath(x string) string {
656     ext := ""
657     if runtime.GOOS == "windows" {
658         ext = ".exe"
659     }
660     return filepath.Join(*goRoot, "pkg", "tool", runtime.GOOS+"_"+fullArch(*arch), x+ext)
661 }

なるほど、$(goapp env GOROOT)/pkg/tool/以下にOSとアーキテクチャにあったツール類があるようです。 手元のOSXで何があるのか見てみましょう。

ls $(goapp env GOROOT)/pkg/tool/darwin_amd64
asm     cgo     compile cover   link

おぉ。これはgo tool compilego tool linkで呼び出されるものと同じですね。 ちなみに、go buildを呼ばすにgo tool compilego tool linkでビルドするには以下のような手順で実行します。 go tool compileでコンパイルし、go tool linkでリンクしています。

$ go tool compile -o main.a main.go
$ go tool link -o main main.a

さて、compileの引数に-uというオプションを渡しているところがありました。

346     if !*unsafe {
347         // reject unsafe code
348         args = append(args, "-u")
349     }

どうやらここで、unsafeなパッケージをリジェクトしているようです。 syscallはどこでリジェクトしてるんでしょうか? syscallで探してみます。

parser.goで見つけました。

514 // checkImport will return whether the provided import path is good.
515 func checkImport(path string) bool {
516     if path == "" {
517         return false
518     }
519     if len(path) > 1024 {
520         return false
521     }
522     if filepath.IsAbs(path) || strings.Contains(path, "..") {
523         return false
524     }
525     if !legalImportPath.MatchString(path) {
526         return false
527     }
528     if path == "syscall" || path == "unsafe" {
529         return false
530     }
531     return true
532 }

どうやら各ファイルのインポートパスを調べて、syscallunsafeを見つけるとリジェクトするようです。 ついでに、相対パスでインポートするのもリジェクトしているようですね。

legalImportPathにマッチしないものもリジェクトしているようです。 呼び出しているメソッドを見る限り正規表現のようなので、定義を見てみましょう。

512 var legalImportPath = regexp.MustCompile(`^[a-zA-Z0-9_\-./~+]+$`)

なるほど?なんかおかしい気がする。 この正規表現は~.許してるようです。。。 識別子には~.は使えません。日本語とかは使えるんですが。 謎のチェックですね。。

まとめ

さて、まとめるといいつつ、ダラダラとコードリーディングしながら書きなぐりましたが、コードリーディングしてみて分かったことを以下にざっとまとめたいと思います。

  • appengine.Mainを呼び出せば、main関数は使えるようだ
  • unsafesyscall以外も、相対パスなインポートパスもNG
  • go compile -uunsafeの呼び出しをリジェクトできる
  • goパッケージを知ってると世界が広がる

ソースコードみると、以外に知らないことがあったりするので、せっかくなので読んでみるといいですね。