フューチャー技術ブログ

Go 1.16のembedとchiとSingle Page Application

シングルページアプリケーションは、1つのHTMLファイルであらゆるページを表現します。history APIを使ってそのようなページが実際にあるかのように振る舞います。

一方で、画面がリロードされたとき、メールでSNSでシェアされたときにその該当ページをきちんと再現するためには、サーバー側でハンドリングを行う必要があります。具体的には、存在しないページがリクエストされたら、アプリケーションのルートとなるHTMLファイルの内容をそのURLから配信するというものです。

https://angular.jp/guide/deployment#server-configuration

それにより、どのURLでもJavaScriptが動作し、そのURLで表示すべきコンテンツが表示されます。もし想定していないパスの場合は、ウェブサーバーではなく、JavaScriptがエラーを出します。

Goでウェブサーバーを作る時もSingle Page Applicationをホストする場合、同じような動作をさせる必要があります。以前、ファイルをバンドルするパッケージを作った時は、そのようなライブラリもセットで実装しました。今回はgo:embedで同じことをやってみます。

https://pkg.go.dev/go.pyspa.org/brbundle

ファイルの配信のハンドラー

やることは単純で、まずgo:embedからファイルを探し、見つからなかったら、指定のファイル(index.html)を返します。拡張子からmimeタイプを決定してヘッダーフィルドに設定しています。

notfound.go
package main

import (
"embed"
"errors"
"io"
"mime"
"net/http"
"path"
"path/filepath"
)

var ErrDir = errors.New("path is dir")

func tryRead(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error {
f, err := fs.Open(path.Join(prefix, requestedPath))
if err != nil {
return err
}
defer f.Close()

// Goのfs.Openはディレクトリを読みこもとうしてもエラーにはならないがここでは邪魔なのでエラー扱いにする
stat, _ := f.Stat()
if stat.IsDir() {
return ErrDir
}

contentType := mime.TypeByExtension(filepath.Ext(requestedPath))
w.Header().Set("Content-Type", contentType)
_, err = io.Copy(w, f)
return err
}

func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
// まずはリクエストされた通りにファイルを探索
err := tryRead(assets, "frontend/dist", r.URL.Path, w)
if err == nil {
return
}
// 見つからなければindex.htmlを返す
err = tryRead(assets, "frontend/dist", "index.html", w)
if err != nil {
panic(err)
}
}

このハンドラを起動するコードは以下のような感じです。環境変数からポート番号を決定するためにサードパーティパッケージを使っています。

chiにはマッチしなかったときに呼び出されるNotFoundハンドラがあるので、それに先に作ったハンドラを設定します。サーバーAPIがある場合は、同じrouter上に定義しておけばアセットの配布とAPIが共存できます。同じオリジンなので、CORSとかは考えなくてもOK。

きちんとシグナルを受けてgraceful shutdownもするように、Go 1.16で追加されたsignal.NotifyContext()を利用してシグナルに応答するようにしておきます。

main.go
package main

import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"strconv"
"time"

"github.com/go-chi/chi/v5"
"github.com/kelseyhightower/envconfig"
)

// 環境変数からポート番号を取得するための構造体
type Env struct {
Port uint16 `envconfig:"PORT" default:"8000"`
}

// ハンドラの実装
func newHandler() http.Handler {
router := chi.NewRouter()

router.Route("/api", func(r chi.Router) {
// 何かAPIを足したい場合はここに足す
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
})
})

// シングルページアプリケーションを配布するハンドラをNotFoundに設定
router.NotFound(NotFoundHandler)
return router
}

func main() {
var env Env
err := envconfig.Process("", &env)
if err != nil {
fmt.Fprintf(os.Stderr, "Can't parse environment variables: %s\n", err.Error())
os.Exit(1)
}

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
defer cancel()

server := &http.Server{
Addr: ":" + strconv.FormatUint(uint64(env.Port), 10),
Handler: newHandler(),
}

go func() {
<-ctx.Done()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)
}()
fmt.Fprintf(os.Stderr, "start receiving at :%d\n", env.Port)
fmt.Fprintln(os.Stderr, server.ListenAndServe())
}

これでウェブサービスを1つのバイナリで提供できるようになります。GCP Cloud Runでも、AWS LambdaやGCP Functionsでも好きなようにデプロイできますね。

フロントエンドのコードも作ってみる

複数のページがあるSPAのページを作ってみます。vue-cliでVue 3+TypeScript+Routerな感じで作りました。

% npx -p @vue/cli vue create frontend

Vue CLI v4.5.12
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, Linter
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

これで複数ページを持ったSPAなWebページができあがるので、ビルドして静的HTMLとJSなどにします。

$ npm run build

:

DONE Build complete. The dist directory is ready to be deployed.
INFO Check out deployment instructions at https://cli.vuejs.org/guide/deployment.html

これをバイナリにバンドルするためのファイルを定義します。

asset.go
package main

import (
"embed"
)

//go:embed frontend/dist/*
var assets embed.FS

あとは先ほどのmain.goとnotfound.goも一緒に置いて、ビルドして実行したら完成です。トップのページだけではなく、/aboutのページで画面をリロードして(最初の読み込みがファイル上存在しない/about)も、正しく表示されればOKです。

正しく表示された/abountの画面

コードは以下のところに置いてあります。

https://github.com/shibukawa/spa-go-1.16

2021/04/09追記

続編のGo 1.16のgoembedとNext.jsの相性が悪い問題と戦う が公開されました。合わせてどうぞ。