Edited at

GolangのGin/bindataでシングルバイナリを試してみた(+React)

More than 3 years have passed since last update.


はじめに

Go言語と言えばシングルバイナリが魅力の一つですが、Webサーバとして利用する際のHTML/JS/CSSといったリソースは自動的には埋め込められません。やりたければ別途、go-bindataなどのツールを使って実行ファイルに埋め込む必要があります。今回、Go言語のGinを使ったWebアプリのシングルバイナリ化を試してみたので簡単に紹介する。

なお、埋め込むリソースとなるクライアント側はFacebook Reactを使ったアプリになっています(お試しなのでHello Worldと表示するだけですが...)。Reactのサーバサイドレンダリングは今回は試していませんが、go-duktapeを使えばGo言語でもできそうなので別途試してみたいと思っています。


作ったもの

https://github.com/wadahiro/gin-react-boilerplate にあります。


ディレクトリ構成

下記のようなレイアウトとした。



  • assets: 最終的にGo言語の実行ファイルに埋め込むリソース群。go-bindataを使ってここからGo言語のソースを生成する。


  • client: Reactを使ったクライアントアプリ。webpackでビルドし、bundle.jsを生成する。bundle.jsassets/jsに出力する。


  • server: Go言語で書いたサーバアプリ。Ginを利用。


  • dist: リリースビルドをするとここに実行ファイルが生成される。

gin-react-boilerplate

├─assets
│ ├─css
│ │ └─main.css
│ ├─index.html
│ └─js
│ └─bundle.js
├─client
│ ├─app
│ │ └─App.jsx
│ ├─index.jsx
│ └─webpack
│ ├─webpack.base.config.js
│ ├─webpack.config.js
│ ├─webpack.dev.config.js
│ ├─webpack.prod.config.js
│ └─webpack.server.js
├─dist
├─.gitignore
├─package.json
├─runner.conf
└─server
├─controllers
│ └─home.go
└─main.go


補足



  • runner.conf: Freshの設定ファイル。FreshはGo言語のソース修正時に自動的にコンパイル&Webサーバを再起動してくれる。開発時にはこれを使う。


  • package.json: クライアントアプリのビルドに必要な各種ライブラリの依存関係定義や、npm run ...実行可能なタスクも少し定義している。


  • .gitignore: ビルドで生成されるものはソースリポジトリにコミットしない方針とし、distbundle.jsbindata.goは除外対象にしている。


クライアント

最終的にassetsに埋め込みたいリソースを置くようにすれば良い。client以下にあるReactを使ったアプリケーションについては、今回はwebpackを使っているのでwebpackコマンドでビルドしてbundle.jsassets/jsに生成するだけ。特に注意点はなし。


サーバ

gin + bindataのやり方を調べたのでそこをメインに紹介。

最初に必要なライブラリをインストール。Webサーバのリソースを扱うため、http.FileSystemインタフェースを実装したソースを生成してくれるgo-bindata-assetfsもインストールする。

go get -u github.com/gin-gonic/gin

go get -u github.com/jteeuwen/go-bindata/...
go get -u github.com/elazarl/go-bindata-assetfs/...

埋め込むリソースフォルダを指定してgo-bindata-assetfsコマンドを実行する。これでbindata_assetfs.goが生成される。また、-oオプションで生成先を指定できる。

go-bindata-assetfs -o ./server/bindata_assetfs.go assets/...

生成されたbindata_assetfs.goを使い下記のようなコードを書くことで、埋め込んだリソースをHTTPで返すようにできる。GinのStaticFSメソッドbindata_assetfs.goassetFS()を組み合わせるだけと簡単。


main.go

package main

import (
"github.com/gin-gonic/gin"
)

func main() {
r := gin.Default()

r.StaticFS("/assets", assetFS())

r.Run()
}


生成したbindata_assetfs.gomain.gogo runでビルド&実行する。

$ go run server/bindata_asssetfs.go server/main.go

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET /assets/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (3 handlers)
[GIN-debug] HEAD /assets/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (3 handlers)
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

curlで叩くと埋め込んだindex.htmlがちゃんと返ってくることを確認できる。

$ curl http://localhost:8080/assets/

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title></title>
<link rel="stylesheet" href="./css/main.css">
</head>
<body>
<div id="app"></div>

