Angular Universal
Сегодня попробую запустить Angular приложение на сервере. Не буду расписывать, что такое Server side rendering (далее SSR) и для чего это нужно, а кратко опишу основные настройки и, более подробно, те проблемы, с которыми мне пришлось столкнуться. А также проведу небольшое тестирование производительности.
Базовая настройка SSR
Настройку производил по официальному мануалу, поэтому кратко и без особых комментариев. Все самое интересное начинается после первого запуска.
Устанавливаем нужные инструменты:
npm install --save @angular/platform-server @nguniversal/module-map-ngfactory-loader ts-loader @nguniversal/express-engine
Client
src/app/app.module.ts
:
imports: [
BrowserModule.withServerTransition({appId: 'yuor-app-id'}),
...
Angular добавляет appId к стилям, собранным сервером, чтобы их можно было удалить при запуске клиентского приложения.
angular.json
:
...
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/browser",
...
}
}
...
Указываем собирать клиент в директории "dist/browser"
Server
src/app/app.server.module.ts
:
import {NgModule} from '@angular/core';
import {ServerModule} from '@angular/platform-server';
import {ModuleMapLoaderModule} from '@nguniversal/module-map-ngfactory-loader';
import {FlexLayoutServerModule} from '@angular/flex-layout/server';
import {AppModule} from './app.module';
import {AppComponent} from './app.component';
@NgModule({
imports: [
AppModule,
ServerModule,
ModuleMapLoaderModule,
FlexLayoutServerModule, // Если юзаете angular/flex-layout
],
providers: [
// Add universal-only providers here
],
bootstrap: [AppComponent],
})
export class AppServerModule {
}
src/main.server.ts
:
export {AppServerModule} from './app/app.server.module';
server.ts
:
import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import {enableProdMode} from '@angular/core';
import * as express from 'express';
import {join} from 'path';
import {ngExpressEngine} from '@nguniversal/express-engine';
import {provideModuleMap} from '@nguniversal/module-map-ngfactory-loader';
enableProdMode();
// Express server
const app = express();
const PORT = process.env.PORT || 4000;
const DIST_FOLDER = join(process.cwd(), 'dist');
// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('./dist/server/main');
app.engine('html', ngExpressEngine({
bootstrap: AppServerModuleNgFactory,
providers: [
provideModuleMap(LAZY_MODULE_MAP)
]
}));
app.set('view engine', 'html');
app.set('views', join(DIST_FOLDER, 'browser'));
// Server static files from /browser
app.get('*.*', express.static(join(DIST_FOLDER, 'browser')));
// All regular routes use the Universal engine
app.get('*', (req, res) => {
res.render('index', {req});
});
// Start up the Node server
app.listen(PORT, () => {
console.log(`Node server listening on http://localhost:${PORT}`);
});
src/tsconfig.server.json
:
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"baseUrl": "./",
"module": "commonjs",
"types": []
},
"exclude": [
"test.ts",
"**/*.spec.ts"
],
"angularCompilerOptions": {
"entryModule": "app/app.server.module#AppServerModule"
}
}
webpack.server.config.js
:
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: {server: './server.ts'},
resolve: {extensions: ['.js', '.ts']},
target: 'node',
mode: 'none',
// this makes sure we include node_modules and other 3rd party libraries
externals: [/node_modules/],
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].js'
},
module: {
rules: [{test: /\.ts$/, loader: 'ts-loader'}]
},
plugins: [
// Temporary Fix for issue: https://github.com/angular/angular/issues/11580
// for 'WARNING Critical dependency: the request of a dependency is an expression'
new webpack.ContextReplacementPlugin(
/(.+)?angular(\\|\/)core(.+)?/,
path.join(__dirname, 'src'), // location of your src
{} // a map of your routes
),
new webpack.ContextReplacementPlugin(
/(.+)?express(\\|\/)(.+)?/,
path.join(__dirname, 'src'),
{}
)
]
};
angular.json
:
"architect": {
...
"server": {
"builder": "@angular-devkit/build-angular:server",
"options": {
"outputPath": "dist/server",
"main": "src/main.server.ts",
"tsConfig": "src/tsconfig.server.json"
}
}
...
}
package.json
:
"scripts": {
...
"build:ssr": "npm run build:client-and-server-bundles && npm run webpack:server",
"serve:ssr": "node dist/server",
"build:client-and-server-bundles": "ng build --prod && ng run client:server",
"webpack:server": "webpack --config webpack.server.config.js --progress --colors",
...
}
Билд
npm run build:ssr
Здесь пришлось доустановить webpack-cli
:
npm i -D webpack-cli
Перезапускаем билд.
Сервим
npm run serve:ssr
В консоли должно появиться:
Node server listening on http://localhost:4000
Открываем и преодолеваем
ɵHttpInterceptingHandler is not a constructor
При первом открытии страницы в браузере я получил ошибку (Github issue):
TypeError: _angular_common_http__WEBPACK_IMPORTED_MODULE_5__.ɵHttpInterceptingHandler is not a constructor at zoneWrappedInterceptingHandler
Решается обновлением зависимостей Angular:
ng update @angular/core
localStorage is not defined
У меня в приложении есть сервис TokenService
, который отвечает за сохранение и извлечение аутентификационного токена в/из localStorage браузера. Но на сервере нету localStorage. Для решения этой проблемы я создал TokenServerService implements TokenService
, в котором исключил все взаимодействие с localStorage. И запровайдил его в app.server.module.ts
:
providers: [
{provide: TokenService, useClass: TokenServerService}
]
window
Та же беда - на сервере отсутствует.
this.onScrollSubscription = window && observableFromEvent(window, 'scroll')
.subscribe(() => {
...
ngOnDestroy() {
this.onScrollSubscription && this.onScrollSubscription.unsubscribe();
}
document
Можно поступить как и с window
, но я обернул блок кода в проверку платформы:
constructor(@Inject(PLATFORM_ID) private platformId: Object,
...) {
this.isBrowser = isPlatformBrowser(this.platformId);
}
ngOnInit() {
if(this.isBrowser){
const stickyBlock = document.querySelector('.sticky-block');
...
}
}
...
nativeElement
В директиву автофокуса пришлось внести изменения в части проверки места запуска приложения:
ngOnInit() {
if (isPlatformBrowser(this.platformId) && (this._autofocus || typeof this._autofocus === 'undefined')) {
this.el.nativeElement.focus();
}
}
ng2-charts
С этим модулем на сервере у меня тоже возникли проблемы:
Error: NotYetImplemented
...
Причина в том, что он использует nativeElement.getContext()
. Поэтому пришлось отказаться от рендеринга диаграмм на сервере:
<div class="mash-chart" *ngIf="isBrowser">
<app-mash-chart></app-mash-chart>
</div>
matTooltip: this._ariaDescriber.removeDescription is not a function
и прочие аналогичные
А вот это крайне неприятные ошибки. Возникают не всегда, не везде, но ломают приложение полностью. Вместо ожидаемой страницы показывает стэк трэйс:
ERROR TypeError: this._ariaDescriber.removeDescription is not a function
at MatTooltip.set [as message] (/usr/src/app/server/server.js:219808:33)
at updateProp (/usr/src/app/server/server.js:15266:37)
at checkAndUpdateDirectiveInline (/usr/src/app/server/server.js:15017:19)
at checkAndUpdateNodeInline (/usr/src/app/server/server.js:16326:20)
at checkAndUpdateNode (/usr/src/app/server/server.js:16288:16)
at prodCheckAndUpdateNode (/usr/src/app/server/server.js:16832:5)
at Object.Of.t.ɵvid.e [as updateDirectives] (/usr/src/app/server/server.js:127276:851229)
at Object.updateDirectives (/usr/src/app/server/server.js:16618:29)
at checkAndUpdateView (/usr/src/app/server/server.js:16270:14)
at callViewAction (/usr/src/app/server/server.js:16511:21)
Воспроизвести можно быстро перейдя по ссылке в момент, пока происходит загрузка стартовой страницы.
Проблема известна и, вроде как, уже решена. Но решение еще не в релизе.
Также есть ряд похожих ошибок: focusMonitor.monitor is not a function, SSR crashes because of undefined functions. Все они связаны с этой проблемой самого Angular, и их частные решения выглядят какими-то костыльными. А пока добавляем в app.server.module.ts
:
import {AriaDescriber, FocusMonitor} from '@angular/cdk/a11y';
import {AutofillMonitor} from '@angular/cdk/text-field';
...
providers: [
AriaDescriber,
FocusMonitor,
AutofillMonitor,
...
flex-layout
устанавливает свойство display:block
на инлайн элементы с fxShow/fxHide
Слетает выравнивание элементов разметки, ибо span
ы, которые я скрываю/показываю в зависимости от ширины окна браузера, внезапно рендерятся как блоки.
Issue обещают пофиксить в ближайшем релизе. Можно подождать, можно указать для них display: inline
.
Мерцания
После загрузки скриптов Universal удаляет пререндеренный контент и загружает вместо него клиентское приложение. Это клиентское приложение начинает обрабатывать роутинг и выполнять http запросы. Если в приложении настроены routing resolvers, то они не будут отображать содержимое компонет в router-outlet
до тех пор, пока не получат (резолвят) ответ от сервера. Поэтому в промежуток времени между удалением пререндеренного контента и загрузкой данных с сервера в router-outlet
будет пустота. Возникают довольно неприятные мерцания на странице.
Для предотвращения этой проблемы, помимо
// app.module.ts
imports: [
BrowserModule.withServerTransition({appId: 'my-app-id'})
...
в файле app-routing.module.ts
необходимо для RouterModule
указать initialNavigation: 'enabled'
:
@NgModule({
imports: [RouterModule.forRoot(routes, {
initialNavigation: 'enabled'
})]
})
В dev
это работает вполне сносно. Однако, тут и тут пишут, что в prod
и с https могут быть проблемы. Требуется дополнительное тестирование.
Коды ответа (404, 500)
Поисковики ругаются на неправильно настроенное отображение несуществующих страниц:
Вероятно, на сайте некорректно настроен возврат HTTP-кода 404 Not Found, что может негативно сказаться на индексировании сайта роботом. Настройте возврат кода 404 на запрос несуществующих страниц. ©Яндекс Вэбмастер
Настроим приложение так, чтобы отрендеренная на сервере страница 404 отдавалась со статусом 404. Для этого в компоненте страницы с ошибкой error-404.component.ts
добавим:
import {Component, Inject, OnInit, Optional, PLATFORM_ID} from '@angular/core';
import {RESPONSE} from '@nguniversal/express-engine/tokens';
import {isPlatformServer} from '@angular/common';
@Component({
selector: 'app-error-404',
templateUrl: './error-404.component.html'
})
export class Error404Component implements OnInit {
constructor(@Inject(PLATFORM_ID) private platformId: Object,
@Optional() @Inject(RESPONSE) private response,
private appService: AppService) { }
ngOnInit() {
if (isPlatformServer(this.platformId)) {
this.response.status(404);
}
}
}
Инжектим platformId
и объект ответа сервера response
. Если страница будет рендериться на сервере, то указываем this.response.status(404)
.
Требуется только запровайдить этот объект response
. Делается это в файле server.ts
:
import {REQUEST, RESPONSE} from '@nguniversal/express-engine/tokens';
...
app.get('*', (req, res) => {
res.render('index', {
req,
providers: [
{
provide: RESPONSE,
useValue: res
}
]
});
});
Аналогично можно поступить и со страницей ошибки 500.
Проблемы с JWT аутентификацией
Мое приложение использует json web token аутентификацию пользователей. Если кратко, то пользователь вводит логин/пароль и сервер выдает ему токен. Этот токен сохраняется в localStorage браузера. Все последующие запросы на сервер выполняются с передачей этого токена в хэдере. Но при первом открытии страницы приложения, когда клиент еще не загружен и не может взять токен из хранилища и вставить его в хэдер, браузер выполняет запрос без токена. Universal пытается отрендерить страницу для неаутентифицированного пользователя. И, если данная страница для него не доступна и функция canActivate
настроена на возврат страницы 404, то будет отрендерена страница 404. Таким образом получаем следующую картину:
- пользователь вошел в приложение, в браузере хранится токен;
- пользователь жмет F5 на странице, доступной только вошедшим;
- браузер запрашивает пререндер этой страницы без токена;
- render натыкается на
canActivate
вRouter
; canActivate
редиректит на страницу 404;- Universal отдает в браузер страницу 404;
- пользователь видит в окне браузер страницу ошибки вместо той, которую запрашивал;
- и только после загрузки и запуска клиентского приложения будет отрисована нужная страница.
И это очень некрасиво с точки зрения UX.
Для решения этой проблемы нужно менять архитектуру приложения и api, использовав для хранения токена cookies. Но мне этого делать ну никак не хочется, поэтому в функции canActivate
я добавил проверку платформы и оставил редирект на 404 страницу только для браузера:
canActivate(route: ActivatedRouteSnapshot,
state: RouterStateSnapshot) {
const availableRoles = route.data.availableRoles;
const userRole = this.userService.isLoggedIn() && this.userService.getUserRole();
if (userRole && availableRoles.includes(userRole)) {
return true;
} else if(isPlatformBrowser(this.platformId)) {
this.router.navigate(['/404'], { skipLocationChange: true });
return false;
} else {
return false;
}
}
Universal возвращает страницу с хэдером, футером, но без основного контента. После загрузки приложения будет отрисовано содержимое защищенной страницы. Или же страница 404, если пользователь не авторизирован. С точки зрения UX это выглядит как постепенная загрузка страницы. Но с точки зрения SEO это не очень хорошо. Если требуется, чтобы защищенные страницы были проиндексированы поисковиками, то нужно все же смотреть в сторону печенек. Или же, как вариант, отлавливать user agent поисковых ботов в server.ts
, провайдить эту инфу в CanActivate
и давать им доступ.
Стресс-тесты
Очень интересно было проверить, а какая же все-таки производительность у серверного рендеринга Angular? Почему-то все измеряют производительность только в миллисекундах, за которые рендерится страница. Мне же еще было интересно, а сколько же страниц одновременно может быть отрендерено без падения сервера? Для этого я воспользовался инструментом Artillery.io. Выбрал достаточно сложную страницу своего приложения (несколько запросов к API, несколько таблиц с ngFor
, много материал компонент), которая больше всего нуждается в индексации поисковиками. В Artillery установил время теста 3 минуты и начал играть с arrivalRate
. Начал с arrivalRate = 5
(каждую секунду создается 5 новых виртуальных пользователей, делающих запрос выбранной страницы):
artillery quick -d 180 -r 5 http://localhost:4000/my-hard-page
И вот результат:
arrivalRate: 5:
Scenarios launched: 900
Scenarios completed: 900
Requests completed: 900
RPS sent: 4.99
Request latency:
min: 287.8
max: 514.2
median: 324.9
p95: 413.8
p99: 445.2
Scenario counts:
0: 900 (100%)
Codes:
200: 900
325 ms - ну так себе.
arrivalRate: 7:
Scenarios launched: 1260
Scenarios completed: 1260
Requests completed: 1260
RPS sent: 6.99
Request latency:
min: 287.6
max: 454.8
median: 323.4
p95: 382.7
p99: 407
Scenario counts:
0: 1260 (100%)
Codes:
200: 1260
Похожий результат. Держимся.
arrivalRate: 9:
Scenarios launched: 1620
Scenarios completed: 1620
Requests completed: 1620
RPS sent: 8.98
Request latency:
min: 289.1
max: 802.6
median: 332.5
p95: 583.9
p99: 697.2
Scenario counts:
0: 1620 (100%)
Codes:
200: 1620
А вот на 9 rps уже заметно, что задержка начала расти. Подымаем еще немного:
arrivalRate: 10:
Scenarios launched: 1800
Scenarios completed: 1800
Requests completed: 1800
RPS sent: 8.09
Request latency:
min: 403.1
max: 51821.2
median: 16316.1
p95: 47372.2
p99: 50949.6
Scenario counts:
0: 1800 (100%)
Codes:
200: 1800
И вот он предел - c 10 rps Universal уже не справляется. Тесты проводил на реальном, довольно крупном приложении, на локальной машине с серьезным процессором и 16 ГБ оперативной памяти. На объективность не претендую и какие-либо выводы делать не берусь.
setTimeout
Как-то совсем забыл про рекомендацию избавиться от всех setTimeout
в приложении. Избавился и провел еще одну серию тестов. В результате для arrivalRate = 5
удалось уменьшить среднее время ответа сервера почти в 3 раза (median: 324.9 vs 113):
arrivalRate: 5:
Scenarios launched: 900
Scenarios completed: 900
Requests completed: 900
RPS sent: 4.99
Request latency:
min: 93
max: 214.2
median: 113
p95: 165.8
p99: 184
Scenario counts:
0: 900 (100%)
Codes:
200: 900
Так что обязательно избавляемся от всех setTimeout
в SSR!
Однако взять планку в 10 rps это все же не позволило. Время ответа выходит за все разумные пределы:
arrivalRate: 10:
Scenarios launched: 1800
Scenarios completed: 1613
Requests completed: 1613
RPS sent: 7.85
Request latency:
min: 499.4
max: 69183.9
median: 39094.5
p95: 62356.7
p99: 65200.5
Scenario counts:
0: 1800 (100%)
Codes:
200: 1613
Errors:
ECONNREFUSED: 187
Итоги
А в итоге такое неприятное ощущение, будто использую какую-то поделку, а не продукт, шестой версии, от корпорации добра, громко заявляющий:
DEVELOP ACROSS ALL PLATFORMS
Learn one way to build applications with Angular and reuse your code and abilities to build apps for any deployment target. https://angular.io/
Слишком много надо допиливать, выравнивать, чинить и костылить. В статье описал далеко не все возникшие проблемы и сложности - и так много текста получилось.
Очень хотелось бы в комментариях почитать отзывы людей, успешно использующих Angular SSR "в бою". Как повышаете производительность, как кэшируете, какие еще проблемы были у вас? Или может кто-то уже использует headless браузеры для этих целей?