30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

動的コンテンツのキャッシュを最適化するプッシュ型アーキテクチャ

エッジサーバからのレスポンスは速い。
コンテンツを CDN のエッジサーバにキャッシュしてそれを返すようにするだけで、ウェブサイトの速度は目に見えて改善される。
特に、リクエストの度にサーバで動的に生成されるコンテンツの場合、キャッシュを利用することで大きな恩恵を受けられる。パフォーマンスが改善されるだけでなく、オリジンサーバの負荷軽減にもつながる。

しかしコンテンツを動的に生成するということは、リクエストの度に生成されるコンテンツが変わる可能性があるということであり、キャッシュを利用するのが難しい。全てのリクエストに対して同じコンテンツが生成されるのであれば、わざわざリクエストの度に生成する必要はないからだ。事前にコンテンツを用意しておいてそれを返せばよい。ビルド時にコンテンツを生成する SSG(Static Site Generation)などがその一例。
リクエストの度にコンテンツが変化する可能性がある以上、安易にキャッシュすることはできない。事前にキャッシュされたコンテンツを返してしまった場合、それは本来提供すべきだったコンテンツとは異なっている可能性がある。
キャッシュの生存期間を長くすればするほど、古いコンテンツがいつまでもクライアントに提供され続けてしまうリスクが高まる。

サーバサイドレンダリング(以下、SSR)も、リクエストの度にサーバでコンテンツを生成しそれを返す手法である。
何らかの理由でリクエストに先立ってコンテンツを用意するのが難しく、それでいてクライアントサイドレンダリング(以下、CSR)も採用できない、あるいはしたくない場合に、SSR が採用される。
SSR においても、エッジサーバにキャッシュさせることで高速化や負荷軽減のメリットを享受できる。特に SSR が返すのは HTML であり、メリットが大きい。HTML はウェブページを表示するための起点であり、クライアントは HTML の内容に基づいて、サブリソース(画像やスタイルシートなど)へのリクエストを開始する。HTML の取得が遅れれば遅れるほど後続のリクエストの開始が遅れるため、HTML を高速に返せる意義は大きい。
しかし既に述べたように、キャッシュを使うということは古いコンテンツを返してしまう恐れがあるということであり、最新のデータに基づいた HTML を返せるという SSR のメリットが損なわれてしまう。しかも先程述べたように HTML の内容に基づいてサブリソースへのリクエストが行われるため、HTML の内容が古かった場合、現在は存在していないサブリソースにリクエストを送ってしまう可能性もある。

つまり、動的コンテンツこそキャッシュによる恩恵が大きいが、動的だからこそキャッシュすることが難しい、というジレンマがある。
できるだけキャッシュの生存期間を長くして再利用性を高めたいが、鮮度の落ちたコンテンツの提供は避けたい、というジレンマはキャッシュ全般が抱えていることだが、動的コンテンツではそれがより顕著になる。

Edge Worker とそれによって操作可能なキーバリューストアを使うことで、このジレンマを解決できる可能性がある、というのがこの記事の主題。
最初に明確にしておくが、「素晴らしいアーキテクチャを思い付いた、みんなも使おう!」という話ではない。
Edge Worker を使えばこれまでとは違ったキャッシュ設計が可能になるかもしれない、という実験のようなものである。

だが Edge Worker によって設計の可能性が広がるのは事実だと思うし、できるだけ多くのコンテンツをキャッシュすることがパフォーマンス上重要であることも間違いない。

まずアーキテクチャの概要を説明し、その後、サンプルアプリを使って具体例を示す。

コンテンツの更新をオリジンサーバからエッジサーバにプッシュする

このアーキテクチャはオリジナルのアイディアではなく、mizchi さんが以下のスライドで提案されている内容が元になっている。

光を超えるためのフロントエンドアーキテクチャ - Speaker Deck

この発表内容に興味があり、理解を深めるために簡単なものでいいから自分で実装してみよう、というのがそもそもの出発点。もちろん本稿の内容は私なりの解釈であり、mizchi さんが主張したかった内容とは乖離している可能性がある。また、スライドではキャッシュのパージを行っているが、本稿ではパージするのではなくキャッシュ内容の更新を行っている。

前述の通り、常に最新のコンテンツを返せるというのが、SSR のメリットである。リクエストの度にコンテンツを生成することで、これを実現させている。
だがよく考えてみると、必ずしも毎回コンテンツを生成する必要はない。前回の生成結果とは異なるコンテンツが生成されるときにのみ、再生成を行えばよいはずである。

