文系プログラマによるTIPSブログ

文系プログラマ脳の私が開発現場で学んだ事やプログラミングのTIPSをまとめています。

GAE/Java+Angular5+@angular/service-workerでSPA・PWA・SEOに対応する

解決しそうで微妙に解決しない、それがGAEの悲しいところ・・・


f:id:treeapps:20170918135756p:plain

私は個人でGoogleAppEngine上でサービスを現状2つ開発していて、そのうちの1つをAngular5で構築しています。以下のサイトです。

www.string-utility.com

GAE+Angularで開発するにあたり、ちょっとしんどい事と、どうやってそれを乗り切っているのか(ごまかしているのか)を書いてみます。

GAE+SPAの辛さ

辛いというか、

GAEのStandard EnvironmentmにNode.jsがあれば万事解決

なのです。。。

しかし現実は厳しく、2018/01現在ではnode.jsはFlexible Environmentmを使わないと実現できません。

これはつまり、サーバサイドがnode.jsではないので、サーバ側がjavaでフロント側がjavascript等と言語が異なってしまうし、何よりビューのテンプレートエンジンの不一致が起きてしまいます。

最も辛いのは、このテンプレートエンジンの不一致です。テンプレートエンジンが不一致になると、SEO(画面表示時のtitleの書き換え等)が非常に厄介になるのです。

ビューのテンプレートエンジンの不一致の何が困るのか

その前に、サーバサイドとフロントの状況を比較してみます。

サーバサイド フロント
言語 Java(kotlin) Javascript(Typescript)
フレームワーク SparkFramework Angular5
viewテンプレートエンジン Thymeleaf Angular標準

こんな感じです。

サーバとフロントでテンプレートエンジンが異なってしまうと、書き方が異なってしまいますよね。そうなると、同じhtmlをサーバ側とフロント側で共有する事ができなくなってしまいます。

共有できないという事は、サーバ用とフロント用のビューを2種類必要になり、しょうもない2重管理になってしまいます。

しかし、今回選んだThymeleafは優れていて、APサーバを通した場合は変数が展開され、APサーバを通さない場合は変数が展開されない、という事が可能になっています。

例えばThymeleafでは以下のようにtitleタグを書きます。

<title th:text="${seo.title}">StringUtility</title>

APサーバ、例えばJettyやTomcatを通してこれを表示すると、seo.titleの値でStringUtilityという文字列を置換される、つまり変数が展開され、ついでにth:text属性は除去され、以下のようにレンダリングされます。

<title>ほげほげ</title>

APサーバを通さない場合は、このタグがそのまま表示され、titleタグは「StringUtility」となり、「th:title」の部分はhtmlの属性と判断されるので、無害(あっても何も起きない)になり、以下のようにレンダリングされます。

<title th:text="${seo.title}">StringUtility</title>

このように、Thymeleafを使う事で、サーバ側とフロント側で同じhtmlを共有する事ができるのでした〜、これにて一件落着!!


・・・・とはいきません。これではSEO問題が解決しないのです。

画面初期表示時はどっちのhtmlを出力する?

一度画面を表示してからは、SPAなので画面遷移が発生しないので、Angular側でtitle・description等を置換します。これは問題ありません。

では、初回の画面表示時は、サーバ側でレンダリングしたビューを表示しますか?それとも静的なhtmlを表示してAngularにSEO情報を書き換えさせますか?

SEO的にも安全なのは前者で、極力サーバサイド側で描画済みのものを表示した方が、クローラに認識されやすいです。

2017/08時点では、googleのクローラはGoogleChromeのv41相当だそうで、かなりのJavaScriptを解釈できるようです。
www.suzukikenichi.com
しかしSPAを完全に解釈できるかどうかは、googleのみぞ知る、というのが現状です。という事で、やはりサーバサイド側で変数を展開済みのビューを初期画面に表示するのが良さそうです。

初期表示時はサーバサイド側のビューを使う」でこの話は終了?

いえ、少し厄介な問題が残ります。

