アセット的なアレを実行バイナリ内に入れる話。

結論

go言語でウェブアプリケーション書くなら、go-bindata使うべし。

はじめに

goで書いたサーバは一つのバイナリに全部入るからデプロイが楽だという話がありますけども、それは全部のコードをgoで書いた時だけです。

ウェブアプリケーションでは、ユーザインターフェース用のテンプレートファイルなど、どうしてもgoのコードではないリソースが発生します。

例えばテンプレートをパーズする標準APIを見ると、こんな風になっています。

func ParseFiles(filenames ...string) (*Template, error) {
    return parseFiles(nil, filenames...)
}

このAPI構造はソースコードを配置しているディレクトリ構造が単純だと特に問題ないのですが、少し複雑なディレクトリ構造になるだけで途端に上手くいかなくなります。

単純なディレクトリ構造の場合

ディレクトリ構造が以下の様に単純な場合、特に問題なく動作します。

simpleproject/
    |   server.go
    |   server_test.go
    |   simpleproject.exe
    |
    +---assets
        home.tmpl

それぞれのソースコードは以下の様になっています。

  • server.go
package main

import (
    "html/template"
    "log"
    "net/http"
)

func NewHandlers() *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
        if t, err := template.ParseFiles("assets/home.tmpl"); err != nil {
            http.Error(w, "Server Error", http.StatusInternalServerError)
            log.Println(err)
        } else {
            t.Execute(w, nil)
        }
    })
    return mux
}

func main() {
    http.ListenAndServe(":8080", NewHandlers())
}
  • server_test.go
package main_test

import (
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
    . "."
)

func TestServer(t *testing.T) {
    router := NewHandlers()
    server := httptest.NewServer(router)
    defer server.Close()

    resp, resperr := http.Get(server.URL)
    if resperr != nil {
        t.Error(resperr)
        t.FailNow()
    }
    defer resp.Body.Close()

    body, readerr := ioutil.ReadAll(resp.Body)
    if readerr != nil {
        t.Error(readerr)
        t.FailNow()
    }

    if strings.Contains(string(body), "Hello, World!!") == false {
        t.FailNow()
    }
}
  • assets/home.tmpl
Hello, World!!

少し複雑なディレクトリ構造の場合

ディレクトリ構造が以下の様にちょっとだけ複雑化すると、サーバは正しく動作するのですがテストコードが正しく動作しなくなります。

complexproject
    |   complexproject.exe
    |   main.go
    |
    +---assets
    |       home.tmpl
    |
    +---server
            server.go
            server_test.go
  • main.go
package main

import (
    "complexproject/server"
    "net/http"
)

func main() {
    http.ListenAndServe(":8080", server.NewHandlers())
}
  • assets/home.tmpl
Hello, World!!
  • server/server.go
package server

import (
    "html/template"
    "log"
    "net/http"
)

func NewHandlers() *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
        if t, err := template.ParseFiles("assets/home.tmpl"); err != nil {
            http.Error(w, "Server Error", http.StatusInternalServerError)
            log.Println(err)
        } else {
            t.Execute(w, nil)
        }
    })
    return mux
}
  • server/server_test.go
package server_test

import (
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
    . "."
)

func TestServer(t *testing.T) {
    router := NewHandlers()
    server := httptest.NewServer(router)
    defer server.Close()

    resp, resperr := http.Get(server.URL)
    if resperr != nil {
        t.Error(resperr)
        t.FailNow()
    }
    defer resp.Body.Close()

    body, readerr := ioutil.ReadAll(resp.Body)
    if readerr != nil {
        t.Error(readerr)
        t.FailNow()
    }

    if strings.Contains(string(body), "Hello, World!!") == false {
        t.FailNow()
    }
}

何故か分かりましたか?

テストコードが正しく動作しない理由は、server.goの中に埋め込まれている以下のコードが、相対パスだからです。

template.ParseFiles("assets/home.tmpl")

しかし、ポータビリティが大きく損なわれるのでテンプレートのパスを絶対パスで書きたくはありません。

加えて、実行バイナリとテンプレートをセットにしてサーバにデプロイするのはいささか面倒です。

go-bindataでひとまとめにしよう

では、go-bindataを使ってみましょう。

インストール

go get github.com/jteeuwen/go-bindata/...

使い方

それでは、先ほどの上手く動作しないコードを動作するように変更していきます。

まずは、assets ディレクトリに対してgo-bindataを実行します。

