En iOS-utviklers nybakte erfaringer om Firestore
Hjelp, jeg er en frontender!
I mine seks år som native iOS apputvikler har jeg opparbeidet meg en god slump erfaringer med hvordan denne plattformen fungerer, og jeg anser meg selv som dreven nok til å kunne analysere og gjenskape de fleste elementene fra andre apper. Men hva er en app uten en backend? Jeg kan godt forsøke å gjenskape iOS-appene til Facebook, Snapchat, DnB og Slack, men de vil jo naturligvis ikke fungere uten å være del av en større tjeneste slik at appen kan kommunisere eksternt.
De fleste apputviklere har vært borti Firebase i en eller annen form, gjerne for analyse eller push-varslinger. I de siste par ukene har jeg utforsket og oppdaget enkelheten i hvordan jeg selv kan lage en veldig simpel miniatyrtjeneste ved hjelp av Firebase sin NoSQL-baserte skydatabase "Cloud Firestore", slik at jeg kan lage iOS-apper som "snakker med hverandre" med felles data, i tillegg til å benytte "Sign in with Apple" for autentisering. Dette var overraskende lett, og overraskende gratis. Men hva skulle jeg lage?
Hjelp, jeg er en pappa!
I midten av januar ble jeg for første gang ferdig med et ni måneder langt sideprosjekt, som resulterte i en datter. I en global pandemi er det dog et noe redusert og uforberedt støtteapparat, spesielt i jordmor-avdelingen, og vi har derfor måttet ty til mye digital hjelp fra det generelle internett. Vi har lest artikler, sett nettkurs, og lastet ned en god slump med ulike apper for å hjelpe oss under hele prosessen.
En av appene mor brukte mest i perioden etter fødsel var "Min baby" av Aleksei Neiman.
Her kan man registrere alle mulige slags hendelser og variabler i babyens initialisering. Bleieskift, varighet og bryst for amming, søvn, størrelse, vekt og humør, bare for å nevne noen få. Dette ble flittig brukt, og ga overraskende nok en bedre struktur i hverdagen. Vi har heldigvis fått en datter som liker å sove, og da må vi faktisk vekke henne på riktig tidspunkt for å passe på at det ikke går mer enn fire-fem timer mellom mating. Ved å bruke denne appen ble det enkelt å finne ut når hun måtte vekkes, hvilket bryst som skulle dies, og spore om det var forstoppelser.
Problemet var at denne appen kun lagrer disse dataene lokalt, og da kun på min kones telefon. Hver gang hun glemte å starte "amme-timeren" og hadde hendene fulle med amming, så måtte jeg finne hennes telefon, låse opp med hennes fjes for FaceID, og bruke appen derfra. Og de gangene kona sov og jeg var på flaske- og bleie-vakt, så måtte jeg bare vente til hun stod opp før det kunne registreres - noe det da gjerne ikke ble gjort.
Dette var ikke bra nok for meg. Jeg ville spille dette spillet i multiplayer.
Som en allerede dreven iOS-utvikler ville det ikke vært noe problem å gjenskape en liten klone-app av dette, men å knytte sammen flere instanser av samme app for deling av baby-informasjon, det krever en backend - og der kommer jeg til kort.
Firebase
Her har Google overgått seg selv hva gjelder enkelhet. Firebase er en stor men ryddig verktøykasse som inneholder alle de viktigste komponentene for å komme i gang med en fullverdig tjeneste. Autentisering, databaser, fil-lagring, push-varsel, maskinlæring, analyse og mer.
Det de kaller "Spark Plan" er en svært generøs gratispakke som lar deg komme i gang selv uten å registrere en betalingsmetode. Denne gratispakken gir tilgang til de fleste verktøyene opp til en gitt grense på f.eks antall kall per dag eller gigabyte per måned. Implisitt betyr dette at en slik privat app som jeg lager for min egen husstand blir helt gratis, da vi ikke overgår disse grensene. Prislappen for en offentlig tilgjengelig tjeneste med titusenvis av brukere vil nok på lang sikt være svært stiv med Firebase i forhold til en tradisjonelt utviklet backend, for ikke å snakke om at man også mister mange muligheter til å tilpasse tjenesten. Jeg kan kun spekulere i hvorvidt det er økonomisk eller teknologisk anbefalt å gå for Firebase som primærbackend for større tjenester. I mitt tilfelle er det perfekt.
Bortsett fra den nær umulige oppgaven om å finne et navn til prosjektet ditt, så er oppsett av Firebase en smal sak. Når prosjektet er opprettet og du vil knytte det sammen med din Web/Android/iOS-app, så får man en enkel og stegvis guide som forklarer alt som skjer. Man skriver så inn appens identifikasjoner, og får returnert en ferdigutfylt konfigurasjonsfil som må ligge i appen som grunnlag for kommunikasjon mellom din app og ditt Firebase-prosjekt.
Underveis i disse stegene får man også informasjon om hvordan man importerer Firebase-bibliotekene til app-prosjektet. For iOS kan man velge mellom å integrere SDK-filer manuelt, bruke det velkjente Cocoapods, eller det "nye" Swift Package Manager. Selv valgte jeg sistnevnte.
Etter dette er det én linje med kode som skal til for å opprette kommunikasjon mellom appen og Firebase-prosjektet: FirebaseApp.configure(). Denne kommandoen vil ta i bruk informasjonen i konfigurasjonsfilen som ble lastet ned tidligere, og vi er nå i mål med oppsett.
Autentisering
I denne bittelille private appen er det ikke nødvendig med autentisering, men jeg ønsket å gi det et forsøk. Jeg skal ikke gå dypt i detalj på hvordan dette fungerer, men i korte trekk så aktiverte jeg "Sign in with Apple" i både Firebase og i Xcode. Kopierte noen hashing-funksjoner fra Firebase-dokumentasjonen, og lagde et login-view som alltid dukker opp dersom Firebase-biblioteket sier at man ikke er logget inn. Voila, man må nå være autentisert for å kunne bruke appen - og man kan også logge inn på appen med samme AppleID på en annen enhet om man vil. Ingen registrering er nødvendig, og ettersom man allerede er logget på med en AppleID på iOS-enheten fra før så trenger man heller ikke "logge inn" med e-post og passord. Ved å begrense det til Apple's egne login-funksjonalitet så er det bokstavelig talt bare å klikke på en knapp, så er man inne.
Dette forsikrer imidlertid kun autentisert bruk av iOS-appen og forhindrer ikke nødvendigvis andre i å koble seg på samme Firebase-tjeneste. Så ved å legge til noen få kodelinjer i "Rules"-konsollen på Firebase vil man kunne forsikre at kun autentiserte brukere er autoriserte til å kalle på den eksterne tjenesten:
Cloud Firestore
Dokumentdatabasen Firestore oppleves som ren magi for en frontender som aldri har vært i nærheten av noe annet enn tradisjonelle databaser og API-er. Det er en automatisk synkroniserende JSON-lignende struktur, som med veldig få linjer kode også lar deg "abonnere" på oppdatering av data. Strukturen består hovedsakelig av ulike "documents" organisert i ulike "collections", fremfor rader og kolonner. På rotnivå oppretter man ulike collections, som hver kan inneholde ulike documents - som igjen kan inneholde subcollections, med sine egne documents. Collections og documents kan nøstes alternerende på denne måten opp til 100 nivåer, og man kan kjøre simple spørringer for å få tak i det meste. Det er dog viktig å være klar over at dette er noe helt annet enn en tradisjonell relasjonsdatabase.
Den første markante forskjellen er at ingen felter eller strukturer i utgangspunktet er "håndhevet" av databasen selv. Det klienten skriver blir sannhet.
Dersom klienten skriver følgende:
Så vil Firestore automatisk "opprette" hele stien med collections og documents for dette feltet, i motsetning til relasjonsdatabaser hvor man på forhånd må modellere tabellene. I samme sekund som jeg kjører koden i appen vil man kunne se en animert oppdatering av databasen på web-grensesnittet til Firestore, som ender opp slik:Herfra kan man be om verdien ved å lese samme sti på denne måten:Bak kulissene jobber Firebase-biblioteket i appen din med en slags "versjonskontroll" av databasen for å forsikre optimal utnyttelse av caching og minimalt bruk av nettverkskall, i form av snapshots. Dette vil si at appen din selv velger om den skal utføre et nettverkskall for å hente verdien du ber om, eller om verdien allerede eksisterer lokalt. Man kan naturligvis også overstyre dette om man vil ha full kontroll.
Den åpenbare fordelen med strukturen til Firestore er at det krever minimalt med oppsett og gir skyhøy fleksibilitet og utviklingshastighet helt fra starten. Om man vil legge til flere felter så gjør man det - uten å måtte tenke på migrasjoner og migrener.
Dette går selvsagt på bekostning av flere positive aspekter en tradisjonell database innehar, som sentralisert håndhevelse og felles skjema. Dersom man legger til flere klient-applikasjoner fra ulike plattformer (f.eks en Android-app) er det nå betydelig vanskeligere å håndheve datastruktur, spesielt i dynamiske miljøer hvor det foregår versjoneringer på ulike tidspunkt.
Det er ikke nødvendig å parse snapshots fra Firestore manuelt, slik jeg gjorde i eksempelet over. Man kan f.eks benytte seg av Swift-protokollen "Codable", som vil si at man kan opprette sine egne klasser og strukturer og sende og motta dem direkte til og fra Firestore. Dette gir noe økt trygghet, og åpner også for muligheten til lage en generator som spytter ut f.eks både Swift- og Kotlin-versjoner av klassene slik at alle plattformer snakker om det samme.
Strukturering av data
En annen markant forskjell mellom Firestore og en relasjonsdatabase er relasjoner. Et node-tre er ikke alltid optimal struktur for data. Det er mange måter å modellere datastrukturene sine på, og fremgangsmåten bør alltid være tilpasset til prosjektet. Som med alle ting her i livet finnes det ikke én riktig struktur som passer for alt.
Det mest åpenbare å begynne med i mitt tilfelle er en collection "Users", som inneholder ett dokument per bruker. Dette kan man fylle med brukernavn, URL til profilbilde, og alt annet koblet direkte til brukeren. Alt rundt autentisering håndteres av Firebase, så dette bruker-dokumentet er kun supplerende data hvor dokumentID'en er lik brukerID'en som mottatt av "FirebaseAuth".
Siden hver bruker skal kunne opprette og håndtere én eller flere babyer så kan man fort tenke at det gir mening å ha en sub-collection i skaperens bruker-dokument som inneholder ett dokument per baby. Altså med path = "users/{userID}/babies/{babyID}". Men hva hvis man skal dele denne babyen med noen andre? Jo, det går greit - det er i utgangspunktet ikke noe i veien med å "krysse stier" på denne måten. Utfordringen blir i så fall å finne en god måte å referere til "en annens baby".
La oss si BrukerA har opprettet BabyA; dersom BrukerB kun vet om ID'en "BabyA" så vil det være kostbart å kjøre en spørring for å finne riktig babydata, da den må iterere over alle brukeres baby-subcollections. I dette tilfellet vil det være billigere, dog muligens noe ukonvensjonelt, å holde på referansen i form av babyens dokument-sti. Altså at BrukerB holder på et array med String-stier til babyer man har "blitt med på", som bl.a "users/BrukerA/babies/BabyA".
Denne strukturen vil da se slik ut for BrukerA:Og slik for BrukerB:
Men hva hvis BrukerA sletter kontoen sin? Jo, dette går også fint - for når et dokument blir slettet så blir faktisk ikke dets subcollections slettet uten videre. Man vil altså fortsatt kunne aksessere BabyA på stien "users/BrukerA/babies/BabyA" dersom dokumentet til "users/BrukerA" blir slettet.
Dette er en helt legitim måte å modellere data på som gir mening for enkelte prosjekter - men byr også på utfordringer. I mitt tilfelle ønsker jeg at appen skal kunne vise en liste over alle babyer man selv har opprettet og de man "er med på". Med strukturen ovenfor måtte jeg i så fall hente dokumenter fra opptil flere ulike collections for å populere én liste.
I stedet ønsker jeg å flate ut datastrukturen, litt nærmere det man ville fått i en mer tradisjonell relasjonsdatabase. I dette tilfellet kan man opprette en collection på rotnivå som heter "babies", hvor samtlige baby-dokumenter vil ligge, og gir heller alle brukere et String-array "babies", kun med babyens ID.
Siden det ikke finnes noen innebygd mekanisme som forsikrer slike dokument-relasjoner så vil dette også by på noen utfordringer hvis man ikke trår varsomt. Med mindre noe annet er spesifisert så vil alle nye dokumenter få en automatisk generert unik ID ved opprettelse. Denne ID'en er faktisk generert lokalt hos klienten, og man har da muligheten til å benytte såkalte "transaksjoner" for å utføre flere Firestore-operasjoner i samme kall, som f.eks å lagre ny baby og lagre denne babyens ID i din bruker-profil. Dette er viktig for å unngå feilsituasjoner som f.eks at internett svikter mellom disse operasjonene - som implisitt vil føre til at man har opprettet en fantom-baby som ingen eier, og som man aldri vil ha mulighet til å få tak i.
SnapshotListener
I eksempelkoden ovenfor brukte jeg "getDocument" for å hente ut et engangs-snapshot av databasens nåværende tilstand for et gitt dokument, som jeg mottar i en såkalt "completion handler", eller "callback". Ved å bruke "addSnapshotListener" vil man få et nytt snapshot hver gang et felt i dette dokumentet har endret verdi.Hvis jeg nå bruker Firebase sitt web-grensesnitt til å manuelt endre verdien fra "Test6" til noe annet, så vil denne koden automatisk kalles og printe ut den nye verdien.
Denne fremgangsmåten gir en ny twist til funksjonell programmering, som nå tillater oss å hoppe over flere steg med KVO(key-value-observation) ved å la callback'en f.eks direkte oppdatere UI-elementer. Dette krever selvsagt en del ekstra forsiktighet med tanke på feilhåndtering, men er absolutt både trygt og fleksibelt dersom det er gjort under riktige omstendigheter for gitte situasjoner.
Resultat
Jeg har lagd en betydelig mindre omfattende app enn Neimans "Min Baby". Men per min kones kravspesifikasjoner har jeg optimalisert den for vårt bruk ved å legge til enda raskere snarveier for våre mest brukte funksjoner, bl.a ved å redusere fra syv til to tastetrykk for amme-timeren.
For å "dele" en baby har jeg valgt å gå for URL-schemes, hvor man sender en egendefinert app-url via f.eks AirDrop eller SMS, som automatisk vil åpne appen og presentere en invitasjon.
Jeg er åpenbart ingen designer, så jeg har i stor grad kopiert UX fra den opprinnelige appen, og brukt ikoner fra Apples SFSymbols-bibliotek. Man må naturligvis også støtte både light og darkmode.
Jeg har svært få lokale datamodeller, og dytter de mottatte snapshot-resultatene tilnærmet direkte inn i UI.
Idet man f.eks starter "amming på høyre bryst" så ber jeg Firestore om å opprette et nytt dokument i collection "babies/{babyID}/events" med en automatisk generert ID. Den inneholder event-type, en boolean for bryst, en eventuell kommentar, et starttidspunkt, og et sluttidspunkt som i dette tilfellet er tomt. Dette vil umiddelbart bli oppdatert hos andre enheter, og alle vil kunne se opptellingen fra starttidspunktet helt til noen klikker "Stopp" og sluttidspunktet blir satt.
Til tross for at appen ble lagd i en fei og har noen småbugs så har vi brukt den daglig i mer enn en måned med stor suksess.
Endelig spiller vi multiplayer.Konklusjon
Firebase/Firestore gjør det unormalt enkelt for en gammal gubbe som meg (tjueni og et halvt år) å sette opp et fungerende endepunkt for egne apper. Det er både moro og effektivt å jobbe med, og det er minimalt med boilerplate-kode som skal til for å komme i gang.
Som apputvikler er det uvant at klienten har denne typen makt over endepunktet den snakker med, og jeg kan i realiteten slette hele databasen ved å kjøre en feil kommando fra klienten. Den tryggheten jeg savner her kan man selvsagt opprettholde ved å forsterke databasens sikkerhetsregler, der man f.eks kan spesifisere at kun den autentiserte brukeren selv har skriverettigheter til sitt eget dokument.
Dokumentasjonen til Firebase er upåklagelig, med en enorm rekkevidde på eksempelkode i 14 ulike programmeringsspråk og litteratur som dekker nesten alle aspekter av det man lurer på.
Med den generøse gratispakken er det nå ingenting som står i veien for å kunne eksperimentere og leke med langt mer avanserte egne prosjekter.
Neste steg på planen var å lage en tilknyttet AppleWatch-app for å videre forenkle prosessen med å "starte amming". Firebase-biblioteket er imidlertid foreløpig ikke støttet direkte på AppleWatch, og løsningen ville vært å manuelt implementere alle nettverkskall og autentisering som biblioteket ellers har håndtert for meg.
Den tiden dette hadde tatt velger jeg heller å bruke på å være pappa.