LoginSignup
15
10

More than 3 years have passed since last update.

Angular Universal on AWS Lambda + S3で快適SSR対応Webアプリ開発

Last updated at Posted at 2019-12-19

この記事は、Angular #2 Advent Calendar 2019 20日目の記事です。

こんにちは。@seapolis です。初めてのアドベントカレンダーで大変緊張しております。

もうあと数日でクリスマスですが、まだまだクリスマスパーティで交換するプレゼントが決まっていらっしゃらない方も多いと思います。
そこで今回は、クリスマスプレゼントとして喜ばれること間違いなしの、Angular Universalアプリのレシピをご紹介したいと思います。

そもそもAngular Universalって?

Angular Universalのことをご存じない方も多いと思いますので、ここで少しばかりご紹介させていただきたいと思います。

Angular Universalは、めっちゃ雑に平たく言うとAngular版のNuxt.jsです。
Nuxt.jsは、Vue.jsに様々な機能をくっつけたフレームワークで、目玉機能としてServer Side Rendering(以下SSR)機能があります。

ご存じの通りVue.jsやAngularはSingle Page Application(以下SPA)を製作するためのフレームワークですが、ただのSPAの場合、ブラウザのJavascriptでHTML構造を構築するため、初期表示に時間がかかる、SEOに弱い、などの欠点がありました。
そこで登場したのがSSRという技術で、ブラウザ側でHTML構造を構築するのではなく、別に建てたNodeサーバーであらかじめHTML構造を構築しておいて、できたHTMLファイルを丸ごとブラウザに返すことで、SPAのUXを保ちつつ、以上の欠点を回避することができます。

Nuxt.jsは、このSSR機能を目玉として標準搭載しています。開発時もvueファイルを変更したらオートリロードしてくれるので、開発者はSSRであることを意識することなく、ただのSPAのような感覚で開発を進めることができ、大変人気があるようです。

そのSSR機能をAngularアプリに適用するためのライブラリが、Angular Universalなのです。

Angular Universalプロジェクトの作り方

https://angular.jp/guide/universal
Angular Universalは、このようにAngular公式に一応適用方法が書いてありますが、この内容を実行しただけだとNuxt.jsのようなホットリロード機能が使えなくなり、ファイルを書き換えるたびにnpm run build:ssr && npm run serve:ssrしなければならず、開発体験が最悪になります。

そこで、Angular Universalを使用するための種々様々な設定をすでにセッティング済みで、かつホットリロード機能が使えるようにセッティングされている、@enten/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が追加されていることがわかります。

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でリッスンされているのがわかります。
主にapp.server.module.tsで読み込まれている各種モジュール・コンポーネント群をビルドした成果物を、URLに応じて返すのが役割ですが、中身はexpress.jsになっているため、カスタマイズすることでこれをBFF層として利用することができます。

api/index.ts
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メソッドに対し、以下のように

api/index.ts
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はSSR用サーバーにexpress.jsが使われている」とご説明しました。ということは、AWS LambdaへのデプロイにServerless Frameworkが使えるのです!

準備

あらかじめ、AWSアカウントを用意の上、aws-cliをインストールし、administrator権限が使える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行だけコードを付け足します。

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';

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を作成します。

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にデプロイコマンドを追加する

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で、先ほどserverless.ymlで記述した${env:STAGE}の部分にdevを注入することになります。
つまり、実行する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.

endpointsの一番上に表示されているアドレスがAPI Gatewayのアドレスになりますので、ここにアクセスして、かつてのAngularおなじみ初期表示画面が表示されればデプロイは成功です!
image.png

AWS CodePipeline + CodeBuildでデプロイを自動化してみよう

せっかくAWSを使うので、GitHubリポジトリにプッシュしたら自動でLambdaにデプロイする仕組みを作ってみましょう。

buildspec.ymlを作成する

CodeBuildで用いる設定ファイルであるbuildspec.ymlを用意します。

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 -> ビルドプロジェクトの画面から、「ビルドプロジェクトを作成する」をクリックします。

image.png

作成画面に遷移しますので、以下のように入力してください。

image.png

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

入力が完了したら、「ビルドプロジェクトを作成する」をクリックします。

image.png

このように作成されました。

そのままではビルドプロジェクトのサービスロールにAdministrator権限がないため、serverlessのdeployが通りません。
必ずAdministratorAccessポリシーを事前にアタッチしておいてください。
(本当は使うところのポリシーだけをアタッチするのがいいのですが、あまりにも多すぎて探すのが大変です)

CodePipelineの設定を行う

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を修正

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のinvalidationを同時に行うように修正します。

angular.jsonを修正

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上での静的ファイルの読み込み先を<URL>/_angular以下に変更するため、deployUrlプロパティを追加します。
これにより、CloudFrontの設定が楽に行えます。

CloudFrontの設定を行う

再びAWS マネジメントコンソールに戻り、CloudFrontのDistributionを作成します。
image.png

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

その他の設定はお好みで大丈夫です。

作成が完了したら、Originを追加します。
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

続いて、BehaviorsでS3バケットへのルーティングルールを追加します。
image.png

  • Path Pattern → _angular/*
  • Origin or Origin Group → 先ほど作成したS3へのOrigin

その他の設定はお好みで。

これでCloudFrontの設定は完了です。
先ほどangular.jsonに行ったdeployUrlの設定のため、ブラウザ用静的ファイルは/_angular以下に存在することになっています。
そこにアクセスしようとしたら、S3バケットからファイルを取ってくるように設定してやるというわけです。

CodeBuild設定を修正する

S3バケット名とCloudFrontのDistribution名を環境変数に追加してやります。
image.png
以上が完了したら、再度CodePipelineを動かしてみて、成功することが確認出来たら完成です!

あとがき

AngularでSSRを行うためのライブラリ、Angular Universalと、それを使った開発環境をクローン一発で構築できる@enten/angular-universal、そしてビルド成果をAWS Lambdaにデプロイしてサーバーレスでホスティングする方法をご紹介しました。
あまりネット上にもベストプラクティス的な情報がなく、公式を見ても「めんどくさそう…」という感想を抱きがちなAngular Universalですが、今回ご紹介した方法で、比較的お手軽にAngular Universalの世界を体験できるのではないかと思います。

SSRならではの初期表示速度の速さは感動ものです。ぜひ一度お試しあれ!
image.png

ソースコード

参考

15
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
10