go:embed 詳解 - 使用編 -

8 min read読了の目安(約7400字

はじめに

Go1.16 にはいつものリリースと同じように興味深い新機能が多数追加されました。その中でも特に注目されている機能として、Go のビルド済みバイナリに読み込み専用の静的ファイルを埋め込む go:embed ディレクティブがあります。これまで静的ファイルをバイナリに埋め込むアプローチは種々提案されてきましたが[1]、ツールや OS ごとに埋め込み方がバラバラで、それぞれ使い方を覚えたり特定ツールへの依存がどうしても避けられませんでした。今回このディレクティブの導入によって Go 公式として統一される形となります。

このディレクティブ導入が注目される理由として、上記のような歴史的な背景ももちろんありますが、次のような疑問もあると思われます。

  • どのような機能があるか?
  • どう使うか?(逆にどう使えないか?)
  • ファイルはどこに埋め込まれているか?
  • ファイルはどのような形式で埋め込まれているか?
  • ファイルの埋め込み元から呼び出し元までどのような過程を辿るか?

そこで go:embed の使い方を説明する 使用編 と、どのような仕組みで実装されているか深ぼる 仕様編 の前編・後編構成に分け、本記事では前編の使い方に的を絞って説明していきます。後編は後日公開するのでお楽しみに!

この記事で使用する Go のバージョンは go1.16 darwin/amd64 です。

階層構造ファイルシステムの統一的インターフェース

go:embed の説明に入る前に、これまた Go1.16 で導入された io.fs パッケージについて述べておきます。

階層構造のファイルシステムは、Dennis Ritchie や Ken Thompson がベル研究所で行った UNIX 研究の主だった成果 であり、今日ではオペレーティングシステムはもちろんのこと、Web の URL や ZIP などの書庫ファイル(Vim で一度くらいは開いたことがあるでしょう;))にまで、いたるところで使われています。それにしたがって、Go にも階層構造のファイルシステムに配置されたファイルを読み取る標準パッケージがそれぞれ実装されています。オペレーティングシステムのファイルを操作するのが os パッケージであり、ZIP は archive/zip パッケージ、静的ファイルのテンプレートから HTML を動的に生成するのが html/template パッケージ、URL に対する静的アセットを直接返すのが http パッケージ(の File/FileSystem 構造体)です。これらは階層構造として同様に扱えるのですが、それぞれ実装が統一されていなかったためなんらかの橋渡しが必要でした。Go が開発されるはるか昔、次世代 UNIX として同じくベル研究所で開発された Plan 9 で階層構造のファイルシステムとして表現されるリソースをプロトコルやアーキテクチャに依存せずに統一的に扱おうとしたにも関わらずです。

そんな中、io/fs パッケージが流星の如く現れた。Plan 9 の意思を受け継ぎ()ようやく Go でもファイルシステムを統一的なインターフェースで扱えるようになりました。

io/fs の説明や使い方については @spiegel_2007 さんの『次期 Go 言語で導入される(かもしれない) io/fs パッケージについて予習する』 の内容がほぼそのままリリース予定なので詳細はそちらを参照していただくとして、ここから go:embed の説明に入っていきます。

基本的な使い方

まず初めにコードの実装に入る前に go.mod 中の go のバージョンを 1.16 以上にアップデートします。go 1.15 以下のままだと次のようにコンパイルエラーになってしまいます。ただし go:embed ディレクティブを使わない場合はこの限りではありません。

go:embed requires go1.16 or later (-lang was set to go1.15; check go.mod)

次にコードを書いていきますが、まずは go:embed ディレクティブを有効にするために embed パッケージをインポートします。embed パッケージを直接利用しない場合はブランクインポートしておきます。

import _ "embed"

次にファイルの埋め込み方について、go:embed ディレクティブは var で宣言した初期化されていない変数に対して埋め込むことができます。他のディレクティブと同様に //go:embed の間に半角スペースなどを挟んでしまうと通常のコメントとして扱われてしまうため、注意が必要です。指定できるファイルはカレントディレクトリ配下のファイルで、相対パスで指定します。Windows のような半角バックスラッシュ \ で階層構造を表すプラットフォーム向けにバイナリをビルドする場合でも、*nix 系のように半角スラッシュ / を使います。

