Angular サーバーサイドレンダリング を Firebase Cloud Functions で動かす

Angular サーバーサイドレンダリング を Firebase Cloud Functions で動かす

Angular でサーバーサイドレンダリング

Angular でサーバーサイドレンダリングを行う方法をまとめます。

基本 この記事 を参考にしていますが、うまくいかない個所もあったので、備忘録がてらやり方をまとめておきます。やり方も少しだけかえています。

Firebase を使ってサービスを構築する予定だったので、サーバーサイドには Firebase Cloud Functions を利用します。元記事には GCP のサービスを使う方法もあります。

Firebase の無料枠で試せます。

Firebase のアカウントの用意、NodeJS & Angular のインストールが済んでいる前提で以下進めていきます。

環境

Windows 10 で動作確認をしています。その他 Angular のバージョンは 8 です。詳しくは以下の通り。

$ ng --version

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/


Angular CLI: 8.0.3
Node: 10.16.0
OS: win32 x64
Angular:
...

Package                      Version
------------------------------------------------------
@angular-devkit/architect    0.800.3
@angular-devkit/core         8.0.3
@angular-devkit/schematics   8.0.3
@schematics/angular          8.0.3
@schematics/update           0.800.3
rxjs                         6.4.0

Node のバージョンが 12 だとうまく動かなかったのでバージョンを 10 に落としました。それで動きます。

サンプル開発

Angular のプロジェクトを作成します。プロジェクトは angular-universal-functions とします。

$ ng new angular-universal-functions

Angular Universal を追加

Angular Universal は Angular アプリをサーバーサイドレンダリングするための機能を提供します。

ng add で追加しましょう。

$ cd angular-universal-functions
$ ng add @nguniversal/express-engine --clientProject angular-universal-functions

実行すると以下のファイルが追加されます。

  • src/main.server.ts
  • src/app/app.server.module.ts
  • tsconfig.server.json
  • webpack.server.config.js
  • server.ts

server.ts が Cloud Functions で実行される Node の Express のサーバーのプログラムです。

既存のファイルもいくつか更新されています。

  • package.json
  • angular.json
  • src/main.ts
  • src/app/app.module.ts

SSR で動かしてみる

Angular Universal を追加したらそれだけで SSR で動かせます。

Angular Universal 追加時に、pckage.json が更新されており、SSR で実行用のコマンドが追加されています。

{
  "scripts": {
    // ..
    "compile:server": "webpack --config webpack.server.config.js --progress --colors",
    "serve:ssr": "node dist/server",
    "build:ssr": "npm run build:client-and-server-bundles && npm run compile:server",
    "build:client-and-server-bundles": "ng build --prod && ng run angular-universal-functions:server:production"
  },
  // ..
}

build:ssr でビルドして、serve:ssr でサーバーを起動します。

$ npm run build:ssr
$ npm run serve:ssr

> angular-universal-functions@0.0.0 serve:ssr C:\dev\web\angular-universal-functions
> node dist/server

Node Express server listening on http://localhost:4000

実行すると、/dist 以下にビルドされたソースコードが配置され、/dist/server にある express サーバーが起動します。

起動したURLをブラウザで確認すると、Angular の初期画面が確認できます。ただしこの状態ではサーバーサイドレンダリングができているかわかりません。なので OGP用のメタタグを埋め込んで確認してみます。

OGPのメタタグを追加する

サーバーサイドレンダリングで OGP のメタタグを追加してみます。Angular にはメタタグを埋め込むための Meta クラスがあります。これを使って Twitter Card 用のメタタグを出力してみます。

簡単に済ませるために AppComponent に実装します。

src/app/app.component.ts

import { Component } from '@angular/core';
import { Meta } from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'angular-universal-functions';

  constructor(private meta: Meta) { }

  ngOnInit() {
    this.meta.addTags([
      { name: 'twitter:card', content: 'summary' },
      { name: 'og:title', content: 'サンプル' },
      { name: 'og:description', content: 'これはサンプルです。' },
      { name: 'og:image', content: 'http://sample.com/sample.png' } // 適当な画像URL
    ]);
  }
}

画像のURLは適当なものに差し替えてください。なお /src/assets 以下に配置しても直接URL越しに見ることができません。Angular アプリ内から読み込むときは見れるのですが。

したがって画像等の静的ファイルは Hosting や Storage に別途上げておくのがよいでしょう。いずれの方法も Firebase のサービス内で完結できます。また同じドメインで画像等を扱う場合は、rewite の設定を書き換える必要があります。今の設定だとすべてのパスが Cloud Functions に改修されてしまいます。

話がそれましたが、一応これでメタタグを追加できました。では、ビルドして起動してみます。

$ npm run build:ssr
$ npm run serve:ssr

ブラウザでページを確認すると、ヘッダータグに以下のようなタグが出力されます。

<head>
  <meta charset="utf-8">
  <title>AngularUniversalFunctions</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <link rel="stylesheet" href="styles.09e2c710755c8867a460.css">
  <style ng-transition="serverApp">/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiIsImZpbGUiOiJzcmMvYXBwL2FwcC5jb21wb25lbnQuc2NzcyJ9 */</style>
  <meta name="twitter:card" content="summary">
  <meta name="og:title" content="サンプル">
  <meta name="og:description" content="これはサンプルです。">
  <meta name="og:image" content="/assets/sample.png">
</head>

ちゃんと出力されているのが確認できます。サーバーサイドレンダリングなので Javascript が無効化されたブラウザでもメタタグが出力されています。

これでSSRはひとまず完了しました。

このビルド結果を Firebase の Hosting と Cloud Functions にデプロイして確認します。

Firebase に デプロイする準備

Firebase Tools を使ってデプロイします。

Firebase Tools をインストール

GitHub – firebase/firebase-tools: The Firebase Command Line Tools

$ npm install -g firebase-tools

Firebase プロジェクトを初期化する

Firebase console で適当なプロジェクトを作っておきます。

firebase init で プロジェクトを初期化して、Hosting、Cloud Functions を使えるようにします。

もろもろの選択肢はいい感じにしてください。

$ firebase init

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  C:\dev\web\angular-universal-functions

Before we get started, keep in mind:

  * You are currently outside your home directory

? Are you ready to proceed? Yes
? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices.
 ( ) Database: Deploy Firebase Realtime Database Rules
 ( ) Firestore: Deploy rules and create indexes for Firestore
 (*) Functions: Configure and deploy Cloud Functions
>(*) Hosting: Configure and deploy Firebase Hosting sites
 ( ) Storage: Deploy Cloud Storage security rules
? What language would you like to use to write Cloud Functions?
  JavaScript
> TypeScript
? What do you want to use as your public directory? public
? Configure as a single-page app (rewrite all urls to /index.html)? Yes

これで /functions が生成され、ここに Cloud Functions へデプロイされるプログラムもろもろのファイルが生成されます。

Firebase の設定

Hosting にリクエストされた内容はすべて Cloud Functions に丸投げするように変更します。firebase.json を次のように書き換えます。

firebase.json

{
  "functions": {
    "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build"
  },
  "hosting": {
    "public": "public", 
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "function": "ssr" // 変更
      }
    ]
  }
}

これですべてのリクエストは Hosting から Cloud Functions の ssr に書き換えられるのですが、公開フォルダに index.html があれば “/” へのアクセスのみ優先されてしまうみたいです。したがって、index.html は消してしまいましょう。

上の設定だと “/public” が Hosting にデプロイされるので、”/public” の index.html を削除 してましょう。

サーバーのリスナを消す

server.ts でポートをリッスンしているのですが、Cloud Functions を使うのでこれが不要になります。

server.ts

// ...
// export を追加
export const app = express();

// そのほかは変更しない

// 以下のコードをコメントアウト
// Start up the Node server
// app.listen(PORT, () => {
//   console.log(`Node Express server listening on http://localhost:${PORT}`);
// });

サーバーのリスナを削除して、app を export します。

Webpack の設定

webpack.server.config.js

  output: {
    // Puts the output at the root of the dist folder
    path: path.join(__dirname, 'dist'),
    library: 'app',
    libraryTarget: 'umd',
    filename: '[name].js'
  },

librarylibraryTarget を追加します。何の設定かわかりません。

functions/index.ts

