シングルページアプリケーションは、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 mainimport ( "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() 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 } 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 mainimport ( "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) { r.Get("/test" , func (w http.ResponseWriter, r *http.Request) { }) }) 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 mainimport ( "embed" ) var assets embed.FS
あとは先ほどのmain.goとnotfound.goも一緒に置いて、ビルドして実行したら完成です。トップのページだけではなく、/about
のページで画面をリロードして(最初の読み込みがファイル上存在しない/about
)も、正しく表示されればOKです。
コードは以下のところに置いてあります。
https://github.com/shibukawa/spa-go-1.16
2021/04/09追記 続編のGo 1.16のgoembedとNext.jsの相性が悪い問題と戦う が公開されました。合わせてどうぞ。