Як та з чим приготувати Firebase Functions

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Вітаю, спільното DOU! Я Анатолій, Android Tech Lead у компанії N-iX та Tech Lead на волонтерському проєкті Donatta. Хочу поділитися досвідом нашої команди у використанні Firebase Functions.

Для контексту зазначу, що це мобільний застосунок на Android та iOS, розроблений на Flutter, а весь бекенд — на Firebase Functions із використанням Node.

У статті розглянемо декілька рецептів приготування Firebase Functions (далі function або функція), їхні переваги та недоліки на реальних прикладах, з якими ми мали справу у проєкті.

Стаття орієнтована переважно на:

  • client side-розробників, які хочуть зазирнути за рамки фронтенду;
  • будь-кого, кому хочеться розібратись із Firebase Functions.

Рецепт № 1. Firestore + Functions

Це підхід, коли мобільний застосунок зчитує та записує дані напряму у базу Firestore, однак частина логіки виконується на функції. Функція може викликатись повторювано через зазначені періоди часу, або як реакція на зазначені події. Мобільний застосунок у цьому випадку нічого не знає про Firebase Function.

Завдяки цьому підходу можна зручно налаштувати безпечний доступ до даних використовуючи Firestore Rules. Ця конфігурація дозволяє легко обмежити зону доступу до даних або забезпечити доступ лише за умови авторизації.

Ще одна перевага використання Firestore у клієнтському застосунку — можливість зробити підписку на оновлення даних, на кшталт сокета.

Отримання даних із Firestore працює досить швидко — швидше, ніж отримання даних із функцій. Крім цього, Firebase робить під капотом необхідне кешування, тож легко зробити офлайн-доступ до останньої версії даних.

Недоліки:

  1. Vendor lock. Дуже багато логіки ґрунтується на SDK та особливостях Firebase. Якщо ви не впевнені у використанні Firebase або є плани мігрувати на щось інше у майбутньому, то цей рецепт може бути вам не до смаку.
  2. Логіка на клієнті. Так чи інакше, дані обростають логікою. І в цьому рецепті значна частина логіки буде потрапляти на клієнтську сторону (валідації, мерджинг даних із різних джерел). Це серйозний архітектурний ризик на який потрібно йти свідомо. Відповідні наслідки: довше деліверити зміни через необхідність робити релізи, складність підтримки зворотної сумісності, security-ризики тощо.

Схематичний приклад такої архітектури можна побачити на зображенні нижче. Triggered Firebase Function — це функція яка може бути викликана на модифікації даних бази, або додаткові інші додаткові івенти. У нашому проєкті така функція викликається на створення нового користувача та робить prepopulate даних. Scheduled Firebase Function — повторювана крон-функція яка може оновлювати дані. Як приклад — оновлення даних про збори у Donatta.

Рецепт № 2. HTTPS Functions

У цьому рецепті з’являється усім відомий протокол HTTPS.

Якщо ви оголосите Firebase-функцію, використовуючи метод onRequest, то її можна буде викликати через HTTPS.

Наприклад, такий код на TypeScript:

export const justFunction = functions
 .region(process.env.DEFAULT_REGION || "")
 // your config
 .https.onRequest(async (request, resp) => {
   // Just do it
   resp.status(200).end();
 });

Дозволить викликати функцію justFunction через такий url: https://<LOCATION>-<PROJECT_NAME>.cloudfunctions.net/justFunction. Де <LOCATION> — це локація сервера, яку ви обрали у налаштуваннях, а <PROJECT_NAME> — назва вашого Firebase-проєкту.

Отже, завдяки HTTPS-функціям можна будувати усіма улюблений REST API. Крім цього, завдяки тому, що HTTPS-функція доступна публічно — її може використовувати не тільки ваш застосунок, а і сторонні сервіси. Тобто ви можете зареєструвати функцію як вебхук. Завдяки цьому ваш застосунок зможе реагувати на події сторонніх сервісів.

У Donatta така можливість використовується для отримання інформації про донати користувачів. Банківський API викликає зареєстровану URL-адресу Firebase-функції й функція виконує усі необхідні перевірки та записує інформацію у базу даних.

