Všimli jsme si, že naše Android aplikace nevyužívá realtime aktualizace Firebase Remote Config. Místo toho opakovaně stahovala hodnoty konfigurace a tenhle polling se pomalu blížil k rate limitům Firebase. Pořádná oprava stejně znamenala podívat se, jak s remote configem pracujeme, a čím déle jsem se na to díval, tím jasnější bylo, že celá vrstva není bezpečná ani znovupoužitelná. Kvůli rate limitu jsem se do kódu podíval. Kvůli bezpečnosti a znovupoužitelnosti jsem ho přepsal.
Tohle je příběh o tom, co jsem postavil a proč: generátor kódu, který z obyčejného Kotlin rozhraní udělá plně validovanou a reaktivní vrstvu Remote Config, se kterou se dobře pracuje i QA.
Co bylo vlastně špatně
Surové Android API Firebase Remote Config vypadá na první pohled nevinně:
val enabled = remoteConfig.getBoolean("cool_new_feature")
Jenže ve velké vícemodulové aplikaci není jednoduché nic, ani remote config:
- Klíče jsou magické stringy. Překlep se bez problému zkompiluje a potichu vrátí výchozí hodnotu. Nikdo si ničeho nevšimne, dokud nezačne být divné, že se nová funkce nikdy nezapne.
- Klíče nejsou definované na jednom místě. Žijí ve Firebase konzoli, v XML souboru s výchozími hodnotami a roztroušené po místech, odkud se volají. Postupem času se tyhle tři světy rozejdou. Klíč zmizí z konzole, ale v kódu zůstane. Klíč přibude v kódu, ale nikdy nedostane výchozí hodnotu. Nic vás neupozorní.
- Je to imperativní, ne reaktivní. Kód si volá
getBoolean(), kdykoli se mu zachce. Chybí plnohodnotný stream, který by hodnotu poslal znovu, když Firebase pushne aktualizaci. Přesně takhle jsme se k pollingu dostali. - Složité typy si každý píše sám. Hodnoty zakódované v JSONu se deserializují ad hoc a každé místo volání má vlastní ošetření chyb.
Problém s parsováním JSONu
Remote Config zná jen stringy. JSON hodnota ve Firebase konzoli je pro Firebase obyčejný řetězec, takže i rozbitý JSON je z jeho pohledu naprosto v pořádku. Někdo v konzoli upraví JSON konfiguraci, zapomene závorku, klikne na publikovat a Firebase rozešle nevalidní hodnotu do všech zařízení.
Mohli byste si říct: dobře, parsování selže, tak se vrátíme k výchozí hodnotě přibalené v aplikaci. Jenže to nejde. API vám dá jen naposledy staženou hodnotu. Jakmile nějaká stažená hodnota existuje, k výchozí hodnotě v aplikaci se už nedostanete a žádné „dej mi radši default“ neexistuje. Zbývá pád aplikace, ad hoc null, nebo cokoli, co si na daném místě kdo vymyslel.
Každý pořádný wrapper proto musí vyřešit dvě věci. Za běhu se při selhání parsování vrátit k výchozí hodnotě a při buildu zaručit, že samotná výchozí hodnota jde naparsovat, aby tahle záchrana vždycky fungovala.
Proč nepoužít existující knihovnu?
Knihovny třetích stran vždycky pokryly jen část toho, co jsme chtěli. Žádná nepokryla všechno a žádná neřešila problém s parsováním JSONu. Například KFirebaseRemoteConfig je skvělá knihovna, ale navenek pořád nabízí volání getString(key): slabě typovaná, nereaktivní a bez validace při kompilaci.
Zvažovali jsme i jednodušší cestu: extension funkce, konvence, případně dobře napsané instrukce pro vývoj s AI. Konvence ale nic nevynucují. Přesouvají zodpovědnost na code review a disciplínu, a právě odtud původní problémy pocházely.
A hlavně vám nedají věc, která se v praxi ukázala jako jedna z nejužitečnějších: vygenerovanou debug obrazovku, kde vývojáři i QA vidí všechny konfigurační klíče, které aplikace skutečně používá, můžou si prohlédnout surové hodnoty z Firebase a kteroukoli z nich lokálně přepsat.
Dřív testování scénářů závislých na konfiguraci znamenalo měnit hodnoty ve sdílené Firebase konzoli, takže si testeři neustále navzájem překáželi. Jeden přepne flag, aby otestoval scénář A, a někomu jinému se uprostřed testu rozbije scénář B. S lokálními overridy si každý tester pouští své scénáře paralelně na vlastním zařízení a konzole zůstává netknutá.
Tohle žádná konvence nedá. Takže generování kódu.
Řešení: stačí napsat rozhraní, zbytek se vygeneruje
Výsledkem je malá knihovna, respektive modul, postavená na dvou nástrojích. Prvním je KSP (Kotlin Symbol Processing), který při kompilaci analyzuje anotovaný kód. Druhým je KotlinPoet, knihovna od Square na generování zdrojových souborů v Kotlinu. KotlinPoet neskládá vygenerovaný kód ze stringů, ale staví ho jako regulérní model Kotlin typů, funkcí a properties. Díky tomu zůstává generátor čitelný a dobře udržovatelný.
Jako vývojář napíšete jen tohle:
@RemoteConfigSource
internal interface FeatureRemoteConfig {
@RemoteConfigProperty("cool_new_feature")
val coolNewFeatureEnabled: StateFlow<Boolean>
@RemoteConfigProperty("feature_rules")
val featureRules: StateFlow<FeatureRules?>
}
K tomu pro každý klíč jeden záznam ve standardním Firebase XML souboru s výchozími hodnotami, tedy v tom samém souboru, který Firebase za běhu stejně načítá:
<entry><key>cool_new_feature</key><value>false</value></entry>
<entry><key>feature_rules</key><value>{"rules":[]}</value></entry>
Všechno ostatní se vygeneruje při kompilaci: implementační třída, metadata pro debug panel i kód pro validaci. Přidat novou konfigurační hodnotu znamená jednu property v rozhraní a jeden záznam v XML. Pro vývojáře rychlé a bezpečné.
Build spadne dřív, než stihnete vydat rozbitou konfiguraci
XML soubor s výchozími hodnotami má dvojí roli. Firebase ho za běhu načítá jako výchozí hodnoty a KSP procesor ho při kompilaci čte jako referenci pro validaci. Každý deklarovaný klíč musí v XML existovat a každá výchozí hodnota musí jít naparsovat na deklarovaný typ.
Překlep v klíči? Build spadne. Výchozí hodnota typu Boolean, která není přesně true nebo false? Build spadne. Výchozí JSON, který není validní JSON? Build spadne. Duplicitní klíče v XML? Build spadne. Celá skupina chyb, které by se jinak projevily až za běhu, se změnila v chyby při kompilaci. A hláška vždycky přesně řekne, u kterého klíče je problém a v čem.
Tím se zároveň řeší i problém s parsováním JSONu. Protože je při buildu ověřené, že výchozí hodnota jde naparsovat, aplikace se k ní za běhu může vždycky vrátit. Když Firebase pošle rozbitý JSON, vygenerovaný kód selhání zaloguje a flow dál posílá hodnotu, se kterou si aplikace poradí. Žádné pády a žádné improvizované ošetřování nullů na místech volání.
Pro vlastní @Serializable typy platí jedno záměrné pravidlo: musí být deklarované jako nullable. A není to konvence, kterou si musíte pamatovat, protože ji vynucuje procesor. Když deklarujete StateFlow<FeatureRules> místo StateFlow<FeatureRules?>, build spadne s chybou, která vysvětlí, že typ musí být nullable, protože deserializace může za běhu selhat.
Build ověří, že výchozí hodnota je validní JSON. Pokud ale vzdálená hodnota za běhu neodpovídá očekávanému schématu, property se vyhodnotí jako null a aplikace nespadne. Daní za to je, že každý, kdo vlastní typ používá, musí null ošetřit. S takovým selháním si ale kód umí poradit, s pádem aplikace ne.
Reaktivní už z principu a sbohem rate limitům
Každá vygenerovaná property je StateFlow. Hned vydá aktuální hodnotu a novou pošle pokaždé, když Firebase pushne aktualizaci nebo se změní lokální override. Konzumentům stačí hodnoty sbírat přes collect. Nikde žádný polling.
Pod kapotou běží pro celou aplikaci přesně jeden Firebase realtime listener. Ten krmí sdílenou invalidační sběrnici, na které visí všechny odvozené flow, takže jedna pushnutá aktualizace znovu vyhodnotí všechny dotčené hodnoty, všude a synchronně.
A tím je vyřešený i původní problém. Opakované stahování, kvůli kterému to celé začalo, zmizelo a k tomu jsme získali spoustu dalších výhod. Jedno živé spojení, mnoho flow, žádný polling.
Overridy, které release buildy nic nestojí
Debug panel pro overridy se generuje ze stejných metadat, která vytváří procesor. Ukazuje všechny zdroje a všechny klíče s jejich typem, výchozí hodnotou a aktuální hodnotou z Firebase a umožňuje nastavit lokální override. Overridy procházejí stejnými pravidly parsování jako skutečná konfigurace, takže override, který nejde naparsovat, se v klidu vrátí k výchozí hodnotě a aplikaci nerozbije.
Panel existuje jen v debug buildech. Podle typu buildu se volí jedna ze dvou implementací úložiště overridů: pro debug je to úložiště nad DataStore, pro release prázdná implementace (no-op). Vyhodnocovací logika i veškerý vygenerovaný kód jsou ve všech typech buildů stejné, release buildy jen nesou úložiště, ve kterém žádné overridy nikdy nejsou. V release verzi po panelu nezůstane ani stopa a debugovací funkce nemůžou v produkci nic rozbít.
Těžká část: testování vygenerovaného kódu
Postavit samotný generátor bylo jednoduché. Pořádně ho otestovat bylo těžké.
Celá funkcionalita nežije na jednom místě, ale rozkládá se přes několik fází buildu a testování. KSP procesor běží při kompilaci na JVM, dřív než aplikace vůbec existuje. Vygenerovaný kód běží uvnitř aplikace. Validace výchozích hodnot musí běžet v unit testech. A každá fáze má přístup jen k některým částem: procesor nevidí běžící aplikaci, unit testy nemůžou znovu spustit procesor a runtime nic neví o XML souboru.
Každá fáze proto potřebovala vlastní strategii testování:
- Unit testy pro parsování v procesoru, rozlišení typů a generování kódu.
- Testy pro vyhodnocování hodnot za běhu a chování listeneru, včetně ověření, že Firebase listener je opravdu jen jeden, ať existuje flow kolik chce.
- Test validace výchozích hodnot, který běží nad skutečným vygenerovaným výstupem.
A nakonec se to celé udržuje samo. Procesor spolu s vygenerovaným kódem vytvoří i manifest pro ServiceLoader a jediný test přes něj najde všechny zdroje konfigurace a zvaliduje jejich výchozí hodnoty. Přidání nového zdroje kdekoli v aplikaci nevyžaduje žádnou úpravu testů: nový zdroj se najde a zvaliduje automaticky. Testy, které se udržují samy, jsou mnohem lepší než testy, o které se musí někdo starat.
Co se reálně změnilo
To měřitelné: přidání konfigurační hodnoty dřív znamenalo sahat na roztroušená místa v kódu, ručně psát parsování a doufat. Teď je to jedna property v rozhraní a jeden záznam v XML a správnost kontroluje kompilátor. Polling i s rizikem rate limitů zmizel a nahradil ho jeden realtime listener.
To hůř měřitelné se ukázalo jako důležitější. QA teď má přehled: override panel živě ukazuje, které klíče aplikace skutečně používá. Když flag ještě není implementovaný nebo už byl odstraněný, testeři to vidí, aniž by se museli ptát vývojáře. A protože jsou overridy lokální, víc testerů může paralelně zkoušet různé konfigurační scénáře a nemusí se přetahovat o sdílenou Firebase konzoli.
A co oceňujeme nejvíc i po měsících: bezpečnost nestojí na konvenci, kterou si někdo musí pamatovat. Je zabudovaná přímo ve struktuře. Konfigurační hodnotu s překlepem v klíči, rozbitou výchozí hodnotou nebo chybějícím ošetřením chyb prostě nejde přidat. Build a testy vás zastaví. Nejlepší pojistky jsou ty, na které nikdo nemusí myslet.
Pokud spravujete rozsáhlý remote config jen přes holé Firebase API, doporučuju podívat se po podobném řešení. Kód se bude v budoucnu líp udržovat, získáte řadu užitečných funkcí pro testování a všechno půjde snáz otestovat.