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 GOROOTはgoappで使われている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.goにParseFilesがありました。
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関数があったら、HasInitがtrueになり、main関数があったらHasMainがtrueになるわけですね。
そして、callsAEMainがtrueの場合は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"ほうほう。なるほど、compileとlinkとか怪しいですね。
gTimerとlTimerの行方を追ってみましょう。
505 // Run the actual compilation.
506 if err := gTimer.run(args, c.env); err != nil {
507 return err
508 }どうやら、gTimer.runはcompilerという型の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 compileやgo tool linkで呼び出されるものと同じですね。
ちなみに、go buildを呼ばすにgo tool compileとgo 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 }どうやら各ファイルのインポートパスを調べて、syscallやunsafeを見つけるとリジェクトするようです。
ついでに、相対パスでインポートするのもリジェクトしているようですね。
legalImportPathにマッチしないものもリジェクトしているようです。
呼び出しているメソッドを見る限り正規表現のようなので、定義を見てみましょう。
512 var legalImportPath = regexp.MustCompile(`^[a-zA-Z0-9_\-./~+]+$`)なるほど?なんかおかしい気がする。
この正規表現は~や.を許してるようです。。。
識別子には~や.は使えません。日本語とかは使えるんですが。
謎のチェックですね。。
まとめ
さて、まとめるといいつつ、ダラダラとコードリーディングしながら書きなぐりましたが、コードリーディングしてみて分かったことを以下にざっとまとめたいと思います。
appengine.Mainを呼び出せば、main関数は使えるようだunsafeとsyscall以外も、相対パスなインポートパスもNGgo compile -uでunsafeの呼び出しをリジェクトできるgoパッケージを知ってると世界が広がる
ソースコードみると、以外に知らないことがあったりするので、せっかくなので読んでみるといいですね。