SPA、しかも今のところAPIもほぼ無いので、ソースコードの割合はサーバサイド1割、フロント9割、という比率のソースコードになっています。

という事はフロントエンド側の方が頑張る量が多いので、フロントエンド側をサクサク開発できる環境が望ましいですね。

ここでちょっと問題が起きます。

ng serveのつらみ

フロントはangular-cliをフル活用するのですが、通常は「ng serve」で開発します。これは簡易サーバを起動し、angular側のコードの変更を検知し、差分ビルド+ブラウザの自動リロードが行われ、非常に快適にフロントの開発を行う事が可能になります。

ここでポイントなのはng serveの挙動にあります。

標準設定でng serveすると、トップページのURLは http://localhost:4200 になります。ここまではいいのですが、問題なのはビルドされたファイルは実体としてファイル出力されず、メモリ上で展開される点です。

この状態で、ng serveした簡易サーバではなく、APサーバ経由でサイトを確認したい場合(GAEにデプロイした状態と同じ)、どうなでしょう。

答えは簡単で、angularがビルドしたjsファイルが見つからないという事になります。実体がメモリ上にしかないので当然ですね。

この挙動は、以下に影響を与える厄介なものです。

  • ローカルのAPサーバ経由のサイトの挙動
  • ローカルのng serve経由の挙動
  • 本番環境(GAEにデプロイ)した状態での挙動

これに対する解決策は2つあります

解決策1:Thymeleaf側でhttp://localhost:4200を参照する

まず、APサーバ(port=4567)と、ng serve(port=4200)を同時に起動した状態にします。

そして、htmlには以下のようにしてjavascriptを読み込むようにします。

<script defer type="text/javascript" th:src="@{${assetsHost} + 'inline.bundle.js'}"></script>
<script defer type="text/javascript" th:src="@{${assetsHost} + 'polyfills.bundle.js'}"></script>
<script defer type="text/javascript" th:src="@{${assetsHost} + 'styles.bundle.js'}"></script>
<script defer type="text/javascript" th:src="@{${assetsHost} + 'vendor.bundle.js'}"></script>
<script defer type="text/javascript" th:src="@{${assetsHost} + 'main.bundle.js'}"></script>

ローカル環境の場合はassetsHost変数に「http://localhost:4200/」をセットし、本番環境の場合は空文字をセットするようにします。

ローカル環境の場合は、メモリ上にしかjsが存在しないならば、4200ポートを見てしまえば良い、本番環境の場合jsは標準設定だとdistに配置されるので、「src="inline.bundle.js"」とすれば良いのです。

ローカルのAPサーバ経由の場合のレンダリング結果
<script defer type="text/javascript" src="http://localhost:4200/inline.bundle.js'}"></script>
<script defer type="text/javascript" src="http://localhost:4200/polyfills.bundle.js'}"></script>
<script defer type="text/javascript" src="http://localhost:4200/styles.bundle.js'}"></script>
<script defer type="text/javascript" src="http://localhost:4200/vendor.bundle.js'}"></script>
<script defer type="text/javascript" src="http://localhost:4200/main.bundle.js'}"></script>

これでng serve中のjsを参照できますが、これは中々な酷いですね。。。

本番環境(GAE上)の場合のレンダリング結果
<script defer type="text/javascript" src="inline.bundle.js"></script>
<script defer type="text/javascript" src="polyfills.bundle.js"></script>
<script defer type="text/javascript" src="styles.bundle.js"></script>
<script defer type="text/javascript" src="vendor.bundle.js"></script>
<script defer type="text/javascript" src="main.bundle.js"></script>

これでng buildした実体のあるファイルを参照できます。

では、ローカルでng serveした場合はどうでしょう。

ローカルのng serve経由の場合のレンダリング結果
<script defer type="text/javascript" th:src="@{${assetsHost} + 'inline.bundle.js'}"></script>
<script defer type="text/javascript" th:src="@{${assetsHost} + 'polyfills.bundle.js'}"></script>
<script defer type="text/javascript" th:src="@{${assetsHost} + 'styles.bundle.js'}"></script>
<script defer type="text/javascript" th:src="@{${assetsHost} + 'vendor.bundle.js'}"></script>
<script defer type="text/javascript" th:src="@{${assetsHost} + 'main.bundle.js'}"></script>