<script src="js/bundle.js"></script>
</body>
</html>

簡単ですね!! と言いたいところですが、静的リソースを/でアクセスできるようにしたい場合に残念ながら困ります。例えば、以下のように/に静的リソース、/api/homeにJSONを返す処理を実装したとすると、、、


main.go

package main

import (
"github.com/gin-gonic/gin"
)

func main() {
r := gin.Default()

r.StaticFS("/", assetFS())

r.GET("/api/home", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "home",
})
})

r.Run()
}


コンパイルは通るけど実行時に下記のようなconflictsエラーになってしまう。

$ go run server/bindata_assetfs.go server/main.go

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET /*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (3 handlers)
[GIN-debug] HEAD /*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (3 handlers)
[GIN-debug] GET /api/home --> main.main.func1 (3 handlers)
panic: path segment '/api/home' conflicts with existing wildcard '/*filepath' in path '/api/home'
...

GinのIssueを漁ると Inability to use '/' for static files を発見。ここを見ると、static middlewareを使えば良さそう。

というわけでstatic middlewareをインストール。

go get github.com/gin-gonic/contrib/static

static middleware + go-bindataのサンプルを参考にして、main.goを書き換える。残念ながらインタフェースを合わせるためにちょっと複雑になってしまう。なお、static middleware + go-bindataのサンプルそのままではコンパイルが通らないので注意。assetfs.AssetFSAssetInfoも渡さないと駄目です。


main.go

package main

import (
"net/http"
"strings"

"github.com/elazarl/go-bindata-assetfs"
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
)

type binaryFileSystem struct {
fs http.FileSystem
}

func (b *binaryFileSystem) Open(name string) (http.File, error) {
return b.fs.Open(name)
}

func (b *binaryFileSystem) Exists(prefix string, filepath string) bool {
if p := strings.TrimPrefix(filepath, prefix); len(p) < len(filepath) {
if _, err := b.fs.Open(p); err != nil {
return false
}
return true
}
return false
}

func BinaryFileSystem(root string) *binaryFileSystem {
fs := &assetfs.AssetFS{Asset, AssetDir, AssetInfo, root}
return &binaryFileSystem{
fs,
}
}

func main() {
r := gin.Default()

r.Use(static.Serve("/", BinaryFileSystem("assets")))

r.GET("/api/home", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "Hello World",
})
})

r.Run()
}


また、bindataはgo-bindataを使って生成するように変更する。

go-bindata -o ./server/bindata.go assets/...

これで/assets配下の静的リソースを割り当てつつ、/api/homeの処理も実装できるようになる。go runすると今度は起動成功する(bindata_assetfs.goはもう不要なので消しておく)。

$ go run server/bindata.go server/main.go

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET /api/home --> main.main.func1 (4 handlers)
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

curlで/にアクセスすると埋め込んだindex.htmlがちゃんと返ってくることを確認できる。

$ curl http://localhost:8080/

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title></title>
<link rel="stylesheet" href="./css/main.css">
</head>
<body>
<div id="app"></div>

<script src="js/bundle.js"></script>
</body>
</html>

Go言語で実装したREST APIもOK。

$ curl http://localhost:8080/api/home

{"message":"Hello World"}


開発モードで効率化


クライアントアプリの開発モード

client以下のReactで作っている部分に関しては、webpackコマンドをwatchモードで起動しておくことでソースの変更時に自動的にビルドしてassets/js/bundle.jsに反映させることができる。npm run devで実行できるようにpackage.jsonにタスクを設定しているのでこれを叩いてビルドする。

$ npm run dev

> golang-web-boilerplate@0.1.0 dev c:\Users\wadahiro\src\github.com\wadahiro\gin-react-boilerplate
> webpack -w --config ./client/webpack/webpack.config.js

NODE_ENV: undefined
Hash: a307cf2e4d362646c0a4
Version: webpack 1.12.12
Time: 1867ms
Asset Size Chunks Chunk Names
bundle.js 851 kB 0 [emitted] main
[0] multi main 28 bytes {0} [built]
+ 350 hidden modules

これでclient以下のソースに手を入れた際はwebpackが自動的にassets/js/bundle.jsをビルドしてくれる。


bindataの生成

go-bindata -o ./server/bindata.go assets/...で生成したbindata.goは、クライアント側のソース修正のたびにgo-bindataの再実行が必要になる。これを回避するための機能がgo-bindataにある。使い方は簡単で、-debugオプションをつけて実行してソースを生成するだけ。

go-bindata -debug -o ./server/bindata.go assets/...

そうすると、生成されるbindata.goにはリソースは直接埋め込まれず、assets以下のオリジナルのファイルを都度読むような実装となる。開発時はこのソースを組み込んでWebサーバを起動すればOK。これでクライアント側のリソースファイルが変更されてもすぐに反映される。


サーバアプリの開発モード

冒頭で少し紹介したFreshを使う。先程はgo runでWebアプリを起動していたが、代わりにfreshコマンドを使う。runner.confに適切に設定していれば、下記のようにfreshコマンドだけで起動する。

$ fresh

Loading settings from ./runner.conf
13:21:19 runner | InitFolders
13:21:19 runner | mkdir ./tmp
13:21:19 runner | mkdir ./tmp: Cannot create a file when that file already exists.
13:21:19 watcher | Watching ./server
13:21:19 watcher | Watching server\controllers
13:21:19 main | Waiting (loop 1)...
13:21:19 main | receiving first event /
13:21:19 main | sleeping for 600 milliseconds
13:21:19 main | flushing events
13:21:19 main | Started! (12 Goroutines)
13:21:19 main | remove tmp\runner-build-errors.log: The system cannot find the file specified.
13:21:19 build | Building...
13:21:22 runner | Running...
13:21:22 main | --------------------
13:21:22 main | Waiting (loop 2)...
13:21:22 app | [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)

13:21:22 app | [GIN-debug] GET /api/home --> main.main.func1 (4 handlers)
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

試しにGo言語のソースを修正すると下記のように自動的に検知して再起動が行われる。

13:22:08 watcher     | sending event "server\\controllers\\home.go": MODIFY

13:22:08 watcher | sending event "server\\controllers\\home.go": MODIFY
13:22:08 main | receiving first event "server\\controllers\\home.go": MODIFY
13:22:08 main | sleeping for 600 milliseconds
13:22:09 main | flushing events
13:22:09 main | receiving event "server\\controllers\\home.go": MODIFY
13:22:09 main | Started! (15 Goroutines)
13:22:09 main | remove tmp\runner-build-errors.log: The system cannot find the file specified.
13:22:09 build | Building...
13:22:11 runner | Running...
13:22:11 runner | Killing PID 7532
13:22:12 main | --------------------
13:22:12 main | Waiting (loop 3)...
13:22:12 app | [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)

13:22:12 app | [GIN-debug] GET /api/home --> main.main.func1 (4 handlers)
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

クライアントとサーバ側の開発モードをまとめて実行したければ、下記コマンドを実行すれば良い。

npm run dev & fresh


リリースビルド

リリースビルドの時には、今度はgo-bindata-debugオプションなしで実行してソースを生成し、それを組み込んでビルドすれば良い。フルビルドの手順としては、



  1. webpack -pでクライアントアプリをリリース用にビルドしてassets/js/bundle.jsを生成


  2. go-bindataassets以下のファイルからbindata.goを生成


  3. go buildでビルドして実行ファイルをdistに生成

という流れになる。ワンライナーで書くと以下。

NODE_ENV=production && webpack -p --config ./client/webpack/webpack.config.js && go-bindata -o ./server/bindata.go assets/... && go build -o ./dist/web server/bindata.go server/main.go

※Windowsだと、set NODE_ENV=productionにする必要あり。

これをnpm run buildで実行できるようにpackage.jsonに設定しているので、

npm run build

と実行すれば、実行可能なシングルバイナリとして./dist/web が生成される。ちなみに今回作ったお試しアプリのサイズは10MBほどでした。


シングルバイナリで動かしてみる

dist以下にできた実行ファイルを起動するだけ。このファイル1つをデプロイするだけで良いので配布&インストールが超楽ちんですね!

$ ./dist/web

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET /api/home --> main.main.func1 (4 handlers)
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080


まとめ


  • ちょっと面倒なところはあったが、Gin + bindataでシングルバイナリを作ることができた

  • watchモードがクライアント、サーバサイドの両方にあるので、高速フィードバックで開発できそう

  • せっかくReactを使っているので、サーバサイドレンダリングを今度は試してみます。