对于每一位尝试过编写后端代码的Flutter开发者来说,都存在这样一种特殊的麻烦。在开发前端时,你每天都在使用表达力强、具有空值安全性且类型严格的Dart语言来编写代码;你的数据模型结构清晰,异步编程逻辑也读起来非常流畅,类型系统甚至能在程序运行之前就发现许多潜在的错误。然而,当你打开一个新的标签页去编写Cloud Function时,突然就会发现自己处于一个TypeScript文件中,需要重新声明那些在Dart中已经定义好的数据模型,还要手动保持这两个版本之间的同步性,同时还要调试那些本应由Dart编译器在几毫秒内就发现的错误。
这种麻烦并非微不足道的小问题,而是对于那些希望完全掌控自己开发流程的Flutter开发者来说,一种根本性的阻碍。你需要维护两种不同语言编写的代码库,这两种语言具有不同的并发模型、类型系统以及包管理机制。任何对共享数据结构进行的修改都需要在两种语言中分别进行相应的修改;而每当客户端与服务器之间的数据交互出现错误时,你都不得不在两种语言之间切换思维来进行问题排查。那些使用Firebase后端来开发Flutter应用的团队,往往会专门聘请后端开发者,因为对于以移动应用开发为主的团队来说,处理JavaScript相关的工作负担实在太大了。
但现在情况已经发生了变化。Firebase的Cloud Functions已经宣布开始支持Dart语言,同时还提供了实验性的Dart Admin SDK,使得你可以在函数代码中直接与Firestore、Authentication、Cloud Storage等Firebase服务进行交互。你可以使用与前端相同的语言来编写后端代码,将数据模型和验证逻辑放在同一个Dart包中供前后端共同使用,然后通过你已经熟悉的firebase CLI来部署服务器代码。多年来开发者们梦寐以求的统一Dart开发栈,终于正式成为了现实。
这本手册就是关于这一统一开发栈的完整指南。它详细介绍了Dart Cloud Function的工作原理,以及它们在架构和部署方式上与Node.js函数的差异;解释了Admin SDK如何帮助你将函数与Firebase服务连接起来;说明了如何通过共同的Dart包在Flutter应用和后端代码之间共享逻辑;还讲解了如何从Flutter中调用这些函数,同时还列出了在使用实验性功能之前需要了解的所有限制因素。这绝不是一本简短的快速入门指南,而是为那些正在考虑是否以及如何使用Dart来开发实际产品的团队准备的权威指导。
读完这本书后,你将从基础原理出发全面理解Dart的全栈开发架构,掌握如何配置、编写、测试和部署Dart Cloud Function,了解Admin SDK的功能用途,学会如何创建能够避免数据模型重复的共享包,并且能够明智地判断出某个实验性功能何时适合在生产环境中使用。
目录
先决条件
在开始学习本手册之前,您需要具备以下基础知识。本指南并不要求读者具备云基础设施方面的专业技能,但整个内容都是建立在对Flutter和Firebase的了解之上的。
熟练掌握Flutter和Dart。您应该能够轻松编写多文件结构的Dart应用程序,熟练使用async/await以及Future关键字,了解Dart的空值安全机制,并能通过pub命令管理包文件。由于示例代码中会涉及从Flutter客户端调用函数,因此建议您具备构建Flutter应用程序的经验;如果您已经将某个Flutter应用发布到了应用商店,那么您就已经符合要求了。
了解Firebase的基本知识。您应该曾经使用过Firebase:在Firebase控制台中创建过项目,通过FlutterFire CLI将其与Flutter应用程序关联起来,并且至少使用过Firestore或Authentication等一项Firebase服务。虽然不需要具备云函数方面的经验,但熟悉无服务器函数的概念会有所帮助。
熟练操作命令行。整个Dart云函数的开发流程都是在命令行中完成的。您需要能够熟练运行命令、查看命令行输出,并通过命令行来导航文件系统。计费方案的选择:要将任何类型的 Cloud Functions 部署到生产环境中,您的 Firebase 项目必须使用 Blaze(按使用量付费)计费方案。Firebase Local Emulator Suite 允许您在无需拥有计费账户的情况下开发和测试函数,因此您可以免费在本地按照本指南中的大部分步骤进行操作。但请注意,实际部署功能时仍然需要使用 Blaze 计费方案。需要准备的工具。在开始之前,请确保以下工具已经安装完毕,并且可以从终端中正常使用:
-
Flutter SDK 3.x或更高版本(其中包含Dart SDK 3.x)
-
Firebase CLI 15.15.0或更高版本(运行
firebase --version进行检查;如需更新,请使用npm install -g firebase-tools命令) -
Node.js 18或更高版本(Firebase CLI需要此版本,但你的Dart代码并不一定需要)
-
安装了Dart插件的代码编辑器(例如带有Dart扩展的VS Code或Android Studio)
-
在Firebase控制台中创建的一个Firebase项目
本指南中使用的依赖包。你的函数目录中的pubspec.yaml文件会包含以下内容:
dependencies:
firebase_functions: ^0.1.0
google_cloud_firestore: ^0.1.0
firebase-functions是核心的Dart依赖包,它提供了fireUp函数、用于注册onRequest和onCall>事件的API,以及你的函数代码中使用的各种数据类型。google_cloud_firestore则是专门在服务器端使用的Dart Firestore SDK,在Cloud Functions环境中使用。这个包与你在Flutter应用中使用的cloudFirestore包不同:虽然它们都用于操作Firestore数据库,但前者是专为运行在Firebase安全规则下的客户端环境设计的,而后者则适用于具有完全管理权限的服务器端进程。
你的共享依赖包(后面会有详细介绍)不会包含任何与Firebase相关的依赖项。你的Flutter应用的pubspec.yaml文件仍然会继续使用标准的firebase_core、cloud_firestore以及其他FlutterFire相关依赖包。
关于这一功能的实验性状态的重要说明。本指南中的所有内容都是基于2026年Google Cloud Next大会上宣布的Dart实验性支持功能来编写的。由于这些功能仍处于实验阶段,因此API可能会随时发生变化;某些在Node.js函数中可用的功能在Dart中还无法使用;同时,Firebase控制台目前也无法直接显示Dart函数的信息,你需要通过Google Cloud Console中的Cloud Run功能页面来查看和管理这些函数。这确实是一个全新的技术领域,开发团队正在积极对其进行优化和完善。本指南会在遇到任何限制时明确指出,以便你能够清楚地了解各项功能的适用范围和限制条件。
什么是Cloud Functions?为什么Dart会改变一切?
Cloud Functions是什么
Firebase的Cloud Functions是一个无服务器计算平台。“无服务器”意味着你只需要编写函数并部署它,其余的一切都由Google来负责处理:包括服务器的配置、扩展性管理、负载均衡、操作系统更新以及服务的可用性保障。你只需为函数实际使用的计算时间付费,这些时间以秒的极小部分来计算;而且你的函数可以在从零请求到数百万请求的各种负载情况下自动进行扩展,而你完全不需要进行任何基础设施配置。
其价值主张非常明确。在没有Cloud Functions的情况下,为Flutter应用程序添加后端逻辑意味着要么自己运行服务器(成本高昂且管理复杂),要么将业务逻辑嵌入客户端代码中(这种方式不安全,而且在不更新客户端代码的情况下很难进行修改)。而Cloud Functions能够为你提供一个轻量级、安全且可扩展的后端层,你可以独立于应用程序本身来更新这一层,同时它还能以客户端根本无法拥有的更高权限与所有的Firebase服务进行交互。
在Dart获得支持之前,编写Cloud Functions时可选的语言包括JavaScript、TypeScript、Python、Java、Go和Ruby。对于Flutter开发者来说,这些语言意味着需要切换开发环境,学习新的语言生态系统及工具,并且还需要在客户端和服务器之间重复编写相同的逻辑代码。现在Dart也成为了这些选项之一,而由于你的Flutter应用程序本来就是用Dart编写的,因此这一变化所带来的影响十分深远。
统一开发栈:真正发生了哪些变化
最显而易见的变化在于编程语言。你现在需要编写`.dart`文件,而不是`.ts`或`.py`文件。但更深层次的变化在于共享代码这一概念。
在TypeScript与Flutter结合的架构中,你的`User`模型会存在两个版本:一个版本用TypeScript编写在服务器端,用于定义Firestore文档的结构以及函数返回的数据格式;另一个版本用Dart编写在客户端,用于指定Flutter应用程序如何解析和显示用户数据。当某个字段的值发生变化时,你需要同时更新这两个版本的代码。如果开发者忘记了进行这样的同步更新,就会产生错误。而在开发阶段,这种错误往往难以被发现,因为服务器端和客户端通常是分开开发和测试的;只有在进行集成测试或正式上线后,这些问题才会暴露出来。
在纯Dart的全栈架构中,你的`User`模型只存在一个版本,这个版本被保存在一个共享的Dart包中,无论是服务器端的函数还是Flutter应用程序都会引用这个包。只要在这个共享包中修改某个字段的值,两端都会立即反映出这一变更。Dart编译器会确保双方都正确使用了这一类型信息。如果需要重命名某个字段,只需进行一次代码重构操作,集成开发环境会自动在整个代码库中完成相应的更改,然后编译器会验证修改后的结果是否正确。

