进阶 flutter.dev 2026-10-10 05:26:19 · 0 阅读

第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

评论 (0)