Jak Google Firebase umożliwia tworzenie aplikacji enterprise małym kosztem
Poniższy artykuł opisuje, jak Google Firebase ułatwia rozwój Agile Pokera oraz co możesz zyskać, korzystając z tych rozwiązań. Dzielimy się wiedzą o strukturze danych, bezpieczeństwie oraz skalowalności wraz z przykładowymi fragmentami kodu.
Roman Lutsiv. VP of Product Engineering w Spartez Software (an Appfire company). Pasjonat chmurowych rozwiązań, przeszedł ścieżkę od Junior Java programisty do VP i jest przekonany, że dobry manager zarówno motywuje jak i wspiera, a nie sprawdza status czy generuje raporty.
Artykuł powstał we współpracy z programistami zespołu rozwijającymi Agile Poker’a: Michał Nykiel, Adrian Bilicz oraz Łukasz Matuszewski.
Agile Poker to wysoce skalowalna aplikacja, z której korzysta ponad 600 firm, a koszt infrastruktury wynosi mniej, niż 200 dolarów miesięcznie. Dzięki Google Firebase możemy obsłużyć x1000 więcej klientów przy nieznacznym wzroście kosztów.
Spis treści
Architecture Diagram wart więcej niż tysiąc słów
Agile Poker jest aplikacją Node.js, z frontendem napisanym w Vue.js. Część frontendowa jest SPA (Single Page Application), która komunikuje się bezpośrednio z Firebase. Większa część logiki aplikacji znajduje się właśnie tutaj – backend jest zbędny po załadowaniu aplikacji i wygenerowaniu tokenu dostępu do Firebase.
Aplikacja jest hostowana na platformie Heroku i uruchomiona na dwóch kontenerach (dynos), choć spokojnie wystarczyłby nam jeden. Ten drugi został dodany ze względu na niezawodność: zaobserwowaliśmy krótkie przerwy w dostępności aplikacji podczas prac konserwacyjnych przez Heroku. Ruch na backendzie jest minimalny: około trzech zapytań na sekundę w godzinach szczytu. Jednak przez większość czasu utrzymuje się on na poziomie jednego zapytania na sekundę.
Cache’ujemy zasoby frontendowe (JS / CSS) dla konkretnej wersji aplikacji. Każde wdrożenie generuje nową wersję, a apka pobiera zaktualizowane paczki frontendowe. Ustawienia aplikacji są cache’owane w Redis — moglibyśmy użyć do tego Jiry, jednak Redis jest znacznie szybszy.
Scheduler jest potrzebny do wysyłania wiadomości e-mail (tę część architektury pominęliśmy na diagramie, ponieważ skupiliśmy się tylko na najważniejszych elementach). Baza danych Postgres mogłaby zostać z łatwością zastąpiona przez Firebase, ale pozostała tam ze względów „historycznych”. Przechowujemy w niej informacje dotyczące klientów Jiry, jak również JWT klucze używane do podpisywania zapytań do konkretnej instancji Jiry.
Jak działa Agile Poker
Zaczniemy od tego, że Agile Poker to aplikacja dla Jiry, więc w części uwierzytelnienia w dużym stopniu polegamy właśnie na niej. A cała logika biznesowa jest we frontendzie oraz Firebase:
- Przy załadowaniu naszej strony (index.html) w Jirze otrzymujemy od niej podpisany token JWT, zawierający Id instancji Jira oraz Id użytkownika. Token jest weryfikowany na naszym backendzie przy użyciu JWT klucza, który zapisaliśmy podczas instalacji aplikacji.
- Generujemy indywidualny token uwierzytelniający Firebase na backendzie i podpisujemy go, używając prywatnego klucza Firebase (bezpieczeństwo nade wszystko!). Token może zawierać spersonalizowane metadane, w naszym przypadku są to Id Jiry, Id użytkownika oraz Id tablicy (board) w Jirze.
- Token ten dodajemy do strony wewnątrz elementu
<meta>
. Firebase SDK na frontendzie wymienia go z API uwierzytelniającym Firebase i uzyskuje token dostępu, który jest regularnie odświeżany (i wszystko to out-of-the-box od Firebase!) - Token dostępu jest następnie używany dla wszystkich zapytań do Firebase. Cała komunikacja odbywa się bezpośrednio z poziomu frontendu; backend w tym czasie „nudzi się”.
- Dostęp do danych Firebase jest kontrolowany za pomocą wbudowanych Reguł Bezpieczeństwa (Security Rules). Definiują one dostęp do odczytu i zapisu danych za pomocą wyrażeń logicznych (boolean expressions), które opierają się na metadanych zawartych w tokenie, strukturze danych (np. nazwa węzła równa się Id instancji Jira) lub spersonalizowanych wartościach (np. Id administratora).
Aktualizacje w czasie rzeczywistym
Komunikacja z Firebase jest asynchroniczna i dwukierunkowa. Użytkownicy informowani są o działaniach swoich kolegów na bieżąco, w czasie rzeczywistym. Nasz frontend obserwuje zmiany w Firebase, aby natychmiast odzwierciedlić je dla wszystkich uczestników.
Model danych Firebase
Dane aplikacji przechowujemy osobno dla klientów i dla każdej tablicy w Jirze (board) w obiektach, które nazywamy sesjami. Rozróżniamy sesje publiczne oraz prywatne, a także aktywne i zakończone. A teraz przejdźmy do konkretów — w jaki sposób zaprojektowaliśmy strukturę danych, aby mieć je pod ręką szybko i małym kosztem?
Dobrą praktyką jest projektowanie modelu danych w sposób, który odzwierciedla funkcjonalność aplikacji. Ponieważ chcieliśmy pokazać listę wszystkich sesji dostępnych dla użytkownika, zaczęliśmy od najprostszego rozwiązania:
{ "sessions": { ".indexOn": ["access"] "$sessionId": { "access": "private" "participants": { "$userId": {} } } } }
Przy takim podejściu możemy łatwo załadować wszystkie publiczne sesje użytkownika w następujący sposób:
ref.child("sessions").orderByChild("access").equalTo('public')
To rozwiązanie nie sprawdzi się jednak w przypadku prywatnych sesji, ponieważ w pierwszej kolejności musimy sprawdzić, czy użytkownik ma do nich dostęp (w rzeczywistości: czy znajduje się na liście uczestników takich sesji), a wyszukiwania Firebase mogą być wykonywane tylko według jednego klucza jednocześnie. Z tego powodu przechowujemy dwie listy w Firebase:
- Listę użytkowników z Id prywatnych sesji dla każdego użytkownika
- Listę sesji z Id uczestników dla każdej sesji
Takie rozwiązanie może wyglądać jak powielanie danych, ale jest ono uzasadnione w rozwiązaniach NoSQL. Nazywa się to denormalizacją danych i pozwala na optymalne ładowanie danych z relacją dwukierunkową. Więcej na temat można przeczytać w oficjalnej dokumentacji Firebase.
Zatem posiadamy teraz kolejny wymiar relacji użytkownik-sesja:
{ "users":{ "$userId":{ privateSessions:{ "$privateSessionId": true } } }, "sessions": { ".indexOn": ["access"] "$sessionId": { "access": "private" "participants": { "$userId": {} } } } }
Teraz możemy łatwo załadować listę prywatnych sesji dla użytkownika:
ref.child("users/$userId/privateSessions")
Mając listę sesji, możemy przejść przez ich Id i wyciągnąć szczegóły każdej prywatnej sesji.
Cloud Functions jako alternatywa backendu
A co zrobić w przypadku niektórych operacji, które nie mogą być wykonane na frontendzie? Na przykład, aktualizacja ról użytkowników, wysyłka maili itd.? Firebase posiada rozwiązania, które to umożliwiają. Możesz napisać kod, który będzie działać na serwerach Firebase i współdziałać z jego innymi serwisami – na przykład wywoływać Cloud Functions.
Fragment naszego kodu, który nasłuchuje wydarzenia zarchiwizowania sesji i uruchamia funkcję, która przenosi tę sesję do osobnego węzła w strukturze danych:
exports.archiveSession = functions.database.ref('jira/{jiraKey}/board/{boardId}/sessions/{sessionId}/archived') .onWrite(async (change, context) => { //move session logic });
Zasady bezpieczeństwa (Security rules)
To chyba jeden z najlepszych aspektów Firebase’a. W naszym przypadku oparliśmy sprawdzanie uprawnień na Id instancji Jiry, Id tablicy (board), Id użytkownika, dostępie do sesji (publicznej lub prywatnej), konfiguracji sesji (np. Id moderatora, lista uczestników sesji).
Przykładowo, aby zweryfikować, czy użytkownik może zmienić nazwę prywatnej sesji, sprawdzamy:
- czy Id Jiry z tokenu dostępu pasuje do Id Jiry w referencyjnej ścieżce sesji (który trzymamy w jednym z nadrzędnych węzłów),
- czy Id boardu z tokenu dostępu pasuje do Id Jiry w referencyjnej ścieżce sesji,
- czy Id użytkownika z tokenu dostępu pasuje do Id moderatora przechowywanego w konfiguracji sesji.
Pierwsze dwie zasady są wspólne dla większości operacji zapisu i odczytu. Ostatnia jest stosowana do konkretnej operacji, która odpowiada logice biznesowej – tylko moderator sesji ma prawo do zmiany nazwy sesji.
Więcej, więcej kodu
Jeżeli nadal czytasz ten artykuł, to, przede wszystkim, cieszymy się, i już prezentujemy kolejne fragmenty naszego kodu, które pokazują, jak łatwa jest integracja z Firebase.
Fragmenty poniższego kodu pochodzą bezpośrednio z repozytorium Agile Poker’a. Wyrzuciliśmy nieistotne fragmenty, aby skupić się tylko na integracji z Firebase.
Uwierzytelnianie
Pierwszą rzeczą, którą potrzebujemy, aby pobrać dane z bazy, jest wygenerowanie tokenu Firebase. Robimy to na backendzie, korzystając z biblioteki firebase-admin
. Token umieszczamy w odpowiedzi punktu końcowego "/index"
.
const admin = require("firebase-admin"); const generateFirebaseToken = async function (context) { const uid = `${context.clientKey}:${context.userAccountId}`; return admin.auth().createCustomToken(uid, { userAccountId: context.userAccountId, jiraKey: context.clientKey, }); } app.get("/index", addon.authenticate(), async function (req, res) { const firebaseToken = generateFirebaseToken(req.context); res.render('index', { // ... firebaseUrl: process.env.FIREBASE_URL, firebaseProjectId: process.env.FIREBASE_PROJECT_ID, firebaseApiKey: process.env.FIREBASE_API_KEY, firebaseToken, // ... }); });
Jest on przechowywany w meta tagu w sekcji <head>
strony wraz z innymi parametrami Firebase.
<head> // ... <meta name="ap-firebase-url" content="{{firebaseUrl}}"> <meta name="ap-firebase-project-id" content="{{firebaseProjectId}}"> <meta name="ap-firebase-api-key" content="{{firebaseApiKey}}"> <meta name="ap-firebase-token" content="{{firebaseToken}}"> // ... </head>
Następnie po stronie użytkownika (w przeglądarce) możemy wczytać wartości z parametrów meta i użyć ich, aby zainicjować połączenie i uwierzytelnienie do Firebase.
function getMetaParameterValue(name) { const metaNode = document.querySelector(`meta[name=${name}]`); return metaNode && metaNode.content; } const firebaseToken = getMetaParameterValue('ap-firebase-token'); const firebaseUrl = getMetaParameterValue('ap-firebase-url'); const firebaseApiKey = getMetaParameterValue('ap-firebase-api-key'); const firebaseProjectId = getMetaParameterValue('ap-firebase-project-id'); firebase.initializeApp({ apiKey: firebaseApiKey, authDomain: `${firebaseProjectId}.firebaseapp.com`, databaseURL: firebaseUrl, }); const db = firebase.database(); async function auth() { const userCredentials = await firebase.auth().signInWithCustomToken(firebaseToken); const tokenResult = await userCredentials.user.getIdTokenResult(); return tokenResult.claims; }
Dalej podczas żądania danych z Firebase wywołujemy funkcję auth()
dla uwierzytelnienia i otrzymujemy referencję do danych, podając ścieżkę do miejsca, w którym są przechowywane.
async function sessionChild(boardId, sessionId) { const { jiraKey } = await auth(); return db.ref(`jira/${jiraKey}/board/${boardId}/sessions/${sessionId}`); }
Referencja może być użyta do odczytu lub zapisu danych do określonej lokalizacji w bazie danych.
Nasłuchiwanie zmian
Znakomitą cechą Firebase Realtime Database jest obserwowanie zmian w bazie danych i natychmiastowa aktualizacja statusu aplikacji. Pozwala to na zapewnienie interakcji pomiędzy użytkownikami w czasie rzeczywistym.
Aby to zrobić, definiujemy listener, który oczekuje na zmianę danych. Do funkcji przekazujemy parametry do lokalizacji danych (boardId, sessionId, path) oraz wywołanie zwrotne (callback).
export async function onSessionPropertyChange(boardId, sessionId, path, onChange) { const ref = await sessionChild(boardId, sessionId, path); const listener = ref.on('value', snapshot => { const value = snapshot.val(); onChange(value); }); return () => ref.off('value', listener); }
Następnie, korzystając z powyższej funkcji, przekazujemy Vuex mutation jako wywołanie zwrotne.
async subscribeToInteractiveSessionChanges({ rootGetters, dispatch, commit }) { const { boardId, sessionId, isFirstBatchOfIssuesLoaded } = rootGetters; const votedIssueIdChangeOff = await onSessionPropertyChange(boardId, sessionId, 'voting/issuedId' votedIssueId => { commit('setVotedIssueId', votedIssueId); }), ]); return () => { votedIssueIdChangeOff(); } }
W kolejnym kroku wykorzystujemy go wewnątrz elementu UI. Po zamontowaniu komponentu, czyli kiedy jest on po raz pierwszy wyświetlony w aplikacji, wywołujemy subskrypcję i w ten sposób nasłuchujemy zmiany. Od tej pory każda zmiana w jira/${jiraKey}/board/${boardId}/sessions/${sessionId}/voting/issueId
będzie skutkowała zaktualizowaniem wartości w aplikacji.
async mounted() { try { this.interactiveSessionUnsubscribe = await this.subscribeToInteractiveSessionChanges(); } catch (error) { throw new NestedError('Failed to subscribe to Firebase', error); } }
Ostatnią rzeczą, o której musimy pamiętać, jest wyrejestrowanie się z nasłuchiwania zmian po opuszczeniu strony przez użytkownika.
beforeDestroy() { this.interactiveSessionUnsubscribe(); }
Dlaczego Firebase? Trzeba było od tego zacząć!
Wyprzedzając pytanie, odpowiedź — ten artykuł nie jest sponsorowany przez Google / Firebase. Odkryliśmy, że ich technologia jest tak dobra, że rozszerzyliśmy zastosowanie Firebase i Firestore na inne produkty. Dlaczego?
- Łatwa implementacja. Dokumentacja jest świetna, kod jest prosty i wszystko działa tak, jak tego się spodziewamy.
- Współpraca wielu użytkowników w czasie rzeczywistym. Firebase idealnie dopasował się do wymagań naszego produktu — wdrażamy oprogramowanie, które ułatwia szacowanie pracy dla zespołów pracujących zdalnie czy w trybie hybrydowym.
- Bezpieczeństwo. Definiujemy zasady bezpieczeństwa na podstawie danych.
- Skalowalność. Firebase skaluje się płynnie i bez konieczności podejmowania jakichkolwiek działań z naszej strony — przejście z 1 req/sek do 20 req/sek odpowiada wzrostowi średniego obciążenia z ~0 do 0,1. Firebase wspiera również sharding dla większych konfiguracji, ale nawet nie zbliżamy się do obecnych limitów (w naszym przypadku liczba jednoczesnych połączeń to maksymalnie 300, podczas gdy „standardowy” limit Firebase to 200 000 połączeń).
- Cena. Przy naszym użyciu Firebase to koszt około 2 dolarów miesięcznie (tak, pozostałe ~198 dolarów wydajemy gdzie indziej, głównie na Heroku: instancje webowe, logi w chmurze i baza danych Postgres). Model danych Firebase ma kluczowe znaczenie w kwestii kosztów. My przechowujemy konfiguracje sesji, odwołania do obiektów Jiry oraz informacje o estymacjach. Przez kilka lat na produkcji zużyliśmy mniej niż 200 MB.
Brzmi ciekawie? Mamy coś dla Ciebie
Głodny sukcesów i nowych wyzwań? Zasil szeregi zespołu Agile Poker’a! Dołącz do nas jako Technical Team Leader i pomóż nam dostarczyć jeszcze ciekawsze rozwiązania dla klientów i odkryć możliwości poza rynkiem Jiry/Atlassiana!