2024年から始めるAngular Universal (SSR) の本番運用
この記事は、Angular Advent Calendar 2023の22日目の記事です。
Angular Universalとは?
Angular Universalは、AngularでServer Side Renderingを実現するための追加ライブラリでした。
というのは、Angular v17からAngular UniversalがAngular CLIに統合され、より簡単にSSR対応アプリを作成できるようになったためです。
In version 17, Universal has been moved into the Angular CLI repo. Code has been refactored and renamed (mostly under @angular/ssr now), but the core functionality and architecture is unchanged.
以下のコマンドを用いて新規プロジェクトを作成するだけで、SSR対応のAngularプロジェクトを構築することができます。
$ ng new --ssr
以前もまあそこまで難しくはなかったのですが、それにも増してより簡単になりました。
今回やること
2019年のアドベントカレンダーにて、当時のAngular Universalを使ってSSR対応アプリをLambdaにデプロイしてみる、という記事を書きました。
あれから4年の歳月を経て新しくなったAngular Universalを、当時と同じようにLambdaにデプロイして、本番運用に耐えうるかを検証してみたいと思います。
今回作成したサンプルコードは以下のリポジトリにて公開しています。
実際にLambdaにデプロイしたアプリケーションはこちら。
作業
初期設定
ということで、先程のコマンドを使って、Angular v17からリニューアルされた初期アプリを作っていきたいと思います。
$ ng new --ssr
$ npm run build && npm run serve:ssr:<application name>
http://localhost:4000
で起動します。
早速Lighthouseを回してみました。FCPがちょっと遅いですが、それでもパフォーマンス96点は、今までのSPA Angularの感覚からすると驚異の数字です。。。
中身を見てみる
SPA Angularとの違いとして目につくのは、以下のファイルが存在することです。
server.ts
src/main.server.ts
src/app/app.config.server.ts
下の2ファイルは、CSRとSSRで設定を変えるための数行のファイルでした。特に弄る必要はなさそうなので、残りのserver.ts
を見てみましょう。
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine();
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y'
}));
// All regular routes use the Angular engine
server.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});
return server;
}
function run(): void {
const port = process.env['PORT'] || 4000;
// Start up the Node server
const server = app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
run();
SSRで使用するフレームワークは昔と変わらずExpressのようです。
ということは、そこまで頑張らなくてもLambdaに載せられそうです。
The Express app is exported so that it can be used by serverless Functions.
わざわざ「サーバーレス関数に使用できるようにExpress APPインスタンスをエクスポートしてるよ!」とご丁寧に書いてくれています。優しい!
Lambdaに載せてみる
Serverless Frameworkの設定
まずはServerless Framework周りのライブラリを入れていきましょう。
$ npm i -D serverless serverless-deployment-bucket @serverless/typescript ts-node
$ npm i @h4ad/serverless-adapter
次に、serverless.ts
を作成します。
import type { AWS } from '@serverless/typescript';
const system = 'angular-v17-ssr-example';
const serverlessConfiguration: AWS = {
service: system,
frameworkVersion: '3',
plugins: ['serverless-deployment-bucket'],
provider: {
name: 'aws',
stage: 'prod',
region: 'ap-northeast-1',
runtime: 'nodejs20.x',
architecture: 'arm64',
stackName: '${param:prefix}-cfstack',
stackTags: {
System: system,
Stage: '${sls:stage}',
Serverless: 'true',
},
deploymentBucket: {
name: '${param:prefix}-sls-deployment-bucket',
serverSideEncryption: 'AES256',
},
iam: {
role: {
name: '${param:prefix}-lambda-role',
},
},
environment: {},
},
// import the function via paths
functions: {
app: {
handler: `dist/${system}/server/server.main`,
name: '${param:prefix}-lambda',
memorySize: 1769,
timeout: 60,
url: true,
package: {
patterns: ['!**', 'dist/**'],
},
},
},
package: { individually: true },
params: {
default: {
prefix: system,
},
},
resources: {},
};
module.exports = serverlessConfiguration;
Lambdaにデプロイするためには、Angular CLIでビルドした成果物をパッケージングしてもらうための対象ファイル設定と、エントリーポイントを指定する設定が最低限必要です。
そのあたりが以下です。
<…前略>
functions: {
app: {
handler: `dist/${system}/server/server.main`,
<…中略>
url: true,
package: {
patterns: ['!**', 'dist/**'],
},
},
},
<…後略>
Angular CLIでビルドした成果物は、dist/<application name>
以下に生成され、SSR用のmjsファイルはserver/server.mjs
という名前で出力されるため、handler
プロパティにその通り設定します。
.main
は関数名です。詳細は後述します。
また、dist
フォルダ内以外のファイルを含まないように、package
プロパティでパターンフィルタリングをかけています。
今回はLambda Function URLでアクセスできることを目的としているため、url
プロパティもtrue
に設定しておきます。
その他のプロパティは適宜編集して構いません。
Expressインスタンス作成部を分離する
server.ts
の内部で、Expressの組み込みサーバーを起動する処理が書かれています。
今回、@h4ad/serverless-adapter
を使ってLambdaの作法に則ったプロキシ処理を行うため、実は組み込みサーバーは必要ありません。
ただし、Expressのインスタンスだけ必要なので、これをapp.ts
として分離していきます。
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine();
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y'
}));
// All regular routes use the Angular engine
server.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});
return server;
}
import { app } from './app';
function run(): void {
const port = process.env['PORT'] || 4000;
// Start up the Node server
const server = app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
run();
コピペするだけでOKです。
handler.ts
を作成する
次に、Lambdaからのエントリーポイントとなるhandler.ts
を作成していきます。
import { ServerlessAdapter, ServerlessHandler } from '@h4ad/serverless-adapter';
import { ApiGatewayV2Adapter } from '@h4ad/serverless-adapter/lib/adapters/aws';
import { ExpressFramework } from '@h4ad/serverless-adapter/lib/frameworks/express';
import { LazyFramework } from '@h4ad/serverless-adapter/lib/frameworks/lazy';
import { DefaultHandler } from '@h4ad/serverless-adapter/lib/handlers/default';
import { PromiseResolver } from '@h4ad/serverless-adapter/lib/resolvers/promise';
import { Express } from 'express';
import { app } from './app';
const framework = new LazyFramework(new ExpressFramework(), async () => app());
export const main: ServerlessHandler<Express> = ServerlessAdapter.new(null)
.setFramework(framework)
.setHandler(new DefaultHandler())
.setResolver(new PromiseResolver())
.addAdapter(new ApiGatewayV2Adapter())
.build();
ここでエクスポートしたmain
関数が、Lambdaのエントリーポイントとなります。
serverless.ts
の.main
の部分ですね。
Lambda Function URLのイベントは、API Gateway V2と同様の形式ですので、ApiGatewayV2Adapter
を使用することで動作します。
@h4ad/serverless-adapter
は、有名な@vendia/serverless-express
の後継を目指しているライブラリで、Express以外にもFastifyやhapi、Koa、NestJS等の著名なJS/TSフレームワークで動作するよう設計されています。
Exampleも豊富にあるので参考にしてみてください。
angular.json
を編集する
SSR時のエントリーポイントを指定するプロパティがあるので、projects.<application name>.architect.build.configurations
に新たに項目を追加し、エントリーポイントをhandler.ts
に変更します。
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"angular-v17-ssr-example": {
<…中略>
"architect": {
"build": {
<…中略>
"configurations": {
<…中略>
"serverless": { <-- ここから
"ssr": {
"entry": "handler.ts"
}
} <-- ここまで
},
"defaultConfiguration": "production"
},
<…中略>
}
}
}
}
tsconfig.json
を継承したtsconfig.sls.json
を作成する
Angular v17では、デフォルトでES Modulesが使われていますが、Serverless FrameworkではまだESM非対応のため、compilerOptions.module
を上書きしたtsconfig.sls.json
を作成します。
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs"
}
}
また、既存のtsconfig.app.json
で指定されているfiles
にapp.ts
とhandler.ts
を足しておきましょう。
<…前略>
"files": [
"src/main.ts",
"src/main.server.ts",
"server.ts",
"app.ts",
"handler.ts"
],
<…後略>
package.json
にコマンドを追加する
先程angular.json
に追記したserverless
という名前の設定を使用して、ビルドしてからデプロイが実行されるようにします。
また、serverless
コマンド実行時は、先ほど作成したtsconfig.sls.json
を使って実行されるよう、TS_NODE_PROJECT
で明示的に指定します。
<…前略>
"scripts": {
<…中略>
"serverless": "TS_NODE_PROJECT=tsconfig.sls.json serverless",
"deploy": "npm run build -- --configuration serverless && npm run serverless -- deploy",
"remove": "npm run serverless -- remove"
},
<…後略>
デプロイしてみる
ここまでの作業で、Lambdaをデプロイできるようになりました。
実際にコマンドを叩いてみます。
$ npm run deploy
> angular-v17-ssr-example@0.0.0 deploy
> npm run build -- --configuration serverless && npm run serverless -- deploy
> angular-v17-ssr-example@0.0.0 build
> ng build --configuration serverless
⠏ Building...
▲ [WARNING] Module '@h4ad/serverless-adapter' used by 'handler.ts' is not ESM
CommonJS or AMD dependencies can cause optimization bailouts.
For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies
▲ [WARNING] Module '@h4ad/serverless-adapter/lib/adapters/aws' used by 'handler.ts' is not ESM
CommonJS or AMD dependencies can cause optimization bailouts.
For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies
▲ [WARNING] Module '@h4ad/serverless-adapter/lib/frameworks/express' used by 'handler.ts' is not ESM
CommonJS or AMD dependencies can cause optimization bailouts.
For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies
▲ [WARNING] Module '@h4ad/serverless-adapter/lib/frameworks/lazy' used by 'handler.ts' is not ESM
CommonJS or AMD dependencies can cause optimization bailouts.
For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies
▲ [WARNING] Module '@h4ad/serverless-adapter/lib/handlers/default' used by 'handler.ts' is not ESM
CommonJS or AMD dependencies can cause optimization bailouts.
For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies
▲ [WARNING] Module '@h4ad/serverless-adapter/lib/resolvers/promise' used by 'handler.ts' is not ESM
CommonJS or AMD dependencies can cause optimization bailouts.
For more information see: https://angular.io/guide/build#configuring-commonjs-dependencies
Prerendered 1 static route.
Initial Chunk Files | Names | Raw Size | Estimated Transfer Size
main.js | main | 204.23 kB | 56.53 kB
polyfills.js | polyfills | 32.69 kB | 10.59 kB
styles.css | styles | 0 bytes | 0 bytes
| Initial Total | 236.92 kB | 67.12 kB
Application bundle generation complete. [3.698 seconds]
> angular-v17-ssr-example@0.0.0 serverless
> TS_NODE_PROJECT=tsconfig.sls.json serverless deploy
Deploying angular-v17-ssr-example to stage prod (ap-northeast-1)
Using deployment bucket 'angular-v17-ssr-example-sls-deployment-bucket'
Using deployment bucket 'angular-v17-ssr-example-sls-deployment-bucket'
✔ Service deployed to stack angular-v17-ssr-example-cfstack (50s)
endpoint: https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws/
functions:
app: angular-v17-ssr-example-lambda (801 kB)
Need a faster logging experience than CloudWatch? Try our Dev Mode in Console: run "serverless dev"
ESM非対応エラーが出ますが、正常にデプロイできます。
最後に表示されたendpoint
のURLにアクセスすると、初期アプリ画面が表示されます。
本番運用で考慮したいこと
このままでも動作はしますが、本番運用時は以下のことを考慮する必要があります。
前段にCDNを噛ます
Lambdaは実行時間単位課金の比率が大きいので、静的なファイルへのリクエストはできるだけCDNから返してもらい、Lambdaへのリクエストを減らすことで節約になります。
AWSの場合はCloudFrontになるかと思います。2019年の記事とは異なり、S3を用意して静的ファイルだけそちらに分離するような小細工をするよりは、Lambda Function URLをそのままオリジンとして使ったほうがシンプルでいいんじゃないかな、と思います。
<…前略>
// Serve static files from /browser
server.get(
'*.*',
express.static(browserDistFolder, {
maxAge: '1y',
})
);
<…後略>
ただ、HTMLを返す部分では、Cache-Control
ヘッダーが付与されていません。
このままだと、ソースコードに変更を加えてデプロイしたときに、CDNキャッシュが更新されず、ユーザー視点で変更が反映されない懸念があるので、予めapp.ts
でCache-Control: no-cache
あたりを付けておくか、CloudFront側でキャッシュポリシーの作り分けをしておいたほうが良さそうです。
<…前略>
// All regular routes use the Angular engine
server.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then((html) => res.send(html)) <-- res.header()で設定すれば良さそう
.catch((err) => next(err));
});
<…後略>
Angular Materialは?
かつてのAngular Universalでは、基本的にAngular Materialとの併用が不可という認識だったのですが、v17からは普通に動作しちゃうようです。(すべての機能を調査したわけではないので、一部動かなかったらごめんなさい)
レスポンスを見ると普通にSSRされているので、CSRでしか利用できない関数を用いているようなコンポーネントやCDKの利用には注意が必要かもしれません。
この辺の情報、公式で出してくれればいいのにね。。。
ISRもできるらしい
RxAngularがSSR環境上でISRを実現するライブラリを公開していました。
サンプルコードを見た限りでは簡単に導入できそうなので、時間のあるときにこちらも試してみたいと思います。
ただ、ドキュメント上のサンプルコードがちょっと古い気がします。(@nguniversal/express-engine
を使ったりしているので)
Lambdaで導入する場合は、インスタンスが大量に生えて大量に消えていく関係上、InMemoryだと旨味がないので、別途共有キャッシュ層が必要ですね。ドキュメントではRedisを使っていましたが、お金かけたくないので、DynamoDBでカスタムキャッシュハンドラーを作りたい。
SSR用のフレームワークの選定
God of god(神)。
Angular CLIで作成するボイラープレートではExpressが使われていましたが、レイテンシを極限まで高めたい場合は、Fastifyでホスティングしてもいいかもしれません。
@h4ad/serverless-adapter
でもFastify対応がありますので、爆速化とLambda実行時間削減によるコスト減が期待できます。
まとめ
これまではまともに使えるとは言い難かったSSR機能がAngular v17で改善されたことにより、SEOやレスポンスタイムが重要な、パブリックなアプリケーションへAngularを採用する余地が生まれました。
そういった分野はNext.jsやRemix等のReact系一択でしたが、とうとうAngularがReactと戦えるようになり、感涙を禁じえません。
今回はAWS Lambdaを使って検証してみましたが、動作原理は抽象化されていて非常に単純なので、その他のサーバーレスホスティングサービスでもそれほど苦労することなく動作させることが可能かと思います。
2024年、みなさんもAngular SSRで本番運用アプリケーション作ってみませんか?
Discussion