一、网络层混乱的代价
2024 年的一次线上事故,让我意识到网络层必须有统一架构。
事故的过程很简单:用户反馈部分聊天消息发送失败后没有重试,消息状态始终停留在「发送中」。排查后发现原因更简单——网络层没有统一的异常处理和重试策略。
具体来说,当时的项目里有 4 种不同的网络请求方式:
- 部分模块用
dio 直接发请求
- 部分模块封装了自己的
HttpClient 类
- 部分模块通过 Platform Channel 走原生侧的 AFNetworking
- WebView 轻应用里的 H5 用
fetch 走自己的逻辑
四种方式各有各的错误处理和重试策略,有的做了 Token 自动刷新,有的没有。消息发送使用的那个模块刚好没有兜底的重试逻辑,网络抖动一次就永久失败。
这个事故促使我们启动了网络层的统一封装。本文将复盘整个过程,包括整体架构设计、拦截器链、缓存策略、安全机制和跨平台适配。
二、网络层的三层架构
1 2 3 4 5 6 7 8 9 10
| ┌─────────────────────────────────────────┐ │ 业务层 (Business Layer) │ │ Repository / Service / Dao │ ├─────────────────────────────────────────┤ │ 网络抽象层 (Network API) │ │ ApiClient 接口 / 请求模型 / 响应模型 │ ├─────────────────────────────────────────┤ │ 网络实现层 (Network Impl) │ │ dio 封装 / 拦截器链 / 证书管理 / 加解密 │ └─────────────────────────────────────────┘
|
2.1 网络抽象层:业务不关心底层实现
最上层定义统一的 ApiClient 接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| abstract class ApiClient { Future<ApiResponse<T>> get<T>( String path, { Map<String, dynamic>? queryParams, T Function(Map<String, dynamic>)? fromJson, });
Future<ApiResponse<T>> post<T>( String path, { Map<String, dynamic>? body, T Function(Map<String, dynamic>)? fromJson, });
Future<ApiResponse<Uint8List>> download(String url, {String? savePath, ProgressCallback? onProgress});
Future<ApiResponse<T>> upload<T>( String path, MultipartFile file, { Map<String, dynamic>? fields, ProgressCallback? onProgress, }); }
|
业务层只依赖 ApiClient 接口,不 import dio 的任何类。这保证了网络库的可替换性——如果将来需要从 dio 切换到 http 包或其他方案,只需修改实现层。
2.2 网络实现层:基于 dio 的完整封装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| class DioApiClient implements ApiClient { late final Dio _dio;
DioApiClient({required String baseUrl, required List<Interceptor> interceptors}) { _dio = Dio(BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), sendTimeout: const Duration(seconds: 15), headers: {'Content-Type': 'application/json'}, )); _dio.interceptors.addAll(interceptors); }
@override Future<ApiResponse<T>> get<T>(String path, {Map<String, dynamic>? queryParams, T Function(Map<String, dynamic>)? fromJson}) async { final response = await _dio.get(path, queryParameters: queryParams); return _parseResponse(response, fromJson); }
ApiResponse<T> _parseResponse<T>(Response response, T Function(Map<String, dynamic>)? fromJson) { if (response.statusCode == 200) { final data = response.data; if (data == null) return ApiResponse.success(null); if (fromJson != null && data is Map<String, dynamic>) { return ApiResponse.success(fromJson(data)); } return ApiResponse.success(data as T); } return ApiResponse.error(ApiException(response.statusCode ?? -1, response.statusMessage ?? 'Unknown error')); } }
|
三、拦截器链:网络层的心脏
拦截器链是网络层最核心的设计。我们设计了五个拦截器,按顺序执行:
3.1 Token 拦截器(第一优先级)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| class TokenInterceptor extends Interceptor { final TokenStorage _tokenStorage; bool _isRefreshing = false; final _pendingRequests = <RequestOptions>[];
@override void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { final token = await _tokenStorage.getAccessToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); }
@override void onError(DioException err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode == 401) { if (!_isRefreshing) { _isRefreshing = true; try { final newToken = await _refreshToken(); _tokenStorage.saveAccessToken(newToken); for (final request in _pendingRequests) { request.headers['Authorization'] = 'Bearer $newToken'; _dio.fetch(request).then((r) => handler.resolve(r)); } _pendingRequests.clear(); err.requestOptions.headers['Authorization'] = 'Bearer $newToken'; final response = await _dio.fetch(err.requestOptions); handler.resolve(response); } catch (e) { _pendingRequests.clear(); handler.reject(err); } finally { _isRefreshing = false; } } else { _pendingRequests.add(err.requestOptions); } } else { handler.next(err); } } }
|
这个拦截器解决了 Token 管理中最棘手的两个问题:
并发刷新控制:多个请求同时收到 401 时,只有第一个触发刷新,其余加入等待队列。刷新成功后统一重试,避免多次重复刷新。
请求排队:等待中的请求不会丢失,刷新完成后按顺序重试。
在企业 IM 中,聊天页面的消息同步、会话列表的增量更新、通讯录的增量同步可能同时发起。_isRefreshing 和 _pendingRequests 的组合保证了这些并发请求在 Token 过期时不会全部失败。
3.2 日志拦截器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| class LogInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { log('[HTTP] ${options.method} ${options.uri}'); log('[HTTP] headers: ${options.headers}'); if (options.data != null) { final safeData = _redactSensitiveData(options.data); log('[HTTP] body: $safeData'); } handler.next(options); }
@override void onResponse(Response response, ResponseInterceptorHandler handler) { log('[HTTP] ${response.statusCode} ${response.requestOptions.uri}'); log('[HTTP] duration: ${response.requestOptions.extra['startTime'] != null ? DateTime.now().difference(response.requestOptions.extra['startTime']).inMilliseconds : '?'}ms'); handler.next(response); }
@override void onError(DioException err, ErrorInterceptorHandler handler) { log('[HTTP] ERROR ${err.type} ${err.message}'); handler.next(err); }
Map<String, dynamic> _redactSensitiveData(dynamic data) { if (data is Map<String, dynamic>) { final safe = Map<String, dynamic>.from(data); ['token', 'password', 'accessToken'].forEach((key) { if (safe.containsKey(key)) safe[key] = '***'; }); return safe; } return {}; } }
|
两个关键细节:生产环境下对敏感字段脱敏,记录每个请求的耗时用于性能监控。
3.3 重试拦截器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| class RetryInterceptor extends Interceptor { final int maxRetries; final List<DioExceptionType> retryableErrors = [ DioExceptionType.connectionTimeout, DioExceptionType.receiveTimeout, DioExceptionType.connectionError, ];
RetryInterceptor({this.maxRetries = 3});
@override void onError(DioException err, ErrorInterceptorHandler handler) async { final retryCount = err.requestOptions.extra['retryCount'] as int? ?? 0;
if (retryCount < maxRetries && retryableErrors.contains(err.type)) { err.requestOptions.extra['retryCount'] = retryCount + 1; await Future.delayed(Duration(seconds: 1 << retryCount)); try { final response = await _dio.fetch(err.requestOptions); handler.resolve(response); } catch (e) { handler.next(err); } } else { handler.next(err); } } }
|
指数退避(Exponential Backoff) 策略:重试间隔依次为 1s、2s、4s,避免在服务端瞬时压力大时发起重试风暴。
重要决策:只重试连接层错误(超时、连接失败),不重试业务层错误(400/500 等 HTTP 状态码)。业务层错误的重试策略应该由业务层自己决定——比如消息发送失败是否重试、扣款请求是否重试,这些决策不能由网络层替业务层做。
3.4 加解密拦截器
企业 IM 中部分敏感数据需要端到端加密(如机密会话的消息体)。加解密拦截器在请求发出前加密 body,在收到响应后解密:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| class EncryptionInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { if (options.extra['encrypt'] == true) { options.data = CryptoUtil.encrypt(options.data); } handler.next(options); }
@override void onResponse(Response response, ResponseInterceptorHandler handler) { if (response.requestOptions.extra['encrypt'] == true) { response.data = CryptoUtil.decrypt(response.data); } handler.next(response); } }
|
注意加解密拦截器放在拦截器链的最后,确保加解密发生在 Token 注入和日志记录之后。这样日志中可以看到加密前的请求参数(用于调试)和加密后的网络传输内容(确保安全)。
3.5 缓存拦截器
对于不频繁变化的数据(如企业组织架构、部门列表、表情包列表),我们使用缓存拦截器减少网络请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| class CacheInterceptor extends Interceptor { final CacheStorage _cache;
@override void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { final cacheDuration = options.extra['cacheDuration'] as Duration?; if (cacheDuration != null) { final cachedData = await _cache.get(options.uri.toString()); if (cachedData != null && !cachedData.isExpired) { handler.resolve(Response( requestOptions: options, data: cachedData.data, statusCode: 200, )); return; } } handler.next(options); }
@override void onResponse(Response response, ResponseInterceptorHandler handler) { final cacheDuration = response.requestOptions.extra['cacheDuration'] as Duration?; if (cacheDuration != null && response.statusCode == 200) { _cache.set(response.requestOptions.uri.toString(), CacheEntry( data: response.data, expiresAt: DateTime.now().add(cacheDuration), )); } handler.next(response); } }
|
使用方式:业务层在请求时通过 extra['cacheDuration'] 声明缓存策略,不想缓存的请求不需要做任何改变。
四、跨平台适配
4.1 证书锁定(SSL Pinning)
企业 IM 使用自签名证书的内网环境,需要支持证书锁定:
1 2 3 4 5 6 7 8 9 10 11 12 13
| class CertificateInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { if (options.extra['useCertificatePin'] == true) { (_dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) { client.badCertificateCallback = (cert, host, port) { return _verifyCertificate(cert, host); }; }; } handler.next(options); } }
|
4.2 鸿蒙平台的网络适配
鸿蒙平台的网络栈与 Android/iOS 差异较大。在鸿蒙适配过程中,我们遇到了两个问题:
问题一:鸿蒙的 HTTP 客户端对 connectionTimeout 参数处理方式不同,部分请求在弱网环境下连接超时时间比预期短。解决方案是在鸿蒙平台单独调大超时参数。
问题二:鸿蒙的代理设置不会自动生效。在开发环境下需要通过 options.extra['proxy'] 指定 Charles 或 Fiddler 的代理地址,鸿蒙网络适配层自动检测并设置。
五、实战效果
统一网络层上线后的效果:
| 指标 |
统一前 |
统一后 |
| 消息发送成功率 |
97.2% |
99.8% |
| Token 过期导致的重登录率 |
5%(每天) |
0.1%(每天) |
| 网络层相关线上事故 |
4 次/季度 |
0 次/季度 |
| 新增接口的开发时间 |
2 天(含异常处理) |
0.5 天 |
最大的收益不是性能指标,而是网络层的问题有了唯一的排查入口。以前线上出现网络异常时,需要逐个模块排查各自的网络实现。现在所有请求都经过同一个拦截器链,日志和监控数据集中,排查效率提升了 10 倍以上。
六、总结
网络层封装的本质是建立统一的关注点分离机制:
- 业务层不关心网络细节。
ApiClient 接口屏蔽了 dio、http 的实现差异。
- 横切关注点集中在拦截器链。Token 管理、日志、重试、加解密、缓存——这些横切关注点通过拦截器统一处理,而不是散落在各个业务模块中。
- 可配置、可插拔。每个请求通过
extra 参数声明自己的特殊需求(是否加密、缓存多久、是否重试),不声明的走默认策略。
一个精心设计的网络层,对业务开发者来说应该是「透明的」——发一个请求,拿回一个结果,中间发生了什么不需要关心。而对架构师来说,它必须是「可控的」——每一个请求的完整生命周期,都在拦截器链的监控和约束之下。