はじめに
みなさんは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
以外も、相対パスなインポートパスもNG -
go compile -u
でunsafe
の呼び出しをリジェクトできる -
go
パッケージを知ってると世界が広がる
ソースコードみると、以外に知らないことがあったりするので、せっかくなので読んでみるといいですね。