AWS Lambda Advent Calendar 2017 の8日目です。
前書き
Server Side Rendering (SSR) は、いわゆる SPA (Single Page Application) において、ブラウザー上(クライアントサイド)で動的に生成される DOM と同等の内容を持つ HTML をサーバーサイドで出力するための仕組みです。
React、Vue 等のモダンなフロントエンドフレームワークに軒並み搭載されつつあるこの機能ですが、 Angular にもバージョン2以降 Universal という SSR の仕組みがあります。
この仕組みを Lambda の上で動かして、 API Gateway 経由で見れるようにしてみる記事です。
SSR すると何がうれしいの?
- Web クローラーへの対応(SEO)
- 検索エンジンのクローラーは HTML の内容を解釈してその内容をインデックスしますが、クライアントサイドで動的に変更された状態までは再現できません。そのため SSR に対応していない SPA では、コンテンツをクローラーに読み込ませるのが困難です。検索エンジンではないですが、 OGP や Twitter Card もクローラーで読み込んだ情報を元に表示していますね
- モバイル、低スペックデバイスでのパフォーマンス改善
- いくつかのデバイスは JavaScript の実行パフォーマンスが非常に低かったり、そもそも実行できなかったりします。 SSR された HTML があれば、そのようなデバイスでもコンテンツを全く見られないという事態を避けられます
- 最初のページを素早く表示する
- ある調査によると、モバイルサイトの読み込みに3秒以上かかる場合、アクセスの53%が諦められてしまうのだそうです。 SPA は多くの機能をクライアントサイドで実装するため、初期ロードが長くなりがちですが、最初のビューが SSR されていればユーザーが何も見られない時間を減らせます
どうやって Lambda で SSR するか
Angular Universal は各 Web サーバーフレームワーク用のミドルウェアとして実装されており、現時点では Express, Hapi, ASP.NET 用のエンジンがリリースされています。
他方 Lambda には AWS が開発した、既存の Express アプリを Lambda 上で動かすための aws-serverless-express があります。
今回は Angular Express Engine と aws-serverless-express を組み合わせて Lambda 上で Angular アプリを SSR してみます。
やってみる
公式の Universal チュートリアルをなぞりつつ、 Lambda 対応に必要な部分をフォローしていきます。
Router を使っていないと面白くないので、公式の Angular チュートリアルである Tour of Heroes の完成段階 をいじって SSR 対応にしてみましょう。
チュートリアルのコードをまず動かす
コードを DL して展開、 yarn
で依存物をインストールします。
ついでに git init
して、 .gitignore
も追加しておきましょう。
curl -LO https://angular.io/generated/zips/toh-pt6/toh-pt6.zip
unzip toh-pt6 -d toh-pt6-ssr-lambda
cd toh-pt6-ssr-lambda
yarn
ng serve
で動くことを確認しておきます。
yarn run ng serve --open
SSR に必要なものをインストール
yarn add @angular/platform-server @nguniversal/express-engine @nguniversal/module-map-ngfactory-loader express
yarn add --dev @types/express
ルートモジュールを SSR 用に改変
import { NgModule, Inject, PLATFORM_ID, APP_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
// ...
@NgModule({
imports: [
BrowserModule.withServerTransition({appId: 'toh-pt6-ssr-lambda'}),
// ...
export class AppModule {
constructor(
@Inject(PLATFORM_ID) private platformId: Object,
@Inject(APP_ID) private appId: string,
) {
const platform = isPlatformBrowser(platformId) ? 'browser' : 'server';
console.log({platform, appId});
}
}
サーバー用ルートモジュールを追加
yarn run ng generate module app-server --flat true
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ServerModule } from '@angular/platform-server';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
@NgModule({
imports: [
CommonModule,
AppModule,
ServerModule,
ModuleMapLoaderModule,
],
declarations: [],
bootstrap: [AppComponent],
})
export class AppServerModule {}
サーバー用ブートストラップローダーを追加
export { AppServerModule } from './app/app.server.module';
サーバーのコードを実装
import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import {enableProdMode} from '@angular/core';
import * as express from 'express';
import {join} from 'path';
// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();
// Express server
export const app = express();
// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('../dist/serverApp/main.bundle');
// Express Engine
import {ngExpressEngine} from '@nguniversal/express-engine';
// Import module map for lazy loading
import {provideModuleMap} from '@nguniversal/module-map-ngfactory-loader';
app.engine('html', ngExpressEngine({
bootstrap: AppServerModuleNgFactory,
providers: [
provideModuleMap(LAZY_MODULE_MAP)
],
}));
app.set('view engine', 'html');
app.set('views', join(process.cwd(), 'dist', 'browserApp'));
// Server static files from /browser
app.get('*.*', express.static(join(process.cwd(), 'dist', 'browserApp')));
// All regular routes use the Universal engine
app.get('*', (req, res) => {
res.render(join(process.cwd(), 'dist', 'browserApp', 'index.html'), {req});
});
import {app} from '.';
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`Node server listening on http://localhost:${PORT}`);
});
チュートリアルからの変更点: あとで Lambda で使い回すのでこのコードでは app.listen()
せずに単に export
しておき、スタート用のファイルは別に用意します。ファイルの置き場所も若干変更しています。
サーバー用のビルド設定を追加
// ...
"apps": [
{
"platform": "browser",
"root": "src",
"outDir": "dist/browser",
// ...
{
"platform": "server",
"root": "src",
"outDir": "dist/server",
"assets": [
"assets",
"favicon.ico"
],
"index": "index.html",
"main": "main.server.ts",
"test": "test.ts",
"tsconfig": "tsconfig.server.json",
"testTsconfig": "tsconfig.spec.json",
"prefix": "app",
"styles": [
"styles.css"
],
"scripts": [],
"environmentSource": "environments/environment.ts",
"environments": {
"dev": "environments/environment.ts",
"prod": "environments/environment.prod.ts"
}
}
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"baseUrl": "./",
"module": "commonjs",
"types": []
},
"exclude": [
"test.ts",
"**/*.spec.ts"
],
"angularCompilerOptions": {
"entryModule": "app/app-server.module#AppServerModule"
}
}
.angular-cli.json
にはクライアント用のビルド設定しか書かれていないので、ここにサーバーサイド用アプリのビルド設定を追加します。 outDir
が被らないように変えておきます。
ng build
では「サーバーサイド用の Angular アプリのビルド」までは面倒を見てくれますが、サーバー自体のコードのビルドは自力でやる必要があるので、もろもろ追加します。
yarn add --dev awesome-typescript-loader webpack copy-webpack-plugin concurrently
const {join} = require('path');
const {ContextReplacementPlugin} = require('webpack');
const CopyWebpackPlugin = require("copy-webpack-plugin");
module.exports = {
entry: {
server: join(__dirname, 'index.ts'),
start: join(__dirname, 'start.ts'),
},
resolve: { extensions: ['.js', '.ts'] },
target: 'node',
externals: [/(node_modules|main\..*\.js)/],
output: {
path: join(__dirname, '..', 'dist', 'server'),
filename: '[name].js'
},
module: {
rules: [{ test: /\.ts$/, loader: 'awesome-typescript-loader' }]
},
plugins: [
new CopyWebpackPlugin([
{from: "dist/browserApp/**/*"},
{from: "dist/serverApp/**/*"},
]),
// Temporary Fix for issue: https://github.com/angular/angular/issues/11580
// for 'WARNING Critical dependency: the request of a dependency is an expression'
new ContextReplacementPlugin(
/(.+)?angular(\\|\/)core(.+)?/,
join(__dirname, '..', 'src'), // location of your src
{} // a map of your routes
),
new ContextReplacementPlugin(
/(.+)?express(\\|\/)(.+)?/,
join(__dirname, '..', 'src'),
{}
)
]
};
サーバーの実装上の都合で (クライアント用の Angular ビルド + サーバー用の Angular ビルド) → サーバーのビルド
という手順を踏む必要があるので、一連のビルド用スクリプトを追加します。
"build:prod": "concurrently --names 'browser,server' 'ng build --prod --progress false --base-href /prod/ --app 0' 'ng build --prod --progress false --base-href /prod/ --app 1 --output-hashing false'",
"prebuild:server": "npm run build:prod",
"build:server": "webpack --config server/webpack.config.js",
"start:server": "node dist/server/start.js"
--base-href
を指定しているのは、後で API Gateway の仕様との整合性をとるためです。
ローカルでサーバーを動かしてみる
yarn run build:server
yarn run start:server
http://localhost:4000/prod/detail/14 あたりにアクセスして、 SSR されているか確認してみます。
最初のリクエストに対するレスポンスで、 Angular が生成した HTML が返ってきていることがわかります。チュートリアル第1章で pipe を使って大文字にしたところもちゃんと再現されていますね。
普通に ng serve
した場合はこんな感じのはずです。
Lambda にデプロイする
おなじみの Serverless Framework を使います。いつもありがとうございます。
ここで aws-serverless-express
も追加しましょう。
yarn add aws-serverless-express
yarn add --dev @types/aws-serverless-express serverless serverless-webpack
Lambda 用のエントリーポイントを追加します。
import {createServer, proxy} from 'aws-serverless-express';
import {app} from '../server';
export default (event, context) => proxy(createServer(app), event, context);
Lambda (serverless-webpack) 用のビルド設定を追加します。さっきのやつとほぼ同じですが、 libraryTarget: "commonjs"
がないと動かないようです。
const {join} = require('path');
const {ContextReplacementPlugin} = require('webpack');
const CopyWebpackPlugin = require("copy-webpack-plugin");
const slsw = require('serverless-webpack');
module.exports = {
entry: slsw.lib.entries,
resolve: { extensions: ['.js', '.ts'] },
target: 'node',
externals: [/(node_modules|main\..*\.js)/],
output: {
libraryTarget: "commonjs",
path: join(__dirname, '..', 'dist', 'lambda'),
filename: '[name].js'
},
module: {
rules: [{ test: /\.ts$/, loader: 'awesome-typescript-loader' }]
},
plugins: [
new CopyWebpackPlugin([
{from: "dist/browserApp/**/*"},
{from: "dist/serverApp/**/*"},
]),
// Temporary Fix for issue: https://github.com/angular/angular/issues/11580
// for 'WARNING Critical dependency: the request of a dependency is an expression'
new ContextReplacementPlugin(
/(.+)?angular(\\|\/)core(.+)?/,
join(__dirname, '..', 'src'), // location of your src
{} // a map of your routes
),
new ContextReplacementPlugin(
/(.+)?express(\\|\/)(.+)?/,
join(__dirname, '..', 'src'),
{}
)
]
};
Serverless の設定ファイルを追加します。あらゆるパスへの GET
を Lambda にルーティングするように設定します。
service: toh-pt6-ssr-lambda
provider:
name: aws
runtime: nodejs6.10
region: ${env:AWS_REGION}
memorySize: 128
plugins:
- serverless-webpack
custom:
webpack: lambda/webpack.config.js
webpackIncludeModules: true
functions:
main:
handler: lambda/index.default
events:
- http:
path: /
method: get
- http:
path: "{proxy+}"
method: get
Serverless をインストールした状態でビルドしようとすると以下のようなエラーが出るので、とりあえず tsconfig.json
を変更して回避しておきます。
ERROR in [at-loader] ./node_modules/@types/graphql/subscription/subscribe.d.ts:17:4
TS2304: Cannot find name 'AsyncIterator'.
ERROR in [at-loader] ./node_modules/@types/graphql/subscription/subscribe.d.ts:29:4
TS2304: Cannot find name 'AsyncIterable'.
"lib": [
"es2017",
"dom",
"esnext.asynciterable"
]
Lambda 用のデプロイスクリプトを追加します。
"predeploy": "npm run build:prod",
"deploy": "serverless deploy"
で、ようやく Lambda のデプロイです。
yarn run deploy
うまくいけば Serverless によって各種 AWS リソースが作られ、 API Gateway のエンドポイントが出力されるはずです。
API Gateway にアクセスして SSR されているか見てみる
先程と同様に確認してみます。
よさげ。
今後の課題
Lambda + API Gateway で SSR することができました。しかし、いろいろと課題はまだありそうです。
HTTP ステータスコード問題
現状のコードでは固定的に 200
を返しているため、ありえないパスにリクエストが来てもクローラー的には OK
と解釈されてしまいます。本来なら 404
などを適切に返すべきです。
HTML 以外をどうやって動的に返すか
一般的なサイトでは sitemap.xml
など動的な内容かつ HTML ではないものも返す必要があります。これを Angular でできるかどうか、調べる必要がありそうです。
Express なくせるんじゃね?
今回は Angular -> Universal + Express Engine -> Express -> aws-serverless-express -> Lambda (API Gateway Proxy Integration)
という繋ぎ方をしました。
これを Angular -> Universal + [何か] -> Lambda (API Gateway Proxy Integration)
にできたら構成要素が減らせていい感じですよね。
renderModuleFactory などを自力で叩く実装ができれば、これが実現できるのかもしれません。