Shogo's Blog

Jul 24, 2019 - 2 minute read - go golang

Goのバイナリに静的ファイルを埋め込むツール assets-life を書いた

日本語の Go コミュニティだと go-bindata (なんか乗っ取り騒動とか色々あってメンテナンスされてない), go-assets (最近メンテナンス滞りがち) が有名(要出典)なやつです。 これらのライブラリに関してたくさん日本語記事が書かれて、今もたくさん検索に引っかかるのですが、残念ながら最近はメンテナンスが滞っています。

最近は statik の名前もよく見るようになりました。 その他は Resource Embedding - Awesome Go からどうぞ。

で、まあ、今回も完全に車輪の再発明なんですが、他の実装には色々と思うところがあり書いてみました。

USAGE

なにはともあれ、まずは go get してきます。

$ go get github.com/shogo82148/assets-life

assets-life というコマンドがインストールされるので、 バイナリに組み込みたいディレクトリと出力先を指定します。

$ assets-life /path/to/your/project/public public

出力先のディレクトリは Go のパッケージとしてインポートできるようになってます。 Root という変数のなかにファイルが埋め込まれており、http.FileSystem インターフェースを介してアクセスできます。

import (
    "net/http"
    "example.com/your/project/public"
)

func main() {
    http.Handle("/", http.FileServer(public.Root))
    http.ListenAndServe(":8080", nil)
}

特長

コードの再生成にコマンドのインストールが不要

これが一番の特長です。 バイナリにファイルを埋め込む都合上、静的ファイルを修正した場合にコードの再生成が必要です。 assets-lifego:generate ディレクティブを埋め込んだコードを出力するので、コードの再生成は go generate でできます。

# /path/to/your/project/public に修正を加える

# コードの再生を行う
$ go generate example.com/your/project/public

面白いのは、このとき assets-life コマンドは必要ない」 ということです。 assets-life はコード生成時に 自分自身のソースコードをパッケージに埋め込みますgo:generate ディレクティブには、埋め込んだ assets-life のソースコードを go run で実行するよう定義してあります。 別途コマンドをインストールする必要はありません。

// 生成されたコードの一部

package public

//go:generate go run assets-life.go ../path/to/your/project/public . public

// 以下略

これのおかげで、 go generate を実行して assets-life: command not found となる悲劇や、 実行する人によって微妙にバージョンが違っていて git diff がたいへんなことになる悲劇から開放されます。


assets-lifeあなたの知らない超絶技巧プログラミングの世界 ではおなじみ クワイン として実装されています。 クワインとして実装すること自体は PerlでもGoでも実行できるQuine書いた で一度やっているので難しくはなかったのですが、 文字列リテラルとして埋め込んだ go:generate ディレクティブや build constraint が誤って Go に検出されてしまい、 それを回避するために難読化する必要があって少しハマりました。

assets-life という名称はこの挙動から来てます。 自分自身をコピーして別のプロジェクトに埋め込むとか生命を感じません?

ファイルの更新日時をあえて返さない

http.FileSystem インターフェースはファイルの更新日時を取得できるようになっていますが、 assets-life は更新日時に time.Time の Zero Value を返します。

このように実装してある理由は、 go generate を実行する環境によらず同じコードが生成されて欲しい、というのがひとつ。 それに加えて、HTTPクライアントに余計なキャッシュをしてほしくないという理由でこのようにしてあります。

Go の http.FileServer は ファイルの更新日時が設定されていると Last-Modified ヘッダーをつけてレスポンスを返します。 Last-Modified ヘッダーを見つけたHTTPクライアントは、Date ヘッダーの情報と合わせて最終更新日時からの経過時間を計算します。 そして他に妥当な判断材料がない場合は その10%をキャッシュの寿命として設定します 。 具体例をあげると以下のようなヘッダーが返ってきた場合

Date: Wed, 24 Jul 2019 08:08:47 GMT
Last-Modified: Sat, 01 Jan 2000 00:00:00 GMT

ファイルの最終更新日時から約19年経っているので、キャッシュの寿命として約1.9年が設定されます。 この間クライアントはキャッシュを利用するので、 ファイルに更新あったとしても最悪1.9年の間は更新が反映されません・・・! このことを知ったときは「そんなことするクライアントいるの?」と疑問だったのですが、 このキャッシュアルゴリズムはRFCやMDNにも記述があり、実際そのような挙動をするクライアントがあるようです。

変なヒューリスティックでキャッシュの有効期限を決められると困るので、assets-life では Last-Modified を削除しています。 しかし、これでキャッシュしなくなるという保証はどこにもないので、HTTPでのファイル配信に使うときはキャッシュ関連のヘッダーを設定しましょう。

ファイル検索にバイナリサーチを使用

他の実装はファイルの管理に map を使っているものが多いですが、 assets-life はすべてスライスに入れています。 ファイルを開くときは、コード生成の時点でソートしておいて、バイナリサーチです。 Nをファイルの数としたとき map の検索コストは O(1)、バイナリサーチの検索コストは O(log N) です。

一見 map のほうが速そうですが、定数倍のオーバーヘッドがあるので、N が小さいときはバイナリサーチのほうが速くなります。 雑に測定した感じだと1000個くらいまではバイナリサーチのほうが速そうです(要出典)。 また、ファイルへの参照をスライスのインデックスで表せるので、 Readdir もまだまだ高速化できそうです。 ちょっと詳しくベンチマークを取りながらチューニングしていきたいと思います。

ファイルの中身は未圧縮・stringで保持

assets-life はzipやzlibでの圧縮には対応していません。 圧縮はバイナリサイズと実行時の展開コストのトレードオフになるわけですが、assets-life は実行時のコスト最小化に寄せてあります。 Goのランタイムだけで数MBあるので、これを大きく超えるようなファイルを扱わない限り、バイナリサイズへの影響はほとんどありません。 そんな用途はそうそう無いだろうという勝手な思い込みにより、このような実装になっています。

まとめ

go-bindata, go-assets, statik の類似品を作りました。 便利だなと思った人はぜひ使ってみてください。

そうでもないな、と思った人も 「最終更新日時からの経過時間の10%を、キャッシュの寿命に設定する」 HTTPクライアントが存在するっていうことだけは覚えて帰ってね。