例えば、リクエスト時の曜日に基づいたコンテンツを返すウェブページがあったとする。リクエストを受け取ったサーバは、曜日に基づいてページのレンダリングを行い、それをクライアントに返す。月曜日にアクセスすれば「月曜日:可燃ごみ」のようなコンテンツを返し、火曜日にアクセスすれば「火曜日:資源ごみ」のようなコンテンツを返す。
コンテンツを動的に生成しているわけだが、リクエストの度に毎回生成する必要は全くない。一度コンテンツを生成したら、あとはそれをキャッシュさせ、日付が変わるまではそのキャッシュを使い続ければよい。そして日付が変わるタイミングでキャッシュの生存期間が切れるようにしておけば、日付が変わったタイミングでまたコンテンツの生成が行われる。

このように、生成されるコンテンツの内容が変わるタイミングが予め分かっていれば、動的に生成されるコンテンツであってもキャッシュするのはそれほど難しくない。
では、変化するタイミングを事前に予測できないコンテンツの場合は、どうすればよいのか。例えば、ブログサービス。記事の編集はユーザーによって任意のタイミングで行われるから、予測できない。1 時間後に内容が大きく書き換わるかもしれないし、1 年以上更新されないかもしれない。このようなコンテンツにおいて、「コンテンツの新鮮さ」と「キャッシュの効率的な利用」を両立させるにはどうしたらよいのか。

コンテンツを変化させる要素をモニタリングして、その要素が変化したときにコンテンツの再生成とキャッシュの更新を行うことで、両立できる。これが、本稿で紹介するアーキテクチャの基本的な発想である。

「コンテンツを変化させる要素」が何であるかは、コンテンツによって異なる。ブログ記事で言えば、記事の本文や著者名、コメントなどが該当するだろう。
これらが変化した場合、生成されるブログ記事も変化する。だが変化していない場合は、何度リクエストしても、同じブログ記事が生成される。つまり、最新のコンテンツを返すために毎回生成を行う必要はなく、記事の本文、著者名、コメントが変更されたときにのみ、コンテンツの生成を行えばよいことになる。
話を簡単にするために「本文」に限って話を進めると、データベースに保存されている本文が更新されたらコンテンツの生成を行うコードを書いておく。そしてさらに、新しく生成したコンテンツをエッジサーバに送信するコードも書いておく。こうすることで、動的なコンテンツでも効率的にキャッシュを利用することが可能になる。

従来のキャッシュの仕組みがプル型であるのに対して、このアーキテクチャはプッシュ型だと言える。

f:id:numb_86:20210707124846p:plain:w650

プル型の問題点は、キャッシュ更新のためのアクションは常にエッジサーバが起点になるため、オリジンサーバでコンテンツが更新されたとしても、それをエッジサーバに伝える術がないことにある。エッジサーバが問い合わせてくれないと、新しいコンテンツを渡せない。つまり、エッジサーバのキャッシュの生存期間が切れるまで、古いコンテンツが提供され続けることになる。そのため、コンテンツの新鮮さを保とうとすると、キャッシュの生存期間を極端に短くしたり、そもそもキャッシュの利用を断念したりすることになる。その結果、オリジンサーバで不必要にコンテンツの生成が繰り返されることになる。

プッシュ型では、この問題点は解決される。コンテンツが更新されたらそれをオリジンサーバがエッジサーバに渡すため、古いコンテンツが提供され続ける、という事態を防ぐことができる。エッジサーバの立場からすると、コンテンツが更新されたらオリジンサーバがそれを教えてくれるわけだから、鮮度について何も気にすることなくキャッシュを配信し続ければよい。そのためキャッシュの生存期間を長く設定することが可能になり、キャッシュの効率性が高まる。

問題は、新しく生成されたコンテンツをどうやってエッジサーバに送信するのかだが、本稿では Cloudflare Workers KV を使ってそれを実現させている。
Cloudflare でなくても、キーバリューストアを扱える Edge Worker なら同じことができると思う。

Cloudflare Workers KV を使った実装

ここからは、サンプルアプリを使って具体的な内容を見ていく。

コードも公開しておいた。

github.com

話の性質上ローカル環境で試しても意味はないため、このアプリをどこかにデプロイし、Cloudflare 経由で配信する必要がある。私の場合は Heroku にデプロイして実験していた。Heroku + Cloudflare + Deno の環境構築については、以前記事を書いた。

numb86-tech.hatenablog.com

