Введение

В жизни многих приложений наступает время, когда существующего положения вещей просто недостаточно, чтобы удовлетворить постоянно меняющиеся потребности веб-функций. Пользователи ожидают подробный предварительный просмотр ссылок, захватывающие интерактивные функции и молниеносные сайты. Кроме того, хорошая поисковая оптимизация (SEO) жизненно важна для того, чтобы веб-сайт был легко обнаруживаемым и привлекательным, а также выглядел значительно более профессиональным. Все мы хотим лучшего для наших сайтов, но иногда изменения могут быть пугающими. Что, если я все испорчу? Что делать, если мой выбор неправильный? Что, если мои изменения случайно вызовут человека в надувном костюме белоголового орла, который просто не оставит меня в покое, сколько бы раз я ни говорил ему, что это не его гнездо ?! К счастью для вас, я сделал все эти ошибки, поэтому вам не придется их делать.

Меня зовут Крис; Я начинаю карьеру разработчика программного обеспечения в Vidaloop, стартапе в области гражданских технологий в Сан-Диего, Калифорния. Я специализируюсь на веб-разработке и разработке полного стека, и мне было предоставлено множество возможностей, чтобы помочь продвинуть наши продукты вперед значимым образом. Сегодня мы сосредоточимся только на одном из продуктов компании - Voterly. Voterly - это крупномасштабная база данных американских политиков, в значительной степени ориентированная на создание дружественной и простой в использовании среды, позволяющей обычным людям узнавать больше о своих представителях. С более чем 150 000 политиков и постоянно растущим объемом данных о них, Voterly уверенно движется к достижению своих целей по повышению информированности избирателей, участвующих в голосовании.

Но хватит глупой ерунды. Как вы, возможно, догадались на основании названия этой статьи, мы собираемся сосредоточиться на переходе от (относительно) ванильного Angular, отрисовываемого на стороне клиента приложения, к отрисованному на стороне сервера Angular Universal. Я расскажу о некоторых проблемах, с которыми я столкнулся при выполнении этой миграции, сделаю общий обзор соответствующих частей архитектуры Voterly и рассмотрю свою собственную методологию для выполнения подобных проектов.

Отправная точка

Итак, это избиратель с высоты 10 000 футов, но для того, чтобы по-настоящему понять изменения, внесенные в эту статью, также полезно получить некоторый контекст, касающийся нашей инфраструктуры, поскольку это войдет в игру позже. Как упоминалось выше, Voterly в настоящее время работает на в основном обычном конвейере сборки и развертывания на основе Angular 9. Размещенные в AWS, файлы сайта загружаются в корзину S3. Эта корзина находится за дистрибутивом CloudFront, который обрабатывает политики кеширования, кэширование и распространение контента для сайта и обеспечивает очень быструю первоначальную доставку пакета JS, поскольку кеш-память CloudFront находится в нескольких зонах доступности. Все это скрывается за простой политикой маршрутизации Route 53, которая направляет весь трафик к распределению CloudFront.

Эта архитектура хорошо зарекомендовала себя для Voterly в течение последних нескольких лет и позволила нашей команде разработчиков быстро и (относительно) безболезненно добавлять в Voterly новые функции, которые используют множество различных функций Angular. Вдобавок к этому у нас есть несколько настраиваемых фрагментов кода, связанных с настройкой, аутентификацией и некоторыми рабочими процессами, связанными с разработкой. Однако по мере роста объема данных и сложности приложения мы немного сместили акцент на улучшение поисковой оптимизации (SEO) и производительности сайта. Мы решили, что лучший путь для Voterly - это перейти на рендеринг на стороне сервера (SSR).

Рассуждение

Для начала мне, вероятно, следует прояснить разницу между рендерингом на стороне клиента (CSR) и сервером. Что касается веб-технологий, для всех целей и задач SSR с самого начала было нормой. Только с появлением более мощных персональных компьютеров и устройств распространенность клиентского кода JavaScript увеличилась, что в конечном итоге привело к появлению сайтов, которые можно полностью отображать на клиенте, просто доставив и запустив пакет javascript. Изменения маршрута в основном символические: они меняют URL-адрес в строке, но за кадром браузер использует JavaScript для отображения следующей страницы. Эта парадигма в сочетании с программированием в реактивном стиле привела к появлению множества клиентских фреймворков: в первую очередь Angular, ReactJS и VueJS. И наоборот, с приложением, полностью отображаемым на стороне сервера, каждое изменение маршрута или запрос к серверу будут полностью отображаться в HTML при каждом запросе.

Как всегда при выборе технологии для любого проекта или приложения в разработке программного обеспечения, у каждого подхода есть свои плюсы и минусы. Начнем с минусов:

Минусы

  1. Немного медленнее время отклика сервера
  2. Более сложная внутренняя инфраструктура
  3. Требуется активный сервер для обработки запросов
  4. В некоторых случаях может потребоваться общий доступ к коду или его дублирование для поддержки динамических элементов страницы.

