9月から入社したフューチャーアーキテクトのアドベントカレンダーのエントリーです。技術的にはウェブフロントエンドとGolangあたりです。
シングルページアプリケーションを数年前に試してみて、やりたい表現はこれで十分できるし、過去大変だったことも大分解消されましたのを感じました。一方でSEOとかOGPとかいくつかそのまま実現できないものがあります。とはいえ、それの解消のためにサーバーサイドレンダリングをするのは実装の手間が大変です。そこで、設計時に考えなきゃいけないことが増えます。ウェブアプリケーション側でその手の考慮&実装をいっさいせずに、今時のウェブアプリケーションでやった方がいいことを実現できる方法について考えました
・・・と思って準備しておいたのですが、@R548さんがDMM.comさんのアドベントカレンダーに書かれてしまった内容と一部かぶります。合わせてお読みいただくと、理解が深まると思います。
プロキシとして実装する
最初は、OGPとか全部をまるっと面倒見てくれる、今時のウェブが簡単につくれるウェブアプリケーションフレームワークをGoで作ろうかと思ったのですが、どうもシンプルに実現するのは簡単ではないですし、URLごとにたくさんのミドルウェア(Node.jsのExpress的な意味)を稼働させて・・・となりがちです。
どうせミドルウェアとしてフィルタのように実装するのであれば、完全に切り分けてプロキシ・サーバーにしてしまえば、ウェブの実装がGoでもRubyでもPHPでもなんでも、同じように使えるんじゃないかと。ちょうど、Chromeが最新版でHeadlessモードを実装してくれましたので、サーバーで頑張るのではなくて、ブラウザを裏で動かして、クライアントが表示しているのと同じような結果をサーバーに埋め込んでしまう方法を考えました。
そこでGoでサーバーサイドレンダリングを代行するプロキシサーバを試しに実装してみました。
まずはDockerでheadlessのchromeを起動します。
$ docker pull knqz/chrome-headless
$ docker run -d -p 9222:9222 --rm --name chrome-headless knqz/chrome-headless
次に、Goのコードを取ってきてビルドします。本当はdocker化しようと思っていたので、go gettableにはなっていません。
$ git clone git@github.com:shibukawa/ssrproxy.git
$ cd ssrproxy/ssrproxyserver
$ go build
$ ./ssrproxyserver
これでプロキシーサーバーが立ち上がります。
なお、サーバーは起動時にカレントディレクトリの config.toml
というファイルを読み込みます。
現在は https://shibukawa.github.io/demo/
をバックエンドのサーバーとして設定しています。
このプロキシーサーバーは、バックエンドサーバーに対してリクエストを送り、指定されたDOMの階層に、クライアントで生成するコンテンツ、あるいはOGPを設定して返すようになっています。これでサーバーサイドレンダリングの実装は不要ですね!サーバー側へのコード変更はゼロです。プロキシーを設定して前段に置けばOKです。
なお、プリレンダーするにはDockerに閉じ込めたChromeのプロセスから読み込まないといけないため、localhostで起動しているWebサーバをバックエンドにしたい場合は、 http://192.168.1.3
とか、マシンのIPを指定します。localhostとか127.0.0.1ではダメです。
実行結果
https://shibukawa.github.io/demo/ が返すHTMLはこんな感じです。
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mithril Sample</title>
<link href="styles.css" rel="stylesheet" />
</head>
<body>
<main id="root">original text</div>
<script src="index.js"></script>
</body>
</html>
Mithrilを使って、 <main>
タグのところにいろいろ表示するようになっています。
こんな感じでOGPのタグとか、Mithrilで生成したコンテンツが埋め込まれるようになっています。まあできるかどうかの実証のためのもので、OGPの内容とかかなり雑で、修正は必要です。
<!DOCTYPE html><html><head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Mithril Sample</title>
<link href="styles.css" rel="stylesheet"/>
<meta property="og:type" content="artcle"/>
<meta content="Hello Worldthis is rendered by Mithrilbutton" name="description"/>
<meta content="summary" name="twitter:card"/>
<meta content="" name="twitter:site"/>
<meta content="Mithril Sample" property="og:title"/>
<meta content="https:/example.com" property="og:image"/>
:
</head>
<body>
<main id="root">
<div>
<h1 class="title">Hello World</h1><p>this is rendered by Mithril</p>
<button>button</button>
</div>
</main>
</body>
</html>
課題とか改善とか
この実装は雑なので、完璧ではありません。いくつか課題とその見通しについてまとめてみます。
遅延ロード&更新が速いコンテンツ
スクロールすると先のコンテンツを表示するような、フロー型のウェブサイトとは相性は良くないと思います。キャッシュすることで高速化を図ろうとしていますが、更新が速いとその効果も見込めず、毎回Headless Chromeのお世話になってしまいます。ようするにTwitterみたいなサイトとかですね。
どこが原因か分からないですが、Headless Chromeは今のところ複数インスタンス起動できない見たいです。当初はプロセスプールのような実装にしていたのですが、2個目以降がもれなくエラーに。
サーバーサイドレンダリングする時だけ、ちょっと多めのコンテンツをあらかじめレンダリングしておく、といったクライアント時とちょっと違う実装が必要でしょう。
WebComponents
WebComponentsも、サーバーサイドレンダリングと相性が良くないものです。ノードの登録、レンダリング時の挙動はJavaScriptで制御されます。つまり、JavaScriptがないと正しい結果が得られない。
Google Chromeチームが提供している、Headless ChromeをDockerで動かすというRendertronでは、ShadowDOMに対応していないブラウザ向けに準備されたPolyfill(ShadyDOM)を強制稼働させるようにして、素のDOM/CSSとして表示させるという方法を使っています。
ただ、サーバーサイドレンダリングの目的がSEOであれば、スタイルとかはどうでもいいので、とりあえずコンテンツを適当な(divタグでもいいので)表示してあげる、という方法でいいのかもしれません。タグが他の外部のタグと連動して動作するような、ちょっとおかしな実装がされてない限りは、そのタグと子供の要素(子タグと属性)だけでレンダリング結果が決定されるはず。タグを検索してシンプルに置換してあげればいいと思います。あるいは、タグを置換して公式な表示にするWebComponents実装をそのままサーバーで使っている言語に移植する、とかですかね。日経みたいな広告で使っているとかであればそもそも置換も必要ないかもしれません。コンテンツを表示するためのものか、編集用の機能追加であってSEO不要なのか、広告のようにそのページのコンテンツではない、という使われ方によって作戦も変わってくるでしょう。
描画完了待ち
今は固定時間待つという雑な実装になっています。今回使ったheadless chromeを操作するgithub.com/knq/chromedpというライブラリは、デバッガー用のプロトコルを通じてChromeの操作をします。今回はタイトルと指定されたDOMのinnerHTMLの取得だけですが、JavaScriptのコードを実行したり、値を取得してきたり、いろいろできます。
Rendertronでは、次の変数の更新を待って取得するようになっています。これはやってもいい気がします。
window.renderComplete = true;
そもそもJavaScriptが重すぎる
サーバーサイドレンダリングしようとも、JavaScriptがでかすぎてロードに時間がかかると、表示されてから操作可能になるまでに時間がかかってしまいます。ユーザーというのは操作のフィードバックを見て使い方を学習していきますので、この「操作できない時間」の間の操作は、誤学習を引き起こします。個人的にはこれはかなり違和感を感じる部分なので、そもそもJavaScriptが重いのであれば、逆にサーバーサイドレンダリングしない方がいいのでは、と思っています。
その場合の方針としては、シングルページアプリケーションの動作に必要なREST API一覧をあらかじめサーバー側で把握しておいて、あらかじめサーバープッシュで送りつける、といったことが考えられます。これで意味のある表示と操作可能になるタイミングが同時になります。ユーザーが間違って学習することがなくなります。SEOが目的?それだったらユーザーエージェントが検索エンジンのクローラーのときだけサーバーサイドレンダリングする、でいいんじゃないですかね。OGPとかもSNSのクローラーから読み込まれる時だけでもいいかなという気持ちもあります。
OGPの内容を正しく設定/AMP
今は雑にHTMLのtext()で取得したものが説明に入っていたりしますし、画像も特に手当してませんが、きちんと意味のあるコンテンツを取り出せるようにしたり、画像もページから取得するなどすると良いですね。
本当はAMPのページの生成もチャレンジしたかったのですが、imgタグをamp-imgに置き換えたり、結構手間暇かかりそうだったのでやめました。がんばればできないことはないかな、と思います。
効率的で効果的なキャッシュ
今回は雑にmemcachedスタイルのローカルのキャッシュに入れていますが、シングルページアプリケーションの各ページを効率よくキャッシュしておくためには、ページのキャッシュの寿命を正しく判定しなければなりません。ページの表示にあたって必要なREST APIの各呼び出しがどれか、また、それぞれの呼び出しの結果に影響の与えるようなことが発生していないかを管理しなければなりません。例えば、ブログ記事なら、それの更新のPUTメソッド呼び出しが該当記事のURLに対して行われていないか、といったことです。必要なAPI一覧と、そのAPIの結果が無効になっていないかどうかが定まれば、無効にすべきキャッシュが決まります。ただでさえ複数の呼び出しをマージしちゃうGraphQLが絡むとさらにややこしいでしょう。
日経の新しいサイトも話題になりました。がんがんCDNに乗っけちゃって高速化というやつです。予めCDNに乗っけるということは、SPAにしないで、どんどんサーバーでやってしまうと。Fastlyではサーバー側でコンテンツを組み合わせて表示(古のSSIのようなもの?)もできるみたいなので、サーバー側ではHTML片を作りまくって提供という方が良さそうです。そうなると、シングルページアプリケーションのJSONから生成ではなくて、事前にユーザーの入力に対してHTML片の生成までやっておく、古き良きエンタープライズな巨大なバッチ処理が一番確実で、現実的な未来なのでは、という。
matsnowさんのサーバレスアーキテクチャ + SPAで SSRなしのSEO対策した話も、事前生成系ですね。
Go実装について
長くなったので説明は省略します。だれかがGoアドベントカレンダーを落としたら書くかも?
→書きました!Goのリバースプロキシーでレスポンスを書き換える
まとめ
どう頑張っても、サーバーサイドレンダリングを考慮したクライアント実装にしなければ対処できないもの、CDNを使いこなす必要があるものを除けば、ある程度割り切って使えそうなものができそうです。
プロキシーという実装方法で実現するというのは別に突飛なアイディアではなくて、Webpack Dev Serverとかもあります。リロード用のコードを埋め込んだJSを返し、ローカルのソースファイルが変更されたらブラウザ側に強制リロードさせる、というプロキシ・サーバ(API呼び出しは後続のExpressに投げる)です。高度なことをやる時に、アプリケーションのフレームワークが全部を丸抱えするのではなくて、プロキシとして仕事するという開発ツールは今後もっとメジャーになってくると思います。
個人的にはHeadless Chromeで遊ぶ題材としてとりあえずサーバーサイドレンダリングをやってみたものの、サーバーサイドレンダリング不要論で書いたように、あまり必要性は感じていません。速度以前の機能性の面で、まだまだサーバーサイドレンダリング以前にもっとシングルページアプリケーションでやるべきこととかを追求したい気持ちの方がまだまだ強いですね。アイディアはまだたくさんあります。例えば、オフラインモードを考慮した共同編集を効果的に実現する方法とかですね。