Завдяки такому рецепту зникає vendor lock на стороні клієнтського застосунку, адже використовуються звичайні HTTPS-запити. Однак разом із цим зникає і можливість потокового отримання даних. Збільшується важливість відповідально реалізовувати логіку функцій для швидкої роботи. І для цього доводиться шукати шляхи оптимізації логіки.

Тут варто згадати, що Firebase Functions із погляду інфраструктури — серверлес.

Тобто кожна функція виконується на окремому інстансі, і якщо максимальна кількість інстансів обмежена, то події на обробку стоять у черзі на опрацювання. Вже піднятий інстанс їх всі опрацьовує, і потім тухне.

Завдяки цьому можна робити in memory-кеші, за для зменшення кількості походів у базу даних. І немає потреби реалізувати актуалізацію кешів, через те, що рано чи пізно функція потухне. А коли підніметься, кеш буде сформовано заново.

Через те що HTTPS-функції доступні публічно, з’являються ризики кібератак. Нападник може підсунути будь-які дані у запит, із метою несанкціонованого доступу до даних. Або провести звичайну DDoS-атаку і накрутити немалий чек за послуги Google. Для захисту від цього, звичайно ж, можна робити свої велосипеди, механізми автентифікації тощо. А можна використати готове рішення — callable firebase functions.

Рецепт № 3. Callable Functions

Це функції які мусять викликатись через firebase sdk на клієнтській стороні. Тут одразу отримуємо певний захист від DDoS. Також Firebase SDK під капотом передає у callable-функції дані про користувача, якщо він автентифікований. Отже, з’являється готовий механізм для захисту даних.

Однак з’являється обмеження у тестуванні. Викликати функцію через curl або postman вже не вийде.

Приклад реалізації callable-функції:

export const justFunction = functions
 .region(process.env.DEFAULT_REGION || "")
 // your config
 .https.onCall(async (data, context) => {
   // Just do it
   return {
     code: 200,
   };
 });

Разом із головною перевагою callable function, захищеністю, приходить і недолік — vendor lock на стороні клієнту.

Однак попри необхідність викликати callable firebase functions через SDK, все одно є ризик що зловмисники можуть дуже захотіти та зареверсити SDK і зробити собі середовище, де вони почнуть викликати ваші чудові функції.

Для захисту від цього callable-функції дозволяють додати перевірку із використанням сервісу App Integrity. Завдяки цьому функції можна буде викликати лише на верифікованих пристроях.

Архітектура

Виходячи із перелічених рецептів, можна зрозуміти що приготувати Firebase Functions у вашому проєкті можна дуже по-різному. До прикладу, у Donatta нам довелося випробувати різні підходи. Зрештою, на певний момент наша діаграма deployment view виглядала наступним чином:

Завдяки тому, що Firebase Functions насправді є Cloud Functions із набору сервісів Google Cloud Platform, то разом із функціями можна використовувати весь арсенал GCP. До прикладу на нашому проєкті використовуються такі сервіси:

  • SecretManager — для безпечної взаємодії із захищеними змінними оточення;
  • PubSub — для побудови черг подій і їх обробки;
  • Alerting — для сповіщення про інциденти.

Підсумок

Firebase Functions — потужний сервіс для швидкої розробки бекенд-частини. Завдяки різноманітним способам використання, можна обрати саме той підхід, який підійде для проєкту як найкраще, враховуючи вимоги із гнучкості та безпеки.

Крім цього, можливість інтеграції із GCP дозволяє розширити можливості, знаходячись в одній екосистемі. Однак варто остерігатись vendor lock, адже для Google вигідно, щоб ви лишились із ним надовго.

А який ваш досвід використання Firebase Functions? Чи можете ви порадити достойні альтернативи? Розкажіть у коментарях.

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Мені цікаво чи був у когось досвід міграціі з такої моделі проекту на більш звичну розробникам але в контексті того ж Firebase та GCP інфраструктури

Наприклад якщо розглянути типову модель Firebase проекту в якому все як описано в статті і як це промоутає Firebase:
— клієнт має прямий доступ в базу данних (Firestore)
— сам зчитує дані і сам записує
— на умовному backend стороні повно onCreate, onUpdate, onDelete тріггерів що реагують на відповідні івенти в БАЗІ ДАНИХ піднімаючи инстанс Cloud Function щоб щось зробити (duplication support, aggregation support, sending email/notification etc)