<script type="text/javascript" src="inline.bundle.js"></script>
<script type="text/javascript" src="polyfills.bundle.js"></script>
<script type="text/javascript" src="styles.bundle.js"></script>
<script type="text/javascript" src="vendor.bundle.js"></script>
<script type="text/javascript" src="main.bundle.js"></script>

一瞬???と思うかもしれませんが、ng serveは、jsの読み込み用のscriptタグをindex.htmlに自動生成するので、元々のThymeleaf側のタグ(上側)とは別に、ng serveによって自動生成されたscriptタグ(下側)があるのです。

よく見ると解りますが、上部のThymeleaf側のscriptにはsrc属性がありません。もしThymeleafのテンプレートにsrcを書いてしまうと、ng serveした時にThymeleaf側に書いておいたsrcとng serveが自動生成するsrcが2重に出力され、2回jsが読み込まれてしまいます。

これを回避するため、トリッキーですが、Thymeleaf側には敢えてsrc属性を記述しない状態にしています。こうする事で、ng serve時は上側のscriptタグにはsrc属性が無いので無視され、下側の自分自身(ng serve)したsrcが適用されるのです。

一応はこれで何とかなるのですが、以下の問題が残ります。

  • サーバサイド側で環境によってassetsHost変数を書き換える処理が必要
  • angular側のコード変更時にSparkが変更を検知しないので都度Sparkの再起動が必要になる

解決策2:ng serveではなくng build --watchする

ng serveの時は、「jsの実体がなくメモリ上にしか展開されない」おかげで、jsを参照する際はhttp://localhost:4200/inline.bundle.js 等という不格好な事をしないといけませんでした。

では、簡易サーバの起動をやめつつ、ビルドしたファイルを実体化させてしまえばいいのです。それがng build --watchです。

ng serveとの違いは、ng serveは簡易サーバが起動するので単独でサイト表示が可能なのに対して、ng build --watchはng serveのように簡易サーバは起動せず、他はng serveと同様リアルタイムに差分ビルドしてくれます。

jsの実体がファイル出力される事で「src="http://localhost:4200/inline.bundle.js"」ではなく「src="inline.bundle.js"」で参照できるようになりましたが、ng buildもやはりscriptタグを自動出力するので、結局は2重にscriptタグが出力されないよう以下のようにする必要があります。

<script defer type="text/javascript" th:src="inline.bundle.js"></script>
<script defer type="text/javascript" th:src="polyfills.bundle.js"></script>
<script defer type="text/javascript" th:src="styles.bundle.js"></script>
<script defer type="text/javascript" th:src="vendor.bundle.js"></script>
<script defer type="text/javascript" th:src="main.bundle.js"></script>

このやり方でも以下の問題は残ります。

  • 簡易サーバ無くなったのでAPサーバ経由でサイトの動作確認をする事になり、サクサク開発しにくくなる。
  • angular側のコード変更時にSparkが変更を検知しないので都度Sparkの再起動が必要になる

結局どうするのが良さそうか

APサーバ経由でのサイトの動作確認は、どうやっても何かしらの問題がある事が解りました。

色々悩みましたが、私は「サーバサイドの確認の時のみSpark経由でサイトを確認し、通常はng serveでサイトをライブリロードしながらサクサク開発していく」のがいいのではないかと思いました。

サーバサイドはほぼSEO以外の処理は今はやっていないので、この際APサーバ経由での確認は必要な時にしかしなくていいのでは?と思ったわけです。

APサーバ経由での確認と、ng serve経由での確認をバッサリ分けた事で、前述したAPサーバ経由の面倒臭さ(リソース変更時のSpark再起動)は無くせました。これにて一件落着!!


・・・・とはいきません(2回目)。これではServiceWorkerのオフライン対応問題が解決しないのです。

