Nestjs, Firebase, GCloud. Как быстро поднять API backend на TypeScript.
Очень здорово, что вы решили открыть эту статью. Меня зовут Федор, я фуллстечу с конца 2021 года на постоянной основе. На всякий случай, ниже прикреплю свой профиль на Github.
Этой небольшой статьей я хочу:
- Дать старт серии туториалов на тему запуска backend API
- Предоставить собранный пример nestjs проекта с интеграцией firebase
- Помочь разработчикам, выходцам из Frontend, быстро подготовить окружение для разработки бэка
Эта статья - описание и step by step инструкция по интеграции firebase с нюансами. В конце статьи - готовый nestjs-boilerplate.
Еще хочу заранее предупредить читателя: Эта и будущие статьи в целом подойдут новичкам, но все же нужно быть знакомым с JavaScript/TypeScript в целом, либо не боятся гуглить то, что здесь будет разобрано не достаточно детально.
Содержимое
Нюансы работы с Firebase
На определенном этапе вам потребуется завести платежный аккаунт в Google Cloud, в моем случае, я взял отдельную карту Казахстанского банка.Если у вас нет возможности привязать свою карту к firebase, есть несколько путей решения этой проблемы:
- Завести карту в стране не находящейся под санкциями
- Найти сервисы по генерации платежных карт
- Заказать карту онлайн
Интеграция IaaS/PaaS решений на подобии Firebase не сильно отличаются друг от друга. Firebase условно бесплатная платформа, если укладываться в лимиты тарифного плана. Так же использование Firebase накладывает ограничения на плохо спроектированый код. На моем опыте, ошибка в проектировании частотности запросов к Firestore, повлекла за собой ущерб компании в 800 долларов в день при кратном росте трафика. Спустя некоторое время дебага удалось сократить расходы в 30 раз.
Примечание: предложенный стек хорошо подойдет для пет проектов и небольших production проектов. Итоговая бойлерплейт репа будет включать в себя необходимый минимум конфигурации для старта разработки. Если вы ранее не работали с Nodejs и Nest.js в частности, у вас могут возникнуть трудности с некоторыми концепциями и конструкциями кода, заранее рекомендую подготовить, например, эту статью.
Личный опыт
На самом деле, хоть я и начал заниматься fullstack лишь несколько лет назад, мне уже доводилось пробовать себя в кросс функицональных проектах, на разных стеках и платформах, в основном это js/ts. С Nestjs познакомился попав в компанию, которая выбрала путь изоморфной Fullstack разработки, с тех пор этот путь меня не отпускает. Так вот, за несколько лет работы над разными проектами, мне удалось собрать простой и минимальный боейлерплейт для очередного MVP стартапа или пет проекта. Я очень надеюсь, что эта статья и итоговая репа, упростит вам жизнь.
Перед инициализацией
Я буду писать этот бойлерплейт со своей точки зрения и окружения, поэтому первым делом предупрежу, что я работаю на платформе MacOS, все предлагаемые действия должны быть кроссплатформенными, но не исключаю трудностей. Буду рад вопросам и рекомендациям в комментариях под этим постом.
Мой сетап
В терминале я использую ZSH оболочку, вместо обычного BASH, поэтому первая ссылка - ohmyzsh.
Еще у меня есть необходимость держать разные версии nodejs, связано это с тем, что за годы работы накапливаются проекты разной свежести + привычки никто не отменял. Для удобной работы, предлагаю установить вам NVM, он же менеджер версий для nodejs.
И напоследок, так как проектов у разработчика может быть много, использую pnpm.
Перейдем к основному набору команд и конфигурации проекта. Для начала, давайте установим nodejs 20 версии при помощи nvm. nvm i 20 && nvm use 20, а после поставим pnpm командой npm i -g pnpm. Предварительно, проверим, что все пакеты доступны в терминале.
Двигаемся дальше и устанавливаем nest cli глобально: npm i -g @nestjs/cli, после успешной установки cli можем перейти к шагу создания проекта. Делается это при помощи команды nest new nestjs-startup-boilerplate, где после слова new вы можете написать название своего проекта. Далее будет предоставлен выбор конфигурации инициализации проекта.
- 1 шаг, выбор пакетного менеджера, я выберу pnpm.
- 2 шаг, а на этом пока все, главное выбрать пакетный менеджер.
На данный момент, у вас должен получится вот такой набор изменений файлов проекта.
Конфигурация
Линтинг, tsconfig, env файлы. Давайте законфигурим это все.
В любом проекте будет полезно иметь path aliases, чтобы не часто видеть импорты кода в таком виде import something from '../../../../modules/something'. Мне больше нравится что-то такое: import something from '~/modules/something. Читать и поддерживать это намного приятнее. В tsconfig файл внесем несколько изменений, объяснять подробно я не буду, подробнее о настройках tsconfig.
Следом, я хочу внести изменения в eslint.js и добавить туда привычной мне конфиг.
Давайте обновим package.json, в scripts добавим следующие команды:
Обязательно установим новую зависимость (это плагин для eslint конфига):
Далее займемся prettier файлом.
ENV файлы
Для начала, добавим наш .env.example. В этом файле, мы примерно подскажем разработчикам, какие переменные можно конфигурить в проекте. На данный момент, он выглядит так:
Обязательно настроим .gitignore файл, добавим в него следующие дополнения:
Очередной коммит с изменениями. Если мы запустим команду pnpm run lint:fix, то получим исправленные по линтеру файлы. Итак, на данном этапе, базовый конфиг проекта готов. Далее нас ждет настройка firebase проекта через кабинет.
Подготовка к работе с Firebase
Обычно в своих проекта я использую Firebase, как провайдер: Google Cloud Storage, Firestore Database, Firebase Auth. Давайте настроим первые две услуги. Сперва открываем консоль firebase проектов.
Жмакаем на Get Started и приступим к созданию проекта. На 1 экране создания у вас попросят ввести название, я назову свой так: nestjs-boilerplate-example. Это название будет участвовать в будущих настройках env файлов проекта. Далее на 2 экране мне предлагают включить аналитику, мне она не нужна, я отказываюсь и создаю проект. После успешного создания у вас появится проект на дашборде + вы можете перейти к нему нажав на Continue в окне ожидания создания проекта.
В целом, пока все просто. Далее, нам нужно включить услуги: Firestore, Authentication и Storage. Эти разделы можно увидеть слева, в сайдбаре консоли.
При открытии каждого раздела, первым делом вы увидите предложение о включении функциональности. Get Started, Create Database и так далее. До определенных тарифных лимитов использования, эти услуги бесплатные. Подробнее о тарифах и лимитах.
Создание базы данных потребует выбора сервера базирования, в целом, можете выбрать любой удобный Вам, все зависит от распределения ваших пользователей, я выбираю обычно Европу (Eur3). + Мод запуска, можете оставить спокойно production режим для БД. Примечание: я пробовал us-central1 и eur3, особой разницы скорости работы в рантайме не заметил.
Ну вот, теперь у вас полностью готовый firebase проект. К слову, таким же образом вы можете спокойно генерировать N firebase проектов под необходимые контуры (Production/Stage/Local/Test, в целом для жизни моих проектов вполне хватает).
Давайте перейдем к следующему этапу. Нам нужно получить необходимые данные для запуска проекта. Для этого перейдите в ваш Project Settings (Эта ссылка на мой проект, открыть вы его не сможете, но ссылку в качестве примера можете посмотреть) (находится в поповере по клику на шестеренку).
Попав в настройки проекта, выберите вкладку, Service Accounts. И нажмите на кнопку, generate new private key. После этого Вы сможете скачаеть файл .json формата. Он нам понадобится для шага подключения к firebase.
Конфигурация проекта для подключения к Firebase.
Итак, давайте разберемся с переменными окружения. Скаченный ранее service json файл из firebase, перенесите в корень проекта и назовите его, например, service-account.json. Название сервис файла занесите в наш .env.dev файл.
.env.dev файл можно сделать на основе примера в .env.example. И еще обновим команды в package.json, раздел scripts.
По сути, мы лишь добавили NODE_ENV переменную для nest команд. Далее это значение можно будет достать из process.env.NODE_ENV переменной. Остальные переменные окружений лучше пробрасывать через конкретные .env файлы. Так же для примера добавим сразу же .env файл (можете создать копию из .env.dev, он у нас будет для production режима в будущем)
Теперь подключим наших env переменные в коде проекта.Нам понадобится 2 файла. app.module.ts и main.ts.Начнем с src/env.ts файла, заполним его небольшой логикой и флагами из process.env.NODE_ENV:
Примечание: переданный при запуске NODE_ENV можно сразу же достать, остальные значения будем подгружать при помощи ConfigService в AppModule.
Еще нам потребуется библиотека для чтения значений и загрузки env. pnpm add @nestjs/config. Теперь можем немного изменить app.module.ts:
Таким образом env можно будет использовать по всему проекту.
Теперь можем добавить шаг подключения к firebase. Для этого нам понадобится установить пакет pnpm add firebase-admin. Открываем main.ts файл и добавляем инициализацию firebase-admin.
Итак, у нас готово подключение к firebase проекту.
Однако это еще не все! Далее мы займемся:
- Модулем Firestore
- Примерами модели данных с контроллерами и сервисами
- Добавим gcloud bucket модуль, для работа с файлами
Firestore Модуль
Начнем с самого модуля. Создадим папку src/providers/firestore. В этой папке у нас будет организован необходимый набор кода для подключения к Firestore коллекциям.
Сразу же можем установить пакет для firestore pnpm add @google-cloud/firestore. В папке firestore добавляем 4 файла:
- firestore.module.ts - для подключения в AppModule проекта
- firestore.providers.ts - для списка фаерстор entity документов
- types.ts - для типизации модуля
- index.ts чисто для красивого реэкспорта firestore модуля.
Осталось подключить новый модуль в app.module.ts.
На этом этапе, у нас в целом все готово для создания модулей с контроллерами, репозиториями и так далее. Вот текущий набор правок.
Example Module
Наконец то мы дошли до создания примера модуля. Мы реализуем базовый CRUD на примере абстрактной сущности example. Конкретно метод delete реализовывать я не стану, его реализация ничем не отличается от других вызовов, разве что вызывать в методах репозитория будем .delete(documentId) и все. Вместо удаления документа можно реализовать мягкое удаление, которыое под капотом будет менять поле status у документа (например status: ACTIVE/ARCHIVED). В нашем примере модуля этого не будет. В общем, начнем мы с того, что создадим src/modules, в котором и заведем example модуль. Эта сущность нужна только для примера реализации, как можно организовать код проекта. Само собой, Вы вольны делать что угодно и как угодно.
Предлагаю, например, такую структуру модулей.
Приступим, сперва добавим helpers, перед этим добавим вспомогательные библиотеки командой pnpm add dayjs uuid
Теперь займемся непосредственно файлами модуля Example. Менять название модуля и таблицы я не буду, но предлагаю использовать example, как некую абстракцию поста, включающую в себя название, текст, флаг опубликовано или нет и доп свойства, id документа и даты создания и последнего обновления.Для дат, в firestore есть подходящая модель Timestamp, с ней достаточно легко работать и фильтровать по ней документы. В качестве id документа будем использовать uuid/v4 утилиту.
Добавим наш контроллер с методами:
- Получение списка с query параметрами для фильтрации examples по флагу isPublished и без
- Получение одного example документа по ID
- Создание example
- Редактирование документа
- Обновление флага isPublished (будет один метод, который переключает флаг без параметров).
Большая часть реализации делегирована сервису ExampleService, давайте добавим и его.
Пока что, покрыт тайной лишь файл example.repository.ts, именно он отвечает за обращения в базу данных Firestore коллекции Example. Давайте добавим и его реализацию.
Само собой в файле example/index.ts организуем реэкспорт модуля и документа.
Теперь можем обновить файл firebase.providers.ts.
Чтобы example модуль заработал, добавим его в app.module.ts файл.
Кажется мы забыли еще кое-что. Работа с firebase без конфиг файлов firebase не совсем удобна, особенно если речь идет о различных контурах. Нужно будет добавить глобально firebase cli npm install -g firebase-tools. После этого, скорее всего вам потребуется авторизоваться командой firebase login. Как только вы пройдете все требуемые шаги, можем приступить к конфигурации работы с firebase из терминала.
В терминале, в корне вашего проекта, потребуется ввести команду firebase init. Эта команда запустит процесс инициализации нового или созданного ранее проекта Firebase. Выберите, в моем случае, Firestore из предлагаемых пунктов. Далее, если вы как и я, уже создали firebse проект, выберите пункт Use an existing project, найдите в предложенном списке ваш проект и выберите его. Продолжайте настройку, обычно далее ничего лишнего не нужно менять, дефолтные названия файлов можем оставить как есть.
Примечание: Выбор осуществляется нажатием на пробел, а продолжение на enter/return (в зависимости от вашей клавиатуры). Опции на выбор представляются в виде полого круга (по сути, radio button).
Примечание 2: Иногда может быть такое, что требуемый проект не подтягивается, в таком случае, готовые файлы конфига можете взять из коммита, который будет в конце этой части настроек
Так же может возникнуть проблема, как в моем случае: Error: It looks like you haven't used Cloud Firestore in this project before. Go to https://console.firebase.google.com/project/nestjs-boilerplate-example/firestore to create your Cloud Firestore database. Она решается путем добавление платежного аккаунта. Если набрать команду firebase init --debug, то можно увидеть конкретную ошибку.
Мой лог ошибки инициализации подключения к проекту без активного платежного аккаунта:
Установить платежный аккаунт можно на странице: GCP Billing
После создания платежного аккаунта в GCP и привязки проекта к этому аккаунту, завершить конфигурацию удасться без лишних проблем.
После того, как вы пройдете все шаги, в проект добавятся такие файлы:
- .firebaserc
- firebase.json
- firestore.indexes.json - список актуальных индексов редактируем в этом файле
- firestore.rules - здесь будет код правила работы с firestore. Его значение мы не меняем в дашборде проекта
Примечание: Вместо файла .firebaserc в гите мы оставляем его пример, .firebaserc.example. То, как организовать CI/CD для всего этого мы разберем в следующей статье про деплой бэкенда на VPS.
В будущем нас будут интересовать только .firebaserc (в нем будет название firebase проекта) и firestore.indexes.json (в нем можно будет конфигурить ваши индексы и деплоить их в проект командой firebase deploy --only firestore:indexes)
Итоговый набор изменений на данном этапе в этом коммите.
На текущем этапе мы уже можем спокойно работать с проектом и добавлять необходимые нам индексы в Firestore и так далее. Однак это не все, например я, не могу жить без husky, дополнительные прекоммит хуки и многое другое можно настроить через него. Особенно это полезно в командной разработке, для дополнительного битья по рукам разработчиков. Вы смело можете пропустить следующий шаг, если вам это не нужно. В итоговом бойлерплейт репозитории уже будут добавлены все необходимые настройки для работы через husky. После настройки прекоммитов, мы обязательно дополнительно разберем работу текущего api/example и добавим необходимые индексы для коллекции example. Надеюсь вам еще не надоело, давайте продолжать!
Продолжаем добавлять
Пройдя все шаги выше у нас есть:
- Рабочий бэкенд API, его уже можно поднять локально и потыкать api/example через curl или, например, Postman.
- Рабочий коннект к Firebase проекту и конфиг firebase/firestore
Впереди еще хочется обсудить:
- husky прекоммит хук для запуска линтера кода
- разберем текущий CRUD и добавим индексы
- добавим API для загрузки файлов в Google Cloud Bucket (он же Storage)
Husky
Дока Husky Get Started
В терминале запускаем команду pnpm add -D husky, далее запускаем инициализацию командой npx husky init, это добавит в проект .husky папку, + pre-commit файл, который будет запускаться на стадии коммита изменений. Давайте немного мутируем package.json новой командой в scripts.
И доабавим файл .husky/install.mjs.
Этот скрипт нужен для избежания ошибки установки husky, после команды установки зависимостей npm i/pnpm i
Еще, нам нужно будет отредактировать файл .husky/pre-commit добавив в него строку ниже.
Так как подразумевается запуск хука, который будет проверять файлы, нужно дополнительно установить пакет pnpm add -D lint-staged и добавить в package.json дополнительный конфиг.
По итогу у нас соберется такой коммит.
Теперь, все будущие коммиты будут валидироватьcя по js(x)/ts(x) файлам на основе eslint/prettier и добавлять правки для будущего коммита при помощи команды, добавленной ранее - lint:fix. Можно расценивать данный пример прекоммит хука, как основу для ваших личных конфигураций.
Полный набор доступных гит хуков можно посмотреть в githooks.
Протестируем и соберем индексы для api/example
В app.module уже подключен пример рабочего контроллера в app.controller.ts, его можно вызвать запросом на урл http://localhost:8080 из браузера или Postman. Я буду использовать Postman для более удобной демонстрации.
Давайте сделаем еще несколько запросов.
Список example документов
В результате мы получим ожидаемую ошибку 404, об отсутствующих документах. (Это поведение изначально заложено в коде контроллера, конфигурировать можно как вам вздумается).
А теперь, давайте запросим список example с фильтром в query параметрах ?isPublished=<false или true>
Так, мы послали запрос с параметром, однако получаем ту же ошибку. На самом деле это нормально, но есть проблема. На данный момент, наше апи не умеет читать и работает с boolean значениями, они отображаются в контроллерах и сервисах, как строковые значения 'true' | 'false'. Если залогировать входящий аргумент query в контроллере GET v1/example, то мы увидим следующую картину
Есть несколько способов, как решить эту проблему.
1. На уровне repository, в методе findGenerator в ручную приводить boolean строку к Boolean типу.
2. Добавить дополнительный шаг по их трансформации.
Давайте попробуем пойти по 2 шагу. Отредактируем метод контроллера.
Таким образом, трансформируем входящий параметр isPublished в boolean значение.
Создание нового example документа
Я создал несколько документов, для примера. Давайте еще раз запросим наш список, с фильтром по isPublished=false
Если присмотреться, можно увидеть, что список подтягивается корректно. Однако для многих коллекций требуется менять направления или гарантировать возвращение списка, например, с учетом даты создания по descending/ascending значениям.
После попытки запросить метод списка еще раз, мы увидим, что ответ изменился на ошибку 500. Если пойти в консоль, то мы увидим, что firestore выкинул ошибку отстутствия индекса на такой запрос спика.
Смело переходите по этому URL в консоль проекта, там вы увидите предложение добавления нового индекса. Не спешите добавлять его от туда (вы можете запускать создание индексов и из консоли, но я бы хотел делать это через файл firestore.indexes.json)
Нам нужно взять данные из этой модалки и вручную завести новый индекс, будет это выглядеть так:
Поле __name__ не нужно указывать в этом конфиге, при деплое, он добавляется автоматически. Еще важно соблюдать очередность полей (fields).
После редактирования файла, смело запускайте команду деплоя firebase deploy --only firestore:indexes.
После запуска, в той же консоле фаербейза, во вкладке indexes вы увидите свой индекс, со статусом Building..., необходимо дождаться его сборки и вновь сделать запрос за списком.
Получение Example по ID
Теперь мы можем проверить метод получения example документа по id, скрин вставлять не буду, так как этот запрос уже должен работать без проблем. В моем случае, это http://localhost:8080/v1/example/7c8a5d30-beca-409a-8509-873616c80f5a, Ваш ID может отличаться от моего.
Редактирование Example документа по ID
Проверим метод редактирования example документа, я отредактирую title.
Если снова запросить список или документ по id, мы так же увидим измененные данные.
Смена флага isPublished
Помимо этого, давайте поменяем значение isPublished в документе, сделав запрос на еще один endpoint.
Если вы проверите состояние списка isPublished=false или true, то увидите изменения в возвращаемых данных.
Так же прикрепляю ссылку на текущий набор api вызовов в Postman json файлик. Можете скачать его и импортировать в свой Postman workspace для быстрой развертки окружения запросов.
Очередной коммит с изменениями. На данном этапе я мог бы остановиться, но мы еще не разобрали момент с публикацией файлов в GCloud Storage...
Storage Bucket
Давайте определим заранее моменты работы со Storage, которые мне известны.
- Nest.js предоставляет документацию по загрузке файлов
- Бесплатный Storage предоставляет 5GB хранилища, сверх этого объема придется платить ежемесячно за каждый байт данных.
- Бесплатный Spark не дает возможности создавать отдельные бакеты, но такой кейс мы учтем в коде. Однако рекомендую использовать дефолтный бакет проекта, а сами файлы резолвить по папкам и подпапкам в коде.
Кейсы использования Storage:
- Чтение и запись файла во временную папку в корне проекта, /uploads в нашем случае
- Запись и удаление файла в Storage
- Генерация публичной ссылки на файл, если такая необходимость нужна (по дефолту мы всегда будем отдавать публичный линк, вы можете переписать или дополнить необходимый кусок кода относительно ваших кейсов, я предоставлю рабочий пример)
Давайте приступим, нас ждет еще N новых файлов. В папке privders, рядом с firebase, добавим новую папку - bucket. В ней мы заведем, само собой файл bucket.module.ts и кучу вспомогательных. Кстати, чуть не забыл, установим пакеты pnpm add @google-cloud/storage multer lodash, и обязательно pnpm add -D @types/multer.
Предлагаемая структура:
Давайте опишем каждый файл, кода будет относительно немного. И добавим загрузку и удаление в отдельный ednpoint работы с изображениями через api/example/:id/image. Начнем со вспомогательных файлов.
bucket.providers.ts представляет из себя ту же концепцию, что и файл firestore.providers.ts.
Наш сервис для работы с Bucket.
Добавляем модуль с логикой подключения к Storage.
Далее нам нужна конфигурация для выгрузки файлов из API во временную папку /uploads.
Константа storage понадобится нам, чтобы не перегружать memory при работе с загружаемым изображением. Если не использовать diskStorage, то мы можем столкнуться с нехваткой оперативной памяти при росте трафика.
Чтобы bucket модуль успешно запустился, нужно импортировать его в app.module.ts.
Добавим helper для валидации файлов. В нем мы опишем разрешенные расширения файлов и размеры.
Теперь займемся контроллером и методом работы с изображением, по пути, добавим новый параметр в example.document.ts и example.repository.ts, детальные изменения можно будет посмотреть в коммите, в конце этой части.
В целом, по загрузке файлов все. Мы добавили простой пример загрузки одного изображения в документ example. Давайте совершим запрос и проверим, что все работает корректно.
Можно заметить, что в модель добавилось поле imageUrl с установленной ссылкой из GCloud Storage. Полный набор изменений можно найти в этом коммите.
Заключение
Надеюсь, что в данной статьей мы достигли намеченной цели, описать минимальный конфиг проекта с примером структуры кода, работы с Firestore и GCloud Bucket. Полный пример nestjs проекта можно найти у меня на GitHub.
Надеюсь, что я смог доступно и с достаточным количеством кода описать основные шаги по генерации CRUD приложения. Я бы не хотел останавливаться на этой статье. Буду рад получить обратную связь и критику. Итоговая репа может послужить не самым плохим примером для Вашего быстрого старта разработки MVP или пет проекта на Nest.js в связке с Firebase или любым другим PaaS решением.
В следующих статьях мы вернемся к этому бойлерплейту и попробуем сделать больше:
- Реализуем методы работы с авторизацией и аутентификацией в Firebase в Nest.js API (На базе этой статьи и текущего example проекта).
- Добавим Swagger, для удобного просмотра контрактов и тестирования API.
- Немного разберем деплой получившегося backend приложения и настроим нотификации в командный чат Telegram.
- Попробуем написать Telegram Bot на основе nestjs-startup-boilerplate.
- Создадим Mini-App в связке с получившимся Telegram ботом.