这张示意图清晰地展示了两者之间的核心架构差异。在左侧的架构中,两端分别独立定义`User`模型,因此其中一端的修改不会自动触发另一端的更新;而在右侧的架构中,双方都引用同一个共享包中的`User`模型。由于这个模型只存在一个版本,Dart编译器会同时检查两处代码中对这一模型的使用情况,从而从结构上确保了数据的一致性,而不仅仅是通过一些额外的措施来避免出现差异。
为什么Dart特别适合无服务器架构
Dart是一种提前编译的语言,这意味着它在运行之前会被编译成本机二进制代码,而不是在运行时被解释执行。这一特性直接影响了无服务器函数所面临的一个最受关注的问题:冷启动问题。
当你的函数处于闲置状态,然后有新的请求到来时,就会发生冷启动现象。此时平台需要启动一个新的实例,如果该过程需要加载庞大的运行时环境(比如Node.js)或虚拟机(比如Java),那么在一段时间的闲置之后,第一个请求的处理时间可能会长达数秒。相比之下,Dart函数会被编译成本机二进制文件,因此不存在运行时开销。因此,Dart函数的冷启动时间明显短于相应的Node.js或Python函数,这使得它更适合那些对首次请求的响应速度要求很高的场景。
部署过程也体现了这一架构特点。当你部署一个Dart函数时,Firebase CLI并不会像部署Node.js应用那样,将你的源代码上传到云端进行编译。它会先在开发机器上将Dart代码编译成本机二进制文件,然后再将其直接上传到Cloud Run平台。这意味着你的开发机器需要安装Dart SDK才能完成编译工作(如果你正在使用Flutter进行开发,那么你的机器已经安装了相应的SDK),而且生产环境中运行的二进制文件与你在本地测试时使用的完全相同。
这一特性解决的问题:Dart出现之前的服务器端开发困境
数据契约问题
即使不考虑语言切换的问题,Flutter客户端与TypeScript后端之间的数据契约也需要人工维护。客户端与服务器之间的每一次API调用,都涉及到双方需要达成一致的数据结构。在实际开发中,通常会出现以下几种情况之一:要么数据契约被记录在README文件中,但这类文档很快就会过时;要么通过共享的OpenAPI或protobuf规范来约束数据契约,但这会增加工具使用的复杂性;要么数据契约没有得到明确的定义,导致错误在集成测试阶段被发现,甚至更糟糕的是,在生产环境中才会暴露出来。
Dart的类型系统在调用双方都得到应用,这一机制从结构上就消除了这一问题。契约本身就是Dart类型的规范,而Dart编译器会同时在调用双方执行这些规范。因此,既不需要维护任何README文件,也不需要生成任何数据结构。
工具链的差异
使用Dart进行开发的Flutter开发者能够享受到丰富且集成度高的开发体验:强大的静态分析工具、热重载功能、优秀的IDE插件、《dart fix》这类自动代码修复工具,以及pub.dev上的包管理系统,这些都能满足他们最常见的开发需求。然而,当这些开发者将后端开发从Dart切换到TypeScript时,他们就不得不放弃熟悉的工具环境,转而使用一套需要单独配置、使用不同的格式化工具、依赖管理工具的新的开发体系。这种认知负担是真实存在的,对于那些需要同时承担多种开发职责的团队来说,这更是导致开发效率下降的根源。
在服务器端使用Dart时,同样的`dart analyze`、`dart format`和`dart pub`命令既可以在Flutter应用程序上使用,也可以在Cloud Functions代码上应用。相同的IDE扩展插件同样适用,开发者所掌握的知识也能直接用于这两种环境。
Dart Cloud Functions的工作原理:核心架构
入口点与启动流程
每一个Dart Cloud Function都是从同一个入口点文件开始的,按照惯例,这个文件的路径是`functions/bin/server.dart`。`main`函数会调用`fireUp`函数,而`fireUp`函数正是`firebase_functions`包提供的初始化逻辑。`fireUp`函数会设置HTTP服务器,用于接收传入的请求并将它们路由到相应的处理函数;同时它会使用Google应用默认凭据自动初始化Firebase Admin SDK,并在正确的端口上开始监听请求。
// functions/bin/server.dart
import 'package:firebase_functions/firebase_functions.dart';
void main(List args) async {
await fireUp(args, (firebase) {
firebase.https.onRequest(
name: 'helloWorld',
options: const HttpsOptions(cors: Cors(['*'])),
(request) async {
return Response.ok('Hello from Dart Cloud Functions!');
},
);
});
}
`fireUp`函数是`firebase_functions`包提供的运行时初始化逻辑。它的第一个参数`args`包含了Cloud Functions环境在启动应用程序时传递的一系列命令行参数,这些参数包括服务器监听的端口以及其他运行时配置信息。`fireUp`会解析这些参数,并利用它们来配置底层的Shelf HTTP服务器。第二个参数是一个回调函数,这个函数会接收一个`firebase`对象,通过这个对象就可以访问Cloud Functions运行时提供的所有功能。在回调函数内部,开发者可以注册自己需要使用的所有函数。`firebase.https`提供了两种注册函数的方法:`onRequest`用于处理普通的HTTP请求,`onCall`则用于处理可调用对象。`name`参数用于为函数指定标识符,这个标识符会出现在Cloud Run的日志中,并用于路由请求。`HttpsOptions`中的`cors: Cors(['*'])`配置允许来自任何域名的跨源请求,在开发阶段这种设置是合适的,但在生产环境中应该限制为特定的域名。`Response.ok(...)`则用于返回一个HTTP 200响应,其中包含指定的响应内容。
使用 onRequest 的 HTTP 函数
HTTP 函数用于响应原始的 HTTP 请求。这种函数类型具有最高的灵活性,因为你可以完全控制请求和响应过程:你可以检查请求头信息、解析任何形式的请求体内容,并返回任意 HTTP 响应码及响应数据。
firebase.https.onRequest(
name: 'getUserProfile',
options: const HttpsOptions(
cors: Cors(['https://yourapp.com', 'https://staging.yourapp.com'],
minInstances: 0,
),
(request) async {
if (request.method != 'GET') {
return Response(405, body: '不允许使用此方法');
}
final userId = request.url.queryParameters['userId'];
if (userId == null || userId.isEmpty) {
return Response(400, body: '需要提供 userId 参数');
}
try {
final doc = await firebase.adminApp
.firestore()
.collection('users')
.doc(userId)
.get();
if (!doc.exists) {
return Response(404, body: '未找到该用户');
}
return Response.ok(
jsonEncode(doc.data()),
headers: {'content-type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(body: '获取用户信息失败');
}
},
);
cors: Cors([...])用于明确指定哪些域名可以从浏览器中调用这个函数。在生产环境中,将允许调用的域名限制为你的应用程序域名,这样可以防止其他网站代表你的用户向你的后端发送请求。minInstances: 0表示不会保留任何实例处于活跃状态,因此该函数在一段时间没有收到请求时可能会遇到冷启动问题。将这个值设置为 1 或更高,则会始终保持一些实例处于活跃状态,从而避免冷启动现象,但这样也会产生相应的成本,即使此时没有任何请求正在被处理。request.method用于判断传入请求的 HTTP 方法类型,在这里检查是为了确保该端点只接受 GET 请求。request.url.queryParameters会以 Map 的形式返回解析后的查询字符串。Response(405, ...)用于构建状态码为 405 的 HTTP 响应。Response.ok(...)是用于生成状态码为 200 的响应的便捷构造函数。headers: {'content-type': 'application/json'}告诉调用者响应数据是以 JSON 格式发送的,这对于那些使用内容协商机制的客户端来说非常重要。Response.internalServerError(...)用于返回状态码为 500 的错误响应,在异常处理代码块中使用这个函数可以避免向调用者暴露内部错误细节。
使用 onCall 的可调用函数
可调用函数是一种专为通过 Firebase 客户端 SDK 直接调用而设计的特殊类型的 HTTP 函数。与普通的 HTTP 函数不同,可调用函数会自动处理 Firebase 认证相关的数据:如果调用该函数的客户端已经登录成功,那么函数会直接获取用户的 UID 和令牌信息,而无需你手动解析请求头中的 Authorization 字段。
firebase.https.onCall(
name: 'createPost',
options: const CallableOptions(
cors: Cors(['*')),
),
(request, response) async {
if (request.auth == null) {
throw FirebaseFunctionsException(
code: 'unauthenticated',
message: '您必须登录后才能创建帖子。'
);
}
final uid = request.auth!.uid;
final data = request.data as Map;
final title = data['title'] as String;
final content = data['content'] as String;
if (title == null || title.trim().isEmpty) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: '帖子标题是必填项。'
);
}
if (content == null || content.trim().isEmpty) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: '帖子内容是必填项。'
);
}
final postRef = await firebase.adminApp
.firestore()
.collection('posts')
.add({
'title': title.trim(),
'content': content.trim(),
'authorId': uid,
'createdAt': FieldValue.serverTimestamp(),
});
return CallableResult({'postId': postRef.id, 'success': true});
},
);
request.auth 这个属性会在调用方在请求中包含有效的 Firebase Authentication ID 令牌时,由 Firebase Functions 运行时自动设置。如果调用方未进行身份验证,request.auth 的值将为 null。因此,检查该属性是否为 null 并抛出 FirebaseFunctionsException 异常(异常代码为 'unauthenticated')是拒绝未经授权的调用者的正确处理方式。Firebase FunctionsException 在这里非常重要,因为当在可调用函数中抛出这种异常时,Firebase Functions 运行时会捕获它,并返回一个结构化的错误响应,客户端 SDK 可以将其解析为 Flutter 侧可以识别的 FirebaseFunctionsException 对象。这样一来,就可以在不需要解析原始 HTTP 错误响应的情况下,获得机器可读的错误代码。request.auth!.uid 表示已登录用户的经过验证的 Firebase Authentication UID,由于其值已经由运行时进行了验证,因此可以安全地用于授权判断。request.data 是 Flutter 客户端发送的数据,这些数据会从请求体中反序列化为 Map 类型。CallableResult(...) 会将返回值包装成可调用协议所期望的格式,Flutter 客户端会接收到这个格式化的结果,具体表现为 HttpsCallableResult.data。
当前的局限性:您需要了解的信息
这是手册中最重要的部分之一,在做出架构决策之前,必须仔细阅读这一章节。
只有 onRequest 和 onCall 可以被部署。背景触发器(如 Firestore 文档触发器、Authentication 触发器、Pub/Sub 触发器、Cloud Storage 触发器以及定时执行的函数)可以在本地模拟器中用于开发目的,但在当前这个实验性版本中,它们还不能被部署到生产环境中。如果您的架构依赖于在文档创建时触发的 Firestore 触发器,那么目前您需要将这种触发器放在 Node.js 函数中实现,而只用 Dart 语言来编写那些不需要背景触发器的业务逻辑部分。
httpsCallable 无法通过名称来调用Dart函数。 标准的Firebase客户端SDK方法FirebaseFunctions.instance.httpsCallable('functionName')是通过服务器上的函数名称来识别这些函数的。但在当前版本中,这种识别机制并不适用于Dart函数。因此,你必须使用httpsCallableFromURL,并传入你的函数在Cloud Run中的完整URL地址——这个地址是在你部署函数时获得的。这一差异会直接影响你如何配置Flutter客户端。
Firebase控制台不显示Dart函数。 当你部署了一个Dart函数后,打开Firebase控制台的“函数”页面,你是看不到该函数的。你需要前往Google Cloud控制台的Cloud Run功能页面,才能查看、管理和监控这些已部署的Dart函数。这是一个工具上的缺陷,不过随着这一功能从实验阶段正式投入使用,这一问题很快就会得到解决。

