Как подружить Nest.js и Angular при помощи gRPC

Alex Dukhnovskiy
Angular Soviet
Published in
12 min readJan 21, 2020

--

Всем привет! После долгого перерыва я таки разродился новой статьёй. На этот раз она в большей степени о бэкенде, но и фронтенд здесь тоже присутствует, куда ж без него. Эту статью я начал писать полгода назад, большую часть времени пытался придумать более-менее вменяемый и одновременно простой и полный пример реализации микросервисов, что оказалось задачей нетривиальной. Сложность заключалась в необходимости демонстрации как можно большего количества приёмов и практик, при этом необходимо было уместить их в небольшое и простое приложение. В конечном счёте остановился на приложении онлайн чата, потому что оно перекрывает большую часть нужных примеров.

Почему я выбрал эту тему? Здесь всё просто: последний год я работаю над проектом, где используется этот стек, мы научились его готовить, набили шишек, успели разочароваться в нём и снова в него влюбиться. И, так как статей по теме в сети очень мало, решил поделиться нашим опытом и практиками в работе с Nest.js, gRPC и Angular.

gRPC

В вольном переводе с официального сайта описание gRPC выглядит так:

gRPC — высокопроизводительный RPC фреймворк с открытым исходным кодом, который работает в любой среде и эффективно соединяет сервисы внутри сети и между дата-центрами с помощью подключаемой поддержки для балансировки нагрузки, трассировки, проверки работоспособности и аутентификации. Он также применим в «последней миле», распределённых вычислениях для подключения устройств, мобильных приложений и браузеров к внутренним службам.

Фреймворк работает поверх HTTP/2, который является бинарным протоколом, что даёт высокую производительность. В HTTP/2 сервер имеет право послать то содержимое, которое ещё не было запрошено клиентом, что применяется gRPC для реализации стримов.

Структуры, используемые gRPC, описываются при помощи Protocol Buffers — протокола сериализации структурированных данных. Protobuf поддерживается большим количеством языков программирования и платформ, что открывает возможности взаимодействия между ними. Таким образом в нашей компании в одной связке работают приложения на Node.js, Go и Angular.

Nest.js

Nest.js — это Node.js фреймворк, написанный на TypeScript и внешне похожий на Angular, поддерживает микросервисную архитектуру и умеет работать с gRPC «из коробки».

Вот так выглядит Protobuf hero.proto из официального примера Nest.js:

Так инициализируется микросервис:

А так создаётся метод в контроллере:

Тело запроса приходит в поле data, заголовки — в metadata.

А вот так выглядит клиент и работа с ним:

Организация стрима имеет небольшое отличие — при описании proto в сервис добавляется ключевое слово stream в зависимости от необходимого типа стрима: если нужен readable stream, то добавляется к Response, а если writable, то к Request.

Пример:

Таким образом и создаётся инфраструктура взаимодействия между микросервисами, приложениями и даже фронтендом.

Выглядит не сложно, но это был всего лишь «Hello World!» из документации. Ниже будем разбираться, как с этим жить в реальном мире.

Типизация Protobuf

Так как Nest.js использует TypeScript, первой нашей проблемой стала типизация для gRPC. Вначале мы писали интерфейсы самостоятельно и «расшаривали» их между сервисами. По мере роста проекта делать это в ручном режиме становилось всё сложнее, поэтому мы искали способы генерировать тайпинги из файлов proto автоматически. Так мы нашли protobuf.js, который предоставляет 2 консольные утилиты: pbjs и pbts.

Это тоже не решило проблему до конца, так как сгенерированная типизация использовала Promise, в то время как клиент Nest.js предоставлял RxJs Observable. Работать с кодом стало проще, но выглядело это иногда причудливо:

Впоследствии мы оборачивали вызовы RPC в RxJS from, тем самым сообщали тайпскрипту, что это Observable, а не Promise, но это было «костылем» и пришлось написать скрипт для поиска Promise в файлах и замены их на Observable.

pbts оказался подходящим вариантом, но не без минусов:

  • кроме интерфейсов генерируются ещё и классы, без которых можно было бы обойтись;
  • названия пакетов превращаются во вложенные неймспейсы;
  • вышеуказанное никак не настраивается.