Хотя эти минусы, безусловно, являются важными моментами, которые следует учитывать, для наших целей (как бизнес с несколькими разработчиками) мы не возражаем против немного более высокой сложности или работающего активного сервера в той мере, в какой это может делать кто-то, разрабатывающий личный проект. Для нас недостатком, на который следует обратить внимание, будет увеличенная задержка сервера при промахе кеша. Мы вернемся к этому позже. На наш взгляд, мы стремимся получить следующие преимущества:

Плюсы

  1. Улучшенная Первая содержательная отрисовка (FCP) и Самая большая содержательная отрисовка (LCP) (показатели производительности загрузки страницы)
  2. Предварительный просмотр динамических ссылок и улучшенное SEO
  3. JavaScript не требуется для просмотра содержимого страницы

Но подождите! Я думал, вы сказали, что начальное время ответа сервера медленнее? Да! И это правда. В случае промаха кеша, внутреннему серверу потребуется отобразить HTML, что займет больше времени, чем просто возврат файла клиенту. Однако после того, как ответ визуализирован и отправлен клиенту, он (в основном) немедленно готов к отображению браузером, практически не требуя анализа или выполнения JavaScript. По сравнению с версией CSR этот процесс намного быстрее на большем количестве устройств, что приводит к общему заметному улучшению времени загрузки страницы.

Таким образом, когда минусы устранены, остаются два других преимущества, которые идут рука об руку, чтобы создать гораздо более удобный для поиска и веб-сайт. Чтобы узнать, откуда мы пришли, представьте, что друг отправил вам ссылку с Reddit или Twitter; На каждой ссылке в превью были только логотип сайта и его название, и, возможно, короткая аннотация об их сайте в целом. Вот наш пример:

Скучно, правда? И это не дает вам представления о том, что вы увидите, когда нажмете на ссылку. Для сайта с высокой плотностью данных, такого как Voterly, это в некоторой степени неприемлемо и является требованием, которое побудило нас начать проект. Но это не единственное преимущество SSR для Voterly! Мы также наконец получим надлежащую индексацию в Интернете не только от Google, но и от поисковых систем. К счастью для нас, Google запускает JavaScript страницы и отображает саму страницу для приложений CSR, поэтому у нас всегда были богатые результаты поиска в Google. Другие поставщики поисковых систем этого не делают, поэтому результаты в Bing, например, выглядят так:

Это работает, но не идеально и определенно заставляет нас выглядеть менее профессионально. Более того, предварительный просмотр динамических ссылок может помочь увеличить конверсию и, как правило, улучшить взаимодействие с пользователем, особенно при совместном использовании страниц.

Исследование

Итак, мы знаем, почему нам нужен SSR, отлично! Теперь мы переходим к самому сложному. Как мы на самом деле это делаем? Если бы мы создавали сайт с нуля, на этот вопрос было бы намного сложнее ответить. Поскольку у нас уже написано большое приложение, мы сильно ограничены в наших возможностях. Это значительно упрощает наш выбор. Единственный разумный вариант для нашей текущей архитектуры - Angular Universal. Angular Universal - отличный выбор для нас, поскольку он позволяет вносить минимальные изменения в разработку, сохраняя при этом паритет функций (или как можно более близкий) между двумя клиентскими архитектурами. Для нас это означает, что самые большие или самые сложные изменения клиента будут связаны с его инфраструктурой, а не с его логикой или реализацией. Для нас это означает изменение нашей текущей архитектуры, поддерживаемой S3.

Как упоминалось ранее, наибольшее изменение в бэкэнде связано с тем, что решения SSR / Angular Universal полагаются на активный бэкэнд сервера. В случае Angular Universal нам понадобится среда Node.js. К счастью для нас, существуют десятки сервисов, предлагающих время выполнения Node.js для нашего кода. Для некоторой предыстории, большинство серверных сервисов Voterly работают в бессерверной вычислительной среде AWS Lambda. Это отлично подходит для поддержки совершенно разных наборов функций каждого из наших сервисов и позволяет нам легко справляться с пиками трафика. Однако у этого подхода есть одна проблема, связанная с Angular Universal: он не имеет состояния. Для серверного API, основная цель которого - взаимодействие со сторонними сервисами, нашей серверной базой данных и другими внутренними процессами, это замечательно и обеспечивает чрезвычайно быстрые вычисления и эластичность. Однако для сервера Express / Angular Universal не так много. Чтобы повысить производительность Angular Universal, его следует запускать на всегда активном сервере. Создание одного экземпляра клиента займет больше времени и потребует больше ресурсов, чем (для сравнения) более простой REST API. По этой причине мы решили не использовать Lambda для этой версии клиента. Вместо этого мы выбрали сервис AWS Elastic Beanstalk. Этот сервис предлагает гибкость, настраиваемую пользователем, но лучше подходит для долго работающих или (частично) приложений с отслеживанием состояния (возможно, с кешем в памяти). Теоретически это должно повысить производительность, хотя и будет стоить несколько дороже.