使用している技術の概要をまず示しておくと、Deno で SSR している React アプリである。
このアプリを Cloudflare 経由で配信しており、Cloudflare Workers を動かしている。そして Cloudflare Workers KV に値を保存し、それをキャッシュとして使っている。
これらの技術についてもいくつか記事を書いた。

numb86-tech.hatenablog.com

numb86-tech.hatenablog.com

numb86-tech.hatenablog.com

また、このサンプルアプリはエッジサーバへのキャッシュについてのみ扱っており、クライアントでのキャッシュについては一切考慮していない。
あくまでも本稿で紹介しているアーキテクチャを説明するためのアプリであり、無関係な要素は極力省いている。データベースも使わず、テキストファイルを読み書きして代用している。

アプリの仕様

App ページ(パスは/)と Admin ページ(パスは/admin)の 2 つのページがある。
そして、App ページには大量のアクセスがあるため、上手くキャッシュを利用したいと考えている。Admin ページは管理者用のページであるため、パフォーマンスや負荷対策については取り敢えず考えなくよいとする。

App ページは動的なコンテンツであり、状況によって返されるコンテンツが変化する。具体的には、商品の在庫の状況によって変化する。

在庫が十分にある場合。

f:id:numb_86:20210706202747p:plain:w400

在庫が残り僅かである場合。

f:id:numb_86:20210706202800p:plain:w400

在庫が存在しない場合。

f:id:numb_86:20210706202813p:plain:w400

この 3 パターンがある。

在庫は豊富にあるので当面は「十分にあるパターン」をキャッシュしてそれを提供し続けたい。だが在庫が一定の数以下になったタイミングでキャッシュを更新し、残り僅かである旨を表示させたい。そして在庫がなくなったら、それを示すコンテンツを表示させるようにしたい。

先ほど説明したアーキテクチャを採用することで、このニーズに応えることができる。
そのためにまず、「コンテンツを変化させる要素」を明確にする必要がある。次に、その要素が変化したらコンテンツを再生成し、それをエッジサーバに送るようにする。

今回のサンプルアプリの場合、「在庫の状態」が「コンテンツを変化させる要素」に該当する。在庫数そのものではないことに注意する。在庫数が1000から999に変化したとしても、在庫数は十分にあると見做され、生成されるコンテンツは変わらない。「在庫の状態」はコードのなかでinventoryStateという名前で呼称している。そして以下のロジックでinventoryStateを決定している。

  • 在庫数が 3 以上
    • 在庫が十分にあると見做しinventoryState"ENOUGH"にする
  • 在庫数が 1 か 2
    • 在庫が残り僅かだと見做しinventoryState"LITTLE"にする
  • 在庫なし
    • inventoryState"NONE"にする

このロジックはgetInventoryStateという関数に書いてある。
動作確認しやすくするために小さな数を採用しているが、現実のプロダクトではもっと大きな数を採用するだろう。

あとは、inventoryStateの変化をモニタリングして、必要に応じてコンテンツ(App ページ)の再生成とエッジサーバへの送信を行えばよい。

先程書いたように、inventoryStateは在庫数によって決まるので、在庫数が変化する場所にコードを仕込んでおけばよい。具体的には、App ページのBuyボタンを押下したときの処理と、Admin ページで在庫数を操作したときの処理に、仕込む。
それらの処理で在庫数を変更したあとに、最新の在庫を元にしたinventoryStategetInventoryStateで取得する。そしてその値を、前回のコンテンツ生成時のinventoryStateと比較する。変化していない場合、コンテンツを再生成してもまた同じコンテンツが生まれるだけで意味がない。そのため、そのまま処理を終える。変化していた場合、ユーザーに提供すべきコンテンツが変化したことを意味するので、再生成する必要がある。そして再生成した結果を、エッジサーバに送信する。

既に軽く説明したが、「エッジサーバへの送信」は Cloudflare Workers KV を使って実現している。
具体的には、公開されている API をオリジンサーバから叩いて、Cloudflare Workers KV に書き込みを行っている。サンプルアプリでいうとsendAppPageHtmlToKv関数のなかで、その処理を行っている。
そして Cloudflare Workers でリクエストを制御し、/へのリクエストはオリジンサーバに問い合わせるのではなく、Cloudflare Workers KV から値を取り出してそれをクライアントにレスポンスするようにしている。Cloudflare Workers で動かすスクリプトは/workersディレクトリに入れてある。

一連の処理をsynchronizeKvValueという関数で行っており、これを商品購入時、そして Admin ページで在庫数を操作した時に、呼び出すようにしている。