На этом наши беды не закончились.

Для структурирования интерфейсов Protobuf мы используем вложенные package и генерируемая типизация выглядит примерно так: api.user.CreateUserReq. При типизации параметров такими вложенными конструкциями GrpcMethod возвращает ошибку: «Module api.user.MyMethod not found», т.е. декоратор пытается работать с ts-типами, как с объектом js.

Решения:

  • создавать переменную для каждого типа;
  • создать обёртку.

Мы используем второй вариант:

Кроме того, генерируемый бойлерплейт подходит для типизации DTO или TypeOrm Entity:

Protobuf допускает использование типа enum. Для удобства мы выносим перечисления в отдельный файл:

Для них protobuf.js должен генерировать не только .d.ts, но и javascript-файлы, иначе при сборке проекта возникнет ошибка «module not found».

Пример использования:

Для запуска вручную нужно вызвать:

В примерах enum начинаются с поля 0 — это зарезервированное значение и потому не используется. gRPC считает значения null, 0, “”, undefined и false значениями по умолчанию и не передаёт их, соответственно, объект дойдёт до адресата с отсутствующим полем. По этой причине необходимо создавать дополнительные проверки и приведения к типам, например, передавать число 0 в виде строки. Некоторые прокси умеют достраивать недостающее.

Генерировать типизацию для proto-файлов поштучно в большом проекте нерационально, поэтому мы написали скрипт для массовой генерации и замены Promise на Observable в получаемом коде. В демо проекте есть его упрощённая версия.

Генератор для фронтенда protoc-gen-grpc-web так же умеет создавать типизацию, но получаемый результат не подходит для использования на бэкенде:

  • типизация gRPC-сервисов не соответствует методам на сервере;
  • название полей в message приводится к строчной нотации, тогда как требуется полное соответствие proto-файлу;
  • для стримовых полей к имени добавляется постфикс List, тогда как бэкенд ожидает имя без него.

Альтернатив мы пока не нашли, поэтому вынуждены использовать protobuf.js и стараться оптимизировать его работу.

Обработка исключений

Вместо привычных сетевых ошибок gRPC возвращает один из своих кодов состояния с необязательным строковым сообщением об ошибке, которое предоставляет дополнительную информацию о том, что произошло. Список статусов можно посмотреть тут. По этой причине не работает ни одно из готовых решений для Node.js, либо работают не полностью. Чтобы получать вменяемый вывод в логах и не пропускать на клиент значимую информацию, вроде ошибок БД с названиями таблиц, нужно писать обработчик ошибок самостоятельно. Мы создали логгер в виде библиотеки, которая реализует RpcExceptionFilter, используемый в контроллерах при помощи декоратора @UseFilters:

У Nest.js gRPC есть особенность — при работе с исключениями, необходимо использовать RpcException, а не HttpException, иначе клиент в лучшем случае получит ошибку со статусом UNKNOWN, в худшем — не получит ничего, т.е. ни обычный throw new Error(), ни HttpException() не дойдут до клиента. Например, используя Guards, в случае ошибки необходимо передавать из него RpcException, но это не запрещает внутри кода использовать обычный Error.

Инструменты для отладки

Для отладки и тестирования gRPC существует не так много инструментов. Вот список наиболее популярных:

Первое время мы использовали grpcc и grpc_cli. Первый пакет работал не всегда корректно, например, ошибку синтаксиса трактовал, как обрыв сети или вовсе не возвращал никаких ошибок там, где они были. Также в нём не работал exec — при попытке его использования grpcc завершал работу с ошибкой. В свою очередь grpc_cli оказался вполне неплох, но его требовалось компилировать. О polyglot и prototool ничего не скажу, т.к. не имею опыта работы с ними, но они тоже в топе предложений. В компании же мы остановились на grpcurl, который легко устанавливается и стабильно работает.

Пример запроса grpcurl:

Отладка gRPC посредством консоли браузера, как это делается при работе с REST, невозможна по причине закодированного в base64 тела запроса и ответа. По этой причине также невозможно использовать инструменты типа Postman. Есть плагин для Chrome grpc-web-devtools, который у меня по каким-то причинам не запускается, но, судя по отзывам, работать он должен.