functions/index.ts

import * as functions from 'firebase-functions';
const universal = require(`${process.cwd()}/dist/server`).app;

export const ssr = functions.https.onRequest(universal);

Cloud Functions にデプロイするメソッドを ssr という名前で作成します。この function は サーバーサイドでのレンダリングを行うために Angular アプリにアクセスする必要があります。

/functions/dist/server に配置した Angular アプリ本体を読み込んでいるのが require(``${process.cwd()}/dist/server``) の部分です。ただしまだ /functions/dist が空なのでビルドした Angular アプリをコピーする必要があります。

Angular アプリのコピー

Firebase Cloud Functions ビルド時に /dist の内容(Angularアプリ)をコピーして、/functions/dist に持ってこれるようにします。

$ cd functions
$ npm i fs-extra

/functions/cp-angular.js

const fs = require('fs-extra');

(async() => {

    const src = '../dist';
    const copy = './dist';

    await fs.remove(copy);
    await fs.copy(src, copy);

})();

単純に /dist の内容を /functions/dist に丸っとコピーするスクリプトです。これを Cloud Functions のビルド時に実行されるように、/functions/package.json のスクリプトを書き換えます。

/functions/package.json

{
  "name": "functions",
  "scripts": {
    "build": "node cp-angular && tsc",
    // ...
  }
}

これでビルド時には必要なアプリをコピーしたうえで実行されるようになります。

では動作を確認するためにローカルで Firebase を実行してみます。

Firebase 実行 & デプロイ

まず、Angularアプリ本体のビルドを行います。

$ npm run build:ssr

次に Firebase Cloud Funtions をビルドします。

$ cd functions
$ npm run buld
$ firebase serve

firebase serve コマンドはローカル環境で Firebase を動かすコマンドです。今回だと Hosting と Cloud Functions が起動します。

=== Serving from 'C:\xxxx\angular-universal-functions'...

!  Your requested "node" version "8" doesn't match your global version "10"
+  functions: Emulator started at http://localhost:5001
i  functions: Watching "C:\xxxx\angular-universal-functions\functions" for Cloud Functions...
i  hosting: Serving hosting files from: public
+  hosting: Local server: http://localhost:5000
i  functions: HTTP trigger initialized at http://localhost:5001/xxxxxxxxxxxxx/us-central1/ssr

上記のような感じで起動したURLがそれぞれ表示されます。

Hosting の URL にアクセスすると、設定した通り Cloud Functions が呼び出され、サーバーサイドでレンダリングされた結果が返ります。

正しく起動することが確認できたら、今度は Firebase にデプロイします。

デプロイは簡単です。

$ firebase deploy

これでデプロイが実行されます。デプロイされたURLが表示されるので実際にブラウザで見てみましょう。

Twitter Card の確認を行う

最後にデプロイしたページが、本当にサーバーサイドでレンダリングされて、Twitter Card が正しく表示されるか確認しておきましょう。

Card Validator | Twitter Developers

上記URLで Twitter で URL がシェアされた時の表示を確認できます。

実際に以下のように表示されたら成功です。

ちゃんと設定した画像、タイトル、テキストが表示されていますね。サーバーサイドで正しくレンダリングしてくれているようです。

ルーティングできるようにする

デフォルトの状態だとルーティングの部分がクライアントサイドでのレンダリングになります。したがって、ルーティングに関して initialNavigation を enable にする必要があります。

まずは適当なモジュールを追加します。

$ ng generate component about -m app

このコンポーネントを表示するために、パスに追加します。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AboutComponent } from './about/about.component';

const routes: Routes = [
  { path: 'about', component: AboutComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes, { initialNavigation: 'enabled' })],
  exports: [RouterModule]
})
export class AppRoutingModule { }

initialNavigation を enable を設定してから再度ビルドして動かします。/about のURLを確認すると about works! と表示されるはずです。

開発の手順

サーバーサイドでレンダリングするとはいえ、Angular アプリなので開発中は普段の手順(ng serve)で開発することになります。

テスト時に firebase serve でサーバーサイドレンダリングでの確認を行い問題なければデプロイという感じでしょうか。

以上。

参考URL

Angularカテゴリの最新記事