І мігрувати на щось таке:
— клієнт не має прямого доступу до бази данних (і взагалі не привязаний до Firestore, і нічого про це не знає)
— усі запити через HTTPS functions — onRequest тріггер в нашому випадку
— ніяких onCreate, onUpdate, onDelete тріггерів , знову ж таки все через відповідні HTTPS запити
— бажано ніяких duplication, aggregation support логіки (які через вище згадані onCreate, onUpdate функції часто і робляться). Якщо потрібні свіжі данні то ці данні збираються з відповідних таблиць

Цікаво з якими викликами команда стикнулася, чи не простіше в такому випадку заново переписати додаток або все ж таки є шлях плавно, по трохи робити таку міграцію в діючому проекті поступово декомісуючи onCreate, onUpdate тріггери переводячи клієнт на HTTPS

Рецепт № 1. Firestore + Functions

Із цим треба бути дуже-дуже обережним )))

Недоліки:

— Відсутня вся необхідна валідация на сервері, бо клієнт напрямму в базу записує дані. Firestore Rules дає трохи інструментів, але воно обмежене
— В наслідок пункту 1 відсутня REST подібні відповіді с 4хх помилками (покладання на відповідь Firestore Rules)
— onCreate, onUpdate etc функціі що спрацьовують в наслідок операцій в базі даних — НАЙГІРШЕ з чим довелось стикнутись (ще колись давно читав про такий антипаттерн як прикручувати бізнес логіку до подій update/create в базі данних на прикладі triggers in postgresql, окрім тільки тайстампів сворення або оновлення записів).
— Важко дебажити, бо клієнт щось сам собі захотів і записав напряму в базу (якщо рули пропустили), чи апдейтнув а ти не завжди можешь це перевірити якщо немає костильного тріггера де би трохи логів було
— Як наслідок сервер майже втрачає контроль із часом бо іде Mobile driven development (але крайнім все одно буде сервер — в цьому можн анавіть не сумніватися)

І насправді недоліків набагато-набагато більше (нема часу і бажання іх всі пригадувати)

Трохи більше про костільні onCreate, onUpdate якщо ними зловживати (а це дуже часто трапляється — зловживання):
— Деви звикають до цього підходу і настає велика небезпека обкластися такими КОСТИЛЬНИМИ тріггерами весь застосунок
— Як результат замість того чоб запитувати актуальні данні вони будуть дублюватися по всій базі і одна проста update операція буде призводити до багатьох read/write операцій де в свою чергу, при наявності у коллекціі цього костильного триггера onUpdate будуть ще read/write оперціі, а далі і ще і ще — і все через один апдейт в базі даних. А іноді воно навіть може спричинити безкінечний цикл (який при відсутності алертів можна не одразу помітити)
— І таких апдейтів може бути багато і ти не хочеш щоб тригерри спрацьовували завжди а вже піздно , піднімається цілий вулик Cloud Functions бо у тебе в базі обновилися дані в батчі записів — і всі ці функціі хочуть щось зробити

Дуже дякую за такий розгорнутий коментар!

Додам і ще один недолік до такого підходу — triggered функції мають бути ідемпотентними.
Тобто виклик однієї функції декілька разів на той самий трігер має призводити тільки до одного і того результату.
firebase.google.com/...​rite_idempotent_functions

Наштовхувався у офіційних туторіалах на зестереження, що функція може викликатись декілька разів на той самий трігер.
Це супер трікі небезпечна річ.

Отже можна на свій страх і ризик не заморочуватись із ідемпотентністю, а інакше доведеться ускладнювати логіку для того щоб забезпечити ідемпотентність.

Наштовхувався у офіційних туторіалах на зестереження, що функція може викликатись декілька разів на той самий трігер.
Це супер трікі небезпечна річ.

Думаю найкраще застереження це не мати onCreate, onUpdate тригерів ))

Набагато безпечніше і якісніше (імо) все робити через старий добрий http — в нашому випадку onRequest тріггер. Тобто усі update/write операціі обовязково через сервер (cloud functions з onRequest трігером в нашому випадку). І нам ніщо не заважає кидати ріал тайм івент клієнту самим якщо ім треба апдейтнуті дані інколи (а не так що клієн слухає апдейти в фаєсторі). Таким чином ми більш контролюємо ситуацію, можемо логувати те що потрібно, кидати нормальні помилки клієнту і т.д.