План

Итак, теперь мы выбрали наш программный стек и стек инфраструктуры. Теперь нам нужно выяснить, как использовать эти технологии. Я предпочитаю проводить подобные исследования с помощью малоизвестной поисковой системы под названием Google. Если вы никогда о нем не слышали, в нем есть тысячи страниц обо всем, о чем вы когда-либо могли подумать, и это здорово, когда мы исследуем темы программного обеспечения! И сам по себе Google может увести вас очень далеко. Для этого проекта Google привел меня к следующим ресурсам:

Ресурсы

Использование вышеупомянутых ресурсов для проведения моих первоначальных исследований, а также их использование в качестве неотъемлемой части поддержки разработки, помогло значительно ускорить процесс. Мне также потребовалось использовать эти ресурсы, чтобы стать де-факто экспертом по SSR, связанным с Angular.

Скоро наступит момент, которого вы все ждали: реализация. Но сначала давайте быстро рассмотрим краткий план, который я написал перед началом перехода:

  1. Запустите ng add @nguniversal/express-engine (команда установки).
  2. Внесите небольшие изменения в конфигурацию.
  3. Внесите более масштабные и радикальные изменения в кодовую базу, которые позволят отображать клиента как на сервере, так и в браузере.
  4. Обновите или замените элементы инфраструктуры, которые необходимо изменить для упрощения SSR.

Этот план послужил моим общим руководством в этом проекте, но на самом деле полезен только на концептуальном уровне. Теперь мы подробно рассмотрим реализацию.

Реализация

ПРЕДУПРЕЖДЕНИЕ. Это не тот учебник по переходу с CSR на SSR. Хотя это даст вам представление о том, как это делается, я также буду разбираться в проблемах, с которыми сталкиваюсь в процессе. Это сделано для того, чтобы вы получили более реалистичное представление о том, с чем вы столкнетесь во время этого процесса.

Общее руководство по моей реализации было взято непосредственно из Руководства по установке Angular Universal. Я также повторю здесь самые важные части.

Установка Angular Universal

Поскольку мы используем Node.js с Express в качестве внутреннего сервера, я решил использовать Express Engine, созданный для работы в качестве интерфейса между Angular и Express (есть больше движков, созданных командой Angular Universal). . Согласно руководству по установке Angular Universal, это должно быть так же просто, как просто запустить эту команду:

ng add @nguniversal/express-engine

Большой! Давай запустим! А также…

Что ж, это не очень хорошо, но это дает нам один намек на проблему. Видите, как /src/app отображается несколько раз в пути к файлу? Этот файл не существует по этому пути в нашем проекте, поэтому это очень похоже на проблему конфигурации. Для справки вот путь к нашему app.module.ts файлу:

src/
  index.html
  main.ts
  ...  
  app/
    app.component.ts
    app.module.ts
    ...

Что ж, это очень похоже на структуру Angular example project, так что это не должно быть так. Изучив эту проблему, я решил проверить angular.json файл нашего проекта. Изучая файл, я заметил одно значение, подозрительно похожее на нашу ошибку выше:

{
  "projects": {
    ...
    "user-client": {
      ...
      "root": "src/app"
    }
  }
}

Давайте попробуем изменить root на "" и повторно запустим команду установки:

Это больше похоже на это! Теперь, когда он установлен, мы собираемся вернуть это значение в src / app, но будем иметь это в виду на случай, если мы увидим какие-либо ошибки, которые подозрительно похожи на эту. Давайте посмотрим, что изменилось после выполнения этой команды:

Файлы добавлены

  • server.ts - Действует как активный сервер для экспресс-сайта. Он обрабатывает все запросы и при необходимости направляет их.
  • src/main.server.ts - Устанавливает среду Angular и импортирует необходимые пакеты Angular Universal.
  • src/tsconfig.server.json - указывает параметры компилятора TS, которые применяются только к компиляции на стороне сервера.
  • src/app/app.server.module.ts - Содержит специфичный для сервера импорт и инъекции. Расширяет app.module.ts. Полезно для внедрения данных или кода, доступ к которым должен иметь только сервер.

Файлы изменены

  • src/main.ts - Вызов platformBrowserDynamic().bootstrapModule(AppModule) заключен в прослушиватель событий, который задерживает его выполнение до тех пор, пока браузер не отправит событие DOMContentLoaded, заставляя начальную загрузку приложения ждать, пока контент не завершит свою первую визуализацию.
  • angular.json - добавляет различные конфигурации запуска / сборки, специфичные для SSR. Также обновит каталог сборки до dist/<app-name>/browser, поэтому имейте это в виду, если скрипты указывают на другой каталог, или измените его обратно.
  • package.json
    Добавляет следующие зависимости:
    - @angular/platform-server
    - @nguniversal/express-engine
    - express
    - (dev) @nguniversal/builders
    - (dev) @types/express

