React使いの元フロントエンドエンジニアです。
表題の件、わかるようでわからないな〜と100億年ぐらい思っていましたが、Webアプリのサーバー構成について学んでようやく理解しました。
ので周辺知識も含めてまとめてみます。
Webアプリにおける典型的なHTTPリクエストのライフサイクルをおさらい
ライフサイクル=クライアントのブラウザからサーバーにリクエストが送られ、サーバーからクライアントのブラウザにレスポンスを返すまでのこと。
以下はそれをステップバイステップでまとめたもの:
サーバー
└ 特定のポートでリクエストを待ち受けている
↓
クライアント
└ サーバーのホスト名とパスに向けて、HTTPリクエストを送る
↓
サーバー
└ Webアプリケーションフレームワーク(Node.jsならExpressなど)
└ HTTPリクエストを受け取るソケットでHTTPリクエストを受け取る
└ リクエストオブジェクト と レスポンスオブジェクト を生成し、フレームワークのコンポーネント(ディスパッチャーと呼ばれる)に渡す
└ ディスパッチャーが、リクエストオブジェクトに書き込まれているパスとメソッドを元に、アプリケーションコードのどのハンドラーを呼び出すか決める
↓
└ アプリケーションコード(開発者が書いたコード)
└ 指定されたハンドラーが呼び出されて実行される(DBに問い合わせる等)
└ (クライアントにJSONを返すとしたら)レスポンスオブジェクトにJSONを渡し、ステータスコードやヘッダーフィールドに情報を書き込む(200、Content-Length=... Content-Type=... 等)
↓
└ Webアプリケーションフレームワーク
└ レスポンスオブジェクトに書き込まれた情報をソケットに書き込み、クライアントに返す
↓
クライアント
└ HTTPレスポンスを受け取り、返ってきたコンテンツを表示したり利用する
Webアプリの構成の歴史を辿る
第1世代:トラディショナルなMPA(SSR)
DBなどから取得したデータをサーバー側でHTMLに成形して、クライアントに返す方法。
当時はSSRという単語はなかったであろうが、サーバー側でHTMLが生成されるという点でこれはSSRである。
クライアントから見ると、静的なHTMLが返ってきているのか動的に生成されたHTMLが返ってきているのか、区別はつかない。
ページが表示されるまでの流れ
クライアント
└ ブラウザでページにアクセスしたタイミングやページリロードされたタイミングで、サーバーのホスト名とパスに向けてHTTPリクエストを送る
↓
サーバー
└ Webアプリケーションフレームワーク
└ HTTPリクエストを受け取る
└ リクエストオブジェクトとレスポンスオブジェクトを生成し、ディスパッチャーに渡す
└ ディスパッチャーが、リクエストオブジェクトに書き込まれているパスとメソッドを元に、アプリケーションコードのどのハンドラーを呼び出すか決める
↓
└ アプリケーションコード
└ 指定されたハンドラーが呼び出されて実行される
└ (DB問い合わせハンドラーが呼び出されたとしたら)DBに問い合わせる
↓
DBサーバー
└ 問い合わせられてデータを取得される
↓
サーバー
└ アプリケーションコード
└ 取得したデータからHTMLを生成する
└ レスポンスオブジェクトに生成したHTMLを渡し、ステータスコードやフィールドに情報を書き込む
↓
└ Webアプリケーションフレームワーク
└ レスポンスオブジェクトに書き込まれた情報をソケットに書き込み、クライアントに返す
↓
クライアント
└ 完成したHTMLを含むHTTPレスポンスを受け取り、画面を丸ごと更新する
より一般的な構成
上記で「サーバー」とまとめている部分について、より一般的にはおそらく、Webサーバー(Apache等)とAPサーバー(Expressはここに置く)に分離する。
以下はこの構成の場合でページが表示されるまでの流れ:
クライアント
└ 略
↓
Webサーバー
└ HTTPリクエストを受け取る
└ 適当な処理と判断を経て、APサーバーにリクエストを転送する(Webサーバーはリバースプロキシの役割を持っていると言える)(転送にはHTTP通信やFastCGIが使われる)
↓
APサーバー
└ リクエストを受け取り、どのハンドラーを呼び出すか決める
└ 指定されたハンドラーが実行される
↓
DBサーバー
└ 問い合わせられてデータを取得される
↓
APサーバー
└ 取得したデータからHTMLを生成する
└ 完成したHTMLをWebサーバーに返す
↓
Webサーバー
└ レスポンスオブジェクトに完成したHTMLを渡し……
以下略
第2世代:Ajaxの登場
クライアント上でも動的にHTMLを更新できるようになる。
- ブラウザでページにアクセスしたタイミングでなくとも、任意のタイミングでクライアントのブラウザ上でJSを実行し、サーバーにHTTPリクエストを送ることができる
- サーバーからのレスポンスを使って画面の一部だけをJSで更新して表示することができる。画面を書き換える=画面を丸ごと更新する、ではなくなったということ
ページが表示されるまでの流れ
クライアント
└ まずは、ブラウザでページにアクセスしたタイミングやページリロードされたタイミングで、サーバーのホスト名とパスに向けてHTTPリクエストを送る
↓
Webサーバー
└ HTTPリクエストを受け取る
└ 適当な処理をして半完成したHTMLをクライアントに返す(これが土台のHTMLになる。SSRで動的生成されたHTMLである場合もあるし、完全に静的なHTMLである場合もある)
↓
クライアント
└ HTTPレスポンスを受け取り、画面を表示する
└ HTMLを解析し、<script>タグを見つける
└ <script>タグのsrc属性に設定されたURL宛にHTTPリクエストを送る
└ このURLはJSファイルを置いてあるサーバーのホスト名とパス。FE(フロントエンド)用として独立して立てておくのが一般的?
↓
FEサーバー
└ HTTPリクエストを受け取る
└ 指定されたJSファイル(FEサーバー上でビルド済みの静的ファイル)をクライアントに返す
↓
クライアント
└ HTTPレスポンスを受け取り、JSファイルを認識する
└ ブラウザ上でJSが実行され、HTMLの一部が書き換えられたりイベントハンドラの付与が起こる
└ 土台のHTMLに含まれていない動的データが画面表示に必要な場合、その部分はこの時点ではまだ表示できていない
└ JSにfetch関数がある場合はここで発火し、指定されたURL宛にHTTPリクエストを送る(動的データの取得処理開始)
↓
Webサーバー
└ HTTPリクエストを受け取る
└ 適当な処理と判断を経て、APサーバーにリクエストを転送する
↓
APサーバー(FEと対比してBEサーバーと呼んでも)
└ リクエストを受け取り、どのハンドラーを呼び出すか決める
└ 指定されたハンドラーが実行される
↓
DBサーバー
└ 問い合わせられてデータを取得される
↓
APサーバー
└ 取得したデータを元にJSONを成形する
└ JSONをWebサーバーに返す
↓
Webサーバー
└ HTTPレスポンスを成形してクライアントに返す
↓
クライアント
└ JSONを含むHTTPレスポンスを受け取る
└ JSでHTMLを加工して画面を描画する
└ 動的データが画面に表示され、画面が完成した
第3世代:SPA
第2世代と仕組みは似ているが、HTMLの生成はJSを使ってほとんどクライアント側で行われる。
クライアント側でHTMLが生成されるのでCSRである。
土台となるHTMLはほぼ空で、どのページでもそれを使い(画面遷移で違うHTMLファイルを読み込むことはしない)、JSがそれにがりがり更新を加えまくって画面を更新しているイメージ。
Router(ブラウザのHistory APIのラップ)でブラウザのアドレスバーも書き換えて、画面遷移しているように見せている。
ページが表示されるまでの流れ
クライアント
└ まずは、ブラウザでページにアクセスしたタイミングやページリロードされたタイミングで、サーバーのホスト名とパスに向けてHTTPリクエストを送る
↓
Webサーバー
└ HTTPリクエストを受け取る
└ 適当な処理をして空のHTMLをクライアントに返す(<script>タグは書いてある)
↓
クライアント
└ HTTPレスポンスを受け取り、画面を表示する(ただしこの時点では空)
└ HTMLを解析し、<script>タグを見つける
└ <script>タグのsrc属性に設定されたURL宛にHTTPリクエストを送る
└ このURLはJSファイルを置いてあるサーバーのホスト名とパス
↓
FEサーバー
└ HTTPリクエストを受け取る
└ 指定されたJSファイルをクライアントに返す
└ この時点で、このWebアプリに必要なJSファイルが全てクライアントに渡される。往々にして大量 1
↓
クライアント
└ HTTPレスポンスを受け取り、JSファイルを認識する
└ ブラウザ上でJSが実行され、HTMLのレンダリングが完了する(イベントハンドラの付与も同時に完了している)
└ その画面の静的な部分はこの時点で表示される
└ 動的データが画面表示に必要な場合、その部分はこの時点ではまだ表示できていない
└ JSのfetch関数がここで発火し、指定されたURL宛にHTTPリクエストを送る(動的データの取得処理開始)
↓
Webサーバー
└ HTTPリクエストを受け取る
└ 適当な処理と判断を経て、APサーバーにリクエストを転送する
↓
APサーバー
└ リクエストを受け取り、どのハンドラーを呼び出すか決める
└ 指定されたハンドラーが実行される
↓
DBサーバー
└ 問い合わせられてデータを取得される
↓
APサーバー
└ 取得したデータを元にJSONを成形する
└ JSONをWebサーバーに返す
↓
Webサーバー
└ HTTPレスポンスを成形してクライアントに返す
↓
クライアント
└ JSONを含むHTTPレスポンスを受け取る
└ JSでHTMLを加工して画面を描画する
└ 動的データが画面に表示され、画面が完成した
SPA(CSR)のメリット
ページ遷移が高速。
JSが全てクライアント側にDLされており、ページ遷移の際にはページ遷移に見せかけた画面描画をJSががりがり実行するだけなので、サーバーへのリクエストが発生しないため。
ただし、遷移後の画面で動的データが必要な場合、それを表示するにはJSのfetch関数等によるAPサーバーへのリクエストが必要。
SPA(CSR)のデメリット
初回のページ表示に時間がかかる。
大量のJSファイルが全てDLされて、JSが実行されてHTMLを生成するまで画面に何も表示できない(空のHTMLしか表示されない)ため。
さらに、そのページで動的データが必要な場合は、そこからもう一度HTTPリクエスト-レスポンスの往復をする必要があり、更に時間がかかる。
第3.5世代:SPAにSSRが組み合わさる
SPAの恩恵をキープしつつ、初回のページ表示を速くするために編み出された方法。
Reactの場合はNext.jsによって実現可能。
ページが表示されるまでの流れ
クライアント
└ まずは、ブラウザでページにアクセスしたタイミングやページリロードされたタイミングで、サーバーのホスト名とパスに向けてHTTPリクエストを送る
↓
FEサーバー(Next.jsアプリ)
└ HTTPリクエストを受け取る
└ Next.jsのルーティング機構が、リクエストで指定されたパスに対応するReactコンポーネントを特定する
└ ReactがJSとして実行され、画面のレンダリングが始まる
└ 動的データが必要な場合はここで取得処理が始まる
└ JSのfetch関数が発火し、指定されたURL宛にHTTPリクエストを送る
↓
Webサーバー
└ HTTPリクエストを受け取る
└ 適当な処理と判断を経て、APサーバーにリクエストを転送する
↓
APサーバー
└ リクエストを受け取り、どのハンドラーを呼び出すか決める
└ 指定されたハンドラーが実行される
↓
DBサーバー
└ 問い合わせられてデータを取得される
↓
APサーバー
└ 取得したデータを元にJSONを成形する
└ JSONをWebサーバーに返す
↓
Webサーバー
└ HTTPレスポンスを成形してFEサーバーに返す
↓
FEサーバー(Next.jsアプリ)
└ JSONを含むHTTPレスポンスを受け取る
└ JSによる画面のレンダリングが完了
└ 完成したHTMLをクライアントに返す
↓
クライアント
└ 完成したHTMLを含むHTTPレスポンスを受け取り、画面を表示する
└ 動的データを含めて画面の見た目は完成している
└ ただし、この時点ではJSが必要な画面操作はまだできない。JSファイルがブラウザに届いていないため
└ HTMLを解析し、<script>タグを見つける
└ <script>タグのsrc属性に設定されたURL宛にHTTPリクエストを送る
└ このURLはJSファイルを置いてあるサーバーのホスト名とパス
↓
FEサーバー(Next.jsアプリ)
└ HTTPリクエストを受け取る
└ 指定されたJSファイルをクライアントに返す2
↓
クライアント
└ HTTPレスポンスを受け取り、JSファイルを認識する
└ ブラウザ上でJSが実行され、ハイドレーション(すでにレンダリングされているHTMLにイベントを付与すること)が起こる
└ 画面操作もできるようになり、画面が完成した
ちなみに:SPA+SSRのページ遷移(Next.jsの場合)
アプリ内のリンク(Linkコンポーネント)をクリックすることによるページ遷移の場合、SPAと同じく、画面のフルリロードではなくJSによる画面の描画でページが更新される。ただし、動的データの取得はクライアントから直接APサーバーに取りに行くのではなく、FEサーバー(Next.jsアプリ)が取得してきて、その結果をクライアントに返す。3
この記事の流れのフォーマットに当てはめると以下:
クライアント(Linkクリック)
↓
FEサーバー(Next.js)
↓
Webサーバー → APサーバー → DBサーバー → APサーバー → Webサーバー
↓
FEサーバー(Next.js)
└ 遷移先ページの描画結果(HTMLではなくRSC Payload)を返す
↓
クライアント
└ Reactが差分だけ画面を更新(リロードもハイドレーションもなし)
SPA vs SPA+SSR in a nutshell
上図の通り、ユーザーから見た体感のページ表示速度は、SPA+SSRが圧倒的に速い(SPA(3) vs SPA+SSR(1))。
ただし、ハイドレーションまで全て完了するまでの速度は、SPA+SSRの方が遅いこともある、らしい。
おわりに、という名のdisclaimer
サーバー構成はいろんなパターンが考えられます。
例えば、Webサーバーの役割とAPサーバーの役割を一つのサーバー内で構築したり、FEサーバーの前にもリバースプロキシとしてWebサーバーをかませたり。
また、ロードバランサーやファイアウォールを挟む構成が実際のプロダクション環境では一般的だと思います。
この記事では単純化・省略していることをご承知おきください。
References
-
ここで全JSをDLしないようにもできます。Vite等のバンドラーを使う+特定のページにしか必要ないJSに関しては動的インポート(lazy loading)されるような実装をする方法です。そう実装されているJSファイルは、そのJSが関係するページへのリンクがブラウザのビューポートに入ったタイミングやそのリンクが実際に押下されてナビゲーションが起こったタイミングでDLされるようになります。これはコードスプリッティング(code splitting)と呼ばれます。 see: Features: Build Optimizations - Vite ↩
-
Next.jsはルートに基づいてコードスプリッティングをデフォルトでしてくれるので、全JSがこのタイミングでDLされることはありません。 see: Prefetching - Next.js ↩
