Next.js production server in Go (no JavaScript runtime)

2023/12/03に公開

こちらの投稿は 12/3 の Go 言語と Next.js の Advent カレンダー用になります。


背景

  • Next.js のパフォマンスは Go に追いついていないためサーバーをより速くしない
    • deno や bun なども試しましたが数倍違う程度で、Go とは数百倍の違いがある
    • Go で DB アクセス含むリクエストよりも Next.js でページ作るのが遅い場合がある
  • メモリー使用率を減らしたい、など

前提と本記事の範囲

  • Next.js サーバーといっても全ての機能を対応できるものではないため以下の技術のみになります。
    • getServerSideProps を Go で処理する (HTMLのBodyのレンダリングはクライアント)
    • HTMLの Head 部分のレンダリングを Go で処理する(SEOのためサーバーサイドで処理)
      • title や meta タグ、各種 script、css などの挿入
      • __NEXT_DATA__ オブジェクトを挿入
    • Next.js pages のルーティングを Go で行う(app router は本記事では対応しておりません)
    • SSG(Server Side Generation)、こちらはただのファイルサーバー的なものなため詳細の説明は省きます。
    • _next/static 配下のファイルサーバー(.css, .js集)、上記同様

HTML 構成

まずは HTML 構成をみてみましょう

<!DOCTYPE html>
<html lang="ja"> <!-- _document.tsx/jsx 参照 -->
<head>
  <!-- 共通ヘッダー -->
  <meta charSet="utf-8"/>
  <meta name="viewport" content="width=device-width"/>
  <meta name="next-head-count" content="2"/>

  <!-- .css ファイルなど ->
  <noscript data-n-css=""></noscript>

  <!-- .js ファイルなど ->
</head>
<body> <!-- _document.tsx/jsx 参照 -->
<div id="__next">
  <!-- ここはクライアント側でレンダリングする -->
</div>
<script id="__NEXT_DATA__" type="application/json">{
  "props": {
    "pageProps": /* getServerSideProps の結果の JSON */
   }
   // ...
}
</script>
</body>
</html>

<div id="__next"> の中身以外はサーバー側で生成可能です。

生成方法

  1. 最初のステップとして next build します。
  2. 成果物のフォルダ(通常 .next フォルダ) から以下の3つのファイルが必要になります。
    1. BUILD_ID: ただのランダムなIDですが、データルート _next/data や アセット _next/static のパスに含まれるIDになります。(テキストファイル)
    2. build-manifest.json
      1. 各ページ内に含まれるスクリプトやCSSのパスが記述されているJSONファイル
    3. routes-manifest.json
      1. ルーティングに利用するパスとページの詳細が記述されているJSONファイル
  3. build-manifest.json を Go の構造体に読み取ります。
type BuildManifest struct {
	PolyfillFiles    []string            `json:"polyfillFiles"`
	LowPriorityFiles []string            `json:"lowPriorityFiles"`
	Pages            map[string][]string `json:"pages"`
}
  1. routes-manifest.json も Go の構造体に読み取ります。
type RouteManifest struct {
	StaticRoutes  []map[string]string `json:"staticRoutes"`
	DynamicRoutes []map[string]string `json:"dynamicRoutes"`
	DataRoutes    []map[string]string `json:"dataRoutes"`
}
  1. ルーティング

    1. Next.js のページのルーティングは StaticRoutes または DynamicRoutes に含まれます。

      1. 基本的にはページ名に変数がある場合 [pageId] はDynamicRoutes、それ以外は StaticRoutes に記述されています。
    2. StaticRoutes の場合、

      {
      	"page": "/",
      	"regex": "^/(?:/)?$",
      }
      

      こちらページが “/” なため、index.jsx (.tsx) 相当になります。正規表現も ^/$ にあたりますのでルートのページを示してます。 (?:/)? はパスの最後のスラッシュを無視しているだけです。全ての正規表現に含まれるので気にしないでいきましょう。

      • Go でこちらのルートをマッチする場合は、http.Request オブジェクトの RequestURIURL.Path 利用して上記の正規表現でマッチことが可能です。以下 net/http の URL構造体
      type URL struct {
      	//...
      	Path        string    // path (relative paths may omit leading slash)
      	//...
      }
      
    3. DynamicRoutes の場合、

      {
      	"page": "/help/[pageId]",
      	"regex": "^/help/([^/]+?)(?:/)?$",
      	"routeKeys": {
      		"nxtPpageId": "nxtPpageId"
      	},
      	"namedRegex": "^/help/(?<nxtPpageId>[^/]+?)(?:/)?$"
      }
      

      こちらも正規表現(2つも)が用意されているのでパターンマッチすればページのパラメータの pageId を取得することが可能です。 nxt という Prefix が付けられていますが、固定なため特に処理には影響ないと思います。

    4. 最後に dataRoutes の場合です。こちらは SSR 時には利用されず、クライアント側でのルーティングが発生するときに呼び出されます(本来の Next.js では getServerSideProps が呼ばれます)

      {
      	"page": "/",
      	"dataRouteRegex": "^/_next/data/BUILD_ID/index.json$"
      }
      

      こちらはトップページをクライアント側でルーティング時に呼び出されます(index.json)

      実際の中身は getServerSideProps の結果を一層上に pageProps としてラップした状態で返せばよいのです。

      {
        "pageProps": {
            /// getServerSideProps の結果のJSON
         },
         "__N_SSP": true
      }
      

      ここの __N_SSP は SSR の意味します。

      ここのルーティングはパース仕方としては /_next/data/BUILD_ID/ を省けば index.json に当たるので page の “/” とマッピングすればどのデータを渡せばよいかわかります。

      Echo フレームワークは場合、こんな感じでデータルートはハンドラーをかけます。

      e := echo.New()
      // ...
      
      dataHandler := func(c echo.Context) error {
         // Go での処理
      }
      
      e.GET("/_next/data/*", dataHandler)
      e.HEAD("/_next/data/*", dataHandler)
      

      SSGの場合、HTTP メソードの HEAD のみまたは HEAD / GET 両方のリクエスト来る場合があるので、どちらも対応します。

  2. ルーティングの情報が揃ったところで、サーバー側でHTMLを作成します。 _document.jsx (.tsx) の箇所は Go で処理することになります。

    1. DOCTYPE や <html>, <head> タグの生成 <!DOCTYPE html><html lang="ja"><head>
    2. buildManifest の poliFills を書き出す
    3. buildManifest の _app という名前のリストから css / js を書き出す(全てのページ共通)
    4. ルーティングで決まった該当ページのスクリプトを書き出す
    5. buildManifest の LowPriorityFiles を書き出す。(基本的に _buildManifest.js_ssgManifest.js 2つ、全ページ共通)
    6. </head> を閉じて、</head><body> HTML Body と __next という IDの DIVタグを空で書き出す。<div id="__next"></div>
    7. __NEXT_DATA__ を書き出す <script id="__NEXT_DATA__" type="application/json">
    8. script と HTMLを閉じて終わり </script></body></html>
  3. 後は React がクライアント側でいい感じに Hydration してくれて、ページが描画されます。

