![]()
go1.16rc1 以降は 👉コチラ で更新していきます。 ![]()
![]()
この記事は Go Advent Calendar 2020 23日目 と CyberAgent Developers Advent Calendar 2020 24日目 の記事です。
はじめに
来年の2月にリリース予定の Go1.16 にはいつものリリースと同じように興味深い新機能が多数追加される。その中でも特に注目されている機能として、Go のビルド済みバイナリに読み込み専用の静的ファイルを埋め込む go:embed ディレクティブがある。これまで静的ファイルをバイナリに埋め込むアプローチは種々提案されてきたが1、ツールやOSごとに埋め込み方がバラバラで、それぞれ使い方を覚えたり、特定ツールへの依存がどうしても避けられなかった。今回このディレクティブの導入により Go 公式として統一される形となる。
このディレクティブ導入が注目される理由として、上記のような歴史的な背景ももちろんあるが、次のような疑問もあると思う。
- どのような機能があるか?
- どう使うか?(逆にどう使えないか?)
- ファイルはどこに埋め込まれているか?
- ファイルはどのような形式で埋め込まれているか?
- ファイルの埋め込み元から呼び出し元までどのような過程を辿るか?
そこで go:embed の使い方を説明する 使用編 と、どのような仕組みで実装されているか深ぼる 仕様編 の前編・後編構成に分け、本記事では前編の使い方に的を絞って説明していく。後編はお楽しみにということで後日公開することにする。
この記事で使用する Go のバージョンは go1.16beta1 darwin/amd64 で、執筆時点ではまだ Go1.16 が正式リリースされていないため、この記事の内容から更新される場合があることに注意されたい。
階層構造ファイルシステムの統一的インターフェース
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 構造体)だ。これらは階層構造として同様に扱えるにも関わらず、それぞれ実装が統一されていなかったため、なんらかの橋渡しが必要であった。次世代 UNIX として同じくベル研究所で開発された Plan 9 で階層構造のファイルシステムとして表現されるリソースをプロトコルやアーキテクチャに依存せずに統一的に扱おうとしたにも関わらずである。
そんな中、io/fs パッケージが流星の如く現れた。Plan 9 の意思を受け継ぎ(?)ようやく Go でもファイルシステムを統一的なインターフェースで扱えるようになったのだ。
io/fs の説明や使い方については @spiegel_2007 さんの『次期 Go 言語で導入される(かもしれない) io/fs パッケージについて予習する』 の内容がほぼそのままリリース予定なので、詳細はそちらを参照していただこう。
使い方
基本的な使い方
さて、ここから本題に入る。
まずはじめに、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、[]byte、embed.FS の3種類であるが、前者2つと最後の1つでは埋め込み方に違いがある。
string と []byte は単一の go:embed ディレクティブによってファイルを読み込み、通常通り初期化を行った変数として扱うことが可能となる。直感に即す通り、複数の go:embed を定義するとコンパイルエラーになる。
一方で embed.FS は go:embed で埋め込むファイルを階層型ファイルシステムとして埋め込むことができる、すなわち io/fs.FS インターフェースを実装している構造体2で、単一または複数のファイルやディレクトリを埋め込むことが可能である。複数のファイルやディレクトリを指定するには、ワイルドカード * を使うか、複数行に分けて指定する。
//go:embed image/* template/*
//go:embed style/*.css
//go:embed html/index.html
var assets embed.FS
最後に go:embed ディレクティブを埋め込むスコープについてだが、グローバルで埋め込むことができるのはもちろん、ローカルすなわち関数内でも埋め込むことは可能である。ローカルで埋め込むのは若干違和感を覚えるものの、通常の変数の初期化と同じように考えるとごく自然である3。
//go:embed global.txt
var global string
func f() {
//go:embed local.txt
var local string
...
}
go:embed ディレクティブの簡単な使い方の説明は以上となる。
さてここからは細かい注意点、もっと言うと「意外とこういった使い方をしてもコンパイルエラーにならない」例や逆に「コンパイルエラーにならなそうで実はコンパイルエラーになる」といった例を挙げていく。
コンパイルエラーにならない例
重複してファイルやディレクトリを参照する
重複して読み込まれるファイルやディレクトリは無視される。それ故、次のように string 型に複数の go:embed ディレクティブを指定してもコンパイルエラーにならない。
//go:embed hello.txt
//go:embed hello.txt
var hello string
test で埋め込む
文字通り test 内でも埋め込むことは可能である。
embed.FS の前に go:embed ディレクティブがない
単に空のディレクトリとして認識されるだけである。
var fs embed.FS
fmt.Println(fs)
// {<nil>}
go:embed ディレクティブとファイルを埋め込む変数の間にスペースがある
(ディレクティブではないが)cgo では、コメントによる C 言語コードの記述と import "C" との間にスペースがあると C 言語コードが認識されないのに対し、go:embed ではスペースがあってもきちんと認識される。もっとも、スペースが複数行に渡る場合は gofmt によって1行にフォーマットされるのだが。
//go:embed hello.txt
var hello.txt
go:embed ディレクティブとファイルを埋め込む変数の間にコメントがある
上記のスペースの例と同様、コメントがあっても問題はない。
//go:embed hello.txt
// --- comment ---
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/mysql や github.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 の中身について覗いていく。
-
io/fs.FSインターフェースはファイルを開くOpen関数を備えるだけでファイルシステムとして最小限のことしかできない。embed.FS構造体はio/fs.FSインターフェースに加えて、ファイルを読むio/fs.ReadFileFSと、ディレクトリを読むio/fs.ReadDirFSも実装している。 ↩ -
ローカル変数へのディレクティブによる埋め込みは、関数間で埋め込んだファイルを共有するか、関数ごとに埋め込んだファイルを分けるか、など問題が出てきたため Go1.16 では実装から削除される方向で進んでいる。 https://github.com/golang/go/issues/43216 ↩
-
https://tip.golang.org/pkg/io/fs/#ValidPath
https://go.googlesource.com/proposal/+/master/design/draft-embed.md#Dot_dot_module-boundaries_and-file-name-restrictions ↩