図で示すと以下のようになる。

f:id:numb_86:20210707120208p:plain:w900

Cloudflare はデフォルトでは HTML ファイルをキャッシュしないので、キャッシュの存在を考慮する必要はなく、キーバリューストアにデータがあればそれを使い、なければオリジンサーバに問い合わせればよい。また、JavaScript ファイルや画像は自動的にキャッシュされるので、これらのファイルについては明示的な操作や指定はしていない。

Cloudflare Workers KV は書き込み時に生存期間を設定できる。このアーキテクチャではコンテンツが更新された際にオリジンサーバがプッシュしてくれるため、理屈上は生存期間を無期限にしても問題ない。とはいえ人間はミスをするし、何らかのイレギュラーが発生する可能性は十分に考えられるので、生存期間を設定しておいたほうがよいだろう。
サンプルアプリでは動作確認のために生存期間を60秒に設定しているが(TTL_OF_APP_PAGE_HTML_KV)、このアーキテクチャの効果を最大化するためにはもっと長くしたほうがいい。キャッシュの鮮度を基本的には気にしなくてよい、というのがこのアーキテクチャの肝なのだから。

Cloudflare Workers KV は結果整合性なので、そこは注意する。

KV achieves this performance by being eventually-consistent. Changes are immediately visible in the edge location at which they're made, but may take up to 60 seconds to propagate to all other edge locations.

https://developers.cloudflare.com/workers/learning/how-kv-works

つまり、KV に保存されたデータが実際に使われるようになるまで、多少の時間がかかる。自分が検証していた際は、10 秒程度の遅れが発生していた。
これを許容できないケースでは、この仕組みは使えない。というよりそういったケースでは、CDN へのキャッシュ自体が使えないだろう。キャッシュは行わずに SSR か CSR を利用することになるはず。
今回のサンプルアプリにおいては、購入処理はボタンを押下したタイミングで行われるため、ページの内容が在庫の状態を正確に反映していなくてもクリティカルな問題にはならないと判断している。

Cloudflare Workers KV と Cache API の比較

Cloudflare Workers には KV の他に Cache API も用意されており、これを使うとキャッシュを操作することができる。なぜ素直にこれを使わずに、KV をキャッシュとして使うという方法を選択したのか。

単純に、Cache API には書き込みを行うための API がないためである。Cloudflare Workers スクリプトではキャッシュへの書き込みが可能だが、外部から操作するための API は用意されていない。パージを行うための API は用意されているので、それを使うことはできる。

また、永続性の問題がある。一般論として、キャッシュは生存期間が切れるまでは必ず存在する、というわけではない。使用頻度が低いキャッシュは削除されてしまう可能性がある。
Cloudflare のエッジサーバがどのような仕組みになっているのかは分からないが、キャッシュは永続性が保証されていない可能性がある。その点 KV は、予め設定しておいた生存期間が切れるか、明示的に削除するまでは、存在が保証される。

また、KV は各エッジサーバで共有されるグローバルな値だが、キャッシュは各エッジサーバに存在するローカルな値である。これも、何か違いを生むかもしれない。

Cache API のほうが優れている点ももちろんある。
Cache API はキャッシュという目的のために用意された仕組みであり、それを利用できるのはメリットである。KV は汎用的なキーバリューストアであり、キャッシュのために用意されたものではない。そのため、どのように使うのか自分で設計を考え、そして実装しなければならない。よく言えば自由であり柔軟な設定が可能になるのだが、それはそのままデメリットでもある。

プッシュ型アーキテクチャと ISR(SWR)の比較

キャッシュを利用してレスポンスを高速化しつつコンテンツの変化にも対応した手法として、ISR(Incremental Static Regeneration)がある。
ISR は要は SWR(Stale While Revalidate)であり、クライアントに対してはキャッシュを返しつつ、バックグラウンドでオリジンサーバへの問い合わせを行って最新のコンテンツを取得してキャッシュし、次のアクセスに備えるという仕組みである。これにより、レスポンスの速さとコンテンツの新鮮さをバランス良く両立させている。

だが SWR はエッジサーバがオリジンサーバに問い合わせるというプル型の仕組みであり、コンテンツの内容が変化したかどうかとは無関係にオリジンサーバへの問い合わせが発生し、コンテンツの再生成が行われる。そのため、オリジンサーバの負荷軽減は期待できない。
コンテンツの生成を最小限に抑えることができるプッシュ型アーキテクチャとはそこが異なる。