gRPC на фронтенде

В настоящее время невозможно реализовать HTTP/2 gRPC spec3 в браузере, поскольку в нём отсутствует API с достаточно детальным контролем над запросами. Нет способа принудительно использовать HTTP/2, и, даже если бы он был, необработанные HTTP/2 фреймы недоступны в браузерах. Спецификация gRPC-Web начинается с HTTP/2, а затем определяет различия. Подробнее об этом на официальном сайте. Там же внизу есть список решений для работы gRPC в браузере, все эти решения представляют из себя прокси, которые трансформируют HTTP/2 в HTTP/1 и обратно. gRPC-вызов по своей сути является обычным http-запросом со специальными заголовками, позволяющими его обнаруживать.

В нашем случае выбор пал на Envoy Proxy. Кроме превращения HTTP/2 в HTTP/1 Envoy умеет агрегировать API нескольких микросервисов, позволяет тонко настраивать доступы к url, даёт возможность совместно с ним использовать Rate limit, Сonsul и многое другое.

Для использования gRPC на фронтеде существует множество решений. Для демо я выбрал grpc-web, т.к. он популярен, рекомендован на официальном сайте, а его создатели обещают поддержку Angular в будущем. Перед началом работы нужно сгенерировать необходимые клиенту grpc-web абстракции. Делается это при помощи инструмента protoc и его расширения protoc-gen-grpc-web.

Посмотрите на его работу в действии, для запуска генерации вызовите этот скрипт в корневой директории демо:

Сгенерированные абстракции применяются в коде следующим образом:

@grpc-web/service/service_grpc_web_pb и @grpc-web/service/service_pb являются сгенерированными файлами, ServicePromiseClient описывает grpc-сервис из вашего proto, а Response и Request — соответственно, messages. Обратите внимание, что здесь нет вложенных неймспейсов, как в бойлерплейте для бэкенда.

Мы превращаем Promise в Observable ради соблюдения best practice Angular. Для отправки чего-либо на сервер необходимо создать объект new Request() и через методы setParam (имена автоматически генерируются согласно имени параметра) добавить значения полям запроса. В Metadata можно передавать то, что мы привыкли передавать в headers при отправке http-запросов, например, JWT-token.

Возвращаемый сервером ответ не является привычным JSON или же строкой, которую можно преобразовать в JSON, значения полей необходимо получать из методов вроде response.getToken(). Существуют так же методы для ленивых — response.toObject() и response.toObjectList(), которые возвращают уже готовый объект или список объектов и подходят для использования в большинстве случаев.

Пример работы со стримом:

Создаём приложение

Здесь я расскажу о приложении-чате, объясню технические решения и дам несколько рекомендаций.

Для удобства я создал backend и frontend в одном репозитории. Пакеты устанавливаются при помощи инструмента Lerna.

Структура проекта:

  • devtools
  • grpc-proto
  • backend
  • frontend

devtools

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

grpc-proto

Здесь лежат proto-файлы, которые используются и бэкендом, и фронтендом. В реальном проекте эти файлы можно держать в отдельном репозитории и использовать в качестве, например, git submodules.

backend

Для реализации бэкенда приложения я воспользовался возможностями Nest.js, позволяющими организовать монорепозиторий для микросервисов и библиотеки. Сборка сконфигурирована как Webpack mode.

Сборку можно запускать как отдельно для каждого микросервиса, так и для всех разом, используя docker-compose.

В качестве базы данных используется Postgresql с клиентом node-postgres и мигратором db-migrate.

Микросервисы можно разрабатывать по отдельности, но для нормальной работы они должны быть запущены внутри одной экосистемы, либо иметь доступ к внешнему кластеру с работающими экземплярами микросервисов. Иначе запущенный локально микросервис не сможет взаимодействовать с «коллегами». В этом проекте такая экосистема создаётся при помощи Docker. Рядом с файлом конфигурации лежат скрипты и конфиги для БД и Envoy — здесь можно посмотреть, как они настраиваются.

Для запуска в docker необходимо выполнить:

Для остановки, соответственно:

Выполнять эти команды нужно из папки backend.

