こんにちは、Web チームの井手です。今日は私たち Web チームが作っている SSR フレームワーク(以下 FW)にについて紹介します。
記事を書くモチベーション
私たちのメインプロダクトである日経電子版においては k2 という自作 SSR 基盤を 2020 年から運用しています。SSR FW といえば多くの方が Next.js を想起すると思いますが、私たちは自作しています。一方で最近は Next.js の進化が凄まじく、自作 SSR 勢としても意識せざるを得なくなっています。もしかしたら「あぁ Next.js で作っておけばよかった」と思う時が来るかもしれません。特に k2 を保守する際には Next.js では実現できない機能を実現するために様々な手法や工夫を編み出していましたが、Next.js の進化に伴ってその必要性はだんだん減ってきているのを実感しています。そのためいつか Next.js に置き換えるときが来るかもしれません。
そこで今日は私たちが
- どういうモチベーションで SSR FW を自作したのか
- どのように SSR FW を自作したのか
- Next.js に置き換えないのか
をスナップショットとして記録したいと思います。いつか置き換える日が来るとしても「あのときはああだったね」と懐かしめる記事を書けると嬉しいです。
現在どのような構成になっているのか
まず日経電子版のクライアントサイドにおける構成は以下の通りです。
CDN である Fastly で画像/動画を除く全てのリクエストを受け付けて, 裏にある Vessel という独自基盤の上に展開されたオリジンサーバーを利用しています。
このうちオリジンサーバーの設計は以下の通りになっています。
fastify をルーターにして、React を採用して SSR をしています。
では、この設計のモチベーションについて解説していきます。
自作のモチベーションや設計思想
欲しいのは React ではなく JSX
私たちが React を採用している理由は型がつくテンプレートエンジンが欲しかったからです。日経ではこれまで handlebars を使っていましたが、推論や型検査という点では不十分でした。一方で速度という面では express + handlebars でも問題はありませんでした。事実、Google I/O でも事例として紹介された 日経電子版を速くする のときは handlebars で実装されていました。
当時の構成は現代から見ると避けられるであろう古い設計ですが、速度やパフォーマンス面で問題にならなかったのは、HTML をエッジで返せばそれで十分に速く、それは現代でも通用するプラクティスだからです。Web 標準に忠実であるため、賞味期限の長い技術選定だったとも言えます。なのでこの HTML をどう作るかというところに日頃から私たちの焦点は当たっており、HTML 生成を開発体験良く行う手法が別にあるのではないかということでテンプレートエンジンとしての JSX に注目しました。つまり私たちは React ベースの SSR FW を作ろうとしていましたが、 JSX が必要であって React のランタイムを求めていたわけではありません。
React のランタイムをあまり求めないのは私たちのアプリケーション特性にあります。それは新聞という特性上、複雑なフォームなどを持つ必要がなく、状態管理の機構が不要だからです。React の嬉しさは状態と UI のマッピングを MVC などによる手続き的な方法ではなく、宣言的 UI による自動反映で比較的コストが低い形で実現できるところです。ところが我々はそもそもその状態管理を要件として必要としないので React は本来は不要ということになります。
ただ、実際の我々の FW では React も使えるようにしています。それは React のランタイム実装が不要とはいえメニューの開閉やリアルタイムに速報ニュースを取得する仕組みを入れたい場面もあり、React があると便利ではあるためです。最近は型検査や補完といった開発体験の向上やレビューや開発のしやすさを理由に React ベースのコンポーネントに置き換え始めてはいます。それでも React を不要とした思想はところどころでソースコードに残っており、例えば動的な振る舞いを実装したい場合は CustomElements を利用していました。どのように React を載せるかについては後半で解説します。
React を利用しなくてもEdge でキャッシュを返せば十分に早い
これも React のランタイムを必要としなかった理由ですが、ルーティングにおいて SPA 上で History API を使ったルーティングをしなくても、HTTP のナビゲーションリクエストでも十分に速いです。もし MPA ベースで HTML を返すことを避ける理由があるとすればその理由の一つに速度が挙げられますが、CDN の活用によりこのデメリットを我々は受けないという点でも HTML を返す設計の理由になっています。
一般的にナビゲーションリクエストはサーバーのルーティングで処理されてレスポンスは HTML で返ります。HTML の生成の裏には認証・DB へのアクセスなどがありコストも多く、HTML 組み立ての処理が全て終わらないと HTML を返せないので一つでも処理の遅い箇所があるとレスポンス自体が遅れてしまいます。一方で SPA でのルーティングは、ルーティングそれ自体はコンポーネントを切り替えて HistoryAPI で URL を更新するだけであって、クライアントで完結するためコストが低いです。そのため SPA の方がルーティング 自体 のコストは低く速いです。しかし Edge 上で生成済みの HTML コンテンツを返すことができれば、オリジンサーバーでのルーティングに紐づく処理が実行されずに済みます。そしてその方法は十分に速度が出ます。
上記の理由により、React 上でのルーティングではなくナビゲーションリクエストによるルーティングをしています。k2 は実質 SSR が可能で、React を動かして SPA Routing もできるようにしているのに関わらず、遷移はナビゲーションリクエストで行っています。
HTTPで取得するリソースの配置を全てコントロールしたい
SSR FW を自作するメリットには、HTTP で取得するリソースの配置を全てコントロールできるようになるという点もあります。例えば、画像・CSS などのサブリソースの取得は .resources/** に全て配置してそこから取るようにし、サブリソース専用の Varnish Configuration Language (以下 VCL) を書いて最適化できます。日経電子版はマイクロサービス構成かつ日経電子版以外にもさまざまなサービスが www.nikkei.com
というドメインに共存しており、Fastly Service が一つに集約されるため、特定の用途のリソースのパスを揃えておけば、その用途に応じた VCL をサービス間で共通化できます。
VCL の共通化という点以外にも監視という点で様々なリソースのパスを揃えておく利点があります。そのおかげでサービスを跨いで Kibana などのログ基盤で簡単に検索できます。
このように自分たちで規約を 0 から作れるのは自作の良いところです。一方で Next.js の場合は .next/ に様々なものが集約される規則や、遷移時に HTML ではなくチャンクの json を返す挙動のため、HTML を返すサービスで構成されるマイクロサービスを前提とした既存の CDN 設定との相性を考えるとあまりよくなかったです。
ただ現在の Next.js 環境下では、Link を使わないことで遷移時に必ず HTML を返させる工夫や、assetPrefix を駆使してリクエストパスのコントロールができるので、実は Next.js と我々の Fastly VCL を組み合わせる可能性も生まれています。その検討と実験については後述します。
SSR FW を自作する
上記のモチベーションの元、SSR FW を自作します。ここでは実装にあたっての要所要所を解説します。
JSX → HTML
ReactDOMServer には renderToString など JSX(正確には React Components, Elements) を HTML に変換する Node.js 向けの API が存在しています。
https://react.dev/reference/react-dom/server
例えばこのようにコンポーネントから HTML 文字列を得ることが可能です。
import { renderToString } from 'react-dom/server';
const html = renderToString(<App />);
そして ReactDOMServer にはこの stream 版が存在し、ストリーム処理でレンダリング可能なところから順次コンテンツを配信することもできます。
https://react.dev/reference/react-dom/server/renderToReadableStream
このような機能を Next.js や Remix が採用したことは記憶に新しいと思います。そして私たちも追従して利用しています。
Routing
どのようにしてクライアントサイドで React を動かしているのか見ていきましょう。
まず Node.js 上で fastify を利用して HTTP Routing を行っています。この Routing では取得した記事データを元に JSX から HTML を出力しています。ここでは Stream 版の API を使っています。
server.register(routeArticle)
export const routeArticle: FastifyPluginAsync = async (f, _) => {
f.get("/article/:id", async (req, res) => {
// ...
// データ取得処理など
const stream = renderToPipeableStream(content, {
onAllReady() {
resolve(stream);
},
onError(err) {
reject(err);
},
});
res.send(stream);
})
}
hydration
これら返した HTML を React から使うためには hydration と呼ばれるステップが必要です。具体的には hydrateRoot を使います。
import { hydrateRoot } from 'react-dom/client';
const domNode = document.getElementById('root');
const root = hydrateRoot(domNode, reactNode);
https://react.dev/reference/react-dom/client/hydrateRoot
Call
hydrateRoot
to “attach” React to existing HTML that was already rendered by React in a server environment.
とある通り、コンポーネントから SSR 時に作った HTML の上で React を動かせるようにしてくれます。
クライアントサイド向けのビルド
SSR した HTML 文字列やそれに対する hydration だけでは、コンポーネント内に定義した hooks を呼び出すことはできません。なのでそのようなクライアントのランタイム側の JS を、SSR した HTML から読み込ませる必要があります。
まずそのためにクライアントコード向けのビルド設定を作ります。k2 ではサーバーサイド側もバンドルしており、バンドル設定の webpack 設定をサーバーとクライアントで分離しています。
export const config: Array<webpack.Configuration> = [
createWebpackConfigForServer({
isForDevelopment: IS_BUILD_ENV_EQUAL_DEVELOPMENT,
entryPoint: SERVER_SRC_ENTRYPOINTS,
distServerDir: DIST_SERVER_DIR,
useSwc: USE_SWC,
}),
createWebpackConfigForBrowser(browserConfig),
];
これはそれぞれの config が server 用の entrypoint / dist, client 用の entrypoint / dist を持っています。つまりこの設定によって、webpack によるビルドを実行すると client 用のバンドル結果は指定した dist に吐かれることとなります。
クライアントでのバンドルの読み込み
あとは SSR する HTML からバンドル結果を読み込むだけです。そのために renderToPipeableStream で読み込むコンポーネントは <Shell />
というコンポーネントでラップするようにして、その Shell は
export const Shell: FunctionComponent<Readonly<ShellProps>> = ({
children,
scripts = [],
styles = [],
helmetElement = null,
headerElement = null,
footerElement = null,
ssrInjectedScripts,
...props
}) => {
const htmlTree = (
<html
lang="ja"
data-service-name={props.serviceName}
>
<head>
{appScriptList}
{styles.map((href, i) => (
<link
key={i}
rel="stylesheet"
href={href}
as="style"
/>
))}
{helmetElement}
</head>
<body>
{children}
<script
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: scriptLoader,
}}
/>
{ssrInjectedScripts}
</body>
</html>
);
return (
<ClientExposedContextProvider value={clientExposedContext}>
{htmlTree}
</ClientExposedContextProvider>
);
};
のように定義し、script を読み込めるようにしています。ここで webpack で吐き出した script を読み込むことでクライアントのランタイムで動いて欲しい JS を読み込んでいます。(実際のコードは script 読み込み箇所を複数に分けているので上記と異なっているのですが、概念的には上の通りです)
ナビゲーションのルーティング
基本的に私たちは SPA Routing ライブラリの Link タグではなく a タグを利用して遷移しています。というより日経LIVE以外の k2 上のサービスではそもそも SPA Routing ライブラリを利用していません。
一般的に SPA Routing を入れるとモノゴトが複雑になります。当時日経 LIVE は preact をランタイムとして採用していましたが、preact-router 起因による hydration mismatch や memory leak に悩まされていました。(当時の修正)HTML に頼らない以上 JS のエコシステムに頼ることとなり、それはバグも受け入れるということになります。FW を使わない以上はそのハンドリングを自分たちで行う必要があり、それは大変でした。
SPA routing を導入する場合は react-router のようなもので Route のコンテナと Route の Element で SSR 対象を囲むと実装できます。ただし fastify のようなサーバールーターとクライアントサイドルーターのルーティングが一致する必要はあるので、ルーティングは
export enum LivePath {
LiveTop = '/live',
LiveEventId = '/live/event/:id',
LiveEventIdWithLastSlash = '/live/event/:id/',
LiveEventIdSubPageType = '/live/event/:id/:subPageType',
LiveSearch = '/live/search',
}
のような enum などで管理しておくと良いです。
ただ、前半でも説明した通り、普通のナビゲーションリクエストを使っても CDN を使っている以上は速度面で遅くなりにくいので、Live 以外の k2 プロダクトでは a タグを利用して遷移しています。
Next.jsも使いたい
ここまで私たちが Next.js を使わずに SSR FW を自作していることについて書きましたが、一方で Web チームでは Nikkei Prime や 日経電子版 FOR OFFICE で Next.js を利用しています。
しかしここでもあまり Next.js のエコシステムには乗らず例えば Link コンポーネントや Image コンポーネントを使わずに開発しています。
Link を使わない
これは先の SPA Routing をしないと同じ理由です。
Image コンポーネントを使わない
Next.js には画像を最適化してくれる Image コンポーネントがありますが、私たちは imgix を使って最適化しているのでこの Image コンポーネントも採用していません。一応 Image コンポーネントにも imgix のローダー があって integration できますが、日経の場合は入稿の段階で imgix を想定しており、secure url を利用していたり(これは Next.js 側でサポートされていない)、表示されて欲しいサイズや想定されているアスペクト比が API から記事情報に含まれて返ってくるので、自分たちで最適な picture コンポーネントを自作しています。
export const Picture: ComponentType<PictureProps> = (props) => {
const { image, sources, isWatermark, credit } = createReactPictureData(props);
return (
<div
className={cx(
pictureStyles.picture,
isWatermark && pictureStyles.watermark,
fullSizeOfContainer && pictureStyles.fullWidth,
props.className
)}
style={style}
>
<picture
className={cx(pictureStyles.content, fullSizeOfContainer && pictureStyles.fullSize)}
>
{sources.map(({ srcSet, media }, i) => (
<source
srcSet={srcSet}
media={media}
key={i}
/>
))}
<img
className={cx(
pictureStyles.image,
fullSizeOfContainer && pictureStyles.fullSize
)}
loading={loadingAttribute}
src={image.url}
alt={image.alt}
/>
</picture>
{credit ? <span className={cx(pictureStyles.credit)}>{credit}</span> : null}
</div>
);
};
こういった独自コンポーネントがすでに存在しているので Next.js の機能に頼らず、自前で画像の最適化をしています。
ビルドエコシステムとしての期待
このように Next.js の機能を封じてまで Next.js を使っていますが、それは Next.js に対してビルドツールとしての期待があるからです。k2 基盤では webpack を利用していますが、正直なところ求められる知識の範囲も深さもあり、我々は 20 人体制で開発していますがビルド設定は触れる人が限られる状況になってしまいました。一方で Next.js はこの辺りの設定をほとんど zero config で実現できます。その楽に設定ができるツールとしての PoC も兼ねて Next.js を採用しましたがその目論見は今のところ当たっています。
そのため Web チームではビルドツールとして Next.js を採用する機会が増えていくかもしれません。また既存の CDN を通さなくていいサービスであれば Next.js の機能をフルに使う場面も来るかもしれません。ただ、いま k2 として運用しているものは、k2 の技術スタックでパフォーマンスや開発体験の側面で特に問題は起きていないので Next.js で置き換えるということは計画していません。ただし、エコシステムに大きな変動があって k2 の保守が難しくなるなどした際はもちろん検討候補に上がるとは予想しており、Web チームでも Next.js の知見や経験値を貯めています。
いま私たち Web チームでは自作 SSR を支えてくれる Ops 寄りのフロントエンドエンジニアの採用も継続しつつ、Next.js などの便利な FW やエコシステムの力を駆使してアプリケーション開発を推進するリーダーを募集しています。もし興味がございましたらカジュアル面談に申し込んでいただけると嬉しいです。
よろしくお願いします。