【文章翻譯】How I converted GenLatte to fullstack Dart

【文章內容使用 Gemini 2.5 Flash 自動翻譯產生】

原文:https://flutter.dev/blog/converted-genlatte-fullstack-dart

我如何將 GenLatte 轉換為全端 Dart

並減少應用程式的伺服器費用

How I converted an app to fullstack Dart
我如何將應用程式轉換為全端 Dart

如果您曾追隨 Flutter 團隊進軍經營快閃咖啡攤的曲折故事,您會知道我們結合了 Flutter、Firebase 和 Gemini 來提供奇異的咖啡。您也知道,由於不收取任何費用,我們未能獲利。

此外,如果您查看了程式碼,您可能還注意到我們的 Flutter 前端是由用 Node.js 撰寫的 Firebase 函式所補充的。這是一個有點令人驚訝的歷史遺物,因為 Firebase 對 Dart 函式的支援幾乎與 GenLatte 首次在 Google Cloud Next 出現的同時進入公開預覽。

在 YouTube 新分頁中觀看:「Flutter 在 Cloud Next 打造了一家咖啡店」

但這正是問題所在——由於 Firebase 對 Dart 的支援在最後一刻熱烈推出,我們根本無法承諾在生產應用程式中使用它,因為時間壓力很大。如果 Firebase 對 Dart 的支援因任何原因而延遲,我們可能就無法及時在 Cloud Next 部署。因此,當 GenLatte 在 4 月、5 月或 6 月出現時,它是以 Node.js 後端運行的。

我個人對此很不滿。

完全擁抱全端 Dart

Dart 和 JavaScript 是具有不同優勢的不同語言。從某種意義上說,這有點像一個不言而喻的說法,但它對伺服器端遷移也有更深層次的影響,因此一對一重寫可能不值得。畢竟,伺服器上的 Dart 可以享受與客戶端上的 Dart 的端到端類型安全,如果仍然堅持使用無類型 Map 那將會是一個真正的遺憾。

考慮到這一點,儘管我的目標可能比我的能力大,但我開始大幅重寫 GenLatte。我的許多目標包括:

  • 在整個堆疊中共享模型(這是一個小小的提升,因為我最初決定將所有資料類別放在單獨的 genlatte_data 套件中)
  • 將我們的 Firebase 函式足跡減少到單一可部署函式
  • 移除所有客戶端寫入;改為專注於呼叫伺服器端函式
  • 移除所有伺服器端觸發器,並明確地在從客戶端呼叫的伺服器端函式中執行任何資料變更
  • 保留所有基於角色的 ACL 檢查
  • 最終進行端到端測試!

這些目標很宏偉,而且不在我 2026 年的路線圖上,所以,我自然而然地保密了我的計畫並開始打字!

執行遷移

與我對原始 GenLatte 的工作不同,該專案主要是在沒有使用 AI 的情況下開發的,我知道我緊迫的時間表需要引入一個 Zoom zoom 打字助手。我選擇的模型是 Gemini 3.6 Flash,它結合了低延遲和通用的知識,這對我來說非常有用。

從高層次來看,我仍然閱讀 Gemini 撰寫的每一行程式碼,以保持對專案的認知所有權。從深入了解的角度開始,這讓事情變得更容易,如果沒有這個承諾,我不認為重寫會在我可用的幾天內成功。程式碼助手,儘管它們很棒,但仍然非常受益於人類的指導。

共享模型

全端類型安全意味著客戶端和伺服器共享程式碼。如前所述,這意味著將任何必須在伺服器上執行的程式碼拆分成自己的套件,並且至關重要的是不能依賴 Flutter SDK。這在很大程度上是簡單的,但確實需要對我自己的資料管理套件 pkg:data_layer 進行補充。它的其中一個補充套件 pkg:data_layer_firestore 依賴於客戶端 Firebase SDK(它依賴於 Flutter),因此我被迫添加了 pkg:data_layer_firestore_admin,它是純 Dart 並且因此對伺服器友好。

使用單一 Firebase 函式

預設情況下,您部署的每個 Firebase 函式都會變成自己的 Cloud Run 服務。(如果您是 Firebase Functions 使用者,而這對您來說是新聞,請導航到 Google Cloud 控制台中的 Cloud Run,看看 Firebase 香腸是如何製作的!)

然而,在 GenLatte 的 Node.js 時代,我依賴了超過 15 個獨立的 Cloud Run 服務,我知道我想要一個更精簡的設定,原因有很多。首先,部署會顯著加快,但更重要的是,它會大幅降低 GenLatte 的伺服器費用

「但是 Craig!」您說,「Cloud Run 在不使用時會縮小到零,所以這實際上很重要嗎?」

好問題。我很高興您有注意。

是的,這非常重要!當 GenLatte 正在使用時,各種資料寫入和非同步任務會啟動所有 15 個服務,雖然每個服務在閒置時都會關閉,但這仍然對我們的伺服器費用產生可預測的影響。但是,更糟糕的是,我們將每個服務的最小節點計數設定為 1,以避免冷啟動,這當然會取消這個縮小到零的功能。最終結果是 GenLatte 的啟動成本出奇地高。

