Funny vote APP – 五美元伺服器陣亡後:用 Firebase 與老分支續命的雲端換心雜記(六)

上一篇聊到我們在 modern-android 分支上花了幾週時間,耐著性子把十年前的 6 頁教學、自訂分享與條款頁面 100% 復刻完成,並在 Samsung S23 實體機上跑出了一條綠線滑到底的 120fps 極致流暢度。

但在狂喜之後,迎面而來的是一記極度尷尬的現實:十年前好友 JJJ 寫的 PHP 後端,連同架在 DigitalOcean 每個月五美元的主機,早在八年前就因為欠費被整台格式化了;連 funny-vote.com 的網址都早就過期被網址蟑螂搶走了。

前台刻得像現代頂級跑車,後端連引擎都沒有。這下尷尬了,難道我們辛辛苦苦刻完的 App,只能看著一堆 Mock 資料自嗨嗎?

「怠惰是開發的源頭,術業有專攻」,身為一個 Android 前端工程師,我只想安安靜靜寫客戶端。要我為了這個老專案去租一台 Linux 主機、裝 Docker、調 Nginx、配 MySQL 連線池,半夜還要擔心伺服器當機被叫起來尿尿?門都沒有。

這篇就來聊聊,我是怎麼用 Google 的 Firebase Firestore,在一行後端代碼都不用寫的前提下,給這款死透了十年的老古董換上一顆高可用、自帶即時推送的雲原生心臟;甚至手癢為十年前的四個老架構分支全數續命的折騰歷程。


一、 為什麼是 Firebase Firestore?怠惰工程師的精明算盤

在決定重構後端的那天深夜,我給自己列了三個底線原則:

  1. 零伺服器運維(Serverless):我不要管任何 Linux 指令,不要管防護牆,不要管資料庫備份。
  2. 真正的即時開票體驗:投票 App 最迷人的地方,在於「我投下一票的瞬間,身邊朋友的手機立刻跳動最新得票數」。在 2016 年我們是用輪詢(Polling)這種蠢方法硬幹,每隔幾秒就發一次 HTTP 請求;在 2026 年,全雙工 WebSocket 或 Server-Sent Events 是標配。
  3. 不用花錢(或者花得極少):這純粹是個致敬青春的重構專案,每個月白白燒幾十塊美金去養雲端伺服器,小弟我的荷包會哭。

挑來選去,Firebase 的 Cloud Firestore 成了唯一的解答。它的免費配額(Spark Plan)極度慷慨:每天 50,000 次讀取、20,000 次寫入、1GB 儲存空間。對於一個重新復活的獨立 App 來說,這個額度足夠在雲端躺平跑上好幾年,一毛錢都不用花。

但天下沒有白吃的午餐。從十年前的關聯式資料庫(MySQL)跳到 NoSQL Document-based 資料庫,如果直接照搬老思維,保證會踩得滿腳泥。


二、 Firestore 資料模型設計:從表格關聯到反正規化快取

在十年前的 MySQL 裡,我們習慣開 votes 表格、options 表格、voters 表格,然後用 LEFT JOIN 與外鍵硬湊出資料。但在 Firestore 裡,每一次 Document 讀取都是要計費(或消耗免費額度)的,而且跨集合 JOIN 根本不存在。

我們在 feature/firebase-backend 分支上,確立了「主集合 + 子集合 + 反正規化摘要」的高效結構:

1. 投票主集合:/polls/{pollId}

存放投票的基本資料與統計總量。為了避免使用者每次滑進首頁,系統都要瘋狂去讀取底下幾十個選項的文件,我們把得票最高的兩個選項(topOptions)直接以陣列形式反正規化快取在主文檔內:

{
  "pollId": "poll_98a72b1c4e",
  "title": "午餐吃什麼?",
  "category": "hot",
  "security": "00",
  "authorId": "user_77a9c1",
  "authorName": "Boochlin",
  "totalVotes": 142,
  "optionCount": 4,
  "topOptions": [
    {"optionId": "opt_1", "title": "雞排便當", "voteCount": 89},
    {"optionId": "opt_2", "title": "巷口乾麵", "voteCount": 53}
  ],
  "searchKeywords": ["午餐", "餐吃", "吃什", "什麼"],
  "createdAt": 1715839200000
}