В ідеалі пересісти з Firestore яким гарненьким би він не здавався , окрім випадків дуже маленьких простих проєктів які не планують розростатися )) Або використовувати його таким чином щоб не дублювати всюди дані настільки наскільки це можливо. (Але тоді з декількох таблиць щось збирати буде не дуже, бо з джойнами воно не дружить)

І нам ніщо не заважає кидати ріал тайм івент клієнту самим якщо ім треба апдейтнуті дані інколи (а не так що клієн слухає апдейти в фаєсторі).

Маєте на увазі використання polling чи long polling?

Думаю це можуть бути ті самі server-sent events (sse), по ходу діла вже можна думати над підходящим рішенням.

Додам і ще один недолік до такого підходу — triggered функції мають бути ідемпотентними

Але в цілому так , це хороший момент , якого варто дотримуватись коли можливі retry або в цілому якщо обробляємо якісь івент чи вебхук де можливе повторення івенту )

Однак можливість робити повторювані (крон, scheduled) функції є дуже зручною.

Так іх дійсно просто і швидко налаштовувати ))

А логи і алертінг — це просто мастхев для будь-якої функції, triggered чи ні.
На мою думку (і власний досвід із набитими шишками) без цього не варто робити будь-що складніше за hello world.

А логи і алертінг — це просто мастхев для будь-якої функції, triggered чи ні.

Це так, але які будуть логи коли клієнт напряму записує криві дані в Firestore ?
Або коли Firestore Rules не пропускають з якоїсь причини юзера то де ті логи шукати , коли воно не проходить через сервер ?

Тут велика проблема що сервер не контролює ситуацію дуже часто.

І так я згоден що це помилка того як користуватися triggered функціями, але вони дуже швидко розростаються, і вписаши onCreate, onUpdate тригери і привчивши до цього клієнт скоріш за все закінчиться дуже погано (для більш менш серйозних проектів, а не простеньких пет проджектів).

1.

Це так, але які будуть логи коли клієнт напряму записує криві дані в Firestore ?

Згоден, на стороні бекенду із таким підходом не вийде щось віддебажити.
Допоможуть тільки логи крашлітики, за умови що на клієнті все грамотно зроблено.
2.

Або коли Firestore Rules не пропускають з якоїсь причини юзера то де ті логи шукати , коли воно не проходить через сервер ?

Помилки Firestore Rules також можна вітстежувати через крашлітику.
Крім цього, на Firestore Rules можна писати тести.
3. В цілому із лінією і додатковою аргументацією, що «Рецепт № 1» небезпечний тим, що контроль ситуації надто сильно зав’язаується на клієнт — згоден.

Тестував Netlify та Vercel функції, для власного проєкту то дорого, дешевше VPS за $5

А якому саме VPS надаєте перевагу? Хецнер?

Стосовно вартості, то кожен обирає сетап відповідно до вимог та обмежень проекту.

Чув такі аргементи проти VPS:
1. Зі сторони security легше помилитись і лишити якусь дірку. Треба значно більше часу витрачати на security management.
2. Якщо треба зробити щось швидко, то у клаудній інфраструктурі більш сприятливі умови бо вже все готове. Навідмінну від VPS де треба все піднімати руками і робити свій мікс із 3rd party технологій.

Звісно у всього є свої переваги та недоліки, але спираючись саме на ті недоліки VPS які зазначені вище, я би обирав саме serverless для власного проєкту, в умоваг браку часу.

А якому саме VPS надаєте перевагу?

Із-за ціни бо при DDoS-атаках ( або випадковій популярності ) буде значно менший рахунок

Хецнер?

Vultr (обережно реферальне посилання)

Хмарні провайдери звісно комфортніші для розробки, особливо командної розробки, але для своїх пет-проєктів мені дешевше налаштувати Ansible-скрипти для встановлення Golang та docker-compose на VPS, ніж переплачувати кожен місяць та хвилюватись за можливі рахунки в $10000+

Супер, дуже дякую що поділились досвідом!

Підписатись на коментарі