在规划应用程序架构时,这个表格是极其重要的参考依据。对于那些依赖“无”这类触发条件的函数,你在决定使用Dart语言进行开发之前,一定要先阅读“已部署到生产环境”这一列的内容。在部署过程中才发现某些限制,然后才去设计相应的解决方案,这种做法带来的麻烦远比事先了解这些限制后再进行设计要大得多。
Dart版本的Firebase Admin SDK
Admin SDK的作用是什么
Firebase Admin SDK是一组服务器端库,它们能让你的函数代码以更高的权限与Firebase服务进行交互。而你的Flutter应用程序所使用的客户端SDK则是遵循Firebase安全规则的:用户只能读取被授权访问的文档,只能修改被允许修改的字段等等。但Admin SDK完全绕过了这些安全规则,它能够让你以管理员权限全面操作你的Firebase项目。
正因为如此,Admin SDK代码绝对不能在客户端环境中运行。它只能在安全的服务器环境(如Cloud Functions、Cloud Run或你自己搭建的服务器)中运行,在这些环境中,授予管理员权限的凭证是受到保护的。在Cloud Functions中,Admin SDK会自动使用函数的服务账户进行初始化,你完全不需要进行任何额外的配置。
在Cloud Functions中的自动初始化
当你的Dart函数在Cloud Functions环境中运行时,Admin SDK会自动使用Google应用默认凭证进行初始化。这些凭证其实就是该函数所关联的服务账户,而这个服务账户拥有对你Firebase项目的管理员权限。你不需要配置任何凭证信息,也不需要加载服务账户的JSON文件或调用任何初始化函数,一切都会自动完成。
await fireUp(args, (firebase) {
firebase.https.onRequest(
name: 'adminExample',
(request) async {
final sensitiveDoc = await firebase.adminApp
.firestore()
.collection('admin_only')
.doc('config')
.get();
return Response.ok(jsonEncode(sensitiveDoc.data()));
},
);
});
firebase.adminApp 是预先初始化好的 Admin SDK 实例。在 fireUp 回调函数中可以立即使用它,因为 fireUp 会在使用 Cloud Run 为函数分配的执行环境之前完成初始化工作,而这个过程会利用服务账户来处理相关操作。firebase.adminApp.firestore() 返回一个拥有完全管理权限的 Firestore 实例,这样的实例可以绕过数据库中的所有安全规则。collection('admin_only').doc('config').get() 从某个只有 Admin SDK 用户才能访问的集合中读取文档,因为普通客户端 SDK 用户会被相关安全规则阻止访问。而 Admin SDK 则没有这样的限制。这就是服务器端代码的力量所在,也是它所承担的责任:服务器端代码可以读取或写入任何数据,因此它绝对不能在客户端环境中运行。
使用 Admin SDK 进行 Firestore 操作
Dart 的 Admin SDK 提供了一套涵盖读、写、更新、删除、查询以及批量操作功能的 Firestore API。这套 API 在结构上与客户端的 cloud_firestore Flutter 包类似,因此使用者会很快熟悉它的使用方法,不过两者并不完全相同。
// 读取单篇文档
final docRef = firebase.adminApp
.firestore()
.collection('posts')
.doc(postId);
final snapshot = await docRef.get();
if (!snapshot.exists) {
return Response(404, body: '未找到该帖子');
}
final data = snapshot.data()!;
final title = data['title'] as String;
final authorId = data['authorId'] as String;
firebase.adminApp.firestore().collection('posts').doc(postId) 会生成一个指向特定文档的引用,而这个过程不会触发任何网络请求。这个引用实际上是一个用于描述 Firestore 中数据路径的轻量级对象。.get() 方法才会发起实际的网络请求,该方法会返回一个 DocumentSnapshot 对象,而 .exists 属性可以用来判断是否存在具有该 ID 的文档。snapshot.data() 会返回文档中的所有字段数据,这些数据以 Map 的形式返回;如果文档不存在,这个返回值将会是 null。在 data() 后加上 ! 是一种空值判断机制,在这里使用它是安全的,因为我们在前面已经通过 .exists 方法检查过了文档是否存在。data['title'] as String 这行代码会将字段值转换为字符串类型。
// 使用服务器生成的 ID 创建新文档
final newPostRef = await firebase.adminApp
.firestore()
.collection('posts')
.add({
'title': '我的帖子',
'authorId': uid,
'createdAt': FieldValue.serverTimestamp(),
});
final newPostId = newPostRef.id;
.add({...}) 会在集合中创建一个新的文档,并让 Firestore 为该文档生成一个随机且唯一的 ID。这个操作会返回一个指向新创建文档的 DocumentReference。通过 newPostRef.id 可以获取到这个生成的 ID,通常你会将这个 ID 返回给客户端,以便客户端能够导航到或引用这个新文档。FieldValue.serverTimestamp() 是一个特殊的值,它会让 Firestore 在写入操作被提交时,用服务器当前的 timestamp 替换该字段中的值,而不是使用客户端或你的函数代码中设置的时钟时间。这样就能确保无论系统时钟存在什么差异,时间戳始终是准确的。
// 更新现有文档中的特定字段
await firebase.adminApp
.firestore()
.collection('posts')
.doc(postId)
.update({
'likeCount': FieldValue.increment(1),
'lastModified': FieldValue.serverTimestamp(),
});
.update({"..."}) 只会修改你指定的那些字段,而不会改变文档中其他任何字段的值。当你只需要更改文档中的一部分字段时,使用这个方法才是正确的选择。.set({"..."}) 则会用你提供的字段值来替换整个文档,同时会删除那些没有被包含在更新参数中的字段。FieldValue.increment(1) 是 Firestore 提供的另一个功能:它可以原子性地将数值型字段的值增加指定的数量。由于 Firestore 会在服务器端进行这种操作,因此即使有多个并发写入请求,这个功能也能保证数据的一致性,从而避免出现竞争条件。
// 使用过滤器进行查询并排序结果
final querySnapshot = await firebase.adminApp
.firestore()
.collection('posts')
.where('authorId', isEqualTo: uid)
.orderBy('createdAt', descending: true)
.limit(10)
.get();
final posts = querySnapshotdocs.map((doc) {
return {'id': doc.id, ...doc.data'};
}).toList();
.where('authorId', isEqualTo: uid) 会过滤查询结果,只返回那些 authorId 字段与给定的 uid 相匹配的文档。可以通过连续调用多个 .where() 方法来添加更多的过滤条件。.orderBy('createdAt', descending: true) 会按照 createdAt 字段的值对查询结果进行排序,最新的记录会排在最前面。当使用 orderBy 方法时,Firestore 要求该字段必须已经被索引,对于简单的查询来说,它会自动处理索引创建的工作。.limit(10) 会将查询结果集的大小限制为 10 条记录,从而避免进行无限制的数据读取。querySnapshotdocs 是所有符合查询条件的 DocumentSnapshot 对象组成的列表。通过将每个文档对象转换为 {'id': doc.id, ...doc.data()} 的形式,我们可以将自动生成的文档 ID 与文档中的字段数据合并在一起,形成一个新的映射结构。
// 批量写入:多个操作会以原子方式一起被执行
final batch = firebase.adminAppFirestore().batch();
batch.set(
firebase.adminApp.firestore().collection('posts').doc(newPostId),
{'title': '新文章', 'authorId': uid},
);
batch.update(
firebase.adminApp.firestore().collection('users').doc(uid),
{'postCount': FieldValue.increment(1)},
);
await batch.commit();
firestore().batch()会创建一个WriteBatch对象,该对象会累积多个写操作,然后将这些操作一起发送到Firestore中。batch.set(...)和batch.update(...)这些方法会将相应的操作放入队列中,而不会立即执行它们。batch.commit()则会将队列中的所有操作一起发送到Firestore,并确保这些操作被原子性地执行;如果其中任何操作失败,那么所有的操作都会被回滚。每当你的业务逻辑要求多个文档作为一个整体进行同步修改时,这种处理方式就是正确的。例如,在创建一篇帖子的同时,还需要增加作者的发文数量。如果没有使用批处理机制,如果这两个操作中的任何一个发生故障,就会导致数据库中的数据出现不一致的情况。
使用Admin SDK进行身份验证操作
Admin SDK使你的函数具备验证ID令牌、根据UID或电子邮件查找用户、创建或删除用户以及在用户令牌上设置自定义属性的功能。这些操作需要管理员权限,而普通客户端SDK是无法执行这些操作的。
firebase.https.onRequest(
name: 'securedEndpoint',
(request) async {
final authHeader = request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
return Response(401, body: '未经授权');
}
final idToken = authHeader.substring(7);
try {
final decodedToken = await firebase.adminApp
.auth()
.verifyIdToken(idToken);
final uid = decodedToken.uid;
return Response.ok(jsonEncode({'uid': uid, 'success': true]));
} on FirebaseAuthException catch (e) {
return Response(401, body: '令牌无效或已过期:${e.message}`);
}
},
);
request.headers['authorization']这一代码用于读取传入的HTTP请求中的Authorization头部信息。Firebase Authentication生成的ID令牌是以“Bearer”开头的字符串形式发送的,因此.startsWith('Bearer ')这个方法用于验证令牌的格式;.substring(7)则用于去除“Bearer”前缀,从而得到原始的令牌字符串。firebase.adminApp.auth().verifyIdToken(idToken)会将这个令牌发送到Firebase的令牌验证服务,该服务会检查令牌的签名是否有效、令牌是否已经过期,以及确认该令牌确实是你的Firebase项目发行的。如果验证成功,就会返回一个DecodedIdToken对象,其中包含用户的UID以及任何自定义属性;如果令牌无效或已过期,就会抛出FirebaseAuthException异常,你可以捕获这个异常并返回401错误响应。这种处理方式特别适用于那些需要知道调用者身份的onRequest函数;而对于onCall函数来说,整个流程都是由运行时自动处理的,这也是使用callable函数而非原始HTTP函数的主要优势之一。
await firebase.adminApp
.auth()
.setCustomUserClaims(uid, {'role': 'admin', 'premiumUser': true});
setCustomUserClaims.uid, {...}) 这个方法可以将任意的键值对数据添加到用户的 Firebase Authentication 令牌中。这些数据会包含在用户后续获得的所有 ID 令牌中,因此你可以在 Admin SDK 代码中通过 decodedToken.claims 获取这些数据,在 Firestore 安全规则中也可以使用 request.auth.token.role 来访问这些信息。自定义声明是在 Firebase 应用程序中实现基于角色的访问控制的标准方法。这些声明会在用户的令牌下次更新时生效,而令牌会自动每小时更新一次;你也可以通过在客户端调用 user.getIdToken(true) 来强制刷新令牌。
逐步设置 Dart Cloud Functions
步骤 1:启用实验性功能
由于 Dart 对 Firebase 的支持目前还处于实验阶段,因此需要在 Firebase CLI 中通过一个开关来启用这一功能。在开始设置之前,你必须先启用这个开关。
firebase experiments:enable dartfunctions
这条命令会在你的本地 Firebase CLI 配置文件中添加相应的开关。这是一个只需执行一次的设置步骤,其效果会在同一台机器上的不同项目和终端中持续生效。
firebase experiments
运行这条命令后,会列出当前所有已启用的实验性功能,这样你就可以确认 dartfunctions 是否出现在列表中再继续下一步操作。如果这个选项没有出现,那么在下一步中执行 firebase init functions 命令时,Dart 将不会作为可选语言出现——这是初次设置过程中最常见的问题之一。
步骤 2:检查你的 CLI 版本
Dart Cloud Functions 要求使用 Firebase CLI 15.15.0 或更高版本。
firebase --version
这条命令会显示当前安装的 Firebase CLI 版本。如果版本号低于 15.15.0,那么在继续之前请先运行更新命令。
npm install -g firebase-tools
这条命令会将 Firebase CLI 更新到你机器上的最新版本。其中 -g 标志表示会全局安装该工具,这样无论你在哪个目录下,都可以使用 firebase 命令。
firebase login
在更新 CLI 后重新登录,可以确保你的认证信息是最新的,并且与正确的 Google 账户关联。如果你最近已经登录过并且确信自己的认证信息是有效的,就可以跳过这一步。
步骤 3:使用 Dart 初始化 Cloud Functions
firebase init functions
当命令行界面询问你选择哪种编程语言时,请选择Dart。当系统询问是否现在就安装相关依赖项时,也请选择是。命令行界面会生成如下结构:

functions/bin/server.dart是程序的入口点。Firebase命令行界面知道应该在这里查找代码,因为firebase.json文件中已经配置好了指向这个文件的路径。functions/lib/文件夹用于存放那些被server.dart文件导入的Dart脚本文件。随着函数数量的增加,将这些文件放在这个文件夹中可以帮助保持函数逻辑的整洁。functions/pubspec.yaml文件是专门用于描述这些函数的包配置信息,它与Flutter应用程序中的pubspec.yaml文件是分开的。firebase.json文件会由命令行界面自动更新,以便其中包含函数的配置信息,比如编译后的二进制文件的路径以及运行时设置等。
生成的server.dart文件中包含了一个可以立即运行的“Hello World”示例函数,你可以通过运行这个函数来验证整个设置是否正确:
import 'package:firebase_functions/firebase_functions.dart';
void main(List args) async {
await fireUp(args, (firebase) {
firebase.https.onRequest(
name: 'helloWorld',
options: const HttpsOptions(cors: Cors(['*'])),
(request) async {
return Response.ok('Hello from Dart Cloud Functions!');
},
);
});
}
这是一个简单但功能完备的Dart Cloud Function示例。main函数会接收命令行参数args,这些参数是由Cloud Functions运行时在启动程序时传递给它的,然后main函数会将这些参数传给fireUp函数,后者会从中读取端口配置信息。onRequest方法为这个函数指定了一个名称,并且当有HTTP请求到来时,该函数会返回一个状态码为200的响应,并且响应内容为纯文本。在本地运行这个示例程序,可以验证模拟器是否能够正确编译并执行你的函数,这样在你投入更多时间去开发更复杂的逻辑之前,就可以先进行这样的测试。
步骤4:运行本地模拟器
firebase emulators:start
模拟器启动后,会输出类似以下的提示信息:

