LoginSignup
2
2

たった1ファイルで、Next.jsのwebアプリケーションを軽量にする

Posted at

「軽量」と言っているのは、メモリ使用量が少ないという意味になります。
Node.js 製のアプリケーションは、どうしてもメモリの使用量が多くなりがちです。

特に困るのが、複数のアプリケーションを起動しないと動作確認ができないような開発です。PCのスペックにも限界はあり、いざ「動作確認しよう!」とすると「固まって再起動しないといけない…」「他に起動しているアプリケーションを落とさないと…」という事態も発生し、気を使う場面もあります。

今回、フロントサイドの開発をするにあたり、構成に関して考える機会がありました。「このパターンもあり・・?」となったので、まとめていきます。

前提

  • フロントエンド の動きとして SSR(=Server Side Rendering)処理 がないこと(CSR, SSG であること)

実現イメージ

一般的には、Next.js で作成したのであれば、Next.js の サーバー起動($ next start)を使うと思います。つまり、Node.js の環境でアプリケーションが動作する状態です。

  1. $ next build
  2. $ next start

確実に上記の方法で動かした方が楽なのですが、今回実現させようとしているのは、もうひと手間を加える方法です。但し、その手間を極力減らした方法で実現させます。
具体的には、下記の手順になります。

  1. $ next build
  2. build したファイルを サーバーに配備(今回は、golang製のサーバーになります)
  3. golang製のサーバーを起動

構成図.jpg

動かした時のイメージは上記のようになります。
違いは、サーバーの図にある吹き出しです(上が「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アプリケーションを作るための手助けになれば幸いです。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2