SSR から一部の Component を除外する必要が出てきたので、その経緯と現状の実装について説明します。
前提
- React, Redux の SPA で、SSR もしている
- CDN に Fastly を使っていて、Fastly で多くのレスポンスをキャッシュしている
- API サーバーは Rails で、Heroku で動いている
初回リクエスト時は SSR します。画面遷移する際には API を叩いて JSON を受け取り、SPA として画面遷移します。API レスポンスの JSON も、SSR した結果の HTML も Fastly がキャッシュします。
なぜ部分SSRが必要なのか
SSR した結果の HTML を Fastly がキャッシュしているわけですが、メインのコンテンツとなる記事本文とはライフサイクルの異なる Component(具体的には下図の「サイドバー」のような Component です)を含むページを丸ごとページキャッシュしてしまうと、更新頻度の高い Component が更新されるたびにキャッシュを purge することになり、キャッシュヒット率が下がってしまうので、SSR から除外して非同期で取得することにしました。1
このサイドバーには「人気の記事」といった感じでアクセスランキングが表示されているんですが、アクセスランキングは一時間に一回更新されるため、ページを丸ごとページキャッシュしてしまうと数千ある記事のキャッシュすべてを一時間に一回 purge することになり、purge した記事へアクセスがあると origin へのリクエストが走るのでキャッシュヒット率が下がってしまいます。サイドバーに表示しているリソースが更新されてもサイドバー以外の部分のキャッシュは purge したくないため、サイドバーは SSR から除外して、非同期で取得することにしました。
現状の実装
Layout の componentDidMount
で dispatch することにしました。componentDidMount
なので SSR では発火せず、hydrate の際に発火してサイドバーを表示するのに必要なリソースを非同期で取得します。SPA として画面遷移する際には発火しません。SPA として画面遷移する際にはメインのコンテンツの部分(上図における「記事本文」の部分です)だけが書き換わる仕様のためサイドバーの state を更新する必要はなく、これで問題ありません。
class Layout extends React.Component {
+ componentDidMount() {
+ this.props.fetchAside();
+ }
render() {
return (
<Fragment>
<Header />
<div className="main">
<Content />
<Aside />
</div>
</Fragment>
);
}
}
Layout.propTypes = {
fetchAside: PropTypes.func.isRequired,
};
import fetchResource from '../utils/fetchResource';
export default function fetchAside() {
return {
type: 'FETCH_ASIDE',
payload: fetchResource('/api/v1/async/aside'),
};
}
export default async function fetchResource(path) {
const response = await fetch(path);
return response.json();
}
Aside(サイドバー)側の Component は、store に Aside の state が存在しない場合は render で null を返すようにしていて、非同期処理で API を叩いて Redux による Aside の state の更新が済めば state が変化した結果、勝手に更新されてサイドバーが表示されます。
API サーバーは Rails で、こんな感じの Controller を作りました。サイドバーを表示するのに必要なリソースを JSON で返していて、この API のレスポンスも Fastly でキャッシュしています。
class AsyncController < ApplicationController
before_action :set_cache_control_headers
def aside
ranking = Ranking.new(requested_date).weekly(size: 5)
articles = Article.with_includes.find(ranking.map(&:id))
set_surrogate_key_header Article.table_key, articles.map(&:record_key)
render json: articles
end
end
ちなみに動作確認するにはブラウザの JavaScript をオフにして、非同期で取得しているサイドバーが表示されないことを確認すると良いと思います。
componentDidMount
は現代の DOMContentLoaded
この非同期処理の dispatch をどこですべきか悩みました。Vanilla JS であれば普通に実装すると DOMContentLoaded
で発火させるかなと思うんですが、一番外側のComponent(以下、Appと呼びます)の componentDidMount
は SPA における DOMContentLoaded
のようなものだから、App の componentDidMount
で呼んでも良かったかもしれないです。ただ、App ではなく Layout の componentDidMount
にしたのは、関心事として、サイドバーのリソースの取得というのは App 全体というよりは Layout の責務と見なすほうが適切かなという気がしました。
ただ、本当は非同期処理を開始するのに Mount を待つ必要はないはずで、hydrate が終わって Redux の store の構築さえ終わっていればすぐに dispatch して非同期処理を開始しても良いはずなので、厳密に言うと余計に長く待ってしまっているようにも思えます。もし、hydrate 完了時に発火する componentDidHydrate
のような Lifecycle メソッドがあったらそっちのほうが良いかなと思ったんですが、調べた限りではそれらしいものは見つかりませんでした。もっと良い方法があったらコメントとかで教えてください。
この件に感じる難しさ
SSR とは本来、CSR するとクローラが JS を解釈してくれない場合に SEO の都合で不利なのと、初回表示を高速化する目的で、サーバーサイドで JS を実行して完全な HTMLを組み立てて初回表示時のレスポンスではそれを返す、という話だったかと思います。それを Fastly でキャッシュすることも加味すると、ページ全体を SSR してしまうとキャッシュヒット率が下がるため、CDN でレスポンスをキャッシュする前提だと Full SSR ではダメで、やはり 部分SSR が必要になるかと思います。Fastly の登場によって Full SSR していて許される時代は終わったのかもしれないです。また、フロントエンドの設計なのに、サーバーサイドの知識だけではなくインフラの知識を必要とする点も難しいですね。この記事を書いていて思ったんですが、将来もし Fastly で JavaScript を実行できるようになって Fastly で SSR することが出来るようになれば、この辺のことは随分シンプルになるのかもしれないです。
補足: Fastly の ESI (Edge Side Includes)
この件を解決するために、ライフサイクルの異なる Component を非同期で表示するという以外にも Fastly の ESI (Edge Side Includes) を使うという手段もあるかと思います。
Using edge side includes (ESI) - Performance tuning | Fastly Help Guides
ESI とは Varnish の機能で、あらかじめ HTML の部品となるコード片を用意しておいて、CDN 側でそれを組み立ててレスポンスとしては完全な HTML を返してくれる機能です。今回の場合、サイドバーで表示しているリソースが SEO 上、重要ではないリソースだったので、非同期で取得するという設計で良いと考えました。これがもし SEO 的に重要なリソースだった場合、ESI を使うしか選択肢がないと思います。
ただ、僕は Varnish の奥深い機能にヘビーに依存するのは極力控えたほうがいいと思っていて、Varnish に深く依存してしまうと Nginx 上で Lua を動かして何かしらの処理をしている場合などと同様、会社組織として保守し続けていくのが難しくなる気がしていて、Varnish の機能に依存するのは極力控えて、アプリケーション側でハンドリングしたほうが良いんじゃないかなという気がしています。RDB においてストアドプロシージャの使用を避けるべきというのと似たような感覚かもしれません。
関連する記事
-
この問題を回避するために後述する Fastly の ESI を使うという選択肢もあるかと思います ↩