📌

Goa v3 で go:embed を利用する試行錯誤

2021/05/03に公開
4

概要

Go で embed が追加されて、バイナリにアセットが埋め込めるようになったとき思いましたよね。
これで Goa の生成する OpenAPI ドキュメントをバイナリに埋め込める!と。

そんなわけで、Goa で静的なドキュメントをバイナリに埋め込む試行錯誤をしてみました。

Files DSL みたいな DSL を模索する

Files DSL は Goa で提供されているファイルをサーブするための DSL です。
以下のように使います。

Files("/assets/{*filepath}", "/www/data/assets")

こうすると、該当ファイルを、指定したエンドポイントで提供できます。上記の例だと、ファイル /www/data/assets/x/y/z/assets/x/y/z へのアクセスのレスポンスとして返すことが出来ます。

この DSL を改造して embed なファイルを提供できるようにすればよさそうです。

・・・が、embed には、embed を記述したフォルダより上のフォルダを見ることが出来ない、という制約があります。Goa が生成するファイルは基本的に gen ディレクトリより下に作成されるので、この下に embed の記述をすると、これより上のファイルを見ることができません。プロジェクトのトップディレクトリに embed 指定をして、それを gen 下で参照することも可能ですが、デザインを再生成したときに、gen 下は捨てられて作り直される、という方針とちょっと合わなくなってしまいます。goa example で作成されるファイル(サービスメソッドを書くためのひな形など)はプロジェクトのトップディレクトリに置かれるので、goa example で生成されるようにするというのがよさそうですが、そこまでするなら自分で書いてしまう方がはやそうです(将来的には誰かがそんな DSL を作ってるかも知れません)。

ファイルのダウンロードのやり方をまねて、embed を利用する

embed で埋め込めるファイルは、その指定をしたファイルのフォルダを含む下層のファイルになるので、プロジェクトトップのサービスメソッドを記述するファイルで指定するのがよさそうです。なので、ファイルダウンロードエンドポイントの作り方にならって実装してみます。ファイルのダウンロードのサンプルは、goa/examplesupload_download にあります。

これを embed を使って書き直してでアセットの埋め込みを試してみます(結果的にこれはうまくいきました)。

1ファイルを指定する

gen 下に生成される openapi.json を指定して提供するエンドポイントは次のようにかけます。

デザイン

	Method("openapidoc", func() {
		Result(func() {
			Attribute("length", Int64, "Length is the downloaded content length in bytes.", func() {
				Example(4 * 1024 * 1024)
			})
			Attribute("encoding", String, func() {
				Example("application/json")
			})
			Required("length", "encoding")
		})

		Error("invalid_file_path", ErrorResult, "Could not locate file for download")
		Error("internal_error", ErrorResult, "Fault while processing download.")

		HTTP(func() {
			GET("/openapi.json")           // エンドポイント
			SkipResponseBodyEncodeDecode() // ←これがミソ。エンコードするコードを生成しないようになります
			Response(func() {
				Header("length:Content-Length") // ← ヘッダに Content-Length 指定できるようにする
				Header("encoding:Content-Type") // ← サービスメソッドで Content-Type を指定できるように
			})
			Response("invalid_file_path", StatusNotFound)          // ファイルないときのエラー
			Response("internal_error", StatusInternalServerError)  // その他エラー
		})
	})

SkipResponseBodyEncodeDecode() を指定するのがポイントです。これは、レスポンスをエンコードするコードを生成することを抑制できます。

embed.FS で埋め込むファイルは、その記述をするファイルのあるフォルダより下層のものならば指定できます。フォルダより上のファイルは読めません。埋め込んだファイルの指定は、その位置からの相対パスで表現されるので、ファイルを指定するときも、その相対パスを指定してやる必要があります。

サービスメソッド

//go:embed gen/http/openapi.json   // ← 対象ファイルを embed.FS で埋め込み。複数ファイル埋め込めるがこの例ではひとつだけ
var openapijson embed.FS

// Openapidoc implements openapidoc.
func (s *assetsrvc) Openapidoc(ctx context.Context) (res *asset.OpenapidocResult, resp io.ReadCloser, err error) {
	f, err := openapijson.Open("gen/http/openapi.json") // ← 埋め込んだファイルを取り出し
	if err != nil {
		return nil, nil, asset.MakeInvalidFilePath(err)
	}
	fi, err := f.Stat() // ← 埋め込んだファイルのサイズを取得するための情報を得る
	if err != nil {
		return nil, nil, asset.MakeInternalError(err)
	}
	return &asset.OpenapidocResult{
		Length: fi.Size(), // ← ファイルサイズを指定
		Encoding: "application/json", // ← MIMEタイプを指定
	}, f, nil // ← ファイルは io.ReadCloser インターフェースを満たすのでそのまま返す
}

embed.FS には複数ファイルを埋め込めますが、この例ではひとつだけ埋め込んでいます。埋め込まれたファイルは、埋め込んだときの相対パスを指定することで取り出すことが出来ます。レスポンスにはこのファイルを返しますが、同時にファイルサイズとMIMEタイプも返す必要があります(←ファイルサイズとMIMEタイプはデザインにおいて Header DSL で指定しているので)。ファイルサイズは File#Stat() によって得られる FileInfo を引くことで分かります。MIMEタイプは application/json なので、Encoding にこれを指定します。

ディレクトリを指定する

次のようなディレクトリ構成で、asset 以下を /asset/ エンドポイントで公開します。

.
├── asset
│   └── swaggerui
│       └── dist
│           ├── favicon-16x16.png
│           ├── favicon-32x32.png
│           ├── index.html
│           ├── oauth2-redirect.html
│           ├── swagger-ui-bundle.js
│           ├── swagger-ui-bundle.js.map
│           ├── swagger-ui-standalone-preset.js
│           ├── swagger-ui-standalone-preset.js.map
│           ├── swagger-ui.css
│           ├── swagger-ui.css.map
│           ├── swagger-ui.js
│           └── swagger-ui.js.map

デザイン

	Method("asset", func() {
		Payload(String, func() {                     // これはエンドポイントの {*filepath} に対応
			Description("Path to downloaded file.")
		})

		Result(func() {
			Attribute("length", Int64, "Length is the downloaded content length in bytes.", func() {
				Example(4 * 1024 * 1024)
			})
			Attribute("encoding", String, func() {
				Example("application/json")
			})
			Required("length", "encoding")
		})

		Error("invalid_file_path", ErrorResult, "Could not locate file for download")
		Error("internal_error", ErrorResult, "Fault while processing download.")

		HTTP(func() {
			GET("/asset/{*filename}")        // asset 以下に任意のファイルパスが書けるように {*filepath} で指定
			SkipResponseBodyEncodeDecode()   // ← レスポンスをエンコードするコードを生成しないようになります
			Response(func() {
				Header("length:Content-Length")  // ← ヘッダに Content-Length 指定できるようにする
				Header("encoding:Content-Type")  // ← サービスメソッドで Content-Type を指定できるように				
			})
			Response("invalid_file_path", StatusNotFound)         // ファイルないときのエラー
			Response("internal_error", StatusInternalServerError) // その他エラー
		})
	})

サービスメソッド

//go:embed asset     // ← ディレクトリを指定することで配下が全て対象となります
var assets embed.FS

// Asset implements asset.
func (s *assetsrvc) Asset(ctx context.Context, p string) (res *asset.AssetResult, resp io.ReadCloser, err error) {
	f, err := assets.Open(filepath.Join("asset", p)) // ← p には asset 以下のパスが来るので、asset と連結して取り出す
	if err != nil {
		return nil, nil, asset.MakeInvalidFilePath(err)
	}
	fi, err := f.Stat()
	if err != nil {
		return nil, nil, asset.MakeInternalError(err)
	}
	return &asset.AssetResult{
		Length: fi.Size(),
		Encoding: mime.TypeByExtension(filepath.Ext(p)), // MIME タイプはファイル拡張子から類推
	}, f, nil
}

ディレクトリを指定する場合もほぼ同じように出来ます。ただ、ディレクトリ以下のどのファイルにアクセスされるかは分からないので、MIMEタイプはアクセスされたファイルの拡張子から類推してセットします。

まとめ

アセットをバイナリに含めることで、バイナリに対応する OpenAPI ドキュメントを埋め込んで実装とドキュメントの齟齬をなくしたり、アセットを埋め込んでリリース時の配布を簡単にしたりすることが出来そうです。そのうちには EmbedFiles みたいな DSL とか plugin を誰かが作ってくれそうな気もします。

まだ試し試しのところもありますが、いろいろ便利に使えそうです。是非やってみて下さい ╭( ・ㅂ・)و ̑̑

Happy hacking!

Discussion

chezgichezgi

thanks ikawaha,
when embedding web files its encoding was set to plain-text.
if you add mime detection based on file extension, it works.
in design:

			Response(func() {
				Header("length:Content-Length")
				Header("encoding:Content-Type")
			})

and in code:

	return &static.FileResultType{
		Length:   fi.Size(),
		Encoding: mime.TypeByExtension(filepath.Ext(fpath)),
	}, f, nil
ikawahaikawaha

Thank you for commenting on an important point!
I have corrected them.

HikaruooHikaruoo

SkipResponseBodyEncodeDecode()の使用ポイントや、embed.FSを活用したディレクトリ指定の詳細な実装が大変参考になります。

アセット埋め込みを活用することで、運用面でも配布が楽になる利点が明確に伝わってきました。このような取り組みは、リリースフローをシンプル化したい開発者にとって有益だと感じます。ありがとうございます。