go-bindata -pkg=server -o=server/assets.go ./assets/...
  • -pkg オプションは出力するリソースのパッケージ名を指定します。
  • -o オプションは出力するリソースのファイル名を指定します。
  • 最後にgo-bindataが処理する対象のディレクトリを指定します。
    /...をディレクトリ名の末尾に付けるとディレクトリを再帰的に処理します。

出来たリソースを確認してみましょう。

  • server/assets.go
package server

import (
    "bytes"
    "compress/gzip"
    "fmt"
    "io"
)

func bindata_read(data []byte, name string) ([]byte, error) {
    gz, err := gzip.NewReader(bytes.NewBuffer(data))
    if err != nil {
        return nil, fmt.Errorf("Read %q: %v", name, err)
    }

    var buf bytes.Buffer
    _, err = io.Copy(&buf, gz)
    gz.Close()

    if err != nil {
        return nil, fmt.Errorf("Read %q: %v", name, err)
    }

    return buf.Bytes(), nil
}

func assets_home_tmpl() ([]byte, error) {
    return bindata_read([]byte{
        0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x00, 0xff, 0xf2, 0x48,
        0xcd, 0xc9, 0xc9, 0xd7, 0x51, 0x08, 0xcf, 0x2f, 0xca, 0x49, 0x51, 0x54,
        0xe4, 0xe5, 0x02, 0x04, 0x00, 0x00, 0xff, 0xff, 0x49, 0x7b, 0xe2, 0x68,
        0x10, 0x00, 0x00, 0x00,
    },
        "assets/home.tmpl",
    )
}

// Asset loads and returns the asset for the given name.
// It returns an error if the asset could not be found or
// could not be loaded.
func Asset(name string) ([]byte, error) {
    if f, ok := _bindata[name]; ok {
        return f()
    }
    return nil, fmt.Errorf("Asset %s not found", name)
}

// _bindata is a table, holding each asset generator, mapped to its name.
var _bindata = map[string]func() ([]byte, error){
    "assets/home.tmpl": assets_home_tmpl,
}

Asset関数を使ってリソースを読みだせますので、server.goを修正します。

  • server/server.go
package server

import (
    "html/template"
    "log"
    "net/http"
)

var ns = template.New("complex")

func ParseAssets(path string) (*template.Template, error) {
    src, err := Asset(path)
    if err != nil {
        return nil, err
    }
    return ns.Parse(string(src))
}

func NewHandlers() *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
        if t, err := ParseAssets("assets/home.tmpl"); err != nil {
            http.Error(w, "Server Error", http.StatusInternalServerError)
            log.Println(err)
        } else {
            t.Execute(w, nil)
        }
    })
    return mux
}

少しヘルパ関数を足しましたけども、ハンドラの関数内は余り大きく変更せずに対応できることが分かります。

開発中のアセットをgo-bindataする

リリースバイナリを作るのであれば、ソースコードに全て内包して良いのですけども、開発中に一々go-bindataするのは面倒です。

そこで、開発中はdebugオプションを使います。

go-bindata -debug=true -pkg=server -o=server/assets.go ./assets/...

この場合、以下のように逐次的にリソースを読みだすコードが生成されます。

  • assets.go
package server

import (
    "fmt"
    "io/ioutil"
)

// bindata_read reads the given file from disk. It returns
// an error on failure.
func bindata_read(path, name string) ([]byte, error) {
    buf, err := ioutil.ReadFile(path)
    if err != nil {
        err = fmt.Errorf("Error reading asset %s at %s: %v", name, path, err)
    }
    return buf, err
}

// assets_home_tmpl reads file data from disk.
// It panics if something went wrong in the process.
func assets_home_tmpl() ([]byte, error) {
    return bindata_read(
        "C:\\development\\go\\projects\\drillhall\\src\\complexproject\\assets\\home.tmpl",
        "assets/home.tmpl",
    )
}

// Asset loads and returns the asset for the given name.
// It returns an error if the asset could not be found or
// could not be loaded.
func Asset(name string) ([]byte, error) {
    if f, ok := _bindata[name]; ok {
        return f()
    }
    return nil, fmt.Errorf("Asset %s not found", name)
}

// _bindata is a table, holding each asset generator, mapped to its name.
var _bindata = map[string]func() ([]byte, error){
    "assets/home.tmpl": assets_home_tmpl,
}

まとめ

go-bindataを使うと全てのファイルを一つのバイナリに上手くまとめられます。

似た様なプロダクトとしてはgo.riceもありますけども、僕の環境では何か承服しがたい理由で上手く動かなかったので使っていません。

実はkocha なら、そういう機能があるようですが、ドキュメントが余りにアッサリし過ぎているので、僕はまだ試していません。