尝试使用AWS Lambda将Angular应用进行服务器端渲染
2017年的AWS Lambda圣诞日历已经到了第八天。
前言
服务器端渲染(SSR)是一种机制,用于在所谓的单页应用(SPA)中,在服务器端上输出具有与在浏览器(客户端)上动态生成的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 开发的 aws-serverless-express 可以运行现有的 Express 应用程序。
这次我们将使用Angular Express Engine和aws-serverless-express来结合,在Lambda上进行Angular应用的SSR。
试一试
在追踪Universal公式教程的同时,我们将跟随Lambda兼容所需的部分。
只有使用路由器才有趣,所以让我们拿官方的 Angular 教程《英雄之旅》的完成版本,并对其进行服务器端渲染(SSR)的调整尝试。
首先运行教程中的代码。
下载代码并解压缩,使用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(),只需导出它。启动文件将单独准备。文件的位置也稍作修改。
新增服务器系统的构建设置
// ...
"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"
为了与API Gateway的规范保持一致,在此处指定了”base-href”。
在本地运行服务器进行尝试
yarn run build:server
yarn run start:server
我会访问http://localhost:4000/prod/detail/14,检查它是否进行了服务器端渲染(SSR)。
在对最初的请求进行响应时,可以看到返回的是 Angular 生成的HTML。在第一章的教程中使用管道将其转换为大写字母,这个过程也得到了正确的再现。
如果正常地运行 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(无服务器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 状态码问题 (HTTP mǎ
由于现有代码固定返回200,即使收到了一个不可能的路径的请求,爬虫仍会将其解释为OK。实际上,应该适当返回404等错误码。
如何动态返回HTML之外的内容?
需要检查在一般网站中是否能够使用Angular来返回不仅限于HTML的动态内容,如sitemap.xml等。
能不能去掉呢?
这次我们使用了 Angular -> Universal + Express Engine -> Express -> aws-serverless-express -> Lambda (API Gateway Proxy Integration) 的连接方式。
如果能将这个转换成 Angular -> Universal + [某个东西] -> Lambda (API Gateway代理集成),就可以减少构建元素,感觉不错呢。
如果能够自己实现调用renderModuleFactory等功能,也许可以实现这个。
我刚写的代码
- y13i/toh-pt6-ssr-lambda
请参考。
-
- Angular – Angular Universal: server-side rendering
- Angular2のServer Side Renderingに触れてみる – Qiita