如何將所有內容塞入一個 Firebase 服務

為了省錢,我決定借鑑 Serverpod 的靈感,引入一個 BackendMessage DTO 來告訴我的單一 Firebase 函式我想要它實際呼叫的內部函式。在 Gemini 幫助我編寫的一些巧妙的 pkg:freezed 技巧下,我甚至能夠獲得類型化的回應。

最終的類別設定有點複雜,但如果您喜歡類型安全和省錢,那麼值得理解。

BackendMessage

這是我在實際函式簽章中使用的父 DTO 類別。

1
2
3
4
abstract interface class BackendMessage<R extends MessageResponse> {
/// Json serializer.
Json toJson();
}

MessageParameters

這實作了 BackendMessage 並使用 pkg:freezed 將個別訊息類型與預期的回應類別綁定。

@Implements.fromString 技巧產生滿足父類別要求以綁定 MessageResponse 類型的類別。雖然源類別使用原始字串感覺類型不安全,但任何拼寫錯誤都會導致產生類別中的錯誤,這意味著它在功能上類型安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@freezed
sealed class MessageParameters with _$MessageParameters {
/// Creates a new latte order.
@Implements.fromString('BackendMessage<SaveOrderResponse>')
const factory MessageParameters.saveOrder({
@LatteOrderConverter() required LatteOrder order,
}) = SaveOrderParameters;

/// Marks a latte order as completed.
@Implements.fromString('BackendMessage<EmptyResponse>')
const factory MessageParameters.completeOrder({
required String orderId,
required String baristaId,
}) = CompleteOrderParameters;

// Many more message types...
}

MessageResponse

這關閉了迴圈,宣告了每個方法呼叫預期的回應類別。它為需要即時回饋的方法提供了單獨的回應類型,並提供了空回應以表示 202 Accepted204 No Content HTTP 回應。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@freezed
sealed class MessageResponse with _$MessageResponse {
/// Return data for [SaveOrderParameters].
const factory MessageResponse.saveOrder({required LatteOrder? latteOrder}) =
SaveOrderResponse;

// Many more response types...

/// Placeholder for method calls which require no return value.
///
/// This is typically because the client will pick up any state changes via
/// watched Firestore collections.
const factory MessageResponse.empty() = EmptyResponse;
}

我的 DTO 都準備好後,我需要一個單一的伺服器端函式來接收並相應地路由每個傳入的 BackendMessage

1
2
3
4
5
6
7
8
9
10
11
firebase.https.onCallWithData<MessageParameters, Object>(
(request) {
final MessageResponse response = switch (request.data) {
SaveOrderParameters msg => saveOrder(msg),
CompleteOrderParameters msg => completeOrder(msg),
...
};
return response.toJson();
},
...
);

最後,我需要滿足其契約的 saveOrdercompleteOrder 方法。

1
2
3
4
5
// Other Dart files

Future<SaveOrderResponse> saveOrder(SaveOrderParameters message) {}
Future<EmptyResponse> completeOrder(CompleteOrderParameters message) {}
// Many more functions...

有了這些系統,我就可以將任意多的伺服器端操作塞進一個 Dart 函式中。太省錢了!

重構所有寫入

上述 BackendMessage 系統為立即實現我的另外三個子目標奠定了基礎:

  1. 我透過在 firestore.rules 檔案中阻擋客戶端寫入來移除所有客戶端寫入。我也重構了我的資料管理層,以呼叫單一後端函式,而不是直接呼叫 docRef.set() 等 Firestore 函式。
  2. 我同樣移除了所有 Firestore 觸發器,但將遺失的功能重新實例化為我可以從客戶端明確呼叫的函式。
  3. 我透過在 Dart 程式碼中引入 ACL 檢查來維持 GenLatte 嚴格的權限模型,作為一個可測試的系統,這讓我晚上睡得安穩。

加入端對端測試

在我的最後一招中,我能夠命令 Gemini 撰寫伺服器端測試,並且非常令人興奮地,在客戶端測試中模擬後端行為,這些測試突然變成了端對端測試。鑒於我的類型安全回傳值,這帶來了我滿意的穩定性和應用程式可靠性!

全端語言的樂趣

無論您喜歡關聯式資料,因此使用 Serverpod 之類的工具,還是像 Cloud Firestore 這樣的非關聯式解決方案,在整個堆疊中以相同的語言撰寫程式碼都是一種夢想。如果該語言是 Dart,並且您發現自己沉浸在完全健全的 null-safe 類型安全的榮光中,那麼勝利將不斷降臨。它確實創造了電腦實際上是您朋友的錯覺!

我很享受將 GenLatte 轉換為處處使用 Dart 的過程,透過這樣做,我將其伺服器費用削減到 Node.js 時代的一小部分,並提高了可靠性效能。我用於執行變更的 Gemini 代幣費用在短短幾分鐘的操作後就輕鬆收回了。現在,GenLatte 可以長期為活動參與者提供個人化咖啡,或者直到我們厭倦經營它為止。