命令firebase emulators:start会启动你在firebase.json文件中配置的所有模拟器。Dart模拟器会在启动服务器之前先在本地编译你的函数代码,因此你会看到在构建过程结束后会出现“Dart模拟器已准备好”的提示信息。默认情况下,函数模拟器会运行在5001端口上,而Firestore模拟器则运行在8080端口上。当在模拟器环境中运行你的函数时,你的函数代码会自动连接到模拟版的Firestore数据库,而不是生产环境中的数据库。你可以通过http://127.0.0.1:5001/your-project-id/us-central1/helloWorld这个地址来调用你的helloWorld函数。Dart模拟器的一个显著优点是支持热重载:当你对.dart文件进行修改并保存后,模拟器会自动检测到这些变化,并重新编译你的函数代码,然后重新启动它,而你无需手动执行任何命令。
步骤5:将您的Flutter应用连接到模拟器
import 'package:cloud_functions/cloud_functions.dart';
void _connectToEmulators() {
FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
}
useFunctionsEmulator('localhost', 5001)这一指令会告诉Flutter应用中的Firebase Functions客户端,将所有函数调用发送到本地模拟器上的5001端口,而不是发送到生产环境。请在应用程序中执行任何函数调用之前调用此方法,通常是在Firebase.initialize()之后立即在main()函数中调用它。这种方法仅影响函数调用,而不影响Firestore或Authentication功能;如果也需要对这两个服务进行模拟测试,它们也有各自对应的设置方法。
if (Platform.isAndroid) {
FirebaseFunctions.instance.useFunctionsEmulator('10.0.2.2', 5001);
} else {
FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
}
Android模拟器是在一个拥有自己网络命名空间的虚拟机中运行的。从Android模拟器的角度来看,localhost指的是模拟器本身,而不是您的开发机器。特殊的地址10.0.2.2是Android模拟器用来访问宿主机器上的localhost的。iOS模拟器不存在这个问题,因为它们共享宿主机器的网络,所以localhost在这些模拟器上可以正常使用。Platform.isAndroid这一判断会在运行时选择正确的地址,从而确保相同的代码在开发过程中能够在两种平台上都能正确运行。
步骤6:部署到生产环境
firebase deploy --only functions
--only functions这个选项告诉命令行工具只部署函数部分,而忽略其他Firebase资源(如Firestore规则、托管服务等)。Dart语言的部署过程与Node.js有很大的不同:Firebase CLI会在您的开发机器上运行dart compile exe命令来生成一个本机二进制文件,然后将其上传到Cloud Run平台。部署完成后,系统会提供已部署函数的URL:
✔ functions: 预发布脚本执行完成。
✔ functions: helloWorld(us-central1)已成功部署。
函数URL (helloWorld(us-central1)):
https://helloworld-abc123def456-uc.a.run.app
请保存这个URL。由于目前httpsCallable的名称解析存在一些限制,因此在从Flutter中调用该函数时,需要直接使用这个URL。URL中的哈希值abc123def456是您的项目和函数所独有的,在同一函数的不同部署版本中这个哈希值也不会发生变化,因此可以直接将其硬编码到Flutter应用中,或者通过Firebase Remote Config来获取这个URL。
从Flutter中调用Dart函数
使用 httpsCallableFromURL 进行调用
由于在当前版本中,httpsCallable('functionName')无法与 Dart 函数一起使用,因此您应该使用 httpsCallableFromURL,并传入完整的 Cloud Run URL:
// lib/services/functions_service.dart
import 'package:cloud_functions/cloud_functions.dart';
class FunctionsService {
static const _createPostUrl =
'https://createpost-abc123def456-uc.a.run.app';
static const _getUserProfileUrl =
'https://getuserprofile-abc123def456-uc.a.run.app';
Future createPost({
required String title,
required String content,
}) async {
try {
final callable = FirebaseFunctions.instance.httpsCallableFromURL(
_createPostUrl,
);
final result = await callable.call({
'title': title,
'content': content,
});
return result.data['postId'] as String;
} on Firebase FunctionsException catch (e) {
throw _mapFunctionException(e);
}
}
Exception _mapFunctionException(FirebaseFunctionsException e) {
switch (e.code) {
case 'unauthenticated':
return UnauthorizedException('请登录后继续。');
case 'invalid-argument':
return ValidationException(e.message ?? '输入无效。');
case 'not-found':
returnNotFoundException(e.message ?? '资源未找到。');
default:
return ServerException(
e.message ?? '发生了意外错误。'
);
}
}
}
将函数 URL 作为 static const 字符串放在服务类的顶部,这样这些 URL 就会集中在一个地方,便于查找和更新。在规模较大的应用中,可以考虑从 Firebase Remote Config 中加载这些 URL,这样就可以在不发布新版本应用程序的情况下更新它们。FirebaseFunctions.instance.httpsCallableFromURL(_createPostUrl) 会创建一个针对指定 URL 的 HttpsCallable 对象。这个对象负责处理与函数调用相关的所有协议细节,包括将数据序列化为请求体以及反序列化响应结果。callable.call({...}) 会执行函数调用,将参数作为请求负载发送到服务器,并在函数执行完成后返回一个 HttpsCallableResult。result.data 就是服务器返回的 Map 类型的数据。FirebaseFunctionsException 可以捕获服务器端抛出的所有结构化错误,e.code 表示错误的代码类型,而 _mapFunctionException 会将这个错误代码转换为应用程序自己异常层次结构中的具体异常类型,从而避免在业务逻辑中使用 Firebase 特有的错误类型。
直接调用 HTTP 函数
对于 `onRequest` 这类 HTTP 函数,你可以像使用 Dart 的 `http` 包来调用其他 HTTP 端点一样来使用它们:
import 'package:http/http.dart' as http;
import 'dart:convert';
class ProfileService {
static const _getUserProfileUrl =
'https://getuserprofile-abc123def456-uc.a.run.app';
Future
FirebaseAuth.instance.currentUser 会从本地的 Firebase Auth 缓存中获取当前已登录的用户信息,而无需进行网络请求。user?.getIdToken() 用于获取用户的当前 ID 令牌;如果该令牌已经过期,系统会自动重新生成它。这里的 ? 表示:如果没有已登录的用户,这个方法会返回 null,而条件性的 header 插入机制能够很好地处理这种情况。if (idToken != null) 'Authorization': 'Bearer \(idToken' 是 Dart 中的 if 语法,只有当 ID 令牌存在时,才会添加这个 Authorization header。这样一来,同一个服务方法既可以用于已认证的用户请求,也可以用于匿名请求;当没有 ID 令牌时,只需省略这个 header 即可。Uri.parse('\)_getUserProfileUrl?userId=$userId') 会将查询参数附加到 URL 中。jsonDecode(response.body) as Map 会将 JSON 格式的响应内容解析成 Dart 的映射对象。如果响应状态码不是 200,就会抛出 ServerException,并且会附带状态码信息以便于调试。
共享包:消除数据模型重复问题
共享包是 Dart 全栈开发中架构上最为重要的一部分。它是一个独立的 Dart 包,既不依赖于 Flutter,也不依赖于 Firebase,它定义了你的 Cloud Functions 后端和 Flutter 前端所共同使用的数据模型、验证逻辑、常量以及实用函数。
创建共享包
dart create --template=package packages/shared
dart create --template=package 命令会生成一个具有标准库结构的新的 Dart 包,其中包含 lib/ 目录用于存放公共代码,test/ 目录用于存放测试代码,以及 pubspec.yaml 文件。指定路径为 packages/shared,意味着这个新包会被放置在项目根目录下的 packages/ 文件夹中——在单仓库结构中,这种放置方式是用于存放内部包的常规位置。执行这条命令后,你的项目结构就会变成如下样子:

这个共享的pubspec.yaml文件被刻意设计得非常简洁:
name: shared
description: 为Kopa应用程序提供的共享数据模型和逻辑代码。
version: 0.1.0
environment:
sdk: ^3.0.0
dependencies:
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.7.0
test: ^1.24.0
这个pubspec.yaml文件最重要的特点就是它没有包含某些特定的依赖项:其中没有flutter、firebase_core、firebase_functions,也没有cloud_firestore。这个共享包仅依赖于纯Dart库,正因为如此,它既可以被服务器端的函数包导入,也可以被Flutter应用程序导入,而不会导致版本冲突。json_annotation提供了用于模型类的@JsonSerializable()注解;json_serializable是一个在构建时运行的代码生成工具,它会读取这些注解并自动生成fromJson/toJson方法,由于它仅在开发阶段运行,而非运行时阶段,因此被归类为开发依赖项。build_runner是用于执行这些代码生成工具的工具,同样属于开发依赖项;而test则用于对这些共享逻辑进行单元测试。
定义共享模型
// packages/shared/lib/src/models/post.dart
import 'package:json_annotation/json.annotation.dart';
part 'post.g.dart';
@JsonSerializable()
class Post {
final String id;
final String title;
final String content;
final String authorId;
final int likeCount;
final DateTime createdAt;
const Post({
required this.id,
required this.title,
required this.content,
required this.authorId,
required this.likeCount,
required this.createdAt,
});
factory Post.fromJson(Map json) => _PostFromJson(json);
Map toJson() => _PostToJson(this);
}
part 'post.g.dart'这一行代码表示,名为post.g.dart的生成文件属于这个库。当你运行dart run build_runner build时,json_serializable代码生成工具会创建这个文件。@JsonSerializable()注解告诉json_serializable》为这个类生成序列化代码。所有字段都被声明为final,因为模型对象应该是不可变的:一旦创建出来,Post>对象的属性就不会再发生变化了。如果需要创建一个新的Post》对象,就需要使用不同的参数值。之所以使用DateTime类型来表示createdAt字段,而不是原始的int类型或String类型,是因为这样可以使模型保持适当的抽象层次。无论是Flutter应用程序还是服务器端函数,在处理这些数据时都会将DateTime类型转换为它们各自使用的具体时间戳格式,因此这个共享模型不会受到任何一方特定处理方式的影响。factory Post.fromJson(...)和toJson()方法会调用生成的_PostFromJson和_PostToJson函数来完成序列化工作,这样就避免了手动编写序列化代码所带来的问题。而大多数与数据结构相关的错误,往往都是由于手动编写序列化代码时出现的疏忽造成的——比如遗漏了某个字段、使用了错误的键名,或者忘记了进行空值检查等等。通过使用代码生成技术,就可以完全避免这类错误的发生。
// packages/shared/lib/src/validation/post_validation.dart
class PostValidation {
static const int titleMaxLength = 120;
static const int contentMaxLength = 10000;
static const int titleMinLength = 3;
static String? validateTitle(String? title) {
if (title == null || title.trim().isEmpty) {
return '标题是必填项。';
}
if (title.trim().length < titleMinLength) {
return '标题长度必须至少为 $titleMinLength 个字符。';
}
if (title.trim().length > titleMaxLength) {
return '标题长度不能超过 $titleMaxLength 个字符。';
}
return null;
}
static String? validateContent(String? content) {
if (content == null || content.trim().isEmpty) {
return '内容是必填项。';
}
if (content.trim().length > contentMaxLength) {
return '内容长度不能超过 $contentMaxLength 个字符。';
}
return null;
}
static bool isValid({required String title, required String content}) {
return validateTitle(title) == null && validateContent(content) == null;
}
}
所有成员都是static类型的,因为PostValidation只是一个用于存放函数的命名空间,并不是一个可以被实例化的类。长度常量titleMaxLength、contentMaxLength和titleMinLength被定义为static const类型,这意味着它们在编译时就已经存在了,在运行时不会占用任何内存,同时既可以在运行时的验证逻辑中使用,也可以用于Flutter组件的配置中(例如,作为TextField组件的maxLength参数)。每个验证函数都遵循Dart语言对于表单验证函数的约定:返回null表示验证通过,返回一个String则表示验证失败,并会附带相应的错误信息。validateTitle方法在检查字符串长度之前会先调用.trim()方法,这样就可以避免那些只包含空白字符的字符串通过长度验证。而isValid这个便捷方法可以让那些只需要一个布尔值结果(而不是错误信息)来判断两个字段是否都符合要求的人,一次性完成这两项检查,例如在决定是否启用提交按钮时使用这个方法。
// packages/shared/lib/srcconstants/api_constants.dart
class ApiConstants {
static const String createPostFunction = 'createPost';
static const String getUserProfileFunction = 'getUserProfile';
static const String likePostFunction = 'likePost';
static const String postsCollection = 'posts';
static const String usersCollection = 'users';
}
ApiConstants类保存了那些在服务器端和客户端都会被引用的函数名称以及Firestore集合名称的字符串标识符。使用常量而不是分散在代码中的字符串字面量,不仅可以避免输入错误,而且当某个名称需要更改时,只需要在一处进行修改,编译器就会自动更新所有使用该名称的地方。在服务器端,函数名称常量被用于firebase.https.onRequest(name: ApiConstants.createPostFunction)这样的代码中;而在客户端,则被用于构建URL或进行日志记录。集合名称常量的存在也确保了服务器和客户端总是从同名的集合中读写数据,从而避免了诸如服务器将集合名称写为大写的“Posts”而客户端却查询小写的“posts”这类错误的发生。
// packages/shared/lib/shared.dart
export 'src/models/post.dart';
export 'src/models/user.dart';
export 'src/validation/post_validation.dart';
export 'srcconstants/api_constants.dart';
这是一个“汇总文件”。它通过一个统一的导入点重新导出了该包所提供的所有内容。使用该包的开发者只需编写import 'package:shared/shared.dart',就可以立即使用Post、PostValidation、ApiConstants以及该包导出的其他所有内容。如果没有这个汇总文件,开发者就需要了解该包的内部目录结构,并分别导入每个文件,而这种细节本应被隐藏起来。
从函数中引用共享包
# functions/pubspec.yaml
name: kopa_functions
version: 0.1.0
environment:
sdk: ^3.0.0
dependencies:
firebase_functions: ^0.1.0
google_cloud_firestore: ^0.1.0
shared:
path: ../packages/shared
shared: path:../packages/shared是一种路径依赖项。它告诉Dart的pub工具,从给定的相对路径在文件系统中查找shared包,而不是从pub.dev网站上获取该包。路径../packages/shared表示从functions/目录向上移动一层到达项目根目录,然后再进入packages/shared/目录。当Firebase CLI编译你的Dart函数以便部署时,它会在你的开发机器上本地解析这个路径依赖项,并将其打包到编译后的二进制文件中,因此即使这是一个本地路径引用,它在生产环境中也能正常工作。
从Flutter项目中引用共享包
# pubspec.yaml (Flutter应用)
dependencies:
flutter:
sdk: flutter
firebase_core: ^3.0.0
cloud_firestore: ^5.0.0
firebase_auth: ^5.0.0
cloud_functions: ^5.0.0
shared:
path: packages/shared
Flutter应用使用path: packages/shared来引用共享包,这个路径是从Flutter项目根目录开始的相对路径。需要注意的是,这里使用的路径是packages/shared,而没有函数包中使用的../前缀。这是因为Flutter的pubspec.yaml文件位于项目根目录,而函数包的pubspec.yaml文件则位于functions/子目录中。尽管路径表示方式不同,但它们实际上引用的是同一个磁盘上的目录。这就是关键所在:两个不同的包,虽然使用了两种不同的pubspec.yaml格式来描述自己的依赖关系,但实际上它们引用的都是相同的源代码。
在云函数中使用共享逻辑
// functions/bin/server.dart
import 'dart:convert';
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloudFirestore.dart' show FieldValue;
import 'package:shared/shared.dart';
void main(List args) async {
await fireUp(args, (firebase) {
firebase.https.onCall(
name: ApiConstants.createPostFunction,
(request, response) async {
if (request.auth == null) {
throw FirebaseFunctionsException(
code: 'unauthenticated',
message: '您必须登录。'
);
}
final data = request.data as Map;
final title = data['title'] as String;
final content = data['content'] as String;
final titleError = PostValidation.validateTitle(title);
if (titleError != null) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: titleError,
);
}
final contentError = PostValidation.validateContent(content);
if (contentError != null) {
throw Firebase FunctionsException(
code: 'invalid-argument',
message: contentError,
);
}
final ref = await firebase.adminApp
.firestore()
.collection(ApiConstants_postsCollection)
.add({
'title': title!.trim(),
'content': content!.trim(),
'authorId': request.auth!.uid,
'likeCount': 0,
'createdAt': FieldValue.serverTimestamp(),
});
return CallableResult({'postId': ref.id});
},
);
});
}
import 'package:shared/shared.dart'; 这一行代码用于导入整个共享包。`ApiConstants.createPostFunction` 使用共享常量作为函数名称,而不是字符串字面值,这样就能确保服务器注册的函数名称与任何日志记录或监控系统所期望的名称完全一致。`PostValidation.validateTitle(title)` 和 `PostValidationvalidateContent(content)` 执行的验证逻辑与Flutter客户端表单中使用的验证逻辑完全相同。即使恶意用户绕过了客户端的验证机制(因为客户端代码并不可信,所以这种情况总是有可能发生的),服务器也会独立地执行相同的规则。`ApiConstants_postsCollection` 是一个共享的集合名称常量,这保证了函数会将数据写入与Flutter应用程序读取数据时所使用的相同路径中。
在Flutter应用程序中使用共享逻辑
// lib/features/create_post/create_post_screen.dart
import 'package:flutter/material.dart';
import 'package:shared/shared.dart';
class CreatePostScreen extends StatefulWidget {
const CreatePostScreen({super.key});
@override
State createState() => _CreatePostScreenState();
}
class _CreatePostScreenState extends State {
final _titleController = TextEditingController();
final _contentController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('新文章')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(labelText: '标题'),
validator: (value) => PostValidation.validateTitle(value),
maxLength: PostValidation.titleMaxLength,
),
const SizedBox(height: 16),
TextFormField(
controller: _contentController,
decoration: const InputDecoration(labelText: '内容'),
validator: (value) => PostValidationvalidateContent(value),
maxLength: PostValidation.contentMaxLength,
maxLines: 8,
),
],
),
),
);
}
@override
void dispose() {
_titleController.dispose();
_contentControllerdispose();
super.dispose();
}
}
validator: (value) => PostValidation.validateTitle(value); 这一行代码将共享的验证函数直接传递给了 `TextFormField` 的 `validator` 属性。当用户提交表单时,Flutter的表单系统会调用这个函数,其返回值要么是 `null`(表示输入有效),要么是一个错误字符串(表示输入无效),这种处理方式与 `PostValidation` 类中使用的约定完全一致。`maxLength: PostValidation.titleMaxLength;` 这一行代码使用共享常量来设置该字段的字符长度限制,这样用户界面就能反映出与验证规则相同的限制要求。如果后来将这个最大长度从120改为200,那么只需更新共享包中的常量,客户端和服务器上的表单字符计数器以及相应的验证规则就会自动得到更新,而无需进行任何额外的操作。
架构:全栈技术的协同工作原理

