App HTTP 客户端

App HTTP Client 是 Dio HTTP 库的一个封装,旨在使网络请求和错误处理更简单、更可预测、更简洁。

教程

注意:本教程是多部分系列的第一部分,我们将构建一个支持身份验证的应用程序,该应用程序提供匿名和已认证的用户流程,同时将数据存储在云端。作者将尽其所能,在任何可能的时候提供关于最佳实践的见解。

有没有想过如何为你的 Flutter 应用程序构建一个简单的 HTTP 客户端?在本教程中,我们将围绕多平台 http 库 Dio 创建一个封装,它支持拦截器、全局配置、表单数据、请求取消、文件下载和超时——几乎是你需要的一切。

为什么?

为什么创建 http 客户端封装?答案主要是“使错误处理变得容易和可预测”。典型的状态驱动型应用程序受益于清晰定义的、有限的错误集。

作为这个应用程序开发系列的一部分,我们将在后续的教程中利用这个客户端来构建我们的应用程序服务层、领域层和状态管理——所有这些都将受益于该封装提供的错误解析。

通过仔细捕获 Dio 错误并将其转换为我们的应用程序关心的简单错误,我们可以极大地简化应用程序状态中的错误处理——正如你所知,简单的代码更容易测试。由于我们使用 Bloc 进行状态管理,我们将以一种使我们的 bloc 内部的错误处理变得轻松的方式来构建我们的封装。

即使你采取了不同的方法,我们也希望你觉得这里介绍的组织技术对你的应用程序有用,无论你使用的是什么 http 库和状态管理框架。

创建 Dart 包

