第19章 我是如何把 GenLatte 迁移到全栈 Dart 的
我如何将 GenLatte 转换为全栈 Dart 并大幅削减服务器账单
Craig Labenz
Sep 22, 2026 · 7 min read
rss_feed
Share on X
Share on Bluesky
Share on LinkedIn
将应用转换为全栈 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 是两种截然不同的语言,各有优势。从某种意义上说,这是一句废话,但它对服务端迁移有着更深的影响,因此逐行重写(1-to-1 rewrite)可能并不划算。毕竟,服务端的 Dart 可以享受与客户端 Dart 端到端的类型安全,而继续坚持使用无类型的 Maps 实在是一种浪费。
基于此考量,尽管我的野心可能比胃口更大,我决定彻底重写 GenLatte。我的诸多目标包括:
- 在全栈间共享模型(鉴于我最初决定将所有数据类放入独立的 genlatte_data 包,这一改动其实很小)
- 将我们的 Firebase 函数足迹减少到单个可部署函数
- 移除所有客户端直接写入操作;改为调用服务端函数
- 移除所有服务端触发器;改为在客户端显式调用服务端函数来执行数据变更
- 持久化所有基于角色的 ACL 检查
- 终于实现端到端测试!
这些目标很高远,也不在我的 2026 年路线图里,所以自然,我把计划保密,直接开始敲代码了。
执行迁移
与主要依靠非 AI 辅助开发的原始 GenLatte 不同,我知道这次紧张的时间线需要引入打字速度飞快的 AI 助手。我选择的模型是 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 个服务。虽然每个服务在空闲时会关闭,但这仍然对服务器账单产生了可预测的影响。更糟糕的是,为了避免冷启动,我们在 GenLatte 使用期间将每个服务的最小节点数设为 1,这当然废除了缩容到零的功能。结果是,GenLatte 开机的成本出人意料地高。
如何将一切塞进一个 Firebase 服务
为了省钱,我决定借鉴 Serverpod,引入 BackendMessage DTO,告诉那个唯一的 Firebase 函数实际要调用哪个内部函数。借助 Gemini 帮我编写的一些巧妙的 pkg:freezed 技巧,我甚至实现了带类型的响应。
最终的类结构有点绕,但如果你喜欢类型安全和省钱,值得理解。
BackendMessage
这是我在实际函数签名中使用的父级 DTO 类。
dart
abstract interface class BackendMessage {
/// Json serializer.
Json toJson();
}
MessageParameters
它实现了 BackendMessage,并使用 pkg:freezed 将单个消息类型与预期的响应类绑定。
使用 @Implements.fromString 技巧生成的类满足了父类要求绑定的 MessageResponse 类型。虽然源类使用原始字符串感觉在类型上很危险,但任何拼写错误都会导致生成类中的错误,因此在功能上是类型安全的。
dart
@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 Accepted 或 204 No Content HTTP 响应的空响应。
dart
@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。
dart
firebase.https.onCallWithData<MessageParameters, Object>(
(request) {
final MessageResponse response = switch (request.data) {
SaveOrderParameters msg => saveOrder(msg),
CompleteOrderParameters msg => completeOrder(msg),
...
};
return response.toJson();
},
...
}
最后,我需要一个 saveOrder 和 completeOrder 方法,以满足它们的契约。
dart
// Other Dart files
Future<SaveOrderResponse> saveOrder(SaveOrderParameters message) {}
Future<EmptyResponse> completeOrder(CompleteOrderParameters message) {}
// Many more functions...
有了这些系统,我就能将任意多的服务端操作塞进单一的 Dart 函数。你好,巨额省钱计划!
重构所有写入
上述 BackendMessage 系统为立即实现其他三个子目标铺平了道路:
- 我通过在我的 firestore.rules 文件中屏蔽操作,移除了所有客户端直接写入。我还重构了数据管理层,使其调用那个唯一的服务端函数,而不是直接调用 Firestore 函数(如 docRef.set())。
- 我同样移除了所有 Firestore 触发器,但将缺失的功能重新实例化为客户端可以显式调用的函数。
- 通过引入 Dart 代码中的 ACL 检查,我保持了 GenLatte 严格的权限模型。作为一个可测试的系统,这让我能安稳入睡。
添加端到端测试
作为最后的 trick,我让 Gemini 编写服务端测试,并且非常兴奋地,我在客户端测试中模拟后端行为,从而使其成为端到端测试。得益于类型安全的返回值,这带来了我对稳定性和应用可靠性都感到满意的结果!
全栈语言的乐趣
无论你喜欢关系型数据并使用像 Serverpod 这样的工具,还是使用像 Cloud Firestore 这样的非关系型方案,在整个技术栈中使用同一种语言编写代码都是一种梦想。如果这种语言是 Dart,且你沉浸在完全健全的空安全类型安全的照耀下,优势只会不断涌现。它真的创造出一种错觉,仿佛计算机真的是你的朋友!
我很享受将 GenLatte 转换为在所有地方使用 Dart。在此过程中,我将其服务器账单削减到使用 Node.js 时的极小一部分,并提高了可靠性和性能。我花在 Gemini token 上的钱执行这次变更,仅需运行几分钟就轻松收回成本。现在 GenLatte 可以继续为活动参与者提供个性化咖啡,直到未来很久,或者直到我们运行它感到腻烦为止。
来自 Flutter 的更多内容
使用 A2UI 的客户端函数进行快速可靠的计算
了解客户端函数如何允许 agent 将本地操作直接委托给运行在用户设备上的 Dart 代码。
Andrew Brogdon
Aug 28, 2026 · 5 min read
Flutter 如何领先于 iOS 发布
了解 Flutter 团队如何驾驭 WWDC、Beta 版本发布和主动工程,以提供 Day 0 iOS 支持。
Craig Labenz, Louise Hsu
Aug 25, 2026 · 7 min read