使用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.
image.png

让我们尝试通过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项目”。

image.png

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

image.png
    • プロジェクト設定 – プロジェクト名 → 任意のもの

 

    • 送信元 – ソース1 → ソースプロバイダをGitHubに設定し、OAuth接続して作成したご自分のリポジトリを選択

 

    • 環境 – オペレーティングシステム → Amazon Linux 2

 

    • 環境 – ランタイム → Standard

 

    • 環境 – イメージ → x86_64のもの

 

    • 環境 – サービスロール → 新しいサービスロール

 

    • 環境 – ロール名 → 任意のもの

 

    • 環境 – 追加設定 – 環境変数 → prodステージなら名前:DEPLOY_STAGE 値:prodとなるように設定

 

    Buildspec – ビルド仕様 → buildspecファイルを使用する

完成输入后,点击”创建构建项目”。

image.png

這是以這種方式創建的。

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

进行CodePipeline的设置

image.png
    • パイプライン名 → 任意のもの

 

    ロール名 → 任意のもの
image.png
    ソースプロバイダー → GitHubを選択し、「GitHubに接続する」をクリック。ご自分のリポジトリとブランチを選択
image.png
    • プロバイダーを構築する → AWS CodeBuild

 

    プロジェクト名 → 先ほど作成したCodeBuildプロジェクトを選択

跳过部署阶段。

image.png

管道的创建已经完成。同时,部署也已经开始,以确认是否成功。
现在,当您将代码推送到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的配置

image.png
    Origin Domain Name → デプロイ済みのAPI GatewayのステージのURL

其他设置可根据您的喜好进行调整,没有问题。

image.png
    • Origin Domain Name → 先ほど作成したS3バケットを選択

 

    • Restrict Bucket Access → Yes

 

    • Origin Access Identity → Create a New Identity

 

    Grant Read Permissions on Bucket → Yes, Update Bucket Policy
image.png
    • Path Pattern → _angular/*

 

    Origin or Origin Group → 先ほど作成したS3へのOrigin

其他设定按照您的喜好。

這樣一來,CloudFront的設置就完成了。
剛才去angular.json設置deployUrl,所以瀏覽器的靜態文件將存在於/_angular資料夾內。
當嘗試訪問該資料夾時,我們需要設置從S3存儲桶中取回文件。

修改CodeBuild的设置

image.png

后记

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

image.png

源代码

请参考。

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

bannerAds