概要
Angular x GCP x NetlifyでSPA開発をして詰まったところ、やらかしたことや新しく知ったことなどを振り返っていきます。開発したのは、アーティストの活動を応援するポータルサイトCOMEARTです。元々はWordPressで作られていたものを今回デザインも含めて全面リニューアルするということで実装面で協力させて頂きました。2020/07/01に公開したのでよかったらのぞいてみてください
そして、今日も今日とてAngularとGCPの話をするのでいつもの免責事項を貼ります。
Disclaimer
※冒頭に置くとTwitter等のサムネの引用でDisclaimerだけ見えるという悲劇が起こるのでこの位置に挟んでいます。
この記事を執筆現在私はGoogleで働いています。記事内でGoogleのプロダクトについて言及するため明確に所属を記載していますが、このことは私が記事内に登場するプロダクトについて特別詳しい知識を持っていることを**意味しません。**1ユーザーが書いた記事としてお楽しみください。
アーキテクチャ
最終的にこんな感じになりました。アーキテクチャ図の書き方がこれで合っているのかよくわからない。初期構想との変更点はNetlify使うかFirebaseHosting使うかだけで、なぜこうなかったかは後述したいと思います。
Angular
ロゴ in mat-icon
ロゴはやっぱりSVGかなあというところまであたりはつけていたのですが、参考までにGCPコンソールのロゴみてみよう〜と開発者ツールでのぞいてみたらSVGをmat-icon化する手法がとられていて面白いと思ったので真似してみました。
例えばこんな感じでAngular MaterialのMatIconRegistryを使用してアイコンとして登録すると、
this.matIconRegistry.addSvgIcon(
'comeart',
this.domSanitizer.bypassSecurityTrustResourceUrl('assets/comeart.svg'));
こんな感じで簡単に呼び出せるので
<mat-icon class="logo" svgIcon="comeart"></mat-icon>
ロゴみたいに繰り返し使いそうなものはこれで処理しておくとスッキリしそうです。
コンポーネントのLazyLoad
Twitterウィジェットを埋め込むとそこそこな量の画像をロードされますが、フッターにいるので最初のビューでロードする必要もないなと思い、Lazyloadしようと思いました。そこで気が付いたのですが、Angular9からコンポーネント単位のLazyLoadがとても簡単になっています(参考記事)。
と言うことで、この方法を取ることにしました。簡単すぎて特に言うことはないのですが、自分もこれをやろうと思うまでAngular9を使っておきながら気づいていなかったので書いてみました。
今ロードのタイミングが雑なので、そのうち「ビューポートに入りそうになったらロード」に変更したいと思っています。
必要十分なカルーセル
極力3rdPartyは避けようと思っていましたが、最初カルーセルコンポーネントだけは外部のライブラリを使っていました。速度を優先したからです。開発を進めるにつれてz-indexが10000になっていたり画像が変わった時のイベントがないことに気が付いてPRを出すようになりましたが、そのうちに最早0から作りたいと言う気持ちになったので開発終盤で自前で用意することにしました。これはこれで単体のライブラリとして配信しています。
依存についてはもちろん、デザインもシンプルにすることを心がけました。カルーセルのライブラリはほとんどボタンまでついて1コンポーネントですが、それが自分の使いたいところに合うデザインかはわからないなと常々思っていたからです。
いちいちライブラリ使うのにスタイルを無理やり上書きするのも極力やりたくないと思うので、今回そのカスタマイズが簡単にできるように本体は必要最低限にして、ボタン付きのコンポーネントは別のコンポーネントとしてexportしました。これにより、そのままのデザインでいい人はそのまま使えるし、カスタマイズしたい人はボタン付きのコンポーネントを参考にしてスタイルやhtmlだけ手を加えた独自コンポーネントを簡単に作れると言う想定です。一方で、スクロールするときのアニメーションを細かく設定することなどはできません。srcset対応などまだまだやりたいことが多いので、今後も細々と開発を続けていきたいと思います。
技術的な面で言うと、画像のスライドはCSSのScrroll Snapでベースを実現しています。これだけでカルーセルっぽい挙動が作れるのでとても便利ですが、あくまで定位置でコンテンツが表示されるようにするためのもので、どのコンテンツを中心に置くかという指定はできないし、何がいつ中心に来たかもわかりません。
スライドの画像が変わるごとに周辺に出している情報を変えたい場面は多いはずなので、画像が変更された時にはイベントが欲しいです。と言うわけで、Intersection Observerで画像の変更を検知するようにしました。従来のやり方だとscrollTop()などを使って要素の位置を調べて見える範囲にいるのか判定することになりますが、これだとメソッドを呼ぶたびにレイアウトの計算が走ってパフォーマンスに負荷がかかります。一方で、IntersectionObserverはそのようなこともなく、かつ非同期なので負担をかけずに変更の監視ができます。
GCP
GAE (Standard) でやたら頻繁に500
結論から言うと、こう言う時にまず確認すべきは
- アプリに異常がないこと
- マシンタイプ等の設定が適切かどうか
です。当時、全てに500が返ってくるわけではない+特定のパスのみで起きているわけではないと言う状況でうっかりいきなり2番の確認に直行してしまいました。
当時のマシンタイプの設定はデフォルトのF1で、ご覧の通りメモリなんかは思いっきり制限の256MBを超えています。
勘のいい方はここで気づかれると思いますたが、ただFirestoreからデータを取ってくるだけ+まだ未公開なのでアクセスしてるのは関係者のみのアプリにしてはメモリ使用量、CPU使用率の上がり方がおかしいです。あと、当時の画像は保存しそこねましたが、インスタンス数の増え方もおかしかったと記憶しています。
と言うわけで、マシンタイプをあげてみたものの効果はなく、もう一度アプリを確認したところ、なんと無限ループを起こしている箇所があることに気がつきました。ループを起こしているエンドポイントを呼ぶと、マシンが消耗されきって死ぬという状態だったと考えるとメモリやインスタンス数の異常にも納得がいきます。
本来ローカルで気づくべきことだったのですが、該当エンドポイントを呼ぶまで起こらないこと、該当エンドポイントはデータ投入を待ってからきちんと実装する予定だったのであまり確認していなかったこと、無限ループなので特にエラーメッセージ等も出ていなかったことから発見が遅れてしまいました。まず自分を疑おう(自戒)。
余談ですが、GAEのデフォルトのマシンはかなり小さいので、テスト用とかでもない限りはある程度開発が進んだ時に見直すことをお勧めします。私もこの問題が解決してから再度健全な状態のメトリックスを基準にして設定し直しましたが、流石にデフォルトより1つ上が必要そうでした。
参考までに、現在のメモリ使用量です。
GCSトリガーで画像処理する時はMetaを使う
画像の最適化のために、オリジナル画像がアップロードされると、それに対してオリジナルの拡張子とwebpで各3種類のサイズを生成するCloud Functionsを書きました。
この時、自動生成した画像もGCSにアップロードしますが、当然このアップロードに対してもアップロードイベントが起き、Functionsは呼び出されます。つまり、すでに処理された画像かどうかと言う判定を入れなければ無限ループになります。
こう言う時にはカスタムのメタデータを利用すると良いようです。
const upload = async (localPath, gcsPath) => {
await bucket.upload(localPath, {
destination: gcsPath,
metadata: {
metadata: {
isConverted: true, <- こう言う感じで設定できます
},
cacheControl: 'public, max-age=31536000'
}
});
console.log('Webp image uploaded to Storage at', gcsPath);
}
metadataの中にmetadataがあると思うとちょっとややこしいのは否めませんが、これでFunctionがトリガーされた時に処理済みのものかどうか確認できます。
CloudFunctionでの画像処理
CloudFuncitonの実行環境には元々ImageMagickと言うライブラリが入っているので、それで対応できることであればそれを使うのが一番無駄がないかもしれません。ただし、入っているバージョンは少し古くwebp対応できないらしいのでwebp変換をする場合は別のライブラリを選択する必要がありそうです。ちなみに私も試しましたがダメでした。(とはいえ公式情報ではないので定かではないです)
そこで、Sharpというライブラリを使うことにしました。
Sharpでの画像変換はとても簡単です。例えば、Webpにするだけならこれだけ
const convertToWebp = async (srcLocalFilePath, webpLocalFilePath) => {
await sharp(srcLocalFilePath)
.webp()
.toFile(webpLocalFilePath);
console.log('Converted and placed at', webpLocalFilePath);
}
で済みます。
Netlify
Hostingの選択肢
Netlifyはレポジトリを選択するだけでPR毎にビルドを勝手にしてくれてとても便利です。使い始めるだけならドメインの取得も必要ありません。とはいえ、今回バックエンドアプリはGAE、サイト内で使う画像はGCSとGCPで揃えていたのでNetlifyで始めて最終的にはFirebase HostingかGCSでのHostingに移行する予定でした。
ですが、最終的に以下の理由からNetlifyのままでいくことにしました。
1.FirebaseHostingを試しに使ってページパフォーマンスを測定したらNetlifyの方が若干早く、移行するほどの優位性が見出せなかった
2.次で述べるOGP対応においてNetlifyの方が手軽だった
動的なOGP書き換え対応
meta tagでogpを設定するとTwitterとかFacebookにUrlを貼った時の画像等を設定することができますが、SPAだと何もしなければindex.htmlに設定したtagが全てにページに適用されます。AngularであればMetaクラスでogpを含めたmeta tagを更新できますが、クローラーは現時点でJSの実行を待たず、これを解釈してくれないのでいくらJS内でupdateしても実際には反映されません。
これに対応するとなって最初に検討したのはRendertronでDynamic Renderingすると言う方法でした。これはクローラーがページにアクセスしてきたとき、Rendertronが間に入ってHeadless ChromeでJS含めページが完全にロードされるのを待ってから結果をクローラーに返してくれると言うものです。
ですがなんと、Netlifyを使っているとチェックボックス1つで簡単にこの問題に対処できてしまいます。Netlify PrerenderingはまだBETA版ですが、クローラーからのリクエストがCDNサーバーに来た時は通常のキャッシュされたリクエストを処理するのとは異なりプリレンダリングした結果を直接返してくれると言うものです。ONにして試してみたところ無事JSでの更新が反映されていたため、この件はNetlify Prerenderingに任せることになりました。
カスタムドメインとNetlifyDNS
Netlifyでカスタムドメインを利用する際、いくつか方法がありますが、NetlifyのCDN機能を活用するにはNetlifyDNSの利用が必要です。今回は旧サイトのカスタムドメインにそのまま移行するため、Delegationを行いました。
この時、ドメインの追加をサイトごとのページからやるとNameserverの情報が出てこなくてつまります。必ずNetlifyのteam's>Domainsから設定しましょう。説明ページには書いてありますが私はこれまでteam'sのページをほとんど使ったことがなかったのでうっかり読み飛ばしてつまりました。
説明ページにもあるように、該当ドメインでメールの受信なども行なっていた場合は、先にそのDNSレコードを移動しないとそのメールサーバーが不通になったりします。大体の場合は元のProviderの設定をそのままコピーしてくるだけでいいみたいですが、今回の場合は元の設定が
a * <IP> -> サブドメインも含めて該当ドメイン全てをあるIPに向ける
mx <domain> <domain> -> 該当ドメイン宛のメールは↑のaレコードの指すところに向ける
になっていたので、そのままコピーすると新ドメインを設定したはずのサイトまで古いサイトを表示するようになってしまいました。(元々使われていたサービスについて私はあまり知りませんが、あまり知識のない人でも簡単にウェブサイト公開とメールの利用ができるように一括で管理されていたのだと思います。)
というわけで、メールサーバー自体のIPを教えてもらい、
a <subdomain> <メールサーバーのIP> -> このサブドメインはメールサーバに向ける
mx <domain> <subdomain> -> 該当ドメイン宛のメールをサブドメインに向ける
に変更しました。
この段階でメールサーバーが再度動き出し、dig <domain>
を引くと正しくNetlifyの方のIPを返すようになったので、変更の反映を少し待って無事設定が完了しました。
終わりに
これまで仕事として開発していたものが検索流入のないものだったこともあり、SEO周りは色々知らないことが多くとても楽しかったです。ループなどという大変初歩的なミスにはまったりもしましたがそういう失敗も含めて学びが多くやりがいのあるプロジェクトでした。細かいところでもっと良くしたい箇所はたくさんあるので1つずつ実装していきたいと思います。
また、今回振り返りを書くにあたってGitHubのIssueがとても役に立ちました。一人開発でもきちんと公開するものをつくとなるとあれやこれや気がつくことが出てきて優先度の管理くらいはしたくなりますし、こうした振り返りにも便利なのでぜひ活用をお勧めします!