如果你已经使用Flutter进行了一段时间的开发工作,那么你很可能写过这样的代码:
try {
final user = await repo.getUser();
print(user.name);
} catch (e) {
print('出现了问题:$e');
}
这段代码可以正常编译并通过测试,但六个月后,总会有一些用户遇到错误,因为他们看到的只是空白屏幕,而实际上是某个地方的catch (e)语句掩盖了真正的故障原因。
这段代码看起来似乎没什么问题,但实际上它存在三个严重的缺陷,这些缺陷只有在系统运行时才会暴露出来。
首先,当出现故障时,开发者无法从代码中直接了解到具体发生了什么。无论repo.getUser()返回什么结果,都无法说明在网络连接中断、令牌过期或响应数据格式错误的情况下会发生什么问题。只有通过阅读代码实现或在实际运行环境中遇到故障时,才能发现这些问题。
其次,编译器也无法帮助开发者检测这些错误。如果团队中的其他成员在代码的其他地方忘记了添加try/catch>语句,应用程序仍然可以正常编译通过。没有任何提示会提醒你这些问题,直到程序在运行时真正崩溃,而这时用户已经遇到了麻烦。
第三,catch (e)语句会无差别地捕获所有类型的错误。无论是输入错误、对空值的引用错误、网络连接故障,还是JSON响应数据格式错误,都会被统一处理到同一个异常处理块中。如果不仔细查看错误信息,根本无法区分这些不同的错误类型,而这种处理方式也非常脆弱,因为一旦错误信息的内容发生变化,整个异常处理机制就会失效。
综上所述,所有的错误处理逻辑实际上都成了函数编写者与调用者之间的一种“默契”,而不是由类型系统强制执行的规则。在压力较大的环境中,或者在大型团队中,这种“默契”很容易被打破,尤其是在半夜发生紧急情况时。
几周前,我写了一篇文章《Dart中的高级错误处理:记录型数据、结果类型、单子模式与冻结异常》,专门讨论了如何利用这些技术来使错误处理过程更加有针对性、可见性,并且确保错误不可能被忽略。这篇文章本身就可以独立阅读,因此在继续讨论这个话题之前,我们先快速回顾一下那篇文章的内容吧。
我们将要讨论的内容:
回顾:上一篇文章留下的内容
那篇文章探讨了多个层次的设计方案,每一层都是为了解决前一层所存在的局限性。
文章首先从使用Dart的Records类型开始讨论,这是一种最简单的解决方案——它是一种包含成功和失败结果的类型化元组:
typedef Result〈E, T〉 = ({E? e, T? data});
这种设计确实比单纯的异常处理方式要好,因为返回类型明确表明函数执行可能会失败。
但是Records类型也存在明显的局限性:你很容易忘记检查哪些字段已经被赋值了,而且如果不先手动拆解结果对象,就无法对其进行转换。
正是这些缺陷促使人们开发出了更加完善的`Result`类型,即`AppResult〈T〉`。这种类型用两个结构不同的子类`AppSuccess`和`AppFailure`来替代原本包含可空字段的Records类型,并且还提供了`when()`方法来确保这两种情况都能得到妥善处理:
sealed class AppResult〈T〉 {
const AppResult();
R when〈R〉>({
required R Function(T value) success,
required R Function(AppFailure failure) failure,
});
}
class AppSuccess〈T〉 extends AppResult〈T〉 {
const AppSuccess(this.value);
final T value;
@override
R when〈R〉>({
required R Function(T value) success,
required R Function(AppFailure failure) failure,
}) => success(value);
}
class AppFailure〈T〉 extends AppResult〈T〉 {
const AppFailure(this.error);
final AppError error;
@override
R when〈R〉>({
required R Function(T value) success,
required R Function(AppFailure failure) failure,
}) => failure(this);
}
由于`AppResult`是`sealed`类型,编译器会强制确保所有情况都被考虑到。因此,你绝对不可能像使用Records类型或`try/catch`语句那样忽略失败处理部分。
随后,文章进一步将`AppResult`扩展成了一个真正的Monad结构,通过添加`map`和`flatMap`方法,使得结果可以在不脱离其包装对象的情况下被转换和链式操作。同时,文章还引入了`dartz`库中的`Either`类型,为那些熟悉这种函数式编程概念的团队提供了更方便的解决方案。最后,文章还讨论了基于Freezed类型的类型化异常处理方式,这样即使是在失败情况下,也能得到结构清晰、适合进行模式匹配的数据。
最终,整个技术体系呈现出这样的架构:使用`sealed`结果类型、结构化的异常对象,以及`map`/`flatMap`方法来进行数据转换,这些组件在存储层、业务逻辑层和用户界面层中都被一致地应用着。
如果你想了解整个设计过程的来龙去脉——包括为什么要添加每一层功能、如何与`dartz`库进行集成,以及Freezed异常类型的实现细节,那篇文章会为你提供详细的解释。而这里的内容仅基于上述最终形成的技术架构进行讨论,并不涉及整个开发过程。
这种模式之后出现的问题
在我发表那篇文章之后,发生了以下这些事情。
DartExceptor的工作原理
DartExceptor是一个轻量级的、不依赖任何外部库的Dart 3包。它实现了我在前一篇文章中提到的那种模式:`Trace
这个包不需要依赖`dartz`库,也不需要`Freezed`或`build_runner`。它只需要`Trace
dependencies:
dart_exceptor: ^1.1.2
import 'package:dart_exector/dart_exceptor.dart';
这就是全部设置内容了。
核心类型
DartExceptor中的每一个操作都会返回一个`Trace
-
T代表成功的结果类型 -
E代表错误的结果类型
`Trace`对象只有两种实现方式:
return Ok(user); // 表示操作成功
return Err(AppException(code: 404, e: 'Not found')); // 表示操作失败
你永远不能直接创建`Trace`对象。你应该返回`Ok`或`Err`,然后在其他地方通过这些类型来处理相应的结果。函数签名本身就已经清楚地说明了可能发生的情况:
Future> getUser(String id);
任何看到这个函数签名的人都能立刻明白:这个操作要么会成功并返回一个`User`对象,要么会失败并抛出一个`AppException`异常。六个月后,这种设计依然非常合理、易于理解。
API接口:四个方法,每个方法负责处理一项具体的任务
如果前一篇文章中提到的`Result`类型包含了`map`、`flatMap`以及用于模式匹配的`when()`函数,那么DartExceptor也是沿用了同样的结构,但将其优化成了四个专门针对特定任务的函数。
`split`函数:退出点的关键
通过`split`函数,你可以离开`Trace`对象所代表的处理流程。由于必须同时使用成功处理和错误处理的逻辑,因此你绝对不能不小心忽略任何可能的失败路径。
result.split(
data: (user) => print(user.name),
e: (e) => print(e.message),
);
map:提取数据并执行转换操作
map方法能够从Ok结果中提取数据,并允许你直接对这些数据进行处理或转换:
final activeUsers = result.map(
data: (users) => users.where((u) => u.isActive).ToList(),
);
mapError:提取数据但转换失败
mapError与map的作用相反,它用于处理转换过程中出现的错误。当数据的异常类型与业务逻辑层的期望不同时,这个方法非常有用:
final domainError = result.mapError(
e: (e) => AppException(code: e.statusCode, e: e.toString()),
);
bind<B>:返回Trace值的链式操作
bind<B>才是真正执行核心处理操作的组件。它允许你将那些本身会返回Trace值的操作串联起来,从而在每一步都对数据类型进行转换。如果其中任何一步失败,后续的所有操作都会被自动跳过。
result
.bind<User>>(
n: (users) {
try {
return Ok(users.firstWhere((u) => u.id == id));
} catch (e) {
return Err(AppException(code: 404, e: '用户未找到'));
}
},
)
.bind<String>>(n: (user) => Ok(user.firstName))
.split(
data: (name) => print('用户:$name'),
e: (e) => print('错误:${e.e}'),
);
List<User>类型被转换为User类型,然后再被转换为String类型。每个bind<B>操作都会改变数据的类型,编译器会检查每一步的处理过程;如果链式操作中的任何一步失败,整个处理流程就会直接跳转到split方法中的错误处理部分。这其实就是上一篇文章中讨论的flatMap机制的逻辑延伸。
这种模式在清洁架构中的应用
原始文章中介绍的这个模式,其意义并不仅仅在于语法结构本身。它的真正目的是让各种层次中的错误都能被清晰地显示出来。DartExceptor正好能够完美契合这种架构设计,且无需进行任何修改:
// 数据层
abstract class DataSource {
Future<Trace<>List<>User>>, AppException>> getAllUsers();
}
// 存储层
abstract class IUserRepository {
Future<Trace<>List<>User>>, AppException>> getAllUsers();
}
// 业务逻辑层
class UserUseCase {
Future<>Trace<>List<>User>>, AppException>> getAllUsers() => repository.getAllUsers();
}
// 表现层
void loadUsers() async {
final result = await useCase.getAllUsers();
result.split(
data: (users) => print('加载了${users.length}个用户'),
e: (e) => print('失败原因:${e.e}'),
);
}
相同的层次结构、相同的分离方式,以及相同类型的错误处理路径——只不过无需每次都重新编写基础代码。
为什么不直接使用 dartz 呢?
前一篇文章已经详细介绍了 dartz 中的 Either 模型,如果你的团队对它的 API 接口感到熟悉,并且也不担心依赖关系带来的问题,那么它确实是一个非常不错的选择。
DartExceptor 的适用场景更为有限——当你只需要结果类型的模式,却不想引入基于 Haskell 风格函数式编程约定的库时,DartExceptor 就非常适合。它没有 Left/Right 这样的类型,也没有 fold> 等函数,更不存在传递性依赖关系。它只需要 Trace、Ok、Err 以及四个方法,这些方法可以直接对应前一篇文章中介绍的实际使用方式。
| DartExceptor | dartz | |
|---|---|---|
| 依赖关系数量 | 零个 | 多个 |
| 是否支持 Dart 3 的原生功能 | 是 | 否 |
| API 接口的复杂性 | 仅包含 4 个方法 | 结构较为复杂 |
| 是否需要理解 Haskell 相关概念 | 不需要 | 需要 |
是否支持类型安全的链式操作 (bind<B>) |
是 | 是(通过 flatMap 可实现) |
马上试试吧
DartExceptor 已经在 pub.dev 上上线了:
dependencies:
dart_exceptor: ^1.1.2
包地址:pub.dev/packages/dart_exceptor;源代码链接:GitHub
如果你已经阅读了前一篇文章,并且自己实现过类似的代码,我非常希望听到你的使用体验。如果 DartExector 能让你再次避免重复编写那些代码,那么在 GitHub 上给它点个星吧!