我们计划重用这个 http 客户端封装,所以让我们把它做成一个包。我们只需要创建一个 Dart 包(而不是 Flutter 包)。我们将它命名为 app_http_client,以便在我们的应用程序中使用它,但你可以为你自己的包命名。 ![wink](https://github.githubassets.com/images/icons/emoji/unicode/1f609.png =20x20)

使用 Dart 创建包相当简单(一旦你知道所需的命令行标志)

dart create --template package-simple app_http_client
cd app_http_client
git init
# Open VS Code from the Terminal, if you've installed the VS Code Shell Extensions:
code . 

要运行带有覆盖率的测试,您需要在 .gitignore 文件中添加以下内容

# Code coverage
coverage/
test/.test_coverage.dart

依赖项

在开始编码之前,让我们设置好我们的依赖项。

生产依赖项

  • Dio——由于我们正在为 Dio 创建封装,我们需要包含它。

开发依赖项

  • test_coverage——使我们能够轻松收集测试覆盖率。
  • Very Good Analysis——我们将使用这些 linter 规则作为开发依赖项,以保持我们的代码库干净整洁且风格一致。
  • Mocktail——提供空安全模拟功能,灵感来自 Mockito

让我们将依赖项添加到 pubspec.yaml

dependencies:
  dio: ^4.0.0

dev_dependencies:
  test: ^1.16.0
  test_coverage: ^0.5.0
  mocktail: ^0.1.4
  very_good_analysis: ^2.1.2

确保你已从 pubspec.yaml 中删除了 Dart 创建新项目时自动添加的 pedantic 开发依赖项。

analysis_options 的内容替换为以下内容

include: package:very_good_analysis/analysis_options.yaml

最后,你可能想在项目根目录中创建一个 .vscode 文件夹,其中包含一个 launch.json 文件,以便你可以运行测试

{
	"version": "0.2.0",
	"configurations": [
		{
			"name": "Run Tests",
			"type": "dart",
			"request": "launch",
			"program": "./test/"
		},
	]
}

运行以下命令,你就拥有了一个新项目

flutter pub get
git add . # Add all files
git commit -m "Initial commit"

要运行带有测试覆盖率的测试,你可以使用以下命令

dart run test_coverage && genhtml -o coverage coverage/lcov.info
open coverage/index.html

问题

想象一下,你有一个非常简单的服务类,它从你的团队的后端服务器获取数据,也许是这样的

import 'package:dio/dio.dart';

class UserService {
  UserService({required this.client});

  final Dio client;

  Future<Map<String, dynamic>?> createUser({
    required String email,
    required String password,
  }) async {
    final response = await client.post<Map<String, dynamic>>('/users', data: {
      'email': email,
      'password': password,
    });
    return response.data;
  }
}

虽然这段代码简单而友好,但它缺少一些关键细节。特别是,没有简单的方法来处理它抛出的错误。在服务类的每个方法中捕获错误可能会导致重复代码,而在服务层之上捕获错误会迫使上面的抽象层处理 Dio 特定的错误。

目前,http 库(在本例中是 Dio)抛出的任何错误都将传播到调用 UserService 的函数。这种错误传播通常是预期的——但如果你的服务器产生你想捕获的验证错误呢?你在哪里拦截它们?

更复杂的是,你如何区分来自你的服务器的预期验证错误(它们可能故意包含某些失败的 http 状态码)与其他失败的请求——例如网络错误或其他运行时错误——由你的 http 客户端库抛出?

由于后端错误响应通常在多个 API 路由之间保持一致,因此总是逐个处理错误的做法可能导致冗余代码,而这些代码难以测试。

以下是我们如果为每个服务方法添加 try/catch 子句时可能的样子。为简洁起见,我们省略了任何必要的自定义错误处理,并在你可能在实际应用程序中找到更多错误处理的地方留下了注释和 rethrow 语句。

  Future<Map<String, dynamic>?> createUser({
    required String email,
    required String password,
  }) async {
    try {
    final response = await client.post<Map<String, dynamic>>('/users', data: {
      'email': email,
      'password': password,
    });
    return response.data;
    } catch (e) {
      // Check which type of error e is, unwrap error response data, 
      // throw custom exceptions, etc
      rethrow;
    }
  }

程序员经常通过引入另一个抽象层来避免这个问题——就像任何其他架构问题一样。你可能认识到这是经典的 适配器装饰器模式

在这种情况下,我们通过简单地创建一个包装所选 http 库的类来避免大多数冗余的错误处理子句。

虽然这有点繁琐,但它可以使错误处理代码更简单、更简洁。此外,使用该封装来执行网络请求的服务开发者无需记住利用常见响应模式的后端服务的细节。

如果我们让该封装能够灵活地处理错误,我们可以大大降低给定应用程序的错误处理复杂性。如果需要,每个服务都可以利用不同的 http 客户端封装,为可能具有相似后端响应模式的 API 请求组提供自定义错误处理。

希望这里提供的代码能让你免于经历很多必需的单调工作,因为你可以根据 MIT 许可证自由复制和粘贴。

考虑错误

要捕获错误,必须了解可以捕获的错误种类。让我们暂停一下,描述一下网络应用程序可能会感兴趣的错误。

核心上,我们的应用程序对 3 种基本错误感兴趣

  • 网络错误
    • 有时,格式正确的请求由于网络状况不佳、数据包丢失、信号差、服务器繁忙等原因而失败,而这些并非其本身的过错。
  • 响应错误
    • 服务器已收到请求,但服务器返回了错误的响应——可能带有指示问题的 http 状态码。验证错误、重定向、格式错误的请求、没有适当授权的请求等,都可能导致服务器拒绝。
    • 就应用程序状态逻辑而言,这些错误最有可能具有实际用途。也许你的后端表单验证系统依赖于从服务器返回的某些错误模式来描述提交的无效字段。
  • 其他 / 运行时错误
    • http 库或服务层代码中的其他错误可能导致问题——跟踪这些对于开发人员很重要,但对于用户而言,这些错误在功能上与网络错误基本相同,除非它完全破坏了应用程序。

我们希望我们的 http 库生成的错误能转换为这 3 种错误类型之一。为了促进这一点,我们需要创建 3 个错误类。

为了方便我们的应用程序进行错误处理,我们将响应错误视为网络错误的子类型。将错误放入以下类层次结构中应该可以大大简化状态管理

   ┌──────────────────────────┐
   │  AppHttpClientException  │
   └──────────────────────────┘
                 ▲
                 │
   ┌──────────────────────────┐
   │   AppNetworkException    │
   └──────────────────────────┘
                 ▲
                 │
 ┌───────────────────────────────┐
 │  AppNetworkResponseException  │
 └───────────────────────────────┘

AppHttpClientException 是基类。对于我们的封装生成的任何错误,表达式 (error is AppHttpClientException) 应该始终为 true

让我们看看实现

class AppHttpClientException<OriginalException extends Exception>
    implements Exception {
  AppHttpClientException({required this.exception});
  final OriginalException exception;
}

相当直接——我们在 AppHttpClientException 的构造函数中需要原始异常,以便开发人员可以轻松调试与所使用的 http 库相关的错误。

此外,编写 AppHttpClientException 的应用特定子类的开发人员可以传递其他异常,如果需要,这些异常可以进一步表示错误类型。

我们可以同样简单地描述网络异常

enum AppNetworkExceptionReason {
  canceled,
  timedOut,
  responseError
}

class AppNetworkException<OriginalException extends Exception>
    extends AppHttpClientException<OriginalException> {
  /// Create a network exception.
  AppNetworkException({
    required this.reason,
    required OriginalException exception,
  }) : super(exception: exception);

  /// The reason the network exception ocurred.
  final AppNetworkExceptionReason reason;
}

最后,我们可以为网络响应错误创建一个类

class AppNetworkResponseException<OriginalException extends Exception, DataType>
    extends AppNetworkException<OriginalException> {
  AppNetworkResponseException({
    required OriginalException exception,
    this.statusCode,
    this.data,
  }) : super(
          reason: AppNetworkExceptionReason.responseError,
          exception: exception,
        );

  final DataType? data;
  final int? statusCode;
  bool get hasData => data != null;

  /// If the status code is null, returns false. Otherwise, allows the
  /// given closure [evaluator] to validate the given http integer status code.
  ///
  /// Usage:
  /// ```
  /// final isValid = responseException.validateStatusCode(
  ///   (statusCode) => statusCode >= 200 && statusCode < 300,
  /// );
  /// ```
  bool validateStatusCode(bool Function(int statusCode) evaluator) {
    final statusCode = this.statusCode;
    if (statusCode == null) return false;
    return evaluator(statusCode);
  }
}

鼓励开发人员为应用程序特定的响应错误子类化 AppNetworkResponseException。稍后将详细介绍。

现在我们的基本错误层次结构已经到位,是时候创建 http 客户端封装类了。

创建 HTTP 客户端封装

我们希望我们的封装接收一个预配置的 Dio 实例,以便实例化该封装的代码能够完全控制网络请求。通过将 Dio 实例注入我们的封装,它鼓励开发人员利用 Dio 提供的所有功能——例如请求拦截器。

我们的封装应该为每个 http 请求方法(如 GETPOSTPUTPATCH 等)提供一个方法。这些方法应该将它们的参数传递给 Dio 实例,并通过以统一的方式捕获错误来执行相关的错误处理。

注意:Dio 公开了许多方法,但我们只对封装使用 String 路径而不是 Uri 的方法感兴趣,这在此场景中似乎过于复杂。

让我们创建一个符合我们标准的类

/// A callback that returns a Dio response, presumably from a Dio method
/// it has called which performs an HTTP request, such as `dio.get()`,
/// `dio.post()`, etc.
typedef HttpLibraryMethod<T> = Future<Response<T>> Function();

/// Function which takes a Dio response object and optionally maps it to an
/// instance of [AppHttpClientException].
typedef ResponseExceptionMapper = AppNetworkResponseException? Function<T>(
  Response<T>,
  Exception,
);

class AppHttpClient {
  AppHttpClient({required Dio client, this.exceptionMapper}) : _client = client;

  final Dio _client;

  final ResponseExceptionMapper? exceptionMapper;

    /// HTTP GET request.
  Future<Response<T>> get<T>(
    String path, {
    Map<String, dynamic>? queryParameters,
    Options? options,
    CancelToken? cancelToken,
    ProgressCallback? onReceiveProgress,
  }) {
    return _mapException(
      () => _client.get(
        path,
        queryParameters: queryParameters,
        options: options,
        cancelToken: cancelToken,
        onReceiveProgress: onReceiveProgress,
      ),
    );
  }

  // ...
  // 
  // see repository for full implementation
  // 
  // ...

  Future<Response<T>> _mapException<T>(HttpLibraryMethod<T> method) async {
    try {
      return await method();
    } on DioError catch (exception) {
      switch (exception.type) {
        case DioErrorType.cancel:
          throw AppNetworkException(
            reason: AppNetworkExceptionReason.canceled,
            exception: exception,
          );
        case DioErrorType.connectTimeout:
        case DioErrorType.receiveTimeout:
        case DioErrorType.sendTimeout:
          throw AppNetworkException(
            reason: AppNetworkExceptionReason.timedOut,
            exception: exception,
          );
        case DioErrorType.response:
          // For DioErrorType.response, we are guaranteed to have a
          // response object present on the exception.
          final response = exception.response;
          if (response == null || response is! Response<T>) {
            // This should never happen, judging by the current source code
            // for Dio.
            throw AppNetworkResponseException(exception: exception);
          }
          throw exceptionMapper?.call(response, exception) ??
              AppNetworkResponseException(
                exception: exception,
                statusCode: response.statusCode,
                data: response.data,
              );
        case DioErrorType.other:
        default:
          throw AppHttpClientException(exception: exception);
      }
    } catch (e) {
      throw AppHttpClientException(
        exception: e is Exception ? e : Exception('Unknown exception ocurred'),
      );
    }
  }
}

错误处理机制

我们封装的真正核心隐藏在私有方法 _mapException() 中。它接受一个名为 method 的参数,它是一个回调(应该调用 Dio 方法)。

_mapException 通过 return await method(); 返回已等待回调的结果,在此过程中捕获任何错误。如果没有发生错误,它只会返回回调返回的任何内容(在本例中,将是回调调用的 Dio 方法返回的响应对象)。

如果发生错误,情况会变得更加有趣。封装中发生的错误处理取决于你选择的 http 库。由于我们使用的是 Dio,我们知道 Dio 已经将所有错误封装在 DioError 对象中。

Dio 的错误相当不错,但我们不希望我们的应用程序代码中的错误处理直接与任何特定的 http 库绑定。如果你需要更改你正在使用的 http 库,编写另一个满足类似接口的封装类比搜索整个应用程序代码中的 http 库特定错误处理要容易得多。

注意:有一个例外——由于我们的方法直接封装了 Dio 的方法,因此参数的类型仅在 Dio 库中找到,例如 OptionsCancelTokenProgressCallback 等。调用我们封装的泛型委托方法的应用程序服务代码在传递这些对象时仍然会绑定到 Dio,但如果更换到另一个封装和 http 库,仅在服务层更改此类详细信息应该相当直接。

我们可以停止并编写一个平台无关的 http 请求接口库,但其回报与所需的大量工作相比微不足道。虽然如果你突然切换 http 库,它将使你免于触碰任何服务代码,但像这样切换依赖项似乎并不像是有充分理由创建一个完整的精心设计的接口库。你还需要为你打算支持的每种 http 库创建并维护从平台无关类到平台特定类的映射……

_mapException 的其余部分继续将每种类型的 Dio 错误映射到我们关心的 3 种错误类型之一。一切都很简单,除了响应错误。

如果仅此而已,我们的封装将没有什么用处。我们创建封装的主要原因是允许使用该封装的代码提供自定义响应错误处理。_mapException 方法使用一些可选的链式调用和空值合并运算符,将任何包含有效响应对象(具有预期的响应类型)的 Dio 响应错误委托给一个可选的映射函数——如果该回调在封装的构造函数中提供: ResponseExceptionMapper? exceptionMapper

exceptionMapper 函数接收两个参数:第一个是 Response<T> 类型的 Dio 响应对象(其中 T 是传递给封装的数据类型,通常对于 JSON 是 Map<String, dynamic>),第二个是捕获的原始异常。

以防你还不确定,你可以在调用我们封装的泛型委托方法时传入预期的类型来指定 Dio 期望返回的数据类型

// Perform a GET request with a JSON return type: Map<String, dynamic>
final response = appHttpClient.get<Map<String, dynamic>>('url');

以下是 Dio 支持的一些响应类型

client.get<Map<String, dynamic>>() // JSON data
client.get<String>()               // Plain text data
client.get<ResponseBody>()         // Response stream
client.get<List<int>>()            // Raw binary data (as list of bytes)

你可以根据自己的喜好实现 exceptionMapper 函数。如果你不知道如何处理 Dio 响应,只需返回 null,让 AppHttpClient 使用默认错误处理逻辑来封装响应错误。如果你的 exceptionMapper 函数能够识别某种响应,它可以返回一个 AppNetworkResponseException 的实例或子类,以更好地表示错误。

在下一节中,我们将构建一个示例 exceptionMapper,该示例将解开它接收到的某种后端错误。

处理错误

想象一下,你定义了以下服务,它调用你的支持物联网的茶壶并错误地命令它冲泡 coffee

import 'package:dio/dio.dart';

class TeaService {
  TeaService({required this.client});

  final AppHttpClient client;

  Future<Map<String, dynamic>?> brewTea() async {
    final response = await client.post<Map<String, dynamic>>(
      '/tea',
      data: {
        'brew': 'coffee',
      },
    );
    return response.data;
  }
}

由于你发出了错误的请求,茶壶应该响应一个 418 我是个茶壶 错误。也许它甚至会在其响应体中回复 JSON 数据

{
  "message": "I can't brew 'coffee'"
}

让我们同时假设,你想捕获这些特定的错误并将它们封装在一个错误类中,保留服务器的错误消息,以便你可以将其显示给你的远程茶水冲泡应用程序的用户。

你只需要做这些

class TeapotResponseException extends AppNetworkResponseException {
  TeapotResponseException({
    required String message,
  }) : super(exception: Exception(message));
}

final client = AppHttpClient(
  client: Dio(),
  exceptionMapper: <T>(Response<T> response) {
    final data = response.data;
    if (data != null && data is Map<String, dynamic>) {
      // We only map responses containing data with a status code of 418:
      return TeapotResponseException(
        message: data['message'] ?? 'I\'m a teapot',
      );
    }
    return null;
  },
);

注意:因为 Dart 泛型是具体化的,你可以在 exceptionMapper 函数中检查响应数据的类型。

要使用你的服务并处理茶壶错误,你只需要这样做

final teaService = TeaService(client: client);

try {
  await teaService.brewTea();
} on TeapotResponseException catch (teapot) {
  print(teapot.exception.toString()); 
} catch (e) {
  print('Some other error');
}

请注意,你可以访问错误的data,因为你创建了一个自定义的 TeapotResponseException 类。此外,它与 Dart 的 try/catch 子句 无缝集成。Dart 开箱即用的 try/catch 子句对于捕获特定类型的错误非常有用——这正是我们的封装所帮助我们的!

所以差不多就是这样了——我个人认为创建一个自定义 http 客户端封装是值得的。 ![stuck_out_tongue_winking_eye](https://github.githubassets.com/images/icons/emoji/unicode/1f61c.png =20x20)

测试

一个仅用于封装错误的封装,如果其代码中有导致它抛出未被封装的错误的错误,那将是完全无用的。哇,这 mouthful。我们可以通过保持封装代码尽可能简单并测试其所有功能来防止这种灾难。

为了防止这种灾难,我尽量减少了封装的代码量,并尽我所能对其进行了测试。由于代码的简洁性,你可以 在此处查看测试,以确保你对其功能满意。

GitHub

https://github.com/definitelyokay/app_http_client