如果你已经使用Flutter进行了一段时间的开发工作,那么你很可能写过这样的代码:

try {
  final user = await repo.getUser();
  print(user.name);
} catch (e) {
  print('出现了问题:$e');
}

这段代码可以正常编译并通过测试,但六个月后,总会有一些用户遇到错误,因为他们看到的只是空白屏幕,而实际上是某个地方的catch (e)语句掩盖了真正的故障原因。

这段代码看起来似乎没什么问题,但实际上它存在三个严重的缺陷,这些缺陷只有在系统运行时才会暴露出来。

首先,当出现故障时,开发者无法从代码中直接了解到具体发生了什么。无论repo.getUser()返回什么结果,都无法说明在网络连接中断、令牌过期或响应数据格式错误的情况下会发生什么问题。只有通过阅读代码实现或在实际运行环境中遇到故障时,才能发现这些问题。

其次,编译器也无法帮助开发者检测这些错误。如果团队中的其他成员在代码的其他地方忘记了添加try/catch语句,应用程序仍然可以正常编译通过。没有任何提示会提醒你这些问题,直到程序在运行时真正崩溃,而这时用户已经遇到了麻烦。

第三,catch (e)语句会无差别地捕获所有类型的错误。无论是输入错误、对空值的引用错误、网络连接故障,还是JSON响应数据格式错误,都会被统一处理到同一个异常处理块中。如果不仔细查看错误信息,根本无法区分这些不同的错误类型,而这种处理方式也非常脆弱,因为一旦错误信息的内容发生变化,整个异常处理机制就会失效。

综上所述,所有的错误处理逻辑实际上都成了函数编写者与调用者之间的一种“默契”,而不是由类型系统强制执行的规则。在压力较大的环境中,或者在大型团队中,这种“默契”很容易被打破,尤其是在半夜发生紧急情况时。

几周前,我写了一篇文章《Dart中的高级错误处理:记录型数据、结果类型、单子模式与冻结异常》,专门讨论了如何利用这些技术来使错误处理过程更加有针对性、可见性,并且确保错误不可能被忽略。这篇文章本身就可以独立阅读,因此在继续讨论这个话题之前,我们先快速回顾一下那篇文章的内容吧。

我们将要讨论的内容:

  1. 上一篇文章的总结

  2. 这种模式使用后出现的问题

  3. DartExector的工作原理

  4. 核心类型的作用

  5. API的设计:四种方法,各自负责一项任务

  6. 这种设计在Clean Architecture中的位置

  7. 为什么不直接使用dartz呢?

  8. 亲自尝试一下吧

回顾:上一篇文章留下的内容

那篇文章探讨了多个层次的设计方案,每一层都是为了解决前一层所存在的局限性。

文章首先从使用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异常类型的实现细节,那篇文章会为你提供详细的解释。而这里的内容仅基于上述最终形成的技术架构进行讨论,并不涉及整个开发过程。

这种模式之后出现的问题

在我发表那篇文章之后,发生了以下这些事情。

每次我开始一个新的项目时,都会重复做同样的事情:重新创建那个被封装好的`Result`类,重新编写`Ok`和`Err`这两个类型,重新实现`map`、`flatMap`等其他函数。我会把大约150行的代码从一个项目复制到另一个项目中,对其中的一些细节进行微调,但偶尔也会因为忘记上次给某个变量起了什么名字,而导致不同项目之间存在不一致的地方。

这种模式本身是没有问题的,但重复编写这些代码确实很麻烦。

如果一种模式每次都需要被重新编写,那么它就根本算不上是一种模式,而只是一种繁琐的任务罢了。因此,我决定将这个流程封装成一个可重用的工具。

DartExceptor的工作原理

DartExceptor是一个轻量级的、不依赖任何外部库的Dart 3包。它实现了我在前一篇文章中提到的那种模式:`Trace`、`Ok`、`Err`,以及一些专门为这种模式设计出来的单子操作函数,从而使得这些功能可以被反复使用。

这个包不需要依赖`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:提取数据但转换失败

mapErrormap的作用相反,它用于处理转换过程中出现的错误。当数据的异常类型与业务逻辑层的期望不同时,这个方法非常有用:

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 等函数,更不存在传递性依赖关系。它只需要 TraceOkErr 以及四个方法,这些方法可以直接对应前一篇文章中介绍的实际使用方式。

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 上给它点个星吧!

Comments are closed.