サーバーサイドレンダリング、Isomorphic、Universal JavaScriptなどの言葉をよく見かけます。なるほどね、良さそうだね、外部公開するサービスを書くことがあったら挑戦してみたいね、Mithrilにもisomorphic-mithrilってのをがんばっている人がいるし、みたいなことを漠然と思っていたのですが、最近ASCII.jpのシステムコールプログラミングの連載を書いていて、あらためてHTTPの仕様を見返してみて、逆にサーバーサイドレンダリングをしない方がいいのではないか、と思い始めました。
追記(23:30): サーバーサイドレンダリングと書いていますがUniversal JavaScriptみたいな凝ったビューの更新の意味です。
サーバーサイドレンダリングの欠点
サーバーサイドレンダリングのメリットとしてあげられるのは次の2点です。
- 検索エンジンのクローラー向け
- 初回表示を早く
このうち、検索エンジンのクローラーですが、GoogleはJavaScriptを解釈し、またYahoo!はGoogleと同じ検索エンジンを使っているので、日本ではまったくもって気にする必要がない気がします。普通に考えて、「JavaScriptを解釈するクローラー」というのは、Phantom.jsのような仕組みを内部で持っていて実行されている気がする(JSエンジンとかDOMツリーとか自前実装するコストがムダ)し、仮にいまそうなってなくても、Fetch APIだ、WebWorkerだ、Service Workerの挙動のエミュレーションだとかひたすらダルいだけなので、将来的には絶対にそうなっていくと思われるので、検索エンジン向け対策はしなくていいかと思っています。
で、初回表示を早くというものですが、仮想DOMのレンダリングで作られるのはHTMLだけではなく、イベントハンドラが適切にリンクされたDOM構造であって、仮想DOMを書き出してブラウザに表示するだけでは意味がありません。Vue.jsも、結局はクライアント側で再度レンダリングしてあげないとダメだとのこと。これはMithrilだと、レンダリングプロセスの中でコンポーネントが使われていると、その中でコンストラクタが呼ばれて云々・・・とレンダリングが複雑な実行結果になることがあるのでなおさらです。
まとめると、サーバーサイドレンダリングでは、検索エンジンのクローラーが自前でやってくれることをやっているし、クライアントで上書きされてしまうコンテンツをせっせとサーバ側で作っている、ということになります。でもまぁ、これでも速度が高速になればエンジニアリング的には正義、ということになりますが、当初僕が考えていたよりも、費用対効果はだいぶ薄まるのではないかと思っています。
もちろん、超巨大フレームワークを使っている場合は、表示にはその巨大JSのダウンロードが必要になります。その巨大JSダウンロードせずにサーバ側でそのロジックだけ使って表示して、その結果のHTML(JSよりも遥かに小さい)を転送するだけで表示するなら効果はあります。Mithril.js 1とかVue.js 2とかサイズが小さいのをウリにしているフレームワークが増えているので、その前提で話をすすめます。
ということで、高速化に限定して別のソリューションを考えてみます。
SSRの別解(1): 通信内容を埋めこんでおく
最新の流行を先取りしたイケてる商品を扱う最新のECサイトを作っているとします。サーバリクエストするコンテンツだけ先に埋めこんでおきます。
var _initialData = [
{name: "うまい棒", price: 10},
{name: "キャベツ太郎", price: 20},
{name: "よっちゃんイカ", price: 30}
];
function loadPrices() {
if (_initialData) {
const deferred = m.deferred();
requestAnimationFrame(() => {
deferred.resolve(_initialData);
_initialData = null;
});
return deferred.promise;
} else {
return m.request({method: "GET", url: "//api.example.com/prices"});
}
}
初回のデータがあればそれを返します。クライアントからは差がないように、MithrilのサーバアクセスAPIと同じようにPromiseを返すようにしています。この方式はユニットテスト用に考えていたんですが、検索エンジンのクローラー問題がないのであれば、初回表示の高速化にも使えますよね。
いちおうレンダリング順序の問題ないようにrequestAnimationFrame()を使っていますが、ロジックを調整すればその場でresolveしちゃってもいいかもしれません。
この方式であれば、サーバリクエストが発生しないため、ロード後に表示されるまでの時間は無視できるレベルになるでしょう。そんでもって、サーバ側でも複雑なレンダリングロジックを実行する必要はなく、テンプレートエンジンでJSONをちょいっと入れてあげるだけで済みます。また、表示ロジックのJavaScriptを再利用する必要がなくなるため、Isomorphicもいらないし、Universalする必要もないです。ようするにサーバの言語はなんでもいいってことになります。
例外としては、クライアントの端末の速度がやたらに遅い時はダメ、というのはあります。この時はこの方法は使えません。あと、MithrilはJSコードがちっちゃいのでいいんですが、巨大なランタイムが必要なJSコードだとそこが再利用できなくなるので厳しいかもしれません。まあデータとコードをJSファイルを複数に分けちゃえばいいですかね。
SSRの別解(2): +chunked形式
HTTP/1.1にはchunked形式というものがあります。ASCII.jpのシステムプログラミングの連載でGoで実装してみた例を紹介しています。chunked形式のメリットとしては最終的なコンテンツを確定する前にレスポンスを返すことができます。
たいてい、レンダリングする時にはデータベースアクセスが必要です。で、結果が出揃ったら、Content-Lengthに生成されたHTMLのサイズを載っけて、クライアントへの転送を開始すると。クライアントへの転送を始める前に、レンダリングが終わってないとダメで、で、そのためにはデータベースへのアクセスが終わってないとダメと。
JSONをただ書いておく方法であれば、JS部とJSON部を別々に書いておくことができます。順序もどっちが先でもいい。非同期で処理して、やってしまおう、というのが次のコードです。読み込みが完了したデータからchunked形式で書き出しています。早く転送すれば早く終わります。転送処理は動かせないボトルネックなので最終的なパフォーマンスはサーバーサイドレンダリングよりも早くなるはず。インターネットが早くなり、モバイルが5Gとか先の未来になると、ファイルサイズ以上にTTLの方が支配的になってくるはずです。TTLは物理限界で決まってしまうため、転送を早く開始するのが大切ということは10年後も20年後も間違いなく意味があります。
func handleResponse(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
wg := new(sync.WaitGroup)
m := new(sync.Mutex)
wg.Add(2)
go func() {
var data = DBアクセスコード
m.Lock()
defer m.Unlock()
f.Fprintf(w, `var _initialData = `)
encoder := json.NewEncoder(w)
encoder.Encode(data);
f.Fprintf(w, `;\n`)
flusher.Flush()
wg.Done()
}
go func() {
jsSource, err := os.Open(JSコードのパス)
// エラーチェックが入る
m.Lock()
defer m.Unlock()
io.Copy(w, jsSource)
flusher.Flush()
wg.Done()
}
wg.Done()
}
DBへのアクセスが複数必要であればその分も並列にします。
SSRの別解(3): Server Push
HTTP2であればchunked形式じゃなくてServer Pushを使う方法も使えるでしょう。この場合は、クライアントからリクエストがあるのを見越してあらかじめデータを送りつけておく、という形式になるので、別解(1)のようなクライアント側の修正は不要です。また、JS部分、HTML部分ともにキャッシュできるので2回め以降のレンダリングはさらに早くできますね。このJSONはコンテンツ表示するのに絶対に必要なものなので、103 Early Hintsを使うまでもなく送りつけて問題ないでしょう。
Server Pushの起点はリクエストが速いHTMLのリクエストに対して行う方が良いでしょう。変更がない静的コンテンツだったとしても、リクエストが省略されてしまうとServer Pushできないため、Cache-Controlでstaleなキャッシュになるようにする必要があるでしょう。
プログラマブルにServer Pushを行うソリューションはまだまだこれからですが、Goは1.8からServer Pushができるようになります。これで大勝利まちがいなし。
結論
Universal JavaScriptのために「node.jsしかない」という状況は減るのでは、と思います。現時点で良さそうな機能が揃っているのはGo 1.8かなぁと思います。
サーバーとクライアントで同じJSコードを駆使してシングルページアプリケーションとサーバーサイドレンダリングの両立をがんばるぐらいなら、シングルページアプリケーションと、ページごとに別のビュー(テンプレート)が必要だけどイベントハンドラ問題は考えなくても良さそうなAMP対応にリソースを使ったほうがユーザメリットは大きいのは間違いないと思います。
僕がどっかの会社のCTOとか技術顧問なら、実装してサービス運用してからどやぁって書いてたかもしれないけど、部下もいない平サラリーマンなのでQiitaに書いておきます。
追記(18:00)
OGPの対応は必要ですよね、というブコメがついてました。はい。大事です。ただ、やることはAMP対応とほぼおなじかなと思います。
クライアントで描画する内容のうち、そのURLに紐づく固定コンテンツ、流動的なコンテンツと分けると、URLに紐づくコンテンツのみ対応が必要ですよね。ブログだったとして、「最新のエントリー一覧」とか「最新のコメント」とか「同月のエントリー」とかそういうのは流動的なコンテンツで、パーマリンク先に置かれているブログの記事そのものは固定コンテンツです。で、その固定コンテンツって入力された瞬間には決定されるので、事前にキャッシュしておける気がするんですよね。
AMPコンテンツの場合はURLもまったく別になるので話は簡単です、というかコンテンツそのものも静的に作ってCDNにでも上げておけばいいのかなーと。まあ最終的にまた別のGoogleのCDNに再コピーされるだけな気がするのでCDNじゃなくてもいいのか。OGPな内容の部分はその事前に作っておいたキャッシュをmetaタグのところに差し込んであげればいいのかなって思います。
追記(2/7)
SPAで作ったというブログが登場しました。OGPとJSON-LD対応しているのですが、初回にブラウザでアクセスしにいったときのURLのOGPの内容がハードコードされています。別ページに遷移するとメタタグの内容と、そのときに表示されるコンテンツの内容の不一致が生じます。ですがこれは問題になりません。別のページに遷移したとしてもURLはきちんと書き換わっていますし、SNSにシェアされるリンクは新しいページのものです。そしてTwitter/Facebookは新しいページのURLで情報を取得しにくるので、結果的に正しい内容のメタタグがSNSに渡ります。人間が読むものではないし、閲覧中は関係ないって割り切る作戦はありですね。