Лог микросервисов доступен по команде:

Или в специализированных инструментах типа WebStorm. Я использую последнее, потому что это удобно.

Бэкенд состоит из 3х микросервисов: user, auth и chat.

  • user работает с пользователями и БД users;
  • auth не работает с базой и получает данные пользователей у user;
  • chat реализует API чата и работает с БД chat.

Я намеренно разделил ответственности именно так, с целью продемонстрировать взаимодействие между различными частями приложения с использованием gRPC.

Каждый микросервис содержит файл env.ts с настройками клиента БД и мигратора, так же может включать в себя иные настройки, например конфигурацию gRPC микросервиса. Но в данном примере я вынес эти настройки в библиотеку для удобства переиспользования — когда одному микросервису нужно что-либо получить от другого, для создания grpc-клиента необходим его конфиг. Вы также можете их держать внутри микросервиса и динамически задавать параметры в зависимости от окружения.

GrpcConfigs:

Note: Nest.js не даёт возможности указывать в одном gRPC-конфиге несколько proto-файлов и пакетов, что иногда бывает необходимо. Некоторое время назад у нас с коллегами возник тред с авторами Nest.js и мы создали pull request с нужной функциональностью. Если вы тоже неравнодушны к проблеме, голосуйте за него на GitHub.

Инициализация микросервиса с использованием gRPC-конфига:

Note: Кроме того, вместе с gRPC Nest.js допускает использование в одном микросервисе других протоколов и технологий, таких как Express или WebSocket, например, может потребоваться, чтобы микросервисы бэкенда сообщались между собой посредством gRPC, а внешне были доступны в виде REST API.

Микросервисы имеют общую архитектуру, где работа с БД, внешними зависимостями и переиспользуемыми элементами отделены от API и бизнес-логики. Они могут содержать один или несколько контроллеров или же вовсе ни одного, в зависимости от конкретной задачи. Контроллеры реализуют gRPC API, доступное снаружи.

Пример унарного метода (единичный запрос типа GET/POST/etc):

Для стримовых методов отличий нет — передается поток Observable. Для работы с другими микросервисами — создаётся и инициализируется клиент:

Обратите внимание, что в клиенте инициализируется rpc UserService, который был определен в user.proto:

Для каждого grpc-сервиса нужно создавать свой клиент.

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

Отправка заголовка X-Grpc-From:

Чтение заголовка X-Grpc-From:

После вызова авторизации в логе появится нечто вроде:

К сожалению, GrpcMethod умеет только получать Metadata, но не передавать.

Note: В компании мы патчим Nest.js для того, чтобы научить GrpcMethod передавать Metadata, например, чтобы отправлять клиенту заголовок Set-Cookie. Я намеренно не привожу здесь код патчей, так как почти при каждом обновлении Nest.js они перестают работать, и мы меняем их вслед за изменениями.

Обратите внимание на UseFilters в контроллере — обработчик ошибок для него кастомизирован согласно рекомендациям Nest.js и вынесен в библиотеку:

Фильтр принимает на входе ошибку и соотносит её с одним из шаблонов.

Кроме обработчика ошибок в контроллерах используются Guards для защиты от неавторизованного доступа к методам. Функция гарда также лежит в библиотеке и представляет из себя валидатор JWT, который либо разрешает доступ, либо возвращает ошибку:

Посмотрите, каким образом создается исключение — об этом я писал выше, в соответствующем разделе. Здесь необходимо возвращать именно RpcException, потому что любой другой тип исключений (например, HttpException) не дойдёт до клиента вызывающей стороны.

Я отказался от использования @nestjs/jwt из-за необходимости подключать в проект избыточные passport-jwt и @nestjs/passport. Вместо них использую jsonwebtoken, который создает и инвалидирует токены (passport-jwt так же работает при помощи этой библиотеки).

Note: Имейте в виду, gRPC не совместим с такими решениями, как Passport.js, поэтому @nestjs/passport с другими стратегиями, скорее всего работать не будет.

Токен JWT генерируется с использованием RSA-ключа, который создается и обновляется динамически при помощи библиотеки pem.

Обновлением публичного ключа для подписи JWT занимается микросервис auth, раздавая новый ключ в режиме реального времени другим микросервисам:

