Files
conti-docs/05-networking.md
T

152 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 05. 网络层设计
## 决策
使用 **[dio](https://pub.dev/packages/dio)**`^5.11.0`2026-08 快照)作为唯一 HTTP client,统一封装在 `core_network` 包里,通过拦截器链处理鉴权、日志、异常归一化,不允许各 `feature_*` 自建 `Dio` 实例。
## 依赖
```yaml
dependencies:
dio: ^5.11.0
```
## 使用规则
- `core_network` 暴露一个单例 `Dio` 实例(通过 Riverpod provider 注入,见 [03-state-management.md](./03-state-management.md)),所有 `feature_*` 的 repository 只能通过依赖注入拿这个实例,不允许 `Dio()` 直接 new。
- 拦截器按固定顺序注册:`LogInterceptor`(仅 dev/staging 环境开启)→ `AuthInterceptor`(附加 token,401 时排队刷新)→ `ErrorMappingInterceptor`(把 `DioException` 统一转成项目自定义的 `AppException` 体系)。
- 业务代码只捕获 `AppException` 及其子类(如 `NetworkException``UnauthorizedException``BusinessException`),不直接处理 `DioException`——异常归一化只在 `core_network` 内部发生一次。
- 每个 feature 的 repository 只声明自己需要的接口方法,不直接拼接完整 URL 字符串到处写,`baseUrl` 和超时时间统一在 `core_network` 里按环境配置(见 [08-build-flavors.md](./08-build-flavors.md),待写)。
- 需要取消请求的场景(比如页面销毁时中断未完成的请求)统一使用 `CancelToken`,在对应 `Notifier`/`State``dispose` 时调用 `cancel()`
## 参考链接
- [dio 官方文档](https://pub.dev/packages/dio)
- [dio | Dart package](https://pub.dev/packages/dio)
- [Dio Interceptors 文档](https://pub.dev/packages/dio#interceptors)
## 附录:dio 是什么,日常怎么用
给还没接触过这套网络层封装方式的同学看的入门说明。
### 要解决的问题
Dart 内置的 `http` 包只提供最基础的请求能力,实际项目里几乎总会遇到这些需求:
1. **每个请求都要带 token**,token 过期了要先刷新再重试,不能让每个业务代码自己判断"要不要刷新 token"。
2. **统一的错误处理**:网络超时、服务端返回的业务错误码、HTTP 状态码错误,最终都要转换成 UI 层能直接判断的统一异常类型,而不是每个 feature 各自写一遍 `try/catch` 判断状态码。
3. **请求/响应日志**:调试时想看到完整的请求参数和返回值,上线后又不希望日志暴露敏感数据。
`http` 包本身没有"拦截器"这个概念,要实现上面这些都得自己在每次调用前后手动包一层逻辑,容易漏、容易不一致。**dio** 内置了 [`Interceptor`](https://pub.dev/packages/dio#interceptors) 机制,可以把这些横切逻辑集中写一次,所有请求自动生效。
### 核心概念
1. **`Dio` 实例**:一个 client 对象,带 `BaseOptions``baseUrl``connectTimeout` 等默认配置),项目里应该只有一个全局实例,而不是每个地方各建一个。
2. **`Interceptor`**:可以拦截请求发出前(`onRequest`)、响应回来后(`onResponse`)、出错时(`onError`)三个时机,多个拦截器按注册顺序像洋葱一样依次包裹。
3. **`DioException`**:dio 所有的错误(超时、连接失败、非 2xx 状态码等)都会包装成这个类型,可以在拦截器里统一识别、转换。
4. **`CancelToken`**:用来主动取消一个还没完成的请求,常见场景是页面销毁或用户切走时中断请求,避免拿到结果时 widget 已经不存在了。
### 使用示例(token 自动附加 + 401 自动刷新排队 + 异常归一化)
```dart
// packages/core_network/lib/src/dio_client.dart
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(BaseOptions(
baseUrl: ref.watch(appEnvProvider).apiBaseUrl, // 见 08-build-flavors.md
connectTimeout: const Duration(seconds: 10),
));
dio.interceptors.addAll([
if (ref.watch(appEnvProvider).enableLog) LogInterceptor(responseBody: false),
AuthInterceptor(ref),
ErrorMappingInterceptor(),
]);
return dio;
});
```
```dart
// packages/core_network/lib/src/auth_interceptor.dart
class AuthInterceptor extends Interceptor {
final Ref _ref;
bool _isRefreshing = false;
final _pendingRequests = <Completer<void>>[];
AuthInterceptor(this._ref);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = _ref.read(authStateProvider).accessToken;
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401) return handler.next(err);
if (_isRefreshing) {
// 已经有一个刷新请求在跑,排队等它完成,避免并发刷新
final completer = Completer<void>();
_pendingRequests.add(completer);
await completer.future;
return handler.resolve(await _retry(err.requestOptions));
}
_isRefreshing = true;
try {
await _ref.read(authRepositoryProvider).refreshToken();
for (final c in _pendingRequests) {
c.complete();
}
_pendingRequests.clear();
handler.resolve(await _retry(err.requestOptions));
} catch (_) {
_ref.read(authStateProvider.notifier).logout();
handler.next(err);
} finally {
_isRefreshing = false;
}
}
Future<Response> _retry(RequestOptions options) {
final dio = _ref.read(dioProvider);
return dio.fetch(options);
}
}
```
```dart
// packages/core_network/lib/src/error_mapping_interceptor.dart
class ErrorMappingInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final mapped = switch (err.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout => NetworkException('网络超时'),
DioExceptionType.badResponse when err.response?.statusCode == 401 =>
UnauthorizedException(),
DioExceptionType.badResponse => BusinessException(
err.response?.data['message'] ?? '请求失败',
code: err.response?.statusCode,
),
_ => NetworkException('网络异常,请稍后重试'),
};
handler.reject(DioException(requestOptions: err.requestOptions, error: mapped));
}
}
```
业务层代码只需要这样写,不用关心 dio 内部细节:
```dart
try {
final stores = await storeRepository.fetchNearbyStores(lat, lng);
} on UnauthorizedException {
// 跳登录页
} on AppException catch (e) {
// 统一展示 e.message
}
```