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 браузеры для этих целей?