Запуск сервера разработки

Помимо новых зависимостей, обнаруженных в package.json, команда установки также добавляет несколько новых сценариев с суффиксом :ssr. Чтобы запустить сервер разработки Angular в режиме SSR, запустите сценарий dev:ssr NPM. Давай сделаем это сейчас:

Эй, никто никогда не говорил, что это будет легко. Итак, давайте сделаем шаг назад и посмотрим на сообщение об ошибке: «this.debug is not a function». Сразу эта ошибка выглядит подозрительно. Он не очень наглядный, и к нему не привязана трассировка стека. На грани безумия, я немного прокрутил окно терминала и ...

Конечно, вот она: SassError: Can't find stylesheet to import. И посмотрите, она даже говорит нам, где можно найти ошибку, насколько это полезно! Это хороший урок для менее опытных программистов (вроде меня). Иногда первое сообщение об ошибке не является причиной проблемы. С этой целью постарайтесь изо всех сил определить проблему, прежде чем начинать исправление. В нашем случае эта ошибка возникла из-за того, что у нас есть некоторые зависимости стилей в каталоге, расположенном за пределами нашего пользовательского клиента, ошибки могут возникать по любому количеству причин, которые могут произойти дальше вверх или вниз по цепочке. Мне удалось исправить эту ошибку, добавив следующий объект JSON в свойство options для объектов architect.server и architect.prerender нашего проекта:

{
  "stylePreprocessorOptions": {
    "includePaths": [
      "../path/to/outside/directory/src/sass",
      "src/sass"
    ]
}

Это исправляет ошибку Sass, которую мы видели. Итак, давайте попробуем снова запустить сервер разработки:

Наконец-то мы видим то, что ожидаем увидеть! Теперь у нас есть клиент SSR Angular и он работает! Похлопайте себя по спине, вы добились прогресса! Теперь все, что осталось сделать, это перейти по URL-адресу сервера разработки и полюбоваться вашей работой!

Oh no.

Но действительно ли мы удивлены? Мы знали, что с этим переходом могут возникнуть некоторые проблемы, потому что Voterly использует необработанные объекты DOM в различных частях сайта. Действительно, если вы посмотрите трассировку стека, вы увидите, что она выброшена из MetadataService.setStaticTags. Мы написали этот сервис, и рассматриваемый код выглядит так:

private setStaticTags(): void {
    this.setCDN('...');
    this.setHost(window.location.origin);
}

Как и предполагалось, мы видим доступ к объекту window. Поскольку Node.js по умолчанию не реализует объект window, он недоступен на сервере. Это может привести к тому, что, казалось бы, безобидный код сломает все наше приложение. Когда вы переводите свой сайт на основе Angular с CSR на SSR, вы заметите, что многие из ошибок, с которыми вы сталкиваетесь, вызваны (прямо или косвенно) случайным доступом к DOM или манипуляциями во время выполнения кода на сервере. К счастью для нас, Angular дает нам несколько способов решить эту проблему.

Устранение проблем с доступом к DOM

Как вы теперь знаете, отсутствие реализации объекта DOM является одной из основных причин проблем с приложениями, связанных с Angular Universal. Вот два метода, которые нам предоставила команда Angular для решения таких проблем:

Идентификатор платформы

Angular предоставляет нам две важные функции, связанные с SSR: isPlatformBrowser(platformId: string)и isPlatformServer(platformId: string). Каждый из них принимает в качестве аргумента один string. Как следует из названий, эти функции будут возвращать true или false в зависимости от текущей среды выполнения. Но возникает вопрос, какое значение вы должны вводить в функции? Как обычно, Angular нас позаботился. Angular также предоставляет токен внедрения с именем PLATFORM_ID, который будет либо "server", либо "browser” в зависимости от среды выполнения. Чтобы использовать его из Angular, просто вставьте его в любой тип Angular, который может принимать внедренные объекты (компоненты, директивы, службы, охранники и т. Д.), Например:

class MyComponent {
  constructor(
    @Inject(PLATFORM_ID) private platformId: string,
    ...
  ) { }
}

Теперь вы можете использовать platformId в качестве входных данных, например, для isPlatformBrowser. Теперь, когда вы хотите использовать объект DOM, например window или document, просто сначала проверьте platformId:

public myFunction(): void {
  if (isPlatformBrowser(this.platformId)) {
    console.log(window.location.href);
    document.getElementById(...);
  } else {
    // This will only run on the server side
    console.log(process.env.HOSTNAME);
    fs.existsSync(...);
  }
}

Здесь я должен отметить, что там, где это возможно, вам следует попытаться использовать абстракции Angular над объектами DOM, а не использовать их напрямую. Эти методы следует использовать только тогда, когда нет другого жизнеспособного варианта, когда прямое манипулирование DOM - единственный способ что-то сделать.

Внедренное окно

Второй вариант обеспечения того, чтобы доступ к DOM происходил только в нужное время, - это внедрение самих объектов, а не флага платформы. В Angular есть встроенный токен внедрения для document, так что нам повезло. Однако, чтобы использовать window, вам нужно будет написать свой собственный InjectionToken (если, конечно, кто-то не оставил вам копию, которую вы можете скопировать прямо под этим абзацем).

import { DOCUMENT } from '@angular/common';
import { inject, InjectionToken } from '@angular/core';
export const WINDOW = new InjectionToken<Window>(
  'An abstraction over the global window object',
  {
    factory: () => {
      const { defaultView } = inject(DOCUMENT);
      
      if (!defaultView) {
        throw new Error('Window is not available');
      }
      
      return defaultView;
    }
  }
};

Используя приведенную выше реализацию, вы сможете внедрить объект window в качестве дополнительной зависимости в любом месте вашего приложения Angular. На сервере это будет просто null, а на клиенте это будет объект window. Это означает, что вы можете ввести его так:

@Optional @Inject(WINDOW) private window: Window | null

Важно ввести его как @Optional, чтобы Angular не выдавал ошибку при значении null. Кроме того, добавление null в качестве типа объединения с Window обеспечивает безопасность типов во время разработки (хотя ни Angular, ни TypeScript не будут кричать на вас, если вы не введете его как объединение null).

Window и Document - не единственные объекты, которые могут вызывать эти ошибки. Доступ к хранилищу (localStorage и sessionStorage) также может вызывать такие ошибки. В самом деле, любые API-интерфейсы для конкретных браузеров могут вызвать эти проблемы, поэтому вы должны быть осторожны и использовать эти API-интерфейсы только тогда, когда это необходимо, с помощью вышеуказанных методов. Если вы будете более точным с доступом к DOM вашего приложения, вы увидите гораздо меньше ошибок, а те, которые вы найдете, будет намного легче диагностировать и исправлять.

Проверка нашей работы

Давайте внесем указанные выше исправления в несколько важных областей и посмотрим, станет ли наш сайт лучше.

Вот что мы хотим видеть! После исправления некоторых случаев доступа к DOM, не поддерживающего SSR, сайт загружается правильно! Теперь мы можем проверить, действительно ли сайт отображается на стороне сервера. Причина, по которой нам нужно выполнить эту проверку, заключается в том, что в случае некоторых типов ошибок сервер Angular фактически вернет часть или весь сайт обратно в CSR. Это отлично подходит для пользователей, так как гарантирует, что сайт не просто сломается, но это может быть немного сложно для нас, разработчиков. Чтобы проверить, выполняется ли рендеринг на стороне сервера:

  1. В Chrome откройте веб-инспектор, щелкнув правой кнопкой мыши в любом месте страницы и выбрав «Проверить», окно будет выглядеть следующим образом:

2. Выберите вкладку Сеть и полностью перезагрузите страницу, используя ярлык вашей системы или нажав «Принудительно перезагрузить эту страницу» в меню Google Chrome.

3. Если ваши сетевые запросы отсортированы по времени (вид водопада), выберите первую запись в списке. «Имя» должно быть localhost, метод: GET и тип: document. После выбора на появившейся панели выберите вкладку Ответ.

4. Прокрутите вниз до HTML-кода ответа. Вы должны где-то увидеть корневой элемент вашего приложения. Для нас он называется voterly-root, но в других проектах он будет другим.

5. Изучите этот элемент. Angular не преобразует свой вывод, поэтому все может быть в одной или двух строках. Проверьте, соответствует ли содержимое корневого элемента содержимому страницы. В нашем случае это нет:

Если HTML внутри корневого элемента действительно соответствует вашим ожиданиям, поздравляю! Это означает, что страница отображается на стороне сервера, и вы, вероятно, закончили с этим руководством (если вы не хотите увидеть, как мы также обновили нашу инфраструктуру). К сожалению для нас, это не так. Итак, давайте посмотрим на нашу консоль и ...

Да, именно этого я и ожидал. Но это похоже на еще одно простое решение. Ошибка вроде navigator is not defined. Итак, пока давайте просто прокомментируем его использование и посмотрим, поможет ли это ...

Вуаля, после исправления этой ссылки (и еще одной ссылки на небольшое окно) мы видим, что Angular успешно отображает HTML в нашем документе. Это показывает важность этих мелких проблем, а также устойчивость Angular к ошибкам. При разработке SSR никогда не игнорируйте ошибки в консоли вашего сервера, так как они могут привести к непредвиденным побочным эффектам, таким как отображение только части вашего сайта на стороне сервера.

Развертывание

Как упоминалось ранее, для поддержки наших усилий по SSR необходимо произвести довольно крупное изменение инфраструктуры. Мы ценим дополнительную гибкость и производительность, которые Amazon S3 и CloudFront предлагают нашему сайту, но одних этих двух частей недостаточно для полной поддержки потребностей нашего сайта при рендеринге на стороне сервера. Фактически, единственное изменение, которое действительно происходит, - это добавление второго пункта назначения, из которого пользователи могут запрашивать файлы. Причина такой структуры двоякая. Во-первых, после загрузки приложение все еще загружается в JavaScript, а это означает, что клиент должен иметь возможность доставлять те же клиентские пакеты, как если бы это был чисто CSR. Это также снижает затраты, поскольку нашему серверу не нужно обрабатывать каждый запрос файлов. Во-вторых, в случае катастрофических ошибок, которые в противном случае нарушили бы работу сервера и остановили бы обслуживание сайта, мы можем быстро переключиться на доставку только пакетов JS из S3. Это ухудшит работу пользователей с отключенным JavaScript, нарушит предварительный просмотр динамических ссылок и т. Д., Но продолжит работу. Упрощенный обзор архитектуры выглядит следующим образом:

  1. Один раздача CloudFront (CF) принимает весь трафик.
  2. Используя поведения, он направляется в:
    a.) Elastic Beanstalk (EB) - Все клиентские маршруты (/*).
    b.) Amazon S3 - Все клиентские файлы (*.*).

Наша инфраструктура с бессерверной структурой

Учитывая, что мы в Vidaloop широко используем фреймворк Serverless и что он помогает нам развертывать большинство сервисов для Voterly, я не хотел отходить от нормы для этого проекта. Таким образом, я попытался инкапсулировать как можно больше логики настройки и развернуть код в бессерверной экосистеме, насколько это возможно. Это потребовало немного больше работы, но, надеюсь, приведет к более плавному развертыванию для будущих разработчиков. Я не собираюсь вставлять весь наш serverless.yml, потому что он никому не принесет особой пользы, так как он очень специфичен для нашего клиента. Однако я опубликую некоторые из наиболее интересных моментов / изменений, которые я сделал. Любые его части, не опубликованные здесь, в основном представляют собой стандартные настройки тарифов и декларации ресурсов, и их можно найти во многих различных руководствах.

Развертывание на Elastic Beanstalk

При принятии решения о развертывании нашего активного сервера в Elastic Beanstalk у нас есть несколько вариантов. Мы можем:

  1. Используйте комбинацию команд EB CLI для загрузки и развертывания файлов наших пакетов.
  2. Используйте бессерверный плагин, например serverless-plugin-elastic-beanstalk.
  3. Напишите наш собственный сценарий, который интегрируется с AWS SDK.

Поскольку нам нужно, чтобы это работало только в одном случае, я фактически решил выбрать вариант №3 - написать собственный сценарий развертывания. Затем я (в основном) обернул этот сценарий в настраиваемый бессерверный плагин, который запускается после обновления стека CloudFormation и загрузки пакета приложения. На базовом уровне скрипт работает так:

  1. Получите местоположение S3 артефакта развертывания, загруженного без сервера
    Примечание. Вы можете получить сегмент бессерверного развертывания для своего приложения, используя следующий код в своем подключаемом модуле: this.provider.getServerlessDeploymentBucketName() и получить каталог артефактов развертывания имя с использованием this.serverless.service.package.artifactDirectoryName
  2. Создайте новую версию приложения EB, используя расположение S3
  3. Разверните созданную версию на всех экземплярах EB.

Используя этот сценарий, разработчику не нужно запускать никаких дополнительных команд, чтобы обеспечить обновление среды Elastic Beanstalk.

Синхронизация файлов развертывания и политик кеширования

Поскольку бессерверный режим в основном используется для развертывания кода для функций Lambda и менее приспособлен (сам по себе) для взаимодействия со статическими сайтами, размещенными в S3, мы решили использовать плагин serverless-s3-sync для автоматической загрузки файлов в заранее определенную корзину. Использование этого плагина также дало нам возможность указывать Cache-Control данные об объектах в корзине с помощью глобальных строк. Вот пример конфигурации s3:

custom:
  s3Sync:
    - bucketName: ${self:custom.bucketName}
      localDir: dist/<project-name>/browser
      params:
        - "/**/*.+(svg|png|jpg|jpeg|gif|ico)":
            CacheControl: "public, max-age=31560000"
        - "/*.*.js":
            CacheControl: "max-age=31560000"

Это легкий, понятный и легко изменяемый способ объявления политик кеширования для определенных файлов или типов файлов. Обратите внимание, что этот метод не работает для объявления политик кеширования на пользовательских клиентских маршрутах. Они могут обрабатываться либо сервером EB, либо с помощью поведения в CloudFront, либо их сочетанием.

Политики кэширования маршрутов

Поскольку приведенная выше настройка кеширования работает только для файлов в корзине S3, нам также нужен был отдельный способ обработки длины кеша для самих маршрутов Angular. Из-за более ранних частей нашей настройки мы уже внедрили объект RESPONSE, который Express получает в наше приложение Angular. Это сделало довольно тривиальным отправку заголовка Cache-Control обратно клиенту.

// In our route listener (called on route changes/initial load)
this.setCacheControlHeader(page.cacheLength);
// implementation:
private setCacheControlHeader(cacheLength: CacheLength = VoterlyCacheLength.Medium): void {
   if (this.platform.isServer) {
      if (this.response) {
         this.response.setHeader('Cache-Control', `max-age=${cacheLength.defaultSeconds}`);
      }
   }
}

Чтобы установка работала, мне также нужно было добавить свойство cacheLength к данным маршрута для конкретного маршрута. Таким образом, вся информация о маршруте (авторизация, кеширование, заголовок и т. Д.) Остается вместе, и ее легче найти. Ниже приводится пример того, как может выглядеть такая структура:

interface RouteData {
   ...
   cacheLength: CacheLength;
   ...
}

CacheLength определяется так:

interface CacheLength {
   minSeconds: number;
   defaultSeconds: number;
   maxSeconds: number;
}

Мы определили его таким образом, чтобы потенциально поддерживать будущие требования о том, что определенные стратегии кеширования могут поддерживать переменное время кеширования между диапазоном (мин. / Макс.), Но на данный момент мы используем только defaultSeconds. Вот пример нашей стратегии кеширования:

const VoterlyCacheLength: {[key: string]: CacheLength} = {
   None: {
      minSeconds: 0,
      defaultSeconds: 0,
      maxSeconds: 0,
   },
   Medium: { ... },
}

Если маршрут не включает свойство cacheLength, используется его родительский элемент. Если ни один из предков этого маршрута не имеет свойства, предоставляется значение по умолчанию. Это помогает нам избежать необходимости предоставлять стратегию кеширования для каждого маршрута и позволяет нам легко предоставлять стратегию кеширования для группы маршрутов, сохраняя при этом возможность ее переопределения.

Создание группы аварийного переключения источника

Для поддержки требования о том, что необслуживаемые запросы к Elastic Beanstalk могут откатиться к версии S3, нам потребовалось создать резервную группу Origin. По сути, это просто именованные группы источников (в данном случае источники с идентификаторами SERVER_ID и ASSETS_ID), где каждое происхождение проверяется в том порядке, в котором они объявлены. Если один из заданных FailoverCriteria.StatusCodes возвращается первым источником, выполняется попытка следующего в цепочке.

resources:
  ...
  CloudFrontDistribution:
    ...
    OriginGroups:
      Quantity: 1
      Items:
        - Id: "FAILOVER_GROUP_ID"
          Members:
            Quantity: 2
            Items:
              - OriginId: "SERVER_ID"
              - OriginId: "ASSETS_ID"
          FailoverCriteria:
            StatusCodes:
              Items:
                - 500
                - 502
                ...

Использовать группу отработки отказа в распределении так же просто, как установить для нее TargetOriginId поведения кеша, например:

- TargetOriginId: "FAILOVER_GROUP_ID"
  PathPattern: "*"

Использование Lambda @ Edge для аутентифицированных маршрутов

До сих пор, даже с нашими модификациями, наша установка и переход прошли относительно гладко и не потребовали значительных изменений в одной части нашего клиента. К сожалению, одно место, где SSR разваливается в нашей настройке, - это наша система аутентификации. До этого перехода мы использовали поток аутентификации на основе JWT и хранили токены в локальном или сеансовом хранилище пользователя. Это сработало нормально, потому что с CSR будет доступен JavaScript для проверки состояний аутентификации и, например, перенаправления пользователя на другой маршрут, если он не вошел в систему. С SSR, в точке, где состояние аутентификации пользователя должно быть проверено (т.е. до обслуживания маршрута), у нас нет доступа к localStorage пользователя. Фактически, как только мы окажемся за уровнем кеширования (CloudFront), практически не будет возможности выполнять маршрутизацию на стороне сервера через Angular. Но это одно из наших требований, так что же дает?

Что ж, оказывается, что дистрибутивы CloudFront предлагают отличную интеграцию с функциями на основе AWS Lambda. Эта функция называется Lambda @ Edge и предлагает следующие привязки для каждого поведения в распределении:

  1. Запрос средства просмотра: когда CF получает запрос от пользователя.
  2. Запрос источника: выполняется между CF и источником, когда CF пересылает запрос (промах в кеше).
  3. Ответ источника: проходит между CF и источником после того, как источник обработал запрос (перед сохранением в кеше).
  4. Ответ средства просмотра: передается между CF и пользователем при отправке ответа.

Для Voterly любые проверки аутентификации должны выполняться до попадания запроса в кеш. Это гарантирует, что проверка происходит при каждом запросе сервера (т. Е. При первой загрузке сайта), а также позволяет нам кэшировать возвращенный HTML по состоянию аутентификации пользователя, по-прежнему обеспечивая хорошую производительность при рендеринге аутентифицированных скелетов страниц на стороне сервера. Для этого мы решили использовать ловушку Viewer Request.

В нашем хуке мы получаем невероятно быстрые проверки токенов с нашим внутренним сервером аутентификации, поэтому добавленная задержка относительно минимальна. Мы проверяем, аутентифицирован ли маршрут, генерируя карту маршрутов из нашего файла маршрутов Angular, добавляя немного метаданных к маршруту, который аутентифицируется во время сборки, и проверяя данные сопоставления в сравнении с состоянием аутентификации пользователя при каждом запросе сервера. Например, запись сопоставления одного маршрута может выглядеть так:

{
  "/feed": {
    ...
    "auth": {
      "isRequired": true
    }
  }
}

После проверки различных токенов пользователя, если он является легитимным пользователем и вошел в систему, он будет допущен к любому маршруту, где требуется аутентификация, как указано выше. Если они не вошли в систему, они будут перенаправлены либо на указанный маршрут перенаправления (на карте маршрутов), либо на страницу входа.

Попадания

ОШИБКА: невозможно выполнить привязку к выбранному, поскольку это неизвестное свойство параметра

Эта ошибка возникла у меня при рендеринге на стороне сервера страницы с элементом select. Хотя это может быть проблема с одной из наших настроек базовых компонентов, я смог исправить ее, просто не включив элементы option на стороне сервера. Хотя это может быть не идеально, но работа сделана. Для этого я написал директиву voterlyBrowser. Реализовано это так:

@Directive({ selector: '[voterlyBrowser]' })
export class BrowserDirective {
  constructor(
    @Inject(PLATFORM_ID) platformId: string,
    templateRef: TemplateRef<any>,
    viewContainerRef: ViewContainerRef,
  ) {
    if (isPlatformBrowser(platformId)) {
      viewContainerRef.createEmbeddedView(templateRef);
    } else {
      viewContainerRef.clear();
    }
  }
}

Чтобы использовать его, просто импортируйте директиву в модуль, который вы хотите использовать, и добавьте ее в качестве структурной директивы к любым элементам, которые вы хотите отображать в браузере только. Например:

<div *voterlyBrowser class=”test-div”>...</div>

Примечание об Elastic Beanstalk SolutionStackName

При создании приложения Elastic Beanstalk через бессерверное приложение вам потребуется указать SolutionStackName в разделе ресурсов под вашим Aws::ElasticBeanstalk::ConfigurationTemplate. Он отформатирован так:

resources:
   BeanstalkConfig:
      Type: AWS::ElasticBeanstalk::ConfigurationTemplate
      Properties:
         ...
         SolutionStackName: "64bit Amazon Linux 2 v5.3.1 running Node.js 14"

Amazon выбрала этот подход для определения типа операционной системы и среды, которые вы хотите использовать на своих инстансах EC2. Независимо от того, согласны ли вы с этой схемой именования, вы придерживаетесь ее. Здесь важно отметить, что развертывание в новой среде с «устаревшей» SolutionStackName может привести к сбою развертывания. Мы считаем, что это связано с тем, что Amazon не сохраняет старые версии, если они не используются в развернутых приложениях. Мы столкнулись с этой проблемой при обновлении новейшей среды с v5.3.0 до v5.3.1. Во многих случаях это не вызовет проблем, но на это следует обратить внимание.

Все возможные (текущие) значения SolutionStackName можно найти здесь.

Вывод

В этой статье мы кратко рассмотрели, как можно перевести большой угловой проект с клиентского на серверный рендеринг. Несмотря на то, что мы здесь многого достигли и были весьма успешными, есть еще кое-что, что нужно улучшить. Например, хотя этот переход значительно улучшил наши оценки Lighthouse (почти в 2 раза по некоторым показателям), есть возможности для улучшения. Более совершенные стратегии кэширования, учитывающие тип устройства и другие параметры, специфичные для пользователя, могут гарантировать, что сайт всегда будет работать быстро для пользователей и обеспечить максимально удобное взаимодействие с пользователем.

Я надеюсь, что эта статья помогла вам узнать больше о процессе рендеринга на стороне сервера и о том, как он связан с Angular, и что мой подход в этом руководстве (не приукрашивая каждое решение) дает вам более реалистичное представление о выполняемой работе. выполнить. Я рекомендую вам задавать вопросы в комментариях, если вы боретесь с этим процессом. Я хотел бы помочь чем смогу! Если вы хотите видеть больше подобного контента в будущем, дайте мне знать!

А теперь перестаньте читать и начните кодировать!