使用Angular Universal在AWS Lambda + S3上进行便捷的SSR适配Web应用开发
这篇文章是2019年Angular #2彩虹日历的第20天文章。
你好。我是 @seapolis。我在第一次的圣诞日历中感到非常紧张。
尽管圣诞节只剩下几天了,但我认为仍然有许多人尚未确定在圣诞派对上交换礼物。
因此,今天我想介绍一个绝对能受到欢迎的圣诞礼物,那就是Angular Universal应用的配方。
Angular Universal是什么?
我想很多人可能不太了解Angular Universal,所以我想在这里做一点简单的介绍。
Angular Universal和Nuxt.js可以说是Angular版本和Vue.js版本的相应框架。
Nuxt.js是一个将多种功能与Vue.js结合在一起的框架,其中引人注目的功能是服务器端渲染(SSR)功能。
正如您所知,Vue.js和Angular是用于创建单页应用程序(以下简称SPA)的框架。然而,仅使用SPA时,由于在浏览器中使用JavaScript构建HTML结构,所以会出现加载时间长、搜索引擎优化较差等缺点。
为解决这些问题,出现了SSR技术。与在浏览器端构建HTML结构不同,SSR通过在专门搭建的Node服务器上预先构建HTML结构,然后将整个HTML文件返回给浏览器。通过这种方式,能够保持SPA的用户体验,同时避免上述缺点。
Nuxt.js内置了这一SSR功能作为其特色。在开发过程中,只要修改了Vue文件,它就会自动重新加载,开发者可以毫无意识地开发,就像开发SPA一样,因此非常受欢迎。
可以将SSR功能应用于Angular应用程序的库是Angular Universal。
如何创建Angular Universal项目。
Angular Universal提供了官方一种用法,但仅仅执行这个方式却无法使用类似Nuxt.js的热重载功能。每次修改文件都需要运行npm run build:ssr && npm run serve:ssr,开发体验非常糟糕。
因此,我们将使用名为@enten/angular-universal的脚手架模板,其中包含了为使用Angular Universal和热重载功能进行各种设置的预设配置。
请按照 README.md 中所写的进行操作。
$ git clone https://github.com/enten/angular-universal
$ cd angular-universal
$ npm install
$ ng serve
我可以非常容易地启动Angular Universal应用程序。
构成
让我们来查看src文件夹的内容。
src
├ api <-- SSR用サーバーのAPIルーティング設定
├ app
├ assets
├ environments
├ favicon.ico
├ index.html
├ main.ts
├ polyfill.ts
├ server.ts <-- SSR用サーバーの立ち上げを行っている
├ styles.scss
└ test.ts
我发现了一个在之前看过的文件夹里添加了api文件夹和server.ts文件的情况。
// These are important and needed before anything else
import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import { createServer } from 'http';
import { join } from 'path';
import { enableProdMode } from '@angular/core';
import { NgSetupOptions } from '@nguniversal/express-engine';
import { MODULE_MAP } from '@nguniversal/module-map-ngfactory-loader';
import { ServerAPIOptions, createApi } from './api';
import { environment } from './environments/environment';
// WARN: don't remove export of AppServerModule.
// Removing export below will break replaceServerBootstrap() transformer
export { AppServerModule } from './app/app.server.module';
// Faster server renders w/ Prod mode.
// Prod mode isn't enabled by default because that breaks debugging tools like Augury.
if (environment.production) {
enableProdMode();
}
export const PORT = process.env.PORT || 4000;
export const BROWSER_DIST_PATH = join(__dirname, '..', 'browser');
export const getNgRenderMiddlewareOptions: () => NgSetupOptions = () => ({
bootstrap: exports.AppServerModuleNgFactory,
providers: [
// Import module map for lazy loading
{
provide: MODULE_MAP,
useFactory: () => exports.LAZY_MODULE_MAP,
deps: [],
},
],
});
export const getServerAPIOptions: () => ServerAPIOptions = () => ({
distPath: BROWSER_DIST_PATH,
ngSetup: getNgRenderMiddlewareOptions(),
});
let requestListener = createApi(getServerAPIOptions());
// Start up the Node server
const server = createServer((req, res) => {
requestListener(req, res);
});
server.listen(PORT, () => {
console.log(`Server listening -- http://localhost:${PORT}`);
});
// HMR on server side
if (module.hot) {
const hmr = () => {
try {
const { AppServerModuleNgFactory } = require('./app/app.server.module.ngfactory');
exports.AppServerModuleNgFactory = AppServerModuleNgFactory;
} catch (err) {
console.warn(`[HMR] Cannot update export of AppServerModuleNgFactory. ${err.stack || err.message}`);
}
try {
requestListener = require('./api').createApi(getServerAPIOptions());
} catch (err) {
console.warn(`[HMR] Cannot update server api. ${err.stack || err.message}`);
}
};
module.hot.accept('./api', hmr);
module.hot.accept('./app/app.server.module', hmr);
module.hot.accept('./app/app.server.module.ngfactory', hmr);
}
export default server;
在server.ts文件中,我们启动了SSR服务器。我们可以看到它在4000端口上监听。
它的主要作用是根据URL返回由app.server.module.ts加载的各种模块和组件的构建结果。由于它是基于express.js的,所以可以通过自定义来将其用作BFF层。
import { NgSetupOptions, ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
export interface ServerAPIOptions {
distPath: string;
ngSetup?: NgSetupOptions;
}
export function createApi(options: ServerAPIOptions) {
const router = express();
router.use(createNgRenderMiddleware(options.distPath, options.ngSetup));
return router;
}
export function createNgRenderMiddleware(distPath: string, ngSetup: NgSetupOptions) {
const router = express();
router.set('view engine', 'html');
router.set('views', distPath);
// Server static files from distPath
router.get('*.*', express.static(distPath));
// Angular Express Engine
router.engine('html', ngExpressEngine(ngSetup));
// All regular routes use the Universal engine
router.get('*', (req, res) => res.render('index', { req, res }));
return router;
}
如果您有接触过express.js的经验,我认为您可以相对容易地进行自定义。例如,对于createApi方法,您可以按照以下方式进行定制:
router.get('/api/*', (req, res, next) => {
res.json(req.url);
});
router.use(createNgRenderMiddleware(options.distPath, options.ngSetup));
如果添加路由配置,您可以创建一个路由,当访问<域名>/api/<任意路径>时,以该路径作为body返回。
使用作为微服务的代理和原本的 BFF 用途同样可以,也可以用作认证服务器的客户端,如果用 express.js,用法无限大啊!
让我们在AWS Lambda上启动
我之前简要介绍了Angular Universal,但若不能在实际运营中使用,那就没有意义了。因此,接下来我想介绍在AWS Lambda上部署Angular Universal的方法。
刚才,我解释了Angular Universal使用express.js作为SSR服务器。这意味着我们可以使用Serverless Framework将其部署到AWS Lambda!
准备好了
请提前准备好一个AWS账户,并安装aws-cli工具,在具有管理员权限的IAM账户上登录。
接下来,使用npm安装aws-serverless-express。
$ npm i aws-serverless-express
这是一个使用Serverless Framework,在lambda上运行express服务器的库。
此外,还需要将Serverless Framework本身作为devDependencies安装。
$ npm i -D serverless
修改server.ts
接下来,在server.ts文件中加入4行代码。
// These are important and needed before anything else
import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import { createServer } from 'http';
import { join } from 'path';
import { enableProdMode } from '@angular/core';
import { NgSetupOptions } from '@nguniversal/express-engine';
import { MODULE_MAP } from '@nguniversal/module-map-ngfactory-loader';
import { ServerAPIOptions, createApi } from './api';
import { environment } from './environments/environment';
import * as awsServerlessExpress from 'aws-serverless-express'; // <-- 追加
// WARN: don't remove export of AppServerModule.
// Removing export below will break replaceServerBootstrap() transformer
export { AppServerModule } from './app/app.server.module';
// Faster server renders w/ Prod mode.
// Prod mode isn't enabled by default because that breaks debugging tools like Augury.
if (environment.production) {
enableProdMode();
}
export const PORT = process.env.PORT || 4000;
export const BROWSER_DIST_PATH = join(__dirname, '..', 'browser');
export const getNgRenderMiddlewareOptions: () => NgSetupOptions = () => ({
bootstrap: exports.AppServerModuleNgFactory,
providers: [
// Import module map for lazy loading
{
provide: MODULE_MAP,
useFactory: () => exports.LAZY_MODULE_MAP,
deps: [],
},
],
});
export const getServerAPIOptions: () => ServerAPIOptions = () => ({
distPath: BROWSER_DIST_PATH,
ngSetup: getNgRenderMiddlewareOptions(),
});
let requestListener = createApi(getServerAPIOptions());
const render = awsServerlessExpress.createServer(requestListener); // <-- 追加
export const handler = (event, context) => // <-- 追加
awsServerlessExpress.proxy(render, event, context); // <-- 追加
// Start up the Node server
const server = createServer((req, res) => {
requestListener(req, res);
});
server.listen(PORT, () => {
console.log(`Server listening -- http://localhost:${PORT}`);
});
// HMR on server side
if (module.hot) {
const hmr = () => {
try {
const { AppServerModuleNgFactory } = require('./app/app.server.module.ngfactory');
exports.AppServerModuleNgFactory = AppServerModuleNgFactory;
} catch (err) {
console.warn(`[HMR] Cannot update export of AppServerModuleNgFactory. ${err.stack || err.message}`);
}
try {
requestListener = require('./api').createApi(getServerAPIOptions());
} catch (err) {
console.warn(`[HMR] Cannot update server api. ${err.stack || err.message}`);
}
};
module.hot.accept('./api', hmr);
module.hot.accept('./app/app.server.module', hmr);
module.hot.accept('./app/app.server.module.ngfactory', hmr);
}
export default server;
创建serverless.yml文件
在项目的根目录下,创建一个用于Serverless Framework的配置文件serverless.yml。
service: ng-univ-lambda-template # lambda関数につけられるサービス名
provider:
name: aws
runtime: nodejs10.x
stage: ${env:STAGE}
region: ap-northeast-1
environment:
STAGE: ${env:STAGE}
NODE_ENV: ${env:NODE_ENV}
iamRoleStatements:
- Effect: 'Allow'
Action:
- 'lambda:InvokeFunction'
Resource:
- Fn::Join:
- ':'
- - arn:aws:lambda
- Ref: AWS::Region
- Ref: AWS::AccountId
- function:${self:service}-${opt:stage, self:provider.stage}-*
package:
exclude:
- ./**
- '!node_modules/**'
include:
- dist/app/**
- package.json
functions:
render:
handler: dist/app/server/main.handler
events:
- http:
path: '/'
method: get
- http:
path: '{proxy+}'
method: get
- http:
path: '/api/{proxy+}'
method: any
environment:
STAGE: ${env:STAGE}
NODE_ENV: ${env:NODE_ENV}
在${env:xxx}中,当从package.json中记录的npm命令执行serverless命令时,可以注入任意值。
在package.json中添加部署命令
"scripts": {
"ng": "ng",
"prestart": "npm run build:prod",
"start": "node ./dist/app/server/main.js",
"build": "ng build",
"build:prod": "ng build -c production",
"dev": "ng serve",
"dev:spa": "ng serve -c spa",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"deploy:dev": "STAGE=dev NODE_ENV=production sls deploy -v",
"deploy:prod": "STAGE=prod NODE_ENV=production sls deploy -v"
}
在script属性中添加Lambda部署所需的命令。
在STAGE=dev的情况下,将dev注入到之前在serverless.yml中所描述的${env:STAGE}部分。
换句话说,通过执行不同的npm命令,可以区分不同的部署阶段。
尝试执行
当完成到这一步时,让我们实际执行部署到AWS Lambda的操作。
$ npm run build:prod
$ npm run deploy:prod
如果以下这样的日志依次滚动显示,并在最后显示“Stack Outputs”,则表示正常完成。
> angular-universal@0.0.0 deploy:prod /mnt/f/Documents/Program/angular-universal-aws-lambda-template
> STAGE=prod NODE_ENV=production sls deploy -v
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service ng-univ-lambda-template.zip file to S3 (42.73 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
CloudFormation - UPDATE_IN_PROGRESS - AWS::CloudFormation::Stack - ng-univ-lambda-template-prod
CloudFormation - UPDATE_IN_PROGRESS - AWS::Lambda::Function - RenderLambdaFunction
CloudFormation - UPDATE_COMPLETE - AWS::Lambda::Function - RenderLambdaFunction
CloudFormation - CREATE_IN_PROGRESS - AWS::Lambda::Version - RenderLambdaVersionElfmXkriWTVEnPo0XfUNZ1T1CVRpAuAdg2aokSEQoo
CloudFormation - CREATE_IN_PROGRESS - AWS::ApiGateway::Deployment - ApiGatewayDeployment1576305173492
CloudFormation - CREATE_IN_PROGRESS - AWS::ApiGateway::Deployment - ApiGatewayDeployment1576305173492
CloudFormation - CREATE_IN_PROGRESS - AWS::Lambda::Version - RenderLambdaVersionElfmXkriWTVEnPo0XfUNZ1T1CVRpAuAdg2aokSEQoo
CloudFormation - CREATE_COMPLETE - AWS::ApiGateway::Deployment - ApiGatewayDeployment1576305173492
CloudFormation - CREATE_COMPLETE - AWS::Lambda::Version - RenderLambdaVersionElfmXkriWTVEnPo0XfUNZ1T1CVRpAuAdg2aokSEQoo
CloudFormation - UPDATE_COMPLETE_CLEANUP_IN_PROGRESS - AWS::CloudFormation::Stack - ng-univ-lambda-template-prod
CloudFormation - DELETE_IN_PROGRESS - AWS::ApiGateway::Deployment - ApiGatewayDeployment1576304091991
CloudFormation - DELETE_SKIPPED - AWS::Lambda::Version - RenderLambdaVersionmvp3W8wvhJNl7SWlw0ZESTVimlL6FGADyblQJnjgLpY
CloudFormation - DELETE_COMPLETE - AWS::ApiGateway::Deployment - ApiGatewayDeployment1576304091991
CloudFormation - UPDATE_COMPLETE - AWS::CloudFormation::Stack - ng-univ-lambda-template-prod
Serverless: Stack update finished...
Service Information
service: ng-univ-lambda-template
stage: prod
region: ap-northeast-1
stack: ng-univ-lambda-template-prod
resources: 15
api keys:
None
endpoints:
GET - https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/
GET - https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/{proxy+}
ANY - https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/api/{proxy+}
functions:
render: ng-univ-lambda-template-prod-render
layers:
None
Stack Outputs
RenderLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:ng-univ-lambda-template-prod-render:14
ServiceEndpoint: https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod
ServerlessDeploymentBucketName: ng-univ-lambda-template-serverlessdeploymentbuck-xxxxxxxxxxxx
Serverless: Removing old service artifacts from S3...
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.

让我们尝试通过AWS CodePipeline + CodeBuild来实现自动化部署。
既然我们使用了AWS,那我们可以尝试着创建一个机制,当我们将代码推送到GitHub仓库时,Lambda函数会自动部署。
创建buildspec.yml文件
我們將準備用於 CodeBuild 的設定文件 buildspec.yml。
version: 0.2
phases:
install:
runtime-versions:
nodejs: 10
commands:
- npm install
build:
commands:
- npm run build:${DEPLOY_STAGE}
post_build:
commands:
- npm run deploy:${DEPLOY_STAGE}
进行CodeBuild的设置
请接下来登录到AWS管理控制台的界面上。
在CodeBuild中,从Build项目的页面中,点击“创建Build项目”。

请在转到创建画面后,按照下面的方式输入。

-
- プロジェクト設定 – プロジェクト名 → 任意のもの
-
- 送信元 – ソース1 → ソースプロバイダをGitHubに設定し、OAuth接続して作成したご自分のリポジトリを選択
-
- 環境 – オペレーティングシステム → Amazon Linux 2
-
- 環境 – ランタイム → Standard
-
- 環境 – イメージ → x86_64のもの
-
- 環境 – サービスロール → 新しいサービスロール
-
- 環境 – ロール名 → 任意のもの
-
- 環境 – 追加設定 – 環境変数 → prodステージなら名前:DEPLOY_STAGE 値:prodとなるように設定
- Buildspec – ビルド仕様 → buildspecファイルを使用する
完成输入后,点击”创建构建项目”。

這是以這種方式創建的。
由于构建项目的服务角色没有管理员权限,因此无法成功部署serverless。
请务必事先附加”AdministratorAccess”策略。
(实际上只附加使用所需的策略更好,但是太多策略会很难找到所需的策略)
进行CodePipeline的设置

-
- パイプライン名 → 任意のもの
- ロール名 → 任意のもの

- ソースプロバイダー → GitHubを選択し、「GitHubに接続する」をクリック。ご自分のリポジトリとブランチを選択

-
- プロバイダーを構築する → AWS CodeBuild
- プロジェクト名 → 先ほど作成したCodeBuildプロジェクトを選択
跳过部署阶段。

管道的创建已经完成。同时,部署也已经开始,以确认是否成功。
现在,当您将代码推送到GitHub时,将会自动将其部署到Lambda中!
只需从S3托管静态文件。
在这个阶段,浏览器用的静态文件和服务器都被部署在Lambda上,并从API Gateway进行托管。
尽管如此,如果想在前端加上CloudFront,最好将浏览器用的静态文件托管在S3上,因为这样的配置更加方便,所以我会尝试将系统改造成这样的结构。
创建一个S3存储桶
用适当的名称创建S3存储桶。
省略创建步骤。
修改buildspec.yml
version: 0.2
phases:
install:
runtime-versions:
nodejs: 10
commands:
- npm install
build:
commands:
- npm run build:${DEPLOY_STAGE}
post_build:
commands:
- aws s3 rm s3://${BUCKET_NAME} --recursive # 追加
- aws s3 sync ./dist/app/browser s3://${BUCKET_NAME}/_angular # 追加
- npm run deploy:${DEPLOY_STAGE}
- aws cloudfront create-invalidation --distribution-id ${DISTRIBUTION_ID} --paths "/*" # 追加
在 post_build 命令中,修正以便同时进行向 S3 的上传和 CloudFront 的刷新无效化。
修改angular.json。
"options": {
"outputPath": "dist/app/browser",
"deployUrl": "/_angular/", // <-- 追加
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
},
为了将HTML中的静态文件加载到/_angular路径下,我们需要添加deployUrl属性。这样一来,CloudFront的设置就更加简便了。
进行CloudFront的配置

- Origin Domain Name → デプロイ済みのAPI GatewayのステージのURL
其他设置可根据您的喜好进行调整,没有问题。

-
- Origin Domain Name → 先ほど作成したS3バケットを選択
-
- Restrict Bucket Access → Yes
-
- Origin Access Identity → Create a New Identity
- Grant Read Permissions on Bucket → Yes, Update Bucket Policy

-
- Path Pattern → _angular/*
- Origin or Origin Group → 先ほど作成したS3へのOrigin
其他设定按照您的喜好。
這樣一來,CloudFront的設置就完成了。
剛才去angular.json設置deployUrl,所以瀏覽器的靜態文件將存在於/_angular資料夾內。
當嘗試訪問該資料夾時,我們需要設置從S3存儲桶中取回文件。
修改CodeBuild的设置

后记
我介绍了一个用于在Angular进行服务器端渲染(SSR)的库Angular Universal,以及可在一次克隆中构建使用该库的开发环境@enten/angular-universal,并将构建结果部署到AWS Lambda以实现无服务器托管的方法。
在网络上很少有关于最佳实践的信息,即使在官方文档中,看起来也很“繁琐”。但通过我们介绍的方法,或许可以相对轻松地体验Angular Universal的世界。

源代码
请参考。
https://qiita.com/kobayashi-m42/items/fbacb46f7603e5a014d7 – Nuxt.js(SSR)をLambdaで配信する【個人開発】
https://qiita.com/MasanobuAkiba/items/7adcfd5050150ac9ba36 – SSR の知識ゼロから始める Angular Universal
https://github.com/enten/angular-universal – @enten/angular-universal