该图展示了单个请求从开始到结束的整个处理流程。Flutter应用程序会使用共享逻辑在本地对请求进行验证,随后会调用相应的函数。Firebase的基础设施会接收这些请求,验证其中的认证信息,并将请求路由到运行在Cloud Run上的相应Dart程序。Dart程序会再次使用相同的共享逻辑进行验证,然后通过Admin SDK访问Firestore数据库并写入数据。最终,Dart程序会返回处理结果,Flutter客户端会以结构化数据的形式接收这些结果。在整个处理流程中,所有可以在客户端和服务器之间共享的代码都会被共享起来;而那些必须分开处理的组件(比如Flutter界面元素和Firebase的管理操作)也会被妥善地分离开来。
全栈Dart项目的项目结构

项目根目录下的三层目录结构是整个项目组织的基础:lib/用于存放Flutter应用程序代码,functions/用于存放后端服务代码,而packages/则用于存放客户端和服务器之间共享的组件。这种分离方式能够让人清楚地了解每一部分代码的具体用途。flutter/app/services/目录中存放的是像FunctionsService这样的类,这样就可以将函数调用逻辑与Flutter界面元素分开来管理。functions/lib/handlers/目录中则存放针对特定业务领域的功能逻辑,这使得server.dart文件保持简洁,其功能也更加专注于处理注册请求。
高级概念
多个函数的组织与管理
随着后端服务规模的扩大,如果将所有函数都放在一个fireUp回调函数中处理,就会变得很不方便。因此,应该将相关的处理逻辑提取到单独的文件中,然后再将这些文件导入到服务器的主入口文件中:
// functions/lib/handlers/post_handler.dart
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloudFirestore.dart' show FieldValue;
import 'package:shared/shared.dart';
void registerPostHandlers(FirebaseApp firebase) {
firebase.https.onCall(
name: ApiConstants.createPostFunction,
(request, response) async {
// 处理逻辑
},
);
firebase.https.onCall(
name: ApiConstants.likePostFunction,
(request, response) async {
// 处理逻辑
},
);
firebase.https.onRequest(
name: ApiConstants.getUserProfileFunction,
(request) async {
// 处理逻辑
},
);
}
registerPostHandlers(FirebaseApp firebase)是一个简单的顶层函数,它接受firebase对象,并使用该对象注册所有与处理POST请求相关的功能。这个函数的参数类型FirebaseApp firebase是由firebase_functions提供的类型定义决定的,因此参数的类型是正确的。这种编写方式与Flutter应用程序中的main.dart文件的工作原理类似:程序通过一个入口点来调用负责不同配置任务的函数。
// functions/bin/server.dart
import 'package:firebase_functions/firebase_functions.dart';
import '../lib/handlers/post_handler.dart';
import '../lib/handlers/user_handler.dart';
void main(List
await fireUp(args, (firebase) {
registerPostHandlers(firebase);
registerUserHandlers(firebase);
});
}
server.dart现在已经成为一个结构清晰、用于协调各种功能的文件。它从各个处理相关请求的文件中导入注册函数,并在fireUp方法中按顺序调用这些函数。要添加一个新的功能模块,只需创建一个新的处理文件,然后在其中添加一行代码即可。fireUp回调函数是唯一能够访问firebase对象的地方,因此所有需要使用该对象的注册函数都必须传递这个对象作为参数。
错误处理模式
在生产环境中使用的Cloud Functions必须具备一致且可预测的错误处理机制。应该定义一个集中的错误处理程序,而不是在每个函数中都分散地使用try-catch语句来处理错误:
// functions/lib/utils/error_handler.dart
import 'package:firebase_functions/firebase_functions.dart';
typedef CallableHandler = Future
CallableRequest request,
CallableResponse response,
);
CallableHandler withErrorHandling(CallableHandler handler) {
return (request, response) async {
try {
return await handler(request, response);
} on FirebaseFunctionsException {
rethrow;
} on ArgumentError catch (e) {
throw Firebase FunctionsException(
code: 'invalid-argument',
message: e.message,
);
} catch (e, stackTrace) {
print('函数中出现了未处理的错误:$e');
print(stackTrace);
throw FirebaseFunctionsException(
code: 'internal',
message: '发生了内部错误,请重新尝试。",
);
}
};
}
typedef CallableHandler为onCall方法所期望的处理函数签名定义了一个Dart类型的别名。这样一来,就可以在不需要重复编写完整的函数签名的情况下,使用withErrorHandling来创建新的处理函数。withErrorHandling实际上是一个高阶函数:它接受一个处理函数作为参数,然后返回一个新的函数,这个新函数会在原来的函数内部添加try-catch语句来处理错误。onFirebaseFunctionsException { rethrow; }这条代码确保了那些在处理过程中故意引发的错误能够原封不动地传递出去,因为这些错误的格式已经符合客户端的要求。on ArgumentError catch (e)则将Dart内置的ArgumentError异常(通常由验证逻辑引发)转换为FirebaseFunctionsException异常,并设置异常代码为invalid-argument,这样客户端就能理解这个错误的原因。catch (e, stackTrace)这条语句则用于处理那些未被处理的异常:它会在内部记录完整的错误信息及堆栈跟踪,但向客户端返回的信息中不会包含任何关于内部错误的细节。
firebase.https.onCall(
name: 'createPost',
withErrorHandling((request, response) async {
if (request.auth == null) {
throw FirebaseFunctionsException(
code: 'unauthenticated',
message: '需要身份验证。'
);
}
return CallableResult({'success': true});
}),
);
withErrorHandling(...)会在函数注册时自动为处理程序添加错误处理逻辑。onCall方法的第三个参数(即处理程序函数)会被withErrorHandling的返回值所替代,而withErrorHandling本身也是一个具有正确签名的函数。由于withErrorHandling已经覆盖了所有可能的错误情况,因此内部的处理程序不需要再包含自己的try-catch块。
测试Dart Cloud Functions
用Dart编写的Cloud Functions其实就是普通的Dart代码,这意味着可以使用标准的Dart测试工具对它们进行全面的测试。你可以将处理程序中的业务逻辑提取出来,转化为不依赖于Firebase的纯函数,然后直接对其进行单元测试:
// functions/lib/handlers/post_logic.dart
import 'package:shared/shared.dart';
PostInput validateCreatePostRequest(Map data) {
final title = data['title'] as String?;
final content = data['content'] as String();
final titleError = PostValidation.validateTitle(title);
if (titleError != null) throw ArgumentError(titleError);
final contentError = PostValidation.validateContent(content);
if (contentError != null) throw ArgumentError(contentError);
return PostInput(
title: title!.trim(),
content: content!.trim(),
);
}
class PostInput {
final String title;
final String content;
const PostInput({required this.title, required this.content});
}
validateCreatePostRequest是一个纯函数:它接收一个Map类型的参数,要么返回一个PostInput对象,要么抛出一个ArgumentError异常。这个函数不依赖于Firebase,也不包含异步调用或任何副作用。因此,只需使用一个简单的dart test命令就可以对其进行测试,甚至不需要使用Firebase模拟器。PostInput是一个简单的值类,用于存储经过验证并去除了多余空格的输入数据。通过返回类型明确的对象,而不是原始的映射结构,可以确保调用者收到的数据是经过有效处理、且格式符合编译器要求的。
// functions/test/post_logic_test.dart
import 'package:test/test.dart';
import '../lib/handlers/post_logic.dart';
void main() {
group('validateCreatePostRequest', () {
test('当输入数据正确时,函数会返回有效的PostInput对象', () {
final result = validateCreatePostRequest({
'title': 'Valid Title',
'content': 'This is valid post content.'
});
expect(result.title, equals('Valid Title'));
expect(result.content, equals('This is valid post content.');
});
test('当标题为空时,函数会抛出ArgumentError异常', () {
expect(
() => validateCreatePostRequest({'title': '', 'content': 'Content'},
throwsA(isA,),
);
});
test('当标题的长度超过限制时,函数会抛出ArgumentError异常', () {
final longTitle = 'A' * 200;
expect(
() => validateCreatePostRequest({
'title': longTitle,
'content': 'Content',
}),
throwsA(isA,),
);
});
test('函数会去除标题和内容中的多余空格', () {
final result = validateCreatePostRequest({
'title': ' Padded Title ',
'content': ' Padded content. '
});
expect(result.title, equals('Padded Title'));
expect(result.content, equals('Padded content.');
});
});
}
group('validateCreatePostRequest', ...) 将相关的测试归类到同一个标签下,从而生成有条理的测试结果,便于查找失败案例。每个 test(...) 调用都会测试一种特定的情况:正常运行情况、标题为空的情况、标题过长且包含大写字母的情况,以及去除空白字符后的情况。expect(result.title, equals('Valid Title')) 是用于进行断言的代码段,它用于检查实际得到的结果是否与预期值相符。throwsA(isA 则是一个匹配器,只有当被调用的函数抛出 ArgumentError 时,这个匹配器才会认为测试通过。而 validateCreatePostRequest 函数对于无效输入确实规定了应该抛出 ArgumentError 这一错误类型。'A' * 200 是一个 Dart 代码,用于生成一个由 200 个字符组成的字符串,而这个字符串的长度超出了共享包中规定的 120 个字符的限制。
cd functions
dart test
运行这些函数测试并不需要 Firebase 模拟器,也不需要网络连接,只需要安装了 Dart SDK 就可以开始测试了。这些测试会在几毫秒内完成。
cd packages/shared
dart test
共享包中的测试都是以相同的方式运行的。这两个命令都会使用标准的dart test运行器,该运行器会递归地查找并执行test/目录下所有以_test.dart结尾的文件。
函数配置选项
firebase.https.onRequest(
name: 'highTrafficEndpoint',
options: const HttpsOptions(
cors: Cors(['https://yourapp.com')),
minInstances: 1,
maxInstances: 10,
concurrency: 80,
memory: Memory.mb512,
timeoutSeconds: 120,
region: 'europe-west1',
),
(request) async {
return Response.ok('Hello from a configured function!');
},
);
minInstances: 1 这个选项会确保始终有一个该函数的实例处于活跃状态,从而完全避免函数出现冷启动的情况。不过这样一来,即使没有请求到达,也会持续消耗资源并产生费用。因此,这个选项只适用于那些冷启动延迟绝对不能被接受的函数,比如用户需要实时与之交互的功能。maxInstances: 10 这个选项将同时运行的函数实例数量限制为 10 个。这样就可以防止突然增加的流量导致函数实例数量激增,从而避免对您的 billing 系统或任何下游服务(比如数据库)造成压力。concurrency: 80 这个选项告诉 Cloud Run 每个实例最多可以同时处理多少个请求。由于 Dart 的异步模型能够高效地处理并发的 I/O 操作,因此这个数值可以设置得比 Node.js 中的更高。memory: Memory.mb512 这个选项为每个函数实例分配 512 兆字节的 RAM 内存。如果您的函数需要执行内存消耗较大的操作(比如图像处理或加载大型数据集),就可以增加这个数值。CPU 资源的分配量会与内存容量成正比,因此增加内存也会提升计算能力。timeoutSeconds: 120 这个选项设置了请求可以运行的最长时间,超过这个时间限制后,Cloud Run 会自动终止该请求。对于那些需要长时间运行的操作来说,增加这个数值是很有必要的。region: 'europe-west1' 这个选项会将函数部署到比利时境内的 Google 数据中心,从而减少欧洲用户的延迟。默认情况下,函数会被部署到 us-central1 地区。
适用于生产环境的最佳实践
将实验性功能视为实验性内容
最重要的做法是根据该功能的实际成熟度来调整其在生产环境中的使用方式。Dart Cloud Functions目前仍处于试验阶段,因此这一原则对生产环境的决策具有重要的指导意义。
首先,API的功能可能会在未经通知的情况下发生变更。未来Firebase CLI的更新可能会改变`fireUp`函数的运行机制、函数注册的方式,或者管理员SDK的使用方法。在那些使用Dart函数的项目中,更新CLI之前,请务必先阅读变更日志,并在测试环境中进行验证。切勿盲目地更新生产环境中的工具。
其次,目前还有一些功能尚未实现。例如后台触发器、基于名称调用的`httpsCallable`功能,以及Firebase Console中的显示界面等,这些都属于当前版本中尚未完善的部分。在项目设计之初就应该考虑到这些限制,而不要等到部署阶段才发现这些问题。
保持处理程序的简洁性,共享逻辑代码
通过`firebase.https.onCall`或`firebase.https.onRequest`注册的处理程序应该尽可能减少自身的功能:仅负责验证请求、提取输入数据、调用执行实际工作的纯函数,然后返回结果即可。这些纯函数应该被放在函数库中或共享包里。这样的结构使得逻辑代码可以在没有Firebase环境的情况下进行测试;同时,如果Flutter应用程序后续需要使用这些逻辑代码,也便于将其移至共享包中。
对于所有时间戳,都应使用FieldValue.serverTimestamp()方法
切勿从客户端发送时间戳,也不要在函数代码中使用`DateTime.now()`来生成时间戳。服务器端的时间戳是由Firestore在数据写入时自动设置的,因此其准确性是有保障的;而客户端生成的时间戳如果用户的设备时钟设置不正确,就可能会出错。虽然函数内部使用的`DateTime.now()`生成的时间戳是准确的,但它会错过函数执行完毕与数据真正写入数据库之间的那段短暂时间差。
有意义地记录日志,但不要过度记录
Cloud Functions生成的日志可以在Google Cloud Console和Cloud Run日志中查看。Dart函数中的`print()`语句会将这些日志写入相应的记录系统中。应该记录那些有助于排查生产环境问题的日志信息:包括函数的调用情况及其输入参数、成功完成的操作结果、出现错误的详细信息及堆栈跟踪,以及与性能相关的事件(如外部API的调用情况)。但不要记录每一条执行指令或每一个数据转换过程,因为这样会导致日志信息过于冗杂,从而使得真正的问题难以被发现。
默认实施速率限制和身份验证机制
任何可以通过互联网访问的Cloud Function,都有可能被任何发现其URL的人调用。那些可以被调用的函数会自动进行Firebase身份验证,但HTTP函数则不会。对于所有需要用户身份验证的onRequest函数,都必须明确地验证用户的ID令牌;而对于无论属于哪种类型的函数,在上线之前都应考虑实施基于用户的速率限制机制,这样既可以防止意外造成的错误操作,也可以避免恶意滥用行为的发生。
何时使用Dart Cloud Functions,何时不应使用它
Dart Cloud Functions在哪些场景能发挥真正价值
当你的团队优先使用Flutter技术,并且希望在不切换开发语言的情况下编写后端逻辑时,Dart Cloud Functions会展现出最大的价值。这种“共享代码包”的架构设计方式具有极高的实用性:每当客户端和服务器都需要相同的验证规则、数据模型、常量或辅助函数时,如果将这些代码放在同一个Dart包中,就能有效避免因代码不一致而引发的各种错误。
对于那些以I/O操作为主的工作负载来说,Dart也非常适合。由于Dart的异步处理机制非常高效,因此当应用程序的大部分时间都用于等待Firestore数据库的查询结果、外部API的响应或其他网络操作时,使用Dart来编写相关逻辑会显得尤为合适。例如,那种从Firestore中读取数据、应用业务逻辑后再将结果写回数据库的功能,正是Dart擅长的处理类型。
在“移动端后端为前端提供服务”的场景中,Dart Cloud Functions也能发挥重要作用:比如那些需要从多个 Firestore集合中收集数据并生成特定屏幕所需的响应内容的功能;那些需要同时更新多份文档才能完成写操作的功能;以及那些需要管理员权限才能创建或修改某些数据记录的功能。
目前在哪些情况下使用Dart Cloud Functions并不合适
目前,基于后台触发事件的函数还无法被部署。如果你的应用程序架构要求某些功能在Firestore文档创建或更新时执行,或者在用户注册时运行,又或者需要按照特定的时间表来执行,又或者需要对Pub/Sub消息做出响应,那么你现在还不能使用Dart来实现这些功能。你必须用Node.js或Python来编写这些代码,直到未来版本中加入了对后台触发事件的支持为止。
对于那些对生产环境来说至关重要的基础设施而言,在决定使用实验性的工具之前,必须进行仔细的评估。如果某个函数的故障会导致数据丢失、财务错误或给用户带来严重的影响,那么Dart这种实验性技术的风险因素就显得尤为突出。因为API接口可能会发生变化,功能表现也可能会改变,而且Firebase团队在处理实验性功能中的关键问题时,其响应速度也会与他们在开发稳定版本时的效率有所不同。
对于那些需要高度并发处理、并且对性能要求极为严格的场景来说,在决定使用Dart之前,先通过实际的生产环境流量进行测试会很有帮助。从理论上讲,Dart函数在性能方面表现优异(例如冷启动速度快,异步I/O处理效率高),但实际的生产环境中的各种复杂情况可能会暴露出一些在本地测试中无法发现的问题。
常见的错误
在pubspec.yaml中错误地使用相对路径
在functions/pubspec.yaml以及Flutter应用的pubspec.yaml中,都是通过相对路径来引用这个共享包的。如果相对路径有误(因为文件夹结构与代码库预期的不同,或者该包的位置发生了变化),那么无论是函数编译还是Flutter构建过程都会因包解析失败而出错。在部署之前,请通过在functions目录中运行dart pub get来检查路径是否正确,确保没有错误发生后再进行部署。
忘记处理httpsCallable名称的限制
当前版本中最常见的集成问题就是使用FirebaseFunctions.instance.httpsCallable('functionName')来调用Dart函数,但却不明白为什么会出现“未找到该函数”的错误。目前这个版本并不支持基于名称来调用Dart函数。因此,必须使用httpsCallableFromURL,并传入完整的Cloud Run URL。请将部署结果中显示的URL保存下来,在Flutter代码中明确地使用它。
在Firebase控制台中查找函数
在部署了Dart函数之后,如果不知道实际上应该是这样,那么打开Firebase控制台的“Functions”页面却什么都没有看到,这种情况其实是很正常的。你的Dart函数是被部署到了Cloud Run上,因此应该在Google Cloud Console的Cloud Run页面中查看它们,而不是在Firebase控制台中。这个问题是当前实验版本中存在的已知缺陷,等到该功能正式上线后,这个问题就会得到解决。
将Firebase依赖项放入共享包中
共享包绝对不能包含任何与Firebase或Flutter相关的依赖项。如果将firebase_functions或cloud_firestore添加为共享包的依赖项,就会破坏其基本的架构:这样共享包就会把服务器端的Firebase依赖项引入Flutter应用中,或者把客户端侧的Firebase依赖项引入函数模块中,从而导致版本冲突和编译错误。共享包应该只包含纯粹的Dart逻辑和模型。与Firebase相关的交互操作应该在functions包以及Flutter应用中分别进行,而这两个组件都会导入共享包。
没有将逻辑提取到纯函数中
如果将所有的业务逻辑直接写在onCall或onRequest回调函数中,那么在没有运行Firebase模拟器的情况下就无法进行单元测试。而Dart语言的最大优势就在于它的可测试性。因此,应该将验证、转换以及业务逻辑提取到functions库或共享包中的纯函数中,然后使用dart test>来测试这些纯函数,而无需依赖任何Firebase基础设施。处理输入输出逻辑的回调函数应该只负责充当连接Firebase与这些纯函数的桥梁作用。
迷你端到端示例
让我们构建一个功能完备、可正常运行的全栈Dart应用程序:该应用程序包含帖子创建功能,使用共享模型和验证规则,还包括一个用于将数据写入Firestore的Dart Cloud Function,以及一个调用该函数的Flutter界面。通过这个项目,我们可以将手册中介绍的所有概念整合到一个可运行的项目中。
共享包
// packages/shared/lib/src/models/post.dart
class Post {
final String id;
final String title;
final String content;
final String authorId;
final int likeCount;
const Post({
required this.id,
required this.title,
required this.content,
required this.authorId,
required this.likeCount,
});
factory Post.fromMap(String id, Map data) {
return Post(
id: id,
title: data['title'] as String? ?? '',
content: data['content'] as String? ?? '',
authorId: data['authorId'] as String? ?? '',
likeCount: data['likeCount'] as int? ?? 0,
);
}
Map toMap() => {
'title': title,
'content': content,
'authorId': authorId,
'likeCount': likeCount,
};
}
Post.fromMap方法既接收文档ID(Firestore会将文档ID存储在文档数据之外),也接收文档的字段映射信息,然后将这些信息组合成一个完整的Post实例。as String? ?? ''这种写法是一种安全的类型转换机制,当某个字段缺失或为null时,会使用空字符串作为替代值,从而避免出现空指针异常。toMap()方法则将Post对象序列化为适合写入Firestore的Map》格式,其中故意省略了id字段,因为Firestore会在文档主体之外生成并存储文档ID。新创建的帖子的likeCount初始值为0,之后会通过服务器端的操作进行更新。
// packages/shared/lib/src/validation/post_validation.dart
class PostValidation {
static const int titleMaxLength = 120;
static const int contentMaxLength = 5000;
static String? validateTitle(String? value) {
if (value == null || value.trim().isEmpty) return '标题是必填项。';
if (value.trim().length > titleMaxLength) {
return '标题的长度不能超过$titleMaxLength个字符。';
}
return null;
}
static String? validateContent(String? value) {
if (value == null || value.trim().isEmpty) return '内容是必填项。';
if (value.trim().length > contentMaxLength) {
return '内容的长度不能超过$contentMaxLength个字符。';
}
return null;
}
}
这是端到端示例中使用的PostValidation类的简化版本。这两个方法都遵循了验证规则:返回null表示验证通过,返回String则表示验证失败,并会说明具体的错误原因。这些验证步骤的顺序是从最常见的错误类型(输入为空)到更具体的错误类型(长度超过限制),这样的安排既符合逻辑,也能提高效率,因为先检查输入是否为空可以避免进行不必要的长度检测。
// packages/shared/lib/src constants/api_constants.dart
class ApiConstants {
static const String createPost = 'createPost';
static const String postsCollection = 'posts';
}
在这个端到端的示例中,ApiConstants类只包含了该功能所需的两个常量:函数名称和集合名称。这样的设计使得示例更加简洁明了。在实际应用中,这个类会包含整个应用程序中使用的所有函数和集合名称。
// packages/shared/lib/shared.dart
export 'src/models/post.dart';
export 'src/validation/post_validation.dart';
export 'srcconstants/api_constants.dart';
该打包文件导出了这三个模块。在项目中,任何导入package:shared/shared.dart的文件都可以直接使用Post、PostValidation和ApiConstants,而无需关心它们具体位于哪个子目录中。
云函数
// functions/bin/server.dart
import 'dart:convert';
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloudFirestore.dart' show FieldValue;
import 'package:shared/shared.dart';
void main(List args) async {
await fireUp(args, (firebase) {
firebase.https.onCall(
name: ApiConstants.createPost,
options: const CallableOptionsCors: Cors(['*'])),
(request, response) async {
if (request.auth == null) {
throw FirebaseFunctionsException(
code: 'unauthenticated',
message: '您必须登录才能创建帖子。'
);
}
final uid = request.auth!.uid;
final data = request.data as Map? ?? {};
final title = data['title'] as String?;
final content = data['content'] as String?;
final titleError = PostValidation.validateTitle(title);
if (titleError != null) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: titleError,
);
}
final contentError = PostValidation.validateContent(content);
if (contentError != null) {
throw FirebaseFunctionsException(
code: 'invalid-argument',
message: contentError,
);
}
try {
final ref = await firebase.adminApp
.firestore()
.collection(ApiConstants_postsCollection)
.add({
'title': title!.trim(),
'content': content!.trim(),
'authorId': uid,
'likeCount': 0,
'createdAt': FieldValue.serverTimestamp(),
});
return CallableResult({
'postId': ref.id,
'success': true,
});
} catch (e) {
print('将帖子写入Firestore时出现错误:$e');
throw FirebaseFunctionsException(
code: 'internal',
message: '创建帖子失败。请重试。'
);
}
},
);
});
}
final data = request.data as Map 这种写法能够安全地处理客户端发送空数据体的情况——当检测到数据体为空时,它会自动使用一个空映射来替代,从而避免在提取具体字段之前发生空指针异常。在 title!.trim() 和 content!.trim() 前加上 ! 也是安全的,因为之前的验证步骤已经确认了这两个值的值既不为空也不为 null。而围绕 Firestore 写操作的 try/catch 结构则构成了最后的防护措施:如果由于任何原因(网络问题、Firestore 配额限制或意外错误)导致写入操作失败,该机制会捕获异常,使用 print 方法将详细的内部错误信息记录到 Cloud Run 日志中,然后向客户端返回一个不包含故障原因的 'internal' 错误信息。
Flutter 应用程序
// lib/services/functions_service.dart
import 'package:cloud_functions/cloud_functions.dart';
class FunctionsService {
static const String _createPostUrl =
'https://createpost-REPLACE-WITH-YOUR-HASH.a.run.app';
Future createPost({
required String title,
required String content,
}) async {
try {
final callable = FirebaseFunctions.instance
.httpsCallableFromURL(_createPostUrl);
final result = await callable.call({'title': title, 'content': content});
return result.data['postId'] as String;
} on Firebase FunctionsException catch (e) {
throw _mapError(e);
}
}
Exception _mapError(FirebaseFunctionsException e) {
switch (e.code) {
case 'unauthenticated':
return Exception('请登录以继续操作。');
case 'invalid-argument':
return Exception(e.message ?? '输入无效。');
default:
return Exception('发生了一些错误,请重试。');
}
}
}
FunctionsService 实际上只是对可调用的函数进行了一次封装。它的主要职责就是使用正确的 URL 构造调用对象,传递数据,提取结果,并将服务器返回的错误信息转换为用户更容易理解的异常类型。_mapError 方法会将包含 Firebase 特有错误代码的 FirebaseFunctionsException 对象转换成普通的 Exception 对象,这样就可以避免在 Bloc 或 Widget 层中使用 Firebase 相关类型,从而减少与 Firebase SDK 的耦合程度,使代码更易于测试和维护。
// lib/features/create_post/create_post_screen.dart
import 'package:flutter/material.dart';
import 'package:shared/shared.dart';
import '../../services/functions_service.dart';
class CreatePostScreen extends StatefulWidget {
const CreatePostScreen({super.key});
@override
State createState() => _CreatePostScreenState();
}
class _CreatePostScreenState extends State {
final _formKey = GlobalKey();
final _titleController = TextEditingController();
final _contentController = TextEditingController();
final _service = FunctionsService();
bool _isSubmitting = false;
String? _errorMessage;
@override
void dispose() {
_titleController.dispose();
_contentControllerdispose();
super.dispose();
}
Future _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_isSubmitting = true;
_errorMessage = null;
});
try {
final postId = await _service.createPost(
title: _titleController.text,
content: _contentController.text,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('帖子创建成功!ID:$postId')),
);
Navigator.of(context).pop();
} catch (e) {
setState(() => _errorMessage = e.toString());
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('发布新帖子')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade800),
),
),
TextFormField(
controller: _titleController,
decoration: InputDecoration(
labelText: '标题',
hintText: '您要发布的内容是什么?',
counterText:
'\({_titleController.text.length}/\){PostValidation.titleMaxLength}',
),
maxLength: PostValidation.titleMaxLength,
validator: (value) => PostValidation.validateTitle(value),
onChanged: (_) => setState(()`),
),
const SizedBox(height: 16),
TextFormField(
controller: _contentController,
decoration: InputDecoration(
labelText: '内容',
hintText: '在这里撰写您的帖子内容……',
counterText:
'\({_contentController.text.length}/\){PostValidation.contentMaxLength}',
alignLabelWithHint: true,
),
maxLength: PostValidation.contentMaxLength,
maxLines: 10,
validator: (value) => PostValidation.validateContent(value),
onChanged: (_) => setState(()`),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('发布帖子'),
),
],
),
),
);
}
}
GlobalKey使_submit()能够访问表单的状态,从而可以同时触发所有字段的验证操作。_formKey currentState?.validate()会针对表单中的每一个TextFormField调用相应的validator函数,只有当所有的验证器都返回null时,这个方法才会返回true。这种在验证失败后立即停止操作的机制可以避免在表单无效的情况下仍然发起网络请求。_isSubmitting这一状态变量用于控制用户界面:在提交操作进行过程中,按钮会被禁用(其onPressed方法会被设置为null),同时会用一个CircularProgressIndicator来替代按钮的标签,从而向用户明确显示当前正在发生什么。async _submit()方法中的if (!mounted) return语句可以确保那些已经被从应用程序树中移除的组件不会尝试调用setState或Navigator方法,因为这样会导致“在组件被销毁后还调用了setState”这样的错误。finally块则能保证无论是否发生了异常,_isSubmitting变量都会被重置为false,从而防止按钮长时间处于加载状态而无法响应用户操作。
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'dart:io' show Platform;
import 'firebase_options.dart';
import 'features/create_post/create_post_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initialize(
options: DefaultFirebaseOptions.currentPlatform,
);
if (const bool.fromEnvironment('USE_EMULATOR', defaultValue: false)) {
final host = Platform.isAndroid ? '10.0.2.2' : 'localhost';
FirebaseFunctions.instance.useFunctionsEmulator(host, 5001);
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Full-Stack Dart Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const CreatePostScreen(),
);
}
}
WidgetsFlutterBinding.ensureInitialized()必须在任何Flutter插件代码被执行之前调用,其中包括Firebase的初始化操作。如果不先执行这个步骤,在调用runApp()之前直接运行Firebase.initialize()就会引发错误。DefaultFirebaseOptions.currentPlatform会从生成的firebase_options.dart文件中获取当前平台对应的Firebase项目配置信息。const bool.fromEnvironment('USE_EMULATOR', defaultValue: false)这一代码用于读取在编译时设置的常量;如果你在运行flutter run命令时添加了--dart-define=USE_EMULATOR=true参数,那么这个常量的值就会被设置为true。与使用kDebugMode相比,这种通过配置文件来切换模拟器的方法更加可靠:因为当将kDebugMode设置为false时,发布版本的应用程序会停止使用模拟器;而如果没有添加上述参数,编译出来的发布版本也会自动达到同样的效果。Platform.isAndroid这一方法会根据当前平台的环境自动选择正确的模拟器主机地址,具体原理在之前的设置说明中已经详细解释过了。
结论
Dart在Cloud Functions中的支持是Flutter社区多年来一直期盼的功能。在2026年Google Cloud Next大会上宣布这一消息时,人们表现出了极大的热情——因为这一长期存在的痛点终于得到了解决。自2023年以来,用户们一直在论坛中不断提出相关请求,而现在这些帖子里充满了庆祝的话语。那些仅仅掌握了足够多的TypeScript知识来编写后端函数、但使用这种语言时始终感到不自在的开发者们,突然又找到了回归他们熟悉的语言的途径。
从技术层面来看,这一功能确实非常扎实。Dart的AOT编译机制使得程序的冷启动时间大大缩短;其基于类型安全的系统使得共享包的模式变得可靠而非仅仅是一种理想化的设计;异步处理模型也能高效地应对那些依赖I/O操作的服务器端任务。`firebase_functions`包的设计风格与Flutter开发者已经习惯使用的FlutterFire包非常相似,因此对于那些已经在客户端项目中使用了Firebase的开发者来说,学习这个新功能的难度并不大。
然而,这一功能目前仍处于实验阶段,这些限制确实需要被认真考虑。例如,背景触发器目前还无法被部署;Firebase控制台也无法显示Dart函数的信息;基于名称调用的功能也不可行。这些并不是无关紧要的局限,它们会直接影响实际的架构设计。因此,各开发团队在规划系统时应该把这些限制因素充分考虑进去,而不能假设这些问题会在功能正式上线之前得到解决。虽然Firebase团队正在积极开发这一功能,而且自宣布以来进展速度也相当快,但对于生产环境而言,仍然需要采取谨慎的规划策略。
无论Dart函数功能最终会发展得多么成熟,共享包这一设计理念都值得被作为架构的核心来考虑。即使由于某些限制,你暂时还需要使用Node.js来实现部分后端逻辑,但在一个共同的Dart包中构建共享的数据模型和验证逻辑,仍然能够显著改善你的代码质量。每当你消除一个重复的类型定义或手动维护的API接口规范时,你就减少了一类潜在的错误来源。而这个共享包正是目前就可以带来的实际收益;而Dart函数功能则成为了让整个开发栈得以统一的关键因素。
Flutter社区才刚刚开始探索大规模应用Dart全栈技术的可能性。如何组织共享包、如何设计便于测试的函数结构、如何在可调用函数和HTTP函数之间做出权衡、以及如何优雅地应对现有的限制措施,这些问题的答案仍在实际项目中不断被探索和完善。这本手册为你提供了必要的基础知识,而随着更多团队将这一功能应用到生产环境中并分享他们的经验,社区将会进一步完善这些内容。
参考资料
官方Firebase文档
-
开始使用实验性的Dart SDK
官方的Firebase文档介绍了如何设置Dart Cloud Functions,内容包括CLI配置、实验模式的相关设置、本地仿真环境以及部署方法。这是最权威的入门指南。https://firebase.google.com/docs/functions/start-dart -
Firebase Cloud Functions概述
这是Cloud Functions的主要文档页面,目前该页面添加了宣传Dart实验性支持的横幅,并提供了指向专门针对Dart的指导手册的链接。https://firebase.google.com/docs/functions -
如何在您的应用中调用Cloud Functions(使用Dart语言)
Firebase文档说明了如何从Flutter环境中调用可执行函数,同时也提到了目前存在的限制以及相应的解决方法,例如与httpsCallable名称解析相关的问题及httpsCallableFromURL的替代方案。https://firebase.google.com/docs/functions/callable -
Firebase AI逻辑相关文档
适用于那些希望通过[Firebase]将Dart Cloud Functions与Gemini AI功能结合使用的团队。https://firebase.google.com/docs/ai-logic
公告与博客文章
-
宣布Firebase的Cloud Functions支持Dart语言
这是Google Cloud Next 2026上发布的官方博客文章,介绍了支持Dart语言的原因、Admin SDK的相关信息、共享代码架构以及AOT编译性能等方面的内容。https://firebase.blog/posts/2026/05/dart-functions-exp -
X平台上的Dart语言:Dart无处不在
Dart团队发布的这篇公告用一句话概括了Dart在全栈开发中的应用情况。
https://x.com/dart_lang/status/2047418350268273060
包
-
pub.dev上的firebase_functions包
这是专为Cloud Functions设计的官方Dart包,提供了fireUp、onRequest、onCall、HttpsOptions、CallableOptions以及FirebaseFunctionsException等类。https://pub.dev/packages/firebase-functions -
GitHub上的firebasefunctions包
该Dart包的源代码、问题报告及使用示例均可在GitHub上找到。README文件中还提供了额外的示例信息以及最新的限制说明。
https://github.com/firebase/firebase-functions-dart -
pub.dev上的dart_firebase_admin包
这是专为在Cloud Functions之外使用而设计的Dart Admin SDK,适用于Cloud Run、独立服务器及命令行脚本等场景。该包由Invertase团队维护。https://pub.dev/packages/dart_firebase_admin -
GitHub上的dart_firebase_admin包
该Dart Admin SDK的源代码及文档齐全,其中包含了针对Firestore、Authentication、Cloud Storage以及FCM等服务的使用示例。https://github.com/invertase/dart_firebase_admin -
pub.dev上的google_cloud_firestore包
这是专门用于在Dart Cloud Functions中执行Firestore操作的独立Dart SDK。
https://pub.dev/packages/google_cloud_firestore
代码实验室与教程
- 使用Firebase的Cloud Functions构建全栈Dart应用
这是Google官方提供的代码实验室教程,通过示例介绍了如何使用共享的Dart包、Cloud Functions以及Flutter前端来开发多人计数器应用程序。这是目前最全面的实践指导资源。https://codelabs.developers.google.com/deploy-dart-on-firebase-functions
相关的Flutter和Dart包
-
cloud_functions (FlutterFire)
用于调用Cloud Functions的Flutter客户端包,在本指南中httpsCallableFromURL功能就是使用了这个包。
https://pub.dev/packages/cloud_functions -
firebase_core
所有FlutterFire包都需要的基础包。https://pub.dev/packages firebase_core -
json_annotation and json_serializable
这些包用于为共享模型生成fromJson和toJson方法,从而避免手动编写序列化代码。https://pub.dev/packages/jsonannotation
本手册编写于2026年5月,其中提到的Dart Cloud Functions功能是基于在2026年Google Cloud Next大会上宣布的试验性技术;同时参考了版本为0.1.x的firebase_functions包以及由Invertase维护的dart_firebase_admin包。由于这些功能仍处于试验阶段,因此API接口及支持的触发类型在未来可能会发生变化。在升级之前,请务必查阅官方的Firebase文档和相关包的更新日志。