ServiceWorkerと検索エンジンのクローラ

ここでようやく記事タイトルのServiceWorkerの話です。

SPA + PWAでネイティブアプリのようにウェイウェイするのが最近の流行りなので(単純に高速化するしオフラインで動くし)、ビッグウェーブに乗ってみました。

angularの場合はServiceWorkerの導入は目茶苦茶楽です。

新規プロジェクト生成時なら「ng new hoge --service-worker」だけで初期準備が整います。

既にプロジェクトが存在する場合は、以下のようにします。

  • yarn add @angular/service-worker する。
  • .angular-cli.jsonのappsの下に「"serviceWorker": true」を追加する。
  • app root(main.tsがある場所)にngsw-config.jsonを作成する。
  • app-module.tsのimportsに「ServiceWorkerModule.register('/ngsw-worker.js', {enabled: environment.production})」を追加する。
  • ng build --prod --output-hashing=none する。(--output-hashing=noneを付けないとSpark側でjsファイル名を特定できない)

こうすると、ビルド時にngsw-config.jsonと同じディレクトリに「ngsw.json」と、それを読み込む「ngsw-worker.js」が生成されます。

ngsw.jsonの自動生成例

{
  "configVersion": 1,
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "updateMode": "prefetch",
      "urls": [
        "/favicon.ico",
        "/index.html",
        "/inline.bundle.js",
        "/main.bundle.js",
        "/polyfills.bundle.js",
        "/styles.bundle.js"
      ],
      "patterns": []
    },
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "urls": [],
      "patterns": []
    }
  ],
  "dataGroups": [],
  "hashTable": {
    "/inline.bundle.js": "a6b91d159ec5c404f79af56e64a01abfed9d57fd",
    "/main.bundle.js": "c904c7921af95a71ef615433c852318a2adff6ba",
    "/polyfills.bundle.js": "6212ebcbc3bc91535ae0d106eda09fb0e9d7c995",
    "/styles.bundle.js": "c03268b506cdfad006567550dc250cf52456bfed",
    "/favicon.ico": "42603f88078e9111bf705e154f57233b61f85a32",
    "/index.html": "e309120eb78eaafb239abec9d15d09668c38382e"
  }
}

ngsw-config.jsonを元に、ngsw.jsonが生成されます。

inline.bundle.js等、ngsw-config.jsonに設定したリソースをハッシュ値を取得し、登録しています。ハッシュ値を登録する理由は、ServiceWorkerのキャッシュ更新のためです。

ファイルの中身が変わるとハッシュ値が変わり、ブラウザのCache Storageにハッシュ値と共にオフライン利用できるようキャッシュされます。次回ビルド時にファイルの中身に変更があればこのハッシュ値が変わり、ServiceWorkerによってCache Storage側のハッシュ値と比較され、ハッシュ値が異なっていればFetchする、という挙動のようです。賢いですね。

ngsw-worker.jsの自動生成例

(function () {
'use strict';

/**
 * @license
 * Copyright Google Inc. All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */
/**
 * Adapts the service worker to its runtime environment.
 *
 * Mostly, this is used to mock out identifiers which are otherwise read
 * from the global scope.
 */
class Adapter {
    /**
     * Wrapper around the `Request` constructor.
     */
    newRequest(input, init) {
        return new Request(input, init);
    }
・・・・略・・・・

一部抜粋ですが、以下のような事をしているようです。

const res = await this.safeFetch(this.adapter.newRequest('ngsw.json?ngsw-cache-bust=' + Math.random()));

ngsw-worker.jsによってServiceWorkerの登録やキャッシュの更新が行われているようです。


ngsw-config.jsonにはオフライン時に表示するためのindexページ指定をするのですが、これはどこを指定しましょう。Thymeleaf側のindex.htmlでしょうか?

「はい」と言いたいところですが、angular-cliには謎の挙動があります。

.angular-cliでappsのrootより外のディレクトリにあるファイルをindexに指定すると何故かminifyされてしまう

これはバグなのか、そういうオプションなのか不明なのですが、Thymeleaf側のindex.htmlを.angular-cliで以下のように指定してng build --prodすると、何故かminifyされてしまうのです。dist側のindex.htmlではなく、src側のindex.htmlが、です。

例えば以下のように指定してしまうと、ng build時に src/main/resources/templates/test.htmlは何故かminifyされます。

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "project": {
    "name": "string-utility"
  },
  "apps": [
    {
      "root": "src/main/resources/client",
      "outDir": "src/main/resources/public",
      "assets": [
        "images",
        "data",
        "favicon.ico"
      ],
      "index": "../templates/test.html",
・・・略・・・

この挙動がちょっと意味不明なので、力技で修正してしまいます。

ng serve・ng build --prod時に、Thymeleaf側のindex.htmlをangular側のapp rootにコピーしてしまいます。例えばpackage.jsonで以下のように感じですコピーを挟んでしまいます。

"scripts": {
  "start": "npm run copy-index-html && ng serve",
  "build": "npm run copy-index-html && ng build --prod",
  "copy-index-html": "cp -f ./src/main/resources/templates/index.html ./src/main/resources/client/sw-index.html"
},

index.htmlのままだとトップページ表示時に静的htmlが優先されてしまう等の面倒が起きそうなので、sw-index.htmlと変えてます。

これで、ngsw-config.jsonにはsw-index.htmlを指定し、無事Thymeleaf側のindex.htmlに集約できるようになりました。。。長く、しょうもない試行錯誤でした。

ngsw-worker.jsって誰がどこで動いてるの?

ネット上でServiceWoekerについて調べると、登録処理でよく以下のようなコードを見かけます。

if("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js");
};

これに当たるコードを今回書いてないのですが、一体どこで登録されているのでしょうか。

答えは以下です。

ServiceWorkerModule.register('/ngsw-worker.js', {enabled: environment.production})

これはビルドするとmain.bundle.jsに格納されます。つまり、main.bundle.jsがロードされるとngsw-worker.jsが読み込まれ、ServiceWorkerの登録・更新処理等が実行されます。楽でいいですね。

人間がサイトを表示した場合とクローラがサイトを表示した場合の違い

人間がServiceWorkerに対応したブラウザ(現在だとChromeとFirefox、IE・Safariはこれから対応される)を使っていると、ServiceWorkerが登録され、リロードすると次回からはServiceWorkerのキャッシュからファイルが読み出されるようになります。つまり動的ではないので、title・description等はサーバサイドでレンダリングされず、angularによって書き換えられます。

一方検索エンジンのクローラですが、まだServiceWorkerに対応していないので、クローラがサイトにアクセスすると、必ず初回画面表示時はAPサーバ経由の動的表示になるので、title・descriptionはサーバサイドレンダリングされるので、SEO的な問題は発生しなくなります。

ユーザはServiceWorkerでウェイウェイし、クローラはサーバサイド側でレンダリングされたtitle・descriptionを確実に参照できるわけです。

今後クローラ側にServiceWorkerが搭載!なんて言われたらこの手法は使えなくなるわけですが、それは無さそうな予感がします。

雑感

正直綺麗には解決していませんね。。。Spark側のホットデプロイに対応してないし、ビューのテンプレートの話はこれでいいものなのか。node.js以外でSPA+SEO対応する時のベストプラクティスが知りたいです。

もしnode.jsが利用可能なら、angular+angular/service-worker+angular/universalで綺麗に統一されたコードが書けただろうなあ・・・と、遠い目になってしまいますね。

ReactもVueもですが、やはりフロントはnode.jsとセットで使えると絶対楽で、GAEで無理にSPAでSEOに対応しようとすると、結構しんどい目に合うのでご注意下さい。

というか、クローラがSPAを高い精度で認識できるようになれば、サーバサイドでのtitle・descriptionの書き換えが不要になるのですが、絶対無理でしょうね。こればっかりはずっと付き纏う問題なのかもしれません。