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

Павел Прудников

Постигающий дзен фулстэк вэб буддизма

Минск, Беларусь

Подписаться на Блог MEAN stack разработчика

Получайте свежие записи прямо на ваш почтовый ящик.

Или подпишитесь на RSS с Feedly!

Комментарии

comments powered by Disqus