网络与数据篇(1/6):网络层封装:请求、拦截器、统一错误码

11 阅读5分钟

网络层封装:请求、拦截器、统一错误码

系列:网络与数据篇· 第 1/6 篇


1. 问题背景

业务里网络代码最容易长成这样:

  • 每个接口各写一套 try/catch,错误文案有的弹 Toast、有的只打日志、有的直接白屏。
  • 后端返回结构不统一:有时是 {code, message, data},有时 HTTP 200 包里塞业务失败;客户端用 dynamic 一路解析到底。
  • Token 过期、切换环境、埋点、耗时统计全散在页面或某个「工具类」里,改一个地方要全文搜索。
  • 测试/联调时要换 Host、Mock、加日志,只能改代码或注释拦截器。

表面上「能发请求」,实际上没有一层能把「协议约定」钉死,稳定性完全依赖个人习惯。


2. 原因分析

核心就两点:缺边界、缺契约。

  1. 边界:HTTP 客户端(Dio/http)、业务解析、UI 反馈是三件事。混写时,拦截器不敢动——怕影响某个页面的奇怪逻辑。
  2. 契约:没有统一的「业务错误模型」(code + message + trace + 是否可重试),就不会有一致的 onError 分支;int code 和字符串魔法值满天飞。
  3. 排查:线上问题来一句「接口报错」,若没有 requestId / 统一日志格式,基本只能猜。

所以封装的目标不是「包一层 Dio」,而是固定流水线:发什么头、怎么序列化、失败怎么归类、成功怎么落到类型上。


3. 解决方案

3.1 整体形状(建议)

分层可以很薄,但角色要清楚:

职责
Client 工厂BaseUrl、超时、证书(如需)、单例/按环境注入
拦截器链Token、公共 Header、日志、耗时、刷新 Token、错误映射入口
ApiService按资源/模块声明方法,返回 Future<Response<T>> 或自定义 ApiResult
MapperDTO → Domain(下一篇会展开),网络层只认 DTO
错误类型AppException 枚举或 sealed class:networkbusinessauthcancel

拦截器顺序(常见做法,按项目微调):
请求侧:追加 Header → 打印/埋点
响应侧:先看 HTTP 状态 → 再解包业务 code → 映射为统一异常 → 必要时刷新 Token 重放(单独拆开,避免递归地狱)

3.2 统一错误码怎么处理

  • HTTP 层:4xx/5xx 先归到「传输/网关」类,带 statusCode
  • 业务层:只认后端的 code(或 errno),在一张表里映射:SESSION_EXPIRED → 跳转登录;NEED_VERIFY → 特定弹窗。不要到处 if (code == 40001)
  • 无法识别的 code:打上 unknown,带上原始 body 片段(注意脱敏)和 requestId,方便后端对账。

3.3 和 UI 的边界

网络层抛出或返回 已经分好类 的结果;是否 Toast、是否重试按钮,由页面或全局 ErrorHandler 决定。拦截器里直接 Navigator 弹全局登录 —— 小团队可以,但要承认这是技术债,至少集中在一处。


4. 关键代码

下面是最小可运行思路的片段(Dio 为例,http 包同理只是没有拦截器链,需自己包装)。

4.1 统一异常

sealed class AppException implements Exception {
  const AppException(this.message, {this.code, this.cause});

  final String message;
  final String? code;
  final Object? cause;
}

class NetworkException extends AppException {
  const NetworkException(super.message, {super.code, super.cause, this.statusCode});
  final int? statusCode;
}

class BusinessException extends AppException {
  const BusinessException(super.message, {super.code, super.cause});
}

class AuthException extends BusinessException {
  const AuthException(super.message, {super.code, super.cause});
}

4.2 响应体约定 + 解析

class ApiEnvelope<T> {
  ApiEnvelope({required this.code, required this.message, this.data});

  final int code;
  final String message;
  final T? data;

  bool get isSuccess => code == 0; // 按后端约定改
}

4.3 拦截器里收口业务 code

class BizCodeInterceptor extends Interceptor {
  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    final data = response.data;
    if (data is! Map) {
      return handler.next(response);
    }
    final code = data['code'];
    if (code is! int) {
      return handler.next(response);
    }
    if (code == 0) {
      return handler.next(response);
    }

    final msg = data['message']?.toString() ?? '业务失败';
    // 按 code 表映射
    if (code == 40101) {
      return handler.reject(DioException(
        requestOptions: response.requestOptions,
        response: response,
        type: DioExceptionType.badResponse,
        error: AuthException(msg, code: '$code'),
      ));
    }
    return handler.reject(DioException(
      requestOptions: response.requestOptions,
      response: response,
      type: DioExceptionType.badResponse,
      error: BusinessException(msg, code: '$code'),
    ));
  }
}

4.4 Client 组装

Dio createDio({required String baseUrl, String? token}) {
  final dio = Dio(BaseOptions(
    baseUrl: baseUrl,
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 20),
  ));

  dio.interceptors.addAll([
    LogInterceptor(requestBody: true, responseBody: true),
    if (token != null)
      InterceptorsWrapper(onRequest: (options, handler) {
        options.headers['Authorization'] = 'Bearer $token';
        handler.next(options);
      }),
    BizCodeInterceptor(),
  ]);

  return dio;
}

4.5 调用侧统一 runCatching(可选)

Future<R> guardApi<R>(Future<R> Function() run) async {
  try {
    return await run();
  } on DioException catch (e) {
    final err = e.error;
    if (err is AppException) throw err;
    throw NetworkException(e.message ?? '网络异常', cause: e, statusCode: e.response?.statusCode);
  }
}

真实项目里还可以:请求 IDx-request-id 或用 uuid)在 onRequest 写入、onError 打一行结构化日志。


5. 效果验证

可以从这几件事看有没有包好:

  1. 改后端错误码:只动映射表 + 1~2 个 UI 分支,而不是全仓库 grep 魔法数字。
  2. 联调:换 BaseUrl、开关日志、临时 Mock,只动 Client/注入,不动业务页面。
  3. 线上问题:日志里能看到同一请求的 path + method + requestId + businessCode,基本能定位是网关还是业务。
  4. Code Review:新接口 PR 里不应该再出现手写 dio.post + 散落的 try/catch 解析 Map

若你还没有「错误码表」,至少先维护一个 Markdown/枚举文档,和拦截器里的分支同步更新。


6. 可复用结论

  • 拦截器做「协议与横切」,ApiService 做「资源与方法」,页面只做展示与交互。
  • 业务失败与 HTTP 失败分开建模,否则重试、登录跳转、统计口径会搅在一起。
  • 魔法数字集中映射,比「每个接口各自判断」便宜得多。
  • 不要在拦截器里藏过多业务(例如复杂弹窗链);刷新 Token 要小心并发与重入。

避坑清单

  • 拦截器里 reject 了但外层又 handler.next,重复回调。
  • 刷新 Token 时所有请求一起重放,导致风暴;应用队列或「单飞」刷新。
  • 日志打完整 body,泄露手机号、Token。
  • dynamic 解析无兜底,null 崩溃留在生产。

下一篇预告:DTO/Model 转换策略与类型安全(怎么避免「半个 Map 在全网跑」)。