Cloudflare Workers KV をキャッシュとして使うことの欠点

既に述べたように結果整合性であり、強整合性が求められるケースでは使えない。

そして最大の欠点が、Cache API との比較でも触れたように、キャッシュのための仕組みを自分で作らないといけないということ。
既存の仕組みから降りることで柔軟さを得られるが、既存の仕組みが提供していた便利な機能を使えなくなる。その結果、今までは意識せずに済んでいた様々な問題に、自分で対処しなければならなくなるかもしれない。

例えば Cloudflare では、Set-Cookieフィールドが含まれているレスポンスはキャッシュしない仕組みになっている。設定を変えることでキャッシュさせることもできるが、その場合はSet-Cookieフィールドを削除した上でキャッシュする。そのため、Cookie がエッジサーバ上にキャッシュされてしまうことはない。

numb86-tech.hatenablog.com

しかし KV にはそのような仕組みはなく、開発者自らが対処しなければならない。
他にも、Cache-Controlとして何を設定するべきなのかなど、考えなければならない要素は数多く存在すると思う。

また、KV をキャッシュとして使うのなら、既存のキャッシュとの競合や協調について考えないといけない。予めこういったことを考慮して設計しないと、値の二重管理のようになってしまう可能性がある。
既に紹介した以下の記事では、その点にも触れている。

Cloudflare Workers KV をキャッシュとして使う - 30歳からのプログラミング

Deno で 学ぶ React のサーバサイドレンダリング

Deno で React のサーバサイドレンダリング(以下、SSR)を実現する方法をハンズオン形式で書いていく。
自分が調べた範囲では、単に JSX で HTML を構築して終わり、という記事が多かった。それではあまり実用的ではないので、この記事ではハイドレーションまで行う。

また、React で SSR する方法を調べたところ、ほとんどの記事が Next.js を前提としていた。確かに Next.js を使わずに SSR するケースはあまりないだろうし、記事としても需要がないのだと思う。
しかし、Next.js のようなフレームワークが裏側で何をやってくれているのかを知ることで、SSR に対する理解を深めることができる。
事実、私は SSR をほとんど使ったことがなかったが、この記事を書くことでかなり考えを整理することができた。

Deno のバージョンは1.11.2で動作確認している。

前置き

予め断っておくと、TypeScript で書くことは断念した。理由は以下の通り。

まず前提として、React の本体をインポートすることが簡単ではない。
Deno では、import React from "https://jspm.dev/react@17.0.2"のように、CDN からインポートすることで npm パッケージを使うことができる。しかし多くの npm パッケージは、Deno で動かすことを想定していない。そのため、インポートしようとしても、クラッシュしてしまうことが多い。
React も、skypackesm.shといった CDN からインポートしようとしたが、上手くいかなかった。jspm.devからバージョン17.0.2をインポートすることで、ようやく動いた。

しかし TypeScript で書くためにはそれだけでは不十分で、型ファイルも手に入れなければならない。これもライブラリ本体と同じ問題を抱えており、既存の.d.tsファイルを使おうとしても、Deno はそれを理解できずにクラッシュしてしまう。Deno で動く React の型定義が有志によって提供されているのだが、そのバージョンは16.13.1であり、17.xは見つけられなかった。
それならばライブラリの本体を16.13.1にするという手もあるが、これも上手くいかない。jspm.devから16.13.1をインポートした場合、Hooks を使うとクラッシュする。dev.jspm.ioからインポートした16.13.1なら動きそうだったが、dev.jspm.io今月いっぱいで終了する予定なので、選択肢から除外した。

そのため最終的には、jspm.devが提供している17.0.2を TypeScript なしで使う、という結論になった。

TypeScript を利用できない時点で、Deno の持っている魅力が大きく損なわれることになる。
だがそれでも、ビルドツールを弄る必要がなく、Node.js の複雑なモジュールシステムから解放されるメリットは大きい。
個人的な感想だが、Deno は全体的にシンプルで、実験や検証のための環境を簡単に構築できるのがよい。そのため、Node.js よりも取り回しがよい印象を持っている。

最も単純なパターン

前向きも終わったので、早速コードを書いていく。

まずはサーバを立てる必要がある。
JSX を扱うためには拡張子を.tsx.jsxにする必要があるが、前述の通り TypeScript は使えないので、.jsx一択となる。
以下の内容のserver.jsxを書く。

import { listenAndServe } from "https://deno.land/std@0.99.0/http/mod.ts";
import React from "https://jspm.dev/react@17.0.2";
import ReactDOMServer from "https://jspm.dev/react-dom@17.0.2/server";

function App() {
  return <div>Hello SSR</div>;
}

listenAndServe({ port: 8080 }, (req) => {
  req.respond({
    status: 200,
    headers: new Headers({
      "Content-Type": "text/html",
    }),
    body: ReactDOMServer.renderToString(
      <html>
        <head></head>
        <body>
          <div id="app">
            <App />
          </div>
        </body>
      </html>
    ),
  });
});

$ deno run --watch --allow-net server.jsxを実行しhttp://localhost:8080/にアクセスすると、Hello SSRと表示される。

これだけでも SSR と呼ぶことはできるが、これではただ単に HTML を書いているのとほとんど変わらない。そのためここから、より「現実的な」内容に書き換えていく。
まずは、React の機能を使ってインタラクティブなページにする。

ページをインタラクティブにする

AppコンポーネントをApp.jsxとして別ファイルに切り出す。そして以下の内容にする。

import React, { useState } from "https://jspm.dev/react@17.0.2";

export function App() {
  const [count, setCount] = useState(0);

  const onClick = () => {
    setCount((currentCount) => currentCount + 1);
  };

  return (
    <div>
      {count}
      <br />
      <button type="button" onClick={onClick}>
        count up
      </button>
    </div>
  );
}

これでページにアクセスすると、0やボタンの描画は上手くいっているのだが、ボタンを押下しても何の反応もない。
これは当然で、サーバはただ単に<div>0<br/><button type="button">count up</button></div>という HTML(ただの文字列)を返しているだけであり、JavaScript が存在しないのだからインタラクティブになるわけがない。
そのため、サーバが返す HTML にscript要素を追加して、ブラウザに JavaScript を読み込ませる必要がある。

JavaScript ファイルの作成

ブラウザに読み込ませたい JavaScript ファイルを、client.jsという名前で作成する。

import React from "https://jspm.dev/react@17.0.2";
import ReactDOM from "https://jspm.dev/react-dom@17.0.2";

import { App } from "./App.jsx";

ReactDOM.hydrate(<App />, document.getElementById("app"));

ここでハイドレーションすることで、サーバサイドで予め描画された HTML を、JavaScript と紐付けている。こうすると、ただの文字列だったAppコンポーネントが、インタラクティブなものになる。

client.jsはそのままではブラウザで読み込めないので、$ deno bundle client.jsx bundle.jsを実行してビルドする。生成されたbundle.jsは、ブラウザで読み込むことができる。

あとは、server.jsxを書き換えて、ブラウザにbundle.jsを読み込ませればよい。
以下が、変更後のserver.jsx。ルーティングの実装と、HTML のなかにscript要素を追加したことが、主な変更点。

import { listenAndServe } from "https://deno.land/std@0.99.0/http/mod.ts";
import React from "https://jspm.dev/react@17.0.2";
import ReactDOMServer from "https://jspm.dev/react-dom@17.0.2/server";

import { App } from "./App.jsx";

const BUNDLE_JS_FILE_URL = "/bundle.js";

const js = await Deno.readFile(`.${BUNDLE_JS_FILE_URL}`);

listenAndServe({ port: 8080 }, (req) => {
  switch (true) {
    case req.url === "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: ReactDOMServer.renderToString(
          <html>
            <head></head>
            <body>
              <div id="app">
                <App />
              </div>
              <script type="module" src={BUNDLE_JS_FILE_URL}></script>
            </body>
          </html>
        ),
      });
      break;
    }
    case req.url === BUNDLE_JS_FILE_URL: {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/javascript",
        }),
        body: js,
      });
      break;
    }
    default: {
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
    }
  }
});

$ deno run --watch --allow-net --allow-read server.jsxでサーバを起動しページにアクセスすると、ボタンが動作するようになっている。

ただこのやり方だと、App.jsxに変更がある度にその都度client.jsをビルドしないといけない。これでは不便なので、server.jsxのなかでビルドが行われるようにする。具体的には、以下のようにする。

    case req.url === BUNDLE_JS_FILE_URL: {
      const js = await Deno.emit("./client.jsx", { bundle: "module" }).then(
        (res) => {
          return res.files["deno:///bundle.js"];
        }
      );
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/javascript",
        }),
        body: js,
      });
      break;
    }

ブラウザから/bundle.jsにアクセスがある度に、Deno.emitでビルドし、その結果を取り出してレスポンスボディとして使っている。
ページをリロードする度にビルドされるので、開発環境ではこちらのやり方のほうがよいはず。本番環境では事前にビルドさせておくべきなので、環境変数を使って処理を分けるのがよいと思う。
また、Deno.emitは unstable な機能なので、--unstableフラグが必要になる。具体的には、$ deno run --allow-net --allow-read --unstable server.jsxでサーバを起動させる必要がある。

アクセスの度に動的に HTML を生成する

これでページがインタラクティブになった。だがこれでもやはり、SSR にする意味はほとんどない。クライアントからアクセスがあった際に動的に HTML を作りそれを返してこそ、SSR にする意味がある。そのためこれから、そのような状況を擬似的に再現していく。

なぜアクセスがある度に HTML を生成するのか。それは、リクエストに先立ってコンテンツを用意することが難しいからだ。
例えば、人によってコンテンツの内容が変わるケースがこれに該当する。ログインしているか否かで見せるべきコンテンツが変わったり、マイページのようにユーザー毎に内容が変わったり。また、コンテンツが可変である場合も、事前にコンテンツを用意することが難しくなる。ブログ記事の「コメント」や「いいね」は時間の経過によって変化していくわけだが、アクセスがあったタイミングでの最新の状態を返す必要がある。そのため、事前にビルドしておく、ということが難しい。
リクエストに先立ってコンテンツを用意できるのであれば、わざわざ SSR する必要性は薄い。SSG のように事前にビルドしておけばよいし、内容によっては自分で HTML ファイルを書いたってよい。
アクセスの度にコンテンツを用意する場合、SSR を使わずクライアントサイドレンダリング(以下、CSR)だけで済ませるという選択肢もある。だが CSR の場合、クライアントで API へのアクセスや UI の構築を行うため、ページの初回表示が遅くなりやすい。検索エンジンや SNS のクローラーがコンテンツを正しく読み込んでくれない、という問題もある。これらを許容できない場合に、SSR の導入が有力な選択肢となってくる。

これから作っていくサンプルも、リクエストに先立ってコンテンツを用意することが何らかの要因で出来ず、アクセスの度にコンテンツを生成する状況を想定している。そして同じく何らかの要因で、SSR が採用された。

server.jsxに手を加えて、簡易的な API を作る。/api/で始まる API にアクセスすると、食品のアンケートに関するデータを JSON 形式で取得できる。
DUMMY_DBという名前を付けているように、実際にはデータベースにアクセスしてデータを取得することを想定している。あるいは、外部 API を叩いていると見做してもよい。そのため、実際にはこれらの値は固定値ではなく、随時変化していく。

const DUMMY_DB = new Map([
  ["potato", { name: "potato", like: 10, dislike: 0 }],
  ["carrot", { name: "carrot", like: 6, dislike: 4 }],
  ["tomato", { name: "tomato", like: 3, dislike: 7 }],
]);

// 中略

    case /^\/api\//.test(req.url): {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "application/json",
        }),
        body: JSON.stringify(DUMMY_DB.get(req.url.slice(5))),
      });
      break;
    }

まずは API の動作確認も兼ねて、CSR だけで UI を構築する。
App.jsxを以下のようにすると、ボタンを押下する度に API から情報を取得し、それを元に UI を作り変えるようになる。

import React, { useState, useEffect } from "https://jspm.dev/react@17.0.2";

export function App() {
  const [food, setFood] = useState(null);

  const onClick = (name) => {
    fetch(`/api/${name}`)
      .then((res) => res.json())
      .then((data) => setFood(data));
  };

  useEffect(() => {
    fetch("/api/potato")
      .then((res) => res.json())
      .then((data) => setFood(data));
  }, []);

  return (
    <div>
      {food && (
        <p>
          name: {food.name}
          <br />
          like: {food.like}
          <br />
          dislike: {food.dislike}
        </p>
      )}
      <p>
        <button type="button" onClick={() => onClick("potato")}>
          potato
        </button>{" "}
        <button type="button" onClick={() => onClick("carrot")}>
          carrot
        </button>{" "}
        <button type="button" onClick={() => onClick("tomato")}>
          tomato
        </button>
      </p>
    </div>
  );
}

きちんと動作していることを確認できる。

f:id:numb_86:20210627160616g:plain