這樣設計的精妙之處在於:使用者在首頁滑過 20 張卡片,Firestore 只需要精確計數 20 次讀取!首頁不需要去碰選項子集合,就能直接把前兩名熱門選項與長條圖繪製完畢。這才是身為架構師該替錢包省下的成本。

2. 選項子集合:/polls/{pollId}/options/{optionId}

當使用者真正點進詳情頁時,才透過子集合監聽完整的選項清單:

{
  "optionId": "opt_1",
  "title": "雞排便當",
  "voteCount": 89,
  "displayOrder": 1,
  "creatorId": "user_77a9c1"
}

3. 防刷票存證子集合:/polls/{pollId}/voters/{userId}

投票 App 最核心的資安底線就是:一人只能投一票。十年前老後端是靠 PHP 查 MySQL 的唯一索引,如果連線併發稍微高一點,使用者連點兩下螢幕就能成功灌票。

在 Firestore 裡,我們利用子集合的文件路徑唯一性:直接將使用者的 userId 作為文件 ID。如果這個文件已經存在,任何重複寫入都會直接在雲端規則層被擋下來。


三、 投票事務(Transaction):高併發下的原子性救贖

在分散式系統裡,最怕的就是 Race Condition(競爭條件)。請想像一下:如果有一百個人在同一秒鐘按下「雞排便當」,系統該怎麼保證票數不會少算?

如果寫出「先讀出票數 89,在手機端算成 90,再寫回資料庫」這種天真代碼,在併發衝擊下,這一百票最後可能只會被算成兩三票。

FirestoreVoteDataSource.kt 中,我們利用了 Firestore 提供的原子事務(Transaction):

// 投票核心:在交易包內完成防重複檢查與原子性計數
override suspend fun submitVote(
    voteCode: String,
    optionIds: List<String>,
    userId: String
): Result<Unit> = runCatching {
    val pollRef = pollsCollection.document(voteCode)
    val voterRef = pollRef.collection("voters").document(userId)

    firestore.runTransaction { transaction ->
        // 1. 悲觀鎖檢查:確認該用戶是否已經投過票(一人一票底線)
        val voterSnapshot = transaction.get(voterRef)
        if (voterSnapshot.exists()) {
            throw IllegalStateException("您已經參與過此投票,無法重複投票!")
        }

        // 2. 選項票數原子性遞增
        optionIds.forEach { optId ->
            val optionRef = pollRef.collection("options").document(optId)
            transaction.update(optionRef, "voteCount", FieldValue.increment(1))
        }

        // 3. 累計所有選項得票總數
        // 註解說明:在多選投票中,一人可投多票,totalVotes 代表全站累計投出的總票數(Ballots Cast);
        // 而防刷票的一人一次參與資格,則是由下方 voters/{userId} 唯一路徑完全鎖死。
        transaction.update(pollRef, "totalVotes", FieldValue.increment(optionIds.size.toLong()))

        // 4. 留下防刷票憑據(記錄投票時間戳記與選中的選項)
        val voterData = mapOf(
            "userId" to userId,
            "votedOptions" to optionIds,
            "votedAt" to FieldValue.serverTimestamp()
        )
        transaction.set(voterRef, voterData)
    }.await()
}

這段代碼的威力在於:整個檢查與票數遞增是在雲端隔離環境下作為單一原子操作執行的。只要有任何衝突,交易會自動重試;而一旦成功,票數絕對不會遺失或多算。更棒的是,透過 callbackFlow 監聽,全台灣所有打開這個畫面的使用者,手機上的長條圖會在同一秒鐘自動向前滑動,爽度直接拉滿。


四、 攻克 Firestore 殘廢盲點:兩階段 Bi-gram 檢索實戰

當我們把投票與列表全部串接完畢後,撞上了 Firestore 最大的先天缺陷:它完全不支援 LIKE 模糊搜尋,也不支援中文分詞檢索!

