「軽量」と言っているのは、メモリ使用量が少ないという意味になります。
Node.js 製のアプリケーションは、どうしてもメモリの使用量が多くなりがちです。
特に困るのが、複数のアプリケーションを起動しないと動作確認ができないような開発です。PCのスペックにも限界はあり、いざ「動作確認しよう!」とすると「固まって再起動しないといけない…」「他に起動しているアプリケーションを落とさないと…」という事態も発生し、気を使う場面もあります。
今回、フロントサイドの開発をするにあたり、構成に関して考える機会がありました。「このパターンもあり・・?」となったので、まとめていきます。
前提
- フロントエンド の動きとして SSR(=Server Side Rendering)処理 がないこと(CSR, SSG であること)
実現イメージ
一般的には、Next.js で作成したのであれば、Next.js の サーバー起動($ next start
)を使うと思います。つまり、Node.js の環境でアプリケーションが動作する状態です。
$ next build
$ next start
確実に上記の方法で動かした方が楽なのですが、今回実現させようとしているのは、もうひと手間を加える方法です。但し、その手間を極力減らした方法で実現させます。
具体的には、下記の手順になります。
$ next build
- build したファイルを サーバーに配備(今回は、golang製のサーバーになります)
- golang製のサーバーを起動
動かした時のイメージは上記のようになります。
違いは、サーバーの図にある吹き出しです(上が「Node.js」、下が「golang」)
※ golang の環境に関しては $ go build
済みのものを配備することで、golang である必要はなく、alpine などでもOKになります。
アプリケーションを作る
Next.js のアプリケーションを作成します。Next.js はサンプルが多いため、参考にして作成することができると思います。他にもスタートアップ系の記事は検索すれば出てくるので、割愛します。
Next.js の設定
公式参照ですが、 next.config.js に output: 'export',
を設定。
$ nuxt build
を実行し、 静的ファイルを生成するようにします。
フロントサーバー の設定
イメージ図でも記載したように、golang の開発環境を準備します。セットアップに関しては、下記など多く見つけられると思いますので、割愛します。
golang製のサーバーに関しては、今回は、Echo というフレームワークも利用します(選定理由は、特にありません)。
main.go
下記を全部コピーし、何かしらのファイル名で保存します。その後、$ go build <保存したファイル名>
を実行します。
※ $ go mod init
など必要な手順は割愛しています
import (
"errors"
"net/http"
"net/url"
"os"
"path"
"strings"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
echoServer := echo.New()
echoServer.Use(nextjsResourceRouter)
echoServer.Start(":3000")
}
func nextjsResourceRouter(next echo.HandlerFunc) echo.HandlerFunc {
const outputRootDirName = "out"
const renderTargetFileSystem = http.Dir(outputRootDirName)
return func(c echo.Context) (err error) {
reqPath, err := getRequestPath(c)
if err != nil {
return err
}
reqName := path.Join(".", path.Clean("/"+reqPath))
file, err := renderTargetFileSystem.Open(reqName)
if err != nil {
if !os.IsNotExist(err) {
return err
}
if err = next(c); err == nil {
return nil
}
var he *echo.HTTPError
if !(errors.As(err, &he) && he.Code == http.StatusNotFound) {
return err
}
file, err = findRequestFile(renderTargetFileSystem, reqName)
if err != nil {
return err
}
}
defer file.Close()
http.ServeContent(c.Response(), c.Request(), file.Name(), file.ModTime(), file)
return nil
}
}
func getRequestPath(c echo.Context) (string, error) {
p := c.Request().URL.Path
if strings.HasSuffix(c.Path(), "*") {
p = c.Param("*")
}
return url.PathUnescape(p)
}
func findRequestFile(fs http.FileSystem, name string) (http.File, error) {
file, err := fs.Open(path.Join(".", name))
if err != nil {
file, err = fs.Open(path.Join(".", name+".html"))
}
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, err
}
if !info.IsDir() {
return file, nil
}
file, err = fs.Open(path.Join(name, "index.html"))
if err == nil {
return file, nil
}
return fs.Open(path.Join(name, name+".html"))
}
補足1
golang の echo には、 staticというmiddleware があります。
しかし、Next.js の build は少し癖があり、これだと太刀打ちできません。
例えば、下記のようなフォルダ構成だったとします。
hoge/
page.tsx
fuga/
page.tsx
piyo/
page.tsx
page.tsx
この場合、「hoge.html」「hoge/fuga.html」「piyo.html」「index.html」が生成されます。
理想としては「hoge/index.html」や「hoge/fuga/index.html」「piyo/index.html」が生成されれば、そのまま利用できますし、この記事を書く必要がなかったわけですが、「想定している構成のリクエストに対して、正しい HTMLファイルを割り当ててレスポンス」する必要があります。
そのため、 staticのmiddleware を参考 & リファクタリングをし、この形になりました。
※ 最小限の設定ですので、本番環境で動かす場合はもう少し変更すると思います
起動の設定
あとは、「goのビルドしたファイル(=サーバー)」と「Next.jsのビルドしたフォルダ」を同階層に置き、 サーバーを起動させるだけです。
実際に本番環境にデプロイする場合でも、「Next.jsビルド用のDocker」「goのサーバービルド用のDocker」「動作デプロイ用のDocker」として マルチステージビルド を活用すれば、一つのDockerファイル で Docker Image が出来上がると思います。
メリットとデメリット
軽量
メリットはタイトルにも記載しているように「軽くなること」です。
とても簡単な検証( for で複数万回 curl して、その時の $ docker stats --no-stream
で見ただけ)ですが、平均値を計算したところ下記の結果となりました。
CPU | MEM | NET I/O | |
---|---|---|---|
node:20.2.0-alpine で $ next start
|
30.14% | 139.6MiB | 485.8kB / 5.4MB |
alpine:3.17.2 で $ ./main
|
3.64% | 6.14MiB | 485.8kB / 6.06MB |
拡張性が高い
この構成を見た時に、「Nginxでも良いんじゃない?」とお思いになった方もいらっしゃると思います。まさしく、その通り!
実際に、 公式でも、 Nginx の設定を変更してね。 という記載があります。
もしもの話ですが、アクセス制御や追加のアクセスログ取得など、何か必要になった場合、Nginx のみで実現するのが面倒な可能性があります。
今回のように golang で作ってしまえば、サーバー処理に追加することも容易だろう…という思惑です。
golang の知識が必要となってしまうデメリットもありますが、カスタマイズ可能な状態であることは大事だと思います。
また、ソースコードを見ていただけたら分かることですが、「リクエストされたファイルが存在しなかった場合、ルーティング用の処理が最初に実行」されるため、「特定のURL処理があった場合に先に処理をする」状態の実現も容易な作りとなっています。
手間がかかる
デメリットは、Next.js のみで開発をした時よりも、手順が多いということです。
これを解消する方法の一つとして、実際の本番環境との差分がある状態になってしまいますが、
最初は $ next dev
の dev起動で Node.js 上で動かす。
最終確認では buildして、 golangサーバー上で動かす。
といった流れにすれば、少しは解消されると思います。
最後に
別のタイトルを付けるのであれば、『echo の Next.js用 static middlewareを作る』といった感じでしょうか。
今回は Next.js をベースにまとめていきましたが、 HTMLやJSにビルドするようなクライアントライブラリ(Svelte など)でも、応用が可能かと思います。
本番環境での運用を想定した場合、また、ローカル環境のストレスを想定することは大事になります。
快適なwebアプリケーションを作るための手助けになれば幸いです。