//go:embed hello.txt
var hello string

//go:embed hello/world.txt
var world []byte

go:embed ディレクティブで埋め込める変数の型は、string[]byteembed.FS の3種類ですが、前者2つと最後の1つでは埋め込み方に違いがあります。

string[]byte は単一の go:embed ディレクティブによってファイルを読み込み、通常通り初期化を行った変数として扱うことが可能となります。直感に即す通り、複数の go:embed を定義するとコンパイルエラーになります。

一方で embed.FSgo:embed で埋め込むファイルを階層型ファイルシステムとして埋め込むことができる、すなわち io/fs.FS インターフェースを実装している構造体[2]で、単一または複数のファイルやディレクトリを埋め込むことが可能です。複数のファイルやディレクトリを指定するには、ワイルドカード * を使うか、複数行に分けて指定します。

//go:embed image/* template/*
//go:embed style/*.css
//go:embed html/index.html
var assets embed.FS

最後に go:embed ディレクティブを埋め込むスコープについてですが、周知の通り変数を宣言できるスコープにはグローバルとローカルの2種類あります。しかし go:embed で埋め込みが可能なのはそのうちのグローバル変数のみで、ローカル変数に埋め込むことはできません[3]

// OK
//go:embed global.txt
var global string

func f() {
	// NG
	//go:embed local.txt
	var local string
	...
}

go:embed ディレクティブの簡単な使い方の説明は以上となります。

さてここからは細かい注意点、もっと言うと「意外とこういった使い方をしてもコンパイルエラーにならない」例や逆に「コンパイルエラーにならなそうで実はコンパイルエラーになる」といった例を挙げていきます。

コンパイルエラーにならない例

文字列にキャスト可能な型の変数にも go:embed で埋め込める

先ほどは string[]byte にしか埋め込めないと述べましたが、それはプロポーザルや go1.16beta1 時点での話。 go1.16rc1 からは文字列にキャスト可能な型、すなわち []uint8string, []byte 等を元に定義した型、およびそれらの型エイリアスにも埋め込むことができるようになりました。

//go:embed hoge.txt
var Hoge []uint8

type fuga []byte
//go:embed fuga.txt
var Fuga fuga

type piyo fuga
//go:embed piyo.txt
var Piyo piyo

重複してファイルやディレクトリを参照する

重複して読み込まれるファイルやディレクトリは無視されます。そのため次のように string 型に複数の go:embed ディレクティブを指定してもコンパイルエラーになりません。

//go:embed hello.txt
//go:embed hello.txt
var hello string

test で埋め込む

文字通り test 内でも埋め込むことができます。

embed.FS の前に go:embed ディレクティブがない

単に空のディレクトリとして認識されます。

var uninitialized embed.FS

fmt.Println(uninitialized)
// {<nil>}

f, err := uninitialized.Open(".")
if err != nil {
	panic(err)
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
	panic(err)
}
if !fi.IsDir() {
	panic("in uninitialized embed.FS, . is not a directory")
}

// You'll get here for sure!

go:embed ディレクティブとファイルを埋め込む変数の間にスペースがある

(ディレクティブではないですが)cgo ではコメントによる C 言語コードの記述と import "C" との間にスペースがあると C 言語コードが認識されないのに対し、go:embed ではスペースがあってもきちんと認識されます。もっとも、スペースが複数行に渡る場合は gofmt によって1行にフォーマットされるのですが。。

//go:embed hello.txt

var hello.txt

go:embed ディレクティブとファイルを埋め込む変数の間にコメントがある

上記のスペースの例と同様、コメントがあっても問題はありません。

//go:embed hello.txt
// --- any comments ---
var hello.txt

コンパイルエラーになる例

空のディレクトリを参照する

go:embed ディレクティブを指定せずに embed.FS の変数を宣言した時のように、空のディレクトリも埋め込むことが可能だと思いきやコンパイルエラーになります。上記で散々 ディレクトリを埋め込む と述べて来たが、実は go:embed ディレクティブで埋め込む単位は ファイル であり、ディレクトリ自体ではありません。go:embed ディレクティブを指定せずに宣言した embed.FS の変数は、元の構造体を見ていただければ分かるのですが、file 型のスライスをメンバに持ち、単にこのスライスに対して明示的に初期化しないが故に0個のファイルを持つ状態になるのに対し、空のディレクトリを埋め込む場合はディレクトリの先に1個以上ファイルがあるのを期待するためコンパイルエラーとなるのです。Git で空のディレクトリをコミットできないことを想像していただければ分かりやすいでしょう。後編で詳しく触れますが、埋め込むファイルは package と同じような扱いであり、空のディレクトリである package をインポートしたときにコンパイルエラーになる(この場合もはや package と呼べるか怪しいが)のと同様に、空ディレクトリを埋め込んだときもコンパイルエラーになります。

ディレクトリのパスを / で終わる

これも埋め込むのはファイルであってディレクトリではないため、コンパイルエラーになります。ディレクトリ内のファイルを全て埋め込みたければ、ワイルドカードで指定します。

embed をインポートせずに go:embed ディレクティブを使う

github.com/go-sql-driver/mysqlgithub.com/mattn/go-sqlite3 のような SQL Driver を使う際にしばしばブランクインポートをするのとは別の理由で、コンパイル時にそう定めているためにコンパイルエラーになります。

存在しないファイルを参照しようとする

これは特段説明はいらないでしょう。

親ディレクトリを参照する

io/fs パッケージでは、依存するファイルが完全にその配下の階層構造ファイルシステムにあることを期待するため、親ディレクトリを参照可能にする .. をパスに含めることは禁止されています [4]embed パッケージも io/fs パッケージが提供するインターフェースに則った階層構造ファイルシステムの構造体を実装しているので、go:embed ディレクティブでも .. を使うことが禁止されています。

カレントディレクトリを参照する

意外に思われるかもしれませんが、カレントディレクトリを指す . も、io/fs パッケージの階層構造ファイルシステムで禁止されています。したがって embed パッケージも(以下省略)。これについては禁止にする明示的な理由を示す文献を見つけられませんでした。

イレギュラーなファイル

シンボリックリンクやデバイスファイルなど一部のファイルは go:embed ディレクティブで埋め込むことができません。具体的には ls -l でファイル一覧を表示したときに -rw-r--r-- などといった形でファイルモードが表示されると思うのですが、その1文字目が通常ファイル - でないファイルは埋め込むことができません。他にどのようなファイルが埋め込めないかは ここ に記載されています。

その他

他にも go:embed ディレクティブで指定できないパターンはいくつかありますが、これを読んでいるあなた自身で見つけるために、楽しみは取っておきましょう;)

おわりに

この記事では go:embed の使い方や、ファイルパスを指定する際の注意点について述べてきました。次回後編では go:embed の中身について覗いていきます。

脚注
  1. ドラフトに書かれているだけでもたくさんある。 ↩︎

  2. io/fs.FS インターフェースはファイルを開く Open 関数を備えるだけでファイルシステムとして最小限のことしかできません。embed.FS 構造体は io/fs.FS インターフェースに加えて、ファイルを読む io/fs.ReadFileFS と、ディレクトリを読む io/fs.ReadDirFS も実装しています。 ↩︎

  3. プロポーザルや go1.16beta1 まではローカル変数にも埋め込むことができていましたがメモリアロケーションなどの問題でできなくなりました。
    https://go-review.googlesource.com/c/go/+/282714/ ↩︎

  4. https://golang.org/pkg/io/fs/#ValidPath
    https://go.googlesource.com/proposal/+/master/design/draft-embed.md#Dot_dot_module-boundaries_and-file-name-restrictions ↩︎