如果你在關聯式資料庫,寫一行 WHERE title LIKE '%午餐%' 就搞定了。但在 Firestore 裡,你只能做 ==(完全相等)或者字首前綴比對。官方文件通常會親切地建議你:「請外掛 Algolia 或 ElasticSearch 服務」。看到這行建議,我心裡只有一句話:我連每個月五美元的 VPS 都懶得續費了,你叫我去買每個月十幾塊美金的搜尋外掛?

身為工程師,「怠惰」不代表妥協。我打開 Kotlin,手刻出了一套零成本的雙字元(Bi-gram)切詞引擎 NgramUtil

// 手刻 Bi-gram 中文切詞引擎
object NgramUtil {
    private const val MAX_INPUT_LENGTH = 60

    fun generateBiGrams(text: String): List<String> {
        val clean = text.trim().take(MAX_INPUT_LENGTH)
        if (clean.length < 2) return if (clean.isEmpty()) emptyList() else listOf(clean)
        val ngrams = mutableSetOf<String>()
        for (i in 0 until clean.length - 1) {
            ngrams.add(clean.substring(i, i + 2).lowercase())
        }
        return ngrams.toList()
    }
}

發起投票「今天午餐吃什麼」時,自動切碎成陣列 ["今天", "天午", "午餐", "餐吃", "吃什", "什麼"] 存進 searchKeywords 欄位。

致命技術陷阱:為什麼只靠 whereArrayContains 會翻車?

很多工程師在接入 Firestore 時,以為只要寫一行:

// 天真錯誤寫法:查準率直接歸零!
pollsCollection.whereArrayContains("searchKeywords", ngrams.first()).get()

這段代碼隱藏著極致命的缺陷:Firestore 的 whereArrayContains 只支援單一元素比對,且不支援多條件 AND 運算

如果使用者搜尋「2026 最受歡迎動畫」,ngrams.first() 取出的是「20」!Firestore 會把所有標題含有「20」的無關投票(例如「20元便當」、「Top 20 好物」)通通倒給你,查準率直接崩盤!

正統解法:兩階段檢索(雲端粗篩 + 客戶端記憶體精準過濾)

真正的生產級架構,必須採用兩階段過濾策略:

// 兩階段中文搜尋實戰:雲端粗篩 + 客戶端記憶體精準二次過濾
override fun searchVotes(query: String): Flow<List<VoteWithDetails>> = callbackFlow {
    val cleanQuery = query.trim()
    val searchBiGrams = NgramUtil.generateBiGrams(cleanQuery)
    if (searchBiGrams.isEmpty()) {
        trySend(emptyList())
        close()
        return@callbackFlow
    }

    // 第一階段:雲端粗篩
    // 挑選特徵最明顯或最長的一個 Bi-gram 作為索引鍵,精確鎖定最多 30 筆候選文檔,節省雲端讀取額度
    val targetToken = searchBiGrams.maxByOrNull { it.length } ?: searchBiGrams.first()
    val listenerRegistration = pollsCollection
        .whereArrayContains("searchKeywords", targetToken)
        .limit(30)
        .addSnapshotListener { snapshot, error ->
            if (error != null) {
                close(error)
                return@addSnapshotListener
            }
            if (snapshot != null) {
                // 第二階段:本機記憶體二次精準過濾
                // 在客戶端記憶體中,保證所有候選文檔標題必須完整包含使用者輸入的原始字串
                val filteredVotes = snapshot.documents
                    .mapNotNull { mapDocToVoteWithDetails(it) }
                    .filter { it.title.contains(cleanQuery, ignoreCase = true) }
                
                trySend(filteredVotes)
            }
        }
    awaitClose { listenerRegistration.remove() }
}

這套兩階段設計兼具了雲端成本控制(一次查詢最多消耗 30 次讀取配額)與 100% 搜尋精準度。零外掛費用,零第三方依賴,這才是高階工程師該拿出來的解法。


五、 終局彩蛋:給十年前的四個老分支續命,與跨越十年的三代代碼對比

modern-android 分支成功換上 Firebase 後,看著雲端控制台裡一筆筆即時跳動的資料,我突然起了一個瘋狂的念頭。

