フューチャー技術ブログ

Go 1.16からリリースされたgo:embedとは

TIGの伊藤真彦です。

この記事はGo 1.16連載の1記事目です。

トップバッターとしてgo:embedについて記事を書きます。

go:embedとは

プロポーザルとなるissueはこちら、2020年9月のissue作成から約5ヶ月の時を経てgo:embedがリリースに含まれることになりました。

embedとは埋め込みという意味です、その名の通りファイル埋め込みをサポートするためのパッケージです。ファイルを読み込むだけならosio/ioutilでも行うことが可能ですが、go:embedならではの特徴を説明します。

ちなみにio/ioutilはGo 1.16でdeprecatedになりました、詳しくは連載の他の記事で説明します。

利用方法

手始めにサンプルコード、main.goを書いてみました。

main.go
package main

import (
_ "embed"
"encoding/json"
"fmt"
)

//go:embed sample.json
var sampleBytes []byte

type sample struct {
Key1 string `json:"key1"`
Key2 string `json:"key2"`
}

func main() {
var s sample
if err := json.Unmarshal(sampleBytes, &s); err != nil {
panic(err)
}
fmt.Printf("%+v\n", s)
}

他のパッケージと同様importして利用できます。
単一のファイルを埋め込みするだけなら_ "embed"として先頭に_をつけてインポートすることが推奨されています。

このmain.goと同一のディレクトリにsample.jsonを配置します。

sample.json
{
"key1": "value1",
"key2": "value2"
}

このコードを実行するとsampleBytesにsample.jsonの中身が反映され、構造体sampleの中身が出力されます

go run .\main.go
{Key1:value1 Key2:value2}

何が起きているのか

go:embedでは一見コメントアウトに見える//go:embed sample.jsonが埋め込みファイルの場所を指示する記述として機能します。

//go:embed sample.json
var sampleBytes []byte

コメントアウト部分のファイル名を書き換えると参照するファイル名が変わることが確認できます。

//go:embed sampl.json
var sampleBytes []byte
go run .\main.go
main.go:9:12: pattern sampl.json: no matching files found

同じことをosで実現しようとするとファイル読み込み~変数の格納までそれなりな行数を要するので、わずか2行でファイルを変数に格納できるのは便利ですね。
勿論json以外でも埋め込み可能です、txt形式の文章や画像なども、バイナリファイルとして扱う事が可能です。

うっかり//の後に半角スペースを入れてしまうと本当にコメントアウトとして処理されてしまうのでご注意ください。
コメントアウトと埋め込みの違いがシンタックスハイライトとして反映できるようになると嬉しいかもしれませんね。

// go:embed sampl.json
var sampleBytes []byte

その他具体的な利用方法

複数ファイルを埋め込む

go:embedは複数のファイルをまとめて埋め込む使い方が用意されています。
embedパッケージを_無しでインポートして、embed.FS型のファイルシステムとして変数に埋め込みます。
この使い方で作成した変数staticio/fsパッケージでも取り扱う事ができました。

main.go
package main

import (
"embed"
"fmt"
"io/fs"
)

//go:embed README.md version.txt
var static embed.FS

func main() {
b, err := static.ReadFile("README.md")
if err != nil {
panic(err)
}
fmt.Printf("%s\n", string(b))

b2, err := fs.ReadFile(static, "version.txt")
if err != nil {
panic(err)
}
fmt.Printf("version: %s\n", string(b2))
}

実行結果は下記のようになります。

 go run .\main.go
## README

It is a sample of `go:embed`
version: 1.0

net/httpパッケージで提供されているファイルシステムとも互換性があるため。
Web APIを開発する場合は大きなメリットとなる事が期待されています。

以下の15行程度の処理で簡易Webサーバーを立てることができました。

main.go
package main

import (
"embed"
"log"
"net/http"
)

//go:embed index.html
var static embed.FS

func main() {
http.Handle("/", http.FileServer(http.FS(static)))
log.Fatal(http.ListenAndServe(":8080", nil))
}

適当なindex.htmlを用意し、ブラウザでlocalhost:8080/index.htmlにアクセスすることでHTMLを表示できました。

別ディレクトリのファイルを参照する

ファイルがmain.go等実行ファイルと同じ場所にない場合は、パス名を指定することが可能です。
embedという名称のアセット管理用フォルダを作成し、sample.jsonを格納した場合は下記のように記載します。

//go:embed embed/sample.json
var sampleBytes []byte

先頭に./等のカレントディレクトリを表す表記は不要です。

//go:embed ./embed/sample.json
var sampleBytes []byte

また、親ファイルまで遡って読み込みを行うような機能は現在サポートされていませんでした。

//go:embed ../embed/sample.json
var sampleBytes []byte

いずれもinvalid pattern syntaxとして処理されます。

 go run .\main.go
main.go:9:12: pattern ../embed/sample.json: invalid pattern syntax

複数のファイルをまとめて埋め込む

go:embedではワイルドカードが利用できるため、階層を掘り下げる形であれば複数ファイルをまとめて配置するようなことも可能です。

//go:embed static/*
var static embed.FS

先ほどの簡易Webサーバーを、ワイルドカードを利用して、favicon.icoindex.htmlをフォルダごと読み込み、展開するような構成に変更してみました。
ディレクトリ構成は以下のようなイメージです。

server
┣ main.go
┗ static
┗ public
┣ index.html
┗ favicon.ico
package main

import (
"embed"
"io/fs"
"log"
"net/http"
)

//go:embed static/*
var static embed.FS

func main() {
public, err := fs.Sub(static, "static/public")
if err != nil {
panic(err)
}
http.Handle("/", http.FileServer(http.FS(public)))
log.Fatal(http.ListenAndServe(":8080", nil))
}

フューチャー技術ブログのfaviconを試しに読み込んでみました、無事に表示されています。

ちなみに変数publicとしてファイルシステムの階層を適宜掘り下げたものを用意しないと読み込んだディレクトリがリンクとして表示されてしまいます。

実際に開発を行う際はginやecho等のWebフレームワークを理想されるケースが一般的と思われますが、それらAPIでも同様の事が可能です。

Webサーバーに話が寄ってしまいましたが、設定やバージョン情報等の管理をgo:embedを使って運用していくような事が期待できます。

go:embedが使えないケース

go:embedでの埋め込みは関数の内部など閉じたスコープで行うことができません。

必然的に広いスコープで扱いたい設定情報等が用途として想定されます。

main.go
package main

import (
"embed"
"fmt"
"io/fs"
)

func main() {
//go:embed README.md version.txt
var static embed.FS

b, err := static.ReadFile("README.md")
if err != nil {
panic(err)
}
fmt.Printf("%s\n", string(b))

b2, err := fs.ReadFile(static, "version.txt")
if err != nil {
panic(err)
}
fmt.Printf("version: %s\n", string(b2))
}

実行すると下記のようなエラーが発生します。

go run .\main.go
# command-line-arguments
.\main.go:10:4: go:embed cannot apply to var inside func

go:embedによって何が嬉しいのか

記事の序盤でも書きましたが、単純に外部ファイルを読み込むだけならosioutilでも行うことが可能です。
go:embedで読み込んだファイルはビルドされたバイナリにも埋め込まれる、という点がその他の読み込み方法との決定的な違いになります。

最初に書いたサンプルコードのosバージョンを作りました。

main.go
package main

import (
"encoding/json"
"fmt"
"log"
"os"
)

type sample struct {
Key1 string `json:"key1"`
Key2 string `json:"key2"`
}

func main() {
bytes, err := os.ReadFile("./sample.json")
if err != nil {
panic(err)
}
var s sample
if err := json.Unmarshal(bytes, &s); err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", s)
}

jsonファイルが適切に配置されていれば、同様にjsonファイルの中身が出力されます。

go run .\main.go
{Key1:value1 Key2:value2}

では、go:embedを利用したものと、osを利用したもので、ビルドした実行バイナリの挙動の違いを確認してみます。

osを利用したものでは、jsonファイルを削除してビルドしたバイナリを実行した場合、エラーが発生します。

 .\main.exe
panic: open ./sample.json: The system cannot find the file specified.

一方、最初に紹介したgo:embedのサンプルコードは、jsonファイルを削除しても問題なく動作します。

 .\main.exe
{Key1:value1 Key2:value2}

go:embedで読み込んだファイルはビルドされたバイナリにも埋め込まれる、の意味がこのような挙動から体験できました。

これはGoの利点の1つである、単一の実行ファイルとしてビルドできることで、展開先の依存関係をシンプルに保つことができるという利点を強力に後押しします。設定ファイルや各種アセットをビルドに含めることで、バージョン管理やリリース作業を一層シンプルに整理できることが期待できます。

先ほど紹介した簡易Webサーバーで例えると、WebサーバーとコンテンツとなるHTML、CSS、JavaScriptが分離している場合、ローカル環境で動いたものを実際の環境にデプロイする場合、実行バイナリと各種アセットをデプロイ対象の環境で適宜整理する必要があります。

これらを全て単一のバイナリに含めることができた場合、作業は実行バイナリを1つコピーして起動するだけになります。
新しいサーバーにデプロイする際の運用フローの整備や、プロダクション向けの構成でコンテナを構築するDockerfileを書いていく事を考えると、go:embedで極限まで簡略化できる部分が想像できるかもしれません。

まとめ

go:embedは外部ファイルを読み込むことができるパッケージです。単一のファイルの中身を簡単に読み込めます、ファイルシステムを提供することも可能です。ローカル変数で利用することは現段階ではできません。

今までのGoでは実行ファイルとアセットファイルに分割されてしまっていた部分を、1つにまとめることが可能になりました。

普段の業務でgo:embedで解決できる部分が無いか、ぜひ探してみていただければと思います。