ここからいよいよ、動的に HTML を作っていく。
今は、常に同じ HTML が返される。そしてAppコンポーネントのマウント後にuseEffectが実行され、/api/potatoからデータを取ってきている。まずボタンだけが描画されデータは時間差で描画されるのは、このためである。
これを、サーバサイドで予めpotatoのデータを取得して、それを元に HTML を生成して返すようにする。そうすると、データベースの最新の値が反映された HTML がサーバから返されることになり、アクセスのタイミングによって HTML の内容が変化することになる。

App.jsxからuseEffectを削除して、props.initialFoodを受け取るようにする。そしてそれをfoodの初期値にする。

-export function App() {
-  const [food, setFood] = useState(null);
+export function App({ initialFood }) {
+  const [food, setFood] = useState(initialFood);

   const onClick = (name) => {
     fetch(`/api/${name}`)
@@ -9,12 +9,6 @@
       .then((data) => setFood(data));
   };

-  useEffect(() => {
-    fetch("/api/potato")
-      .then((res) => res.json())
-      .then((data) => setFood(data));
-  }, []);
-

そして、HTML を生成する際にデータベース(DUMMY_DB)からデータを取得し、それをAppに渡すようにする。

    case req.url === "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: ReactDOMServer.renderToString(
          <html>
            <head></head>
            <body>
              <div id="app">
                <App initialFood={DUMMY_DB.get("potato")} />
              </div>
              <script type="module" src={BUNDLE_JS_FILE_URL}></script>
            </body>
          </html>
        ),
      });
      break;
    }

これで、potatoの最新のデータが埋め込まれた HTML が生成され、それがブラウザに渡されるようになる。
しかし動作確認してみると、初期表示は確かに意図したものになっているが、すぐに消えてしまう。

f:id:numb_86:20210627160721g:plain

これは、ハイドレーションする際にAppコンポーネントにprops.initialFoodを渡していないために発生している。
ページアクセス時に発生している具体的な処理の流れは、以下の通り。

  1. potatoの情報を含んだ HTML がサーバサイドで構築され、それがブラウザに渡される
  2. ブラウザはその HTML を表示するため、potatoのデータが表示される
  3. ブラウザはbundle.jsclient.jsをビルドしたもの)を読み込む
  4. bundle.js<App />をハイドレーションする
  5. initialFoodが渡されていないので、foodundefinedになり、potatoに関する表示が消えてしまう

つまり、ブラウザで読み込まれる JavaScript ファイル(bundle.js、ビルド前はclient.js)においても、AppコンポーネントにinitialFoodを渡す必要がある。
だがinitialFoodはサーバサイドで作られるため、ブラウザで実行される JavaScript は、その内容を知ることができない。
そこで、initialFoodとして使われるデータを HTML に埋め込んでおくことで、この問題に対処する。

server.jsxの HTML 生成部分を、以下のように書き換える。
新しくscript要素を作成し、そのdata-json属性に、potatoのデータを埋め込んでいる。これで、initialFoodとして使われているpotatoのデータをブラウザに渡せるようになる。

    case req.url === "/": {
      const initialFood = DUMMY_DB.get("potato");
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: ReactDOMServer.renderToString(
          <html>
            <head></head>
            <body>
              <div id="app">
                <App initialFood={initialFood} />
              </div>
              <script
                id="initial-food"
                type="text/plain"
                data-json={JSON.stringify(initialFood)}
              ></script>
              <script type="module" src={BUNDLE_JS_FILE_URL}></script>
            </body>
          </html>
        ),
      });
      break;
    }

そしてclient.jsで、その埋め込まれたデータを HTML から取り出してAppコンポーネントに渡せばよい。

import React from "https://jspm.dev/react@17.0.2";
import ReactDOM from "https://jspm.dev/react-dom@17.0.2";

import { App } from "./App.jsx";

const initialFood = JSON.parse(
  document.getElementById("initial-food").getAttribute("data-json")
);

ReactDOM.hydrate(
  <App initialFood={initialFood} />,
  document.getElementById("app")
);

これで、意図した通りに動くようになる。

f:id:numb_86:20210627160806g:plain

まとめ

整理すると、ハイドレーションも含む SSR を実現するためには、以下の要素を実現する必要がある。

  • 動的なデータをコンポーネントに渡した上で、それ使って HTML を構築する
  • ハイドレーションするためのスクリプトをビルドし、HTML に挿入しておく
  • HTML を構築する際に使ったデータをハイドレーションでも利用するため、そのデータを(JavaScript から利用可能な形で)HTML に埋め込んでおく

これらをどのように実現しているかはフレームワークによって異なるだろうが、基本的な考え方や仕組みは、このようなものだと思われる。

参考資料