在我們這個專案的 Git 歷史裡,整整齊齊沉睡著十年前的四個老架構分支:
mvp (2016 Java 純手刻)
mvp_dagger (2016 依賴注入初探)
mvp_rxjava (2017 響應式管線)
mvp_kotlin (2018 Kotlin 1.2 始祖)

我手癢為這四個老分支各拉出了一個 *_firebase 分支,把當年的資料來源硬生生抽換成 Firestore SDK。而在實現同一個「從 Firebase 取得投票詳情」的過程中,我整理出了這份跨越十年的三代代碼演進圖鑑:

1. 2016 年 Java 7/8 回呼地獄(Callback Hell)

// 2016 年經典寫法:Task 滿地回呼,執行緒切換得手動丟回主執行緒
Tasks.call(Executors.newSingleThreadExecutor(), new Callable<Vote>() {
    @Override
    public Vote call() throws Exception {
        return fetchVoteFromFirestore(voteCode);
    }
}).addOnSuccessListener(new OnSuccessListener<Vote>() {
    @Override
    public void onSuccess(final Vote vote) {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                view.showVoteDetail(vote); // 回到主執行緒更新 UI
            }
        });
    }
}).addOnFailureListener(new OnFailureListener() {
    @Override
    public void onFailure(@NonNull Exception e) {
        view.showError(e.getMessage());
    }
});

2. 2017 年 RxJava 2 響應式橋接(Reactive Bridge)

// 2017 年解法:用 Single.create 把非同步回呼硬包裝成 Observable 管線
Single.<Vote>create(emitter -> {
    docRef.get().addOnSuccessListener(snapshot -> {
        Vote vote = snapshot.toObject(Vote.class);
        if (vote != null) emitter.onSuccess(vote);
        else emitter.onError(new NoSuchElementException());
    }).addOnFailureListener(emitter::onError);
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(vote -> view.showVoteDetail(vote), error -> view.showError(error.getMessage()));

3. 2026 年 Kotlin 協程(Coroutines Suspend)

// 2026 年現代寫法:一行 await() 優雅解決,沒有回呼、沒有膠水樣板
suspend fun getVoteDetail(voteCode: String): VoteWithDetails? {
    return pollsCollection.document(voteCode).get().await().toObject(VoteWithDetails::class.java)
}

看著這三段代碼,軟體工程十年間的演進躍然紙上。從 Java 回呼地獄的痛苦掙扎,到 RxJava 試圖用繁複操作符理順非同步邏輯,再到 Kotlin 協程用平鋪直敘的順序語法寫出非同步代碼,這才是技術演進帶給工程師最深刻的幸福感。

更重要的是,這項實驗證明了高內聚、低耦合架構的終極價值:十年前我們在合約介面(Contract)上下的功夫沒有白費。只要介面定義得好,業務邏輯完全不需要重寫,十年前的老爺車換上現代噴射引擎,在今天一樣能狂飆。


六、 結尾:雲原生跑起來了,但資安警報響了

十年前死透了的 App,在 2026 年終於徹底活了過來。全站無伺服器架構、即時推送、一人一票、兩階段中文搜尋、老分支全數復活,一切看起來都美好得不像真的。

但正當我沉浸在「現代雲原生架構真香」的喜悅中時,我們身邊那位冷面審查員 Codex 走了過來,拿著一份資安報告,冷冷地給了我一記重擊:

「你知道你剛剛寫的 Ngram 切詞函式,只要有人惡意灌入一篇長文章,你的 Firestore 索引就會直接被塞爆、甚至產生近萬元的帳單核彈嗎?你知道你的 Firestore 規則門戶大開,任何登入者都能用 Python 腳本竄改全站投票嗎?」

是的,在我們借助 AI 極限手速狂飆開發(Vibe Coding)的過程中,我們埋下了一堆差點讓整個專案破產的致命地雷。

在最後的終局第七篇裡,我們就由審查員 Codex 親自操刀,帶大家深入這趟 Vibe Coding 旅程中最驚心動魄的翻車現場,開箱我們如何用護欄訓服 AI,並把這款 App 的資安防禦打磨到 OWASP MASVS 100 分滿分!

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *