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


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

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

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


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

## 実現イメージ

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

1. `$ next build`
1. `$ next start`

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

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

![構成図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2802395/16a4808b-935c-d427-c4cd-084f4b03ff36.jpeg)

動かした時のイメージは上記のようになります。
違いは、サーバーの図にある吹き出しです（上が「Node.js」、下が「golang」）

※ golang の環境に関しては `$ go build` 済みのものを配備することで、golang である必要はなく、alpine などでもOKになります。


# アプリケーションを作る

Next.js のアプリケーションを作成します。Next.js はサンプルが多いため、参考にして作成することができると思います。他にもスタートアップ系の記事は検索すれば出てくるので、割愛します。

https://github.com/vercel/next.js/tree/canary/examples


## Next.js の設定

https://nextjs.org/docs/pages/building-your-application/deploying/static-exports

公式参照ですが、 next.config.js に `output: 'export',` を設定。
`$ nuxt build` を実行し、 静的ファイルを生成するようにします。

## フロントサーバー の設定

イメージ図でも記載したように、golang の開発環境を準備します。セットアップに関しては、下記など多く見つけられると思いますので、割愛します。

https://qiita.com/fsd-osw/items/0e569b0b828455cdef9e

golang製のサーバーに関しては、今回は、Echo というフレームワークも利用します(選定理由は、特にありません)。

https://echo.labstack.com/

### main.go

下記を全部コピーし、何かしらのファイル名で保存します。その後、`$ go build ＜保存したファイル名＞` を実行します。

※ `$ go mod init` など必要な手順は割愛しています

```golang
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](https://echo.labstack.com/guide/static-files/) があります。
しかし、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 の設定を変更してね。](https://nextjs.org/docs/app/building-your-application/deploying/static-exports#deploying) という記載があります。

もしもの話ですが、アクセス制御や追加のアクセスログ取得など、何か必要になった場合、Nginx のみで実現するのが面倒な可能性があります。

今回のように golang で作ってしまえば、サーバー処理に追加することも容易だろう…という思惑です。
golang の知識が必要となってしまうデメリットもありますが、カスタマイズ可能な状態であることは大事だと思います。

また、ソースコードを見ていただけたら分かることですが、「リクエストされたファイルが存在しなかった場合、ルーティング用の処理が最初に実行」されるため、「特定のURL処理があった場合に先に処理をする」状態の実現も容易な作りとなっています。

## 手間がかかる

デメリットは、Next.js のみで開発をした時よりも、手順が多いということです。
これを解消する方法の一つとして、実際の本番環境との差分がある状態になってしまいますが、
最初は `$ next dev` の dev起動で Node.js 上で動かす。
最終確認では buildして、 golangサーバー上で動かす。
といった流れにすれば、少しは解消されると思います。


# 最後に

別のタイトルを付けるのであれば、『echo の Next.js用 static middlewareを作る』といった感じでしょうか。
今回は Next.js をベースにまとめていきましたが、 HTMLやJSにビルドするようなクライアントライブラリ（Svelte など）でも、応用が可能かと思います。

本番環境での運用を想定した場合、また、ローカル環境のストレスを想定することは大事になります。
快適なwebアプリケーションを作るための手助けになれば幸いです。