Остальные микросервисы при инициализации подписываются на стримовый метод рассылки ключей, принимают ключ и запоминают его в переменную, из которой JwtGuard получает его для верификации токена. Для удобства разработки я сгенерировал статичный ключ и положил его в микросервис auth, иначе при пересборке предыдущий токен станет негодным. Динамическая смена ключей — это кейс для продакшна.

Это был пример взаимодействия микросервисов посредством gRPC стрима. Унарный запрос можно посмотреть в микросервисе auth, там он запрашивает у микросервиса user верификацию пользователя для его авторизации:

Алгоритм авторизации пользователя в приложении выглядит следующим образом. Присылаемые клиентом авторизационные данные отправляются микросервису user, где проверяются в БД. В ответ возвращаются либо данные пользователя, либо ошибка. В случае успеха создается JWT и отправляется клиенту. Токен имеет короткий срок жизни, поэтому клиент вынужден периодически запрашивать его снова через метод updateAuth — это защищенный гардом метод, где выдача нового токена происходит только после верификации предыдущего, что заставляет клиент обновлять его не дожидаясь истечения ttl. Предыдущий токен разрешает создание нового токена и тем самым достигается подобие сессии.

В микросервисе chat основным элементом является шина событий:

Я вынес её в директорию services, потому что она используются обоими модулями API chat. Модуль Message передаёт данные в шину:

Модуль Chat слушает изменения и возвращает новый Observable:

ChatService используется контроллером для дальнейшей передачи данных в стрим:

Таковы основные моменты работы бэкенда, более подробно предлагаю вам разобраться с ним самостоятельно. Давайте перейдем к фронтенду.

frontend

Клиентская часть приложения состоит из модулей User и Chat, периферии из сервисов-фасадов, обеспечивающих работу gRPC и вспомогательных функций, типа гардов и общих сервисов, вынесенных в отдельную директорию.

Например, сервис auth, сохраняющий пользователя авторизованным и отвечающий за своевременное обновление JWT. При получении payload токена из него достаётся значение ttl, преобразующееся во «время до» и с этим временем запускается таймер, по истечении которого инициируется grpc-запрос к бэкенду обновляющий токен:

AuthGrpcService:

При такой организации кода его становится намного меньше, чем в примере, описанном в разделе ознакомления с gRPC на фронтенде. Здесь используются обёртки для стримового и унарного методов.

Для унарных запросов:

Пример использования:

Для стримовых:

Пример использования:

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

Note: Angular Interceptor не может перехватывать запросы grpc-web, т.к. он работает только с HttpClient.

Для создания объекта Metadata используется метод grpc-web Metadata:

Созданный объект метаданных передаётся при вызове вторым аргументом.

Для удобства я вынес повторяющуюся операцию по созданию метадаты с JWT в отдельный хелпер:

Давайте перейдем к контроллерам. В них используются grpc-сервисы, которые выполняют те же функции, что и HttpClient, когда мы работаем с REST:

В остальном всё достаточно тривиально, например, таким способом приложение получает список сообщений чата от бэкенда:

А так отправляет новые сообщения на сервер:

Остальное вы можете разобрать самостоятельно.

В завершение

Я считаю, что у данного стека имеется огромный потенциал. Nest.js, gRPC и Angular активно развиваются, вокруг них образовались многочисленные сообщества, помогающие в развитии. Инструментарий также не стоит на месте, что упрощает работу и способствует снижению порога входа.

Преимущество gRPC перед REST или WebSoket в том, что он в рамках одного протокола и реализации предоставляет как стримы, так и обычные запросы. В вашем проекте не будет зоопарка технологий. А главный плюс gRPC заключается в использовании одних и тех же файлов интерфейсов всеми участниками приложения— если на бэкенде что-нибудь меняется, фронтендеры узнают об этом не от реальных пользователей (в продакшне), а при ближайшем CI — сборка завершится с ошибкой и в прод не попадет некорректная версия продукта.

Этот стек может занять достойное место рядом с MEAN, остается добавить к нему вашу любимую БД.

Связаться со мной или задать вопросы можно на GitHub или в телеграме:

--

--