パフォマンス測定

以下の JSXの例。(本来の Next.js / Node.js の場合)

export async function getServerSideProps(context) {
    const res = await fetch('http://127.0.0.1:9090/q/index', {
        method: "POST"
    });
    const repo = await res.json();
    return {
        props: repo
    };
}

export default function Page(props) {
  return (
        <main className={`min-h-screen`}>
            <h1 className="text-3xl">Blogs</h1>
            <ul className="list-disc">
                {props.blogs.map(x => (
                    <li key={x.blogId}>{x.title}</li>
                ))}
            </ul>
        </main>
    )
}

Next.js のベンチマーク結果(約 630 RPS)

Running 10s test @ http://127.0.0.1:4040/
  9 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   307.34ms   64.73ms 662.65ms   87.52%
    Req/Sec    73.83     40.32   212.00     65.32%
  Latency Distribution
     50%  282.40ms
     75%  316.73ms
     90%  382.98ms
     99%  570.92ms
  6337 requests in 10.06s, 18.12MB read
Requests/sec:    629.91
Transfer/sec:      1.80MB

バックエンドだけパフォマンス (約 43万RPS)

Running 10s test @ http://127.0.0.1:9090/q/index
  9 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   511.70us  488.87us  42.75ms   87.86%
    Req/Sec    48.48k     4.75k  160.57k    97.47%
  Latency Distribution
     50%  423.00us
     75%  714.00us
     90%    1.05ms
     99%    1.86ms
  4378918 requests in 10.10s, 5.67GB read
Requests/sec: 433569.72
Transfer/sec:    575.16MB

Go で HTML レンダリングして返す場合のパフォマンス(約 39 万RPS)

Running 10s test @ http://127.0.0.1:9090/go/
  9 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   563.99us  484.10us  14.08ms   77.21%
    Req/Sec    43.79k     3.61k  132.80k    97.91%
  Latency Distribution
     50%  470.00us
     75%  793.00us
     90%    1.17ms
     99%    2.04ms
  3960649 requests in 10.10s, 6.37GB read
Requests/sec: 392169.99
Transfer/sec:    646.28MB

おまけに

今回 HTML のボディーのレンダリングはクライアント側で行っていますが、将来的には Rust や Go などでもできるようになればいいと思います。Next.js 内部で使っている Rust 製の swc とかはJSX / TSX をちゃんと理解していますし、実際変換できるじゃないかと思ってます。 例えば以下の JSX は 普通の JSに変更可能です。

export default function Page(props) {
  return (
        <main className={`min-h-screen`}>
            <h1 className="text-3xl">Blogs</h1>
            <ul className="list-disc">
                {props.blogs.map(x => (
                    <li key={x.blogId}>{x.title}</li>
                ))}
            </ul>
        </main>
    )
}

JS

export default function Page(props) {
    return React.createElement("main", {
        className: `min-h-screen`
    }, React.createElement("h1", {
        className: "text-3xl"
    }, "Blogs"), React.createElement("ul", {
        className: "list-disc"
    }, props.blogs.map((x) => React.createElement("li", {
            key: x.blogId
        }, x.title))));
}

相当のコードは Go でも記述可能です。

func Page(props *PageProps) string {
    return react.CreateElement("main", attrs{
        "className": "min-h-screen",
    },
        react.CreateElement("h1", attrs{
            "className": "text-3xl",
        }, "Blogs"),
        react.CreateElement("ul", attrs{
            "className": "list-disc",
        }, props.Blogs.Map(func(x *Blog) string {
                return react.CreateElement("li", attrs{
                    "key": x.BlogId,
                }, x.Title)
            }),
        ),
    )
}

今回はここまでとします。最後まで読んでいただきありがとうございました。

Discussion