请添加图片描述

架构设计

网络层是客户端应用最关键的底层基础设施。它的设计目标有三个:封装 HTTP 通信细节使上层代码简洁、处理 API 特有的响应格式(信封解包、错误码映射)、追踪频率限制信息供 UI 展示和限流预警。

项目由三个核心类组成:

职责 所在文件
AtomGitApiClient HTTP 请求封装、Header 管理、响应处理、分页加载 core/network/api_client.dart
ApiResponse 标准化响应模型(data + rate limit 信息) core/network/api_client.dart
ApiException 类型化异常,含错误类型枚举和中文消息 core/network/api_client.dart

AtomGitApiClient 核心设计

依赖注入

ApiClient 通过 Provider 注入,而非直接 new 或单例:

// app.dart 中的全局注入
Provider<AtomGitApiClient>(
  create: (_) => AtomGitApiClient(),
)

// 各页面通过 context.read 获取
final apiClient = context.read<AtomGitApiClient>();

通过 Provider 注入的好处是可以轻松替换为 Mock 实现进行测试。生产代码使用真实的 HTTP 客户端,测试代码可以注入返回固定数据的 Mock。

核心属性

在这里插入图片描述

class AtomGitApiClient {
  final http.Client _httpClient;
  String? _accessToken;
  int rateLimitRemaining = ApiConstants.rateLimitUnauthenticated;
  DateTime? rateLimitReset;

  AtomGitApiClient({http.Client? httpClient})
      : _httpClient = httpClient ?? http.Client();
}

_httpClient 支持可选注入——生产环境使用 http.Client(),测试环境可以注入自定义 Client。rateLimitRemaining 默认值设为未认证用户的限制(60 次/小时),登录后会逐步更新为认证用户的配额(5000 次/小时)。

Header 构建

Map<String, String> get _headers => {
  'Accept': 'application/json',
  'Content-Type': 'application/json',
  'X-Api-Version': '2023-02-21',
  if (_accessToken != null)
    'Authorization': 'Bearer $_accessToken',
};

四个 Header 的设计考量:

Accept: application/json:告知服务器客户端期望 JSON 格式。AtomGit API 默认返回 JSON,此 Header 是明确声明而非必需。

Content-Type: application/json:告知 POST/PUT 请求的 Body 类型。GET 请求携带此 Header 无影响。

X-Api-Version: 2023-02-21:AtomGit 特定的 API 版本标识。这是访问 AtomGit API 的必需 Header,不携带会返回错误。版本号是日期格式,表示该版本 API 规范的发布日期。

Authorization: Bearer <token>:条件性 Header。if (_accessToken != null) 是 Dart 的集合条件语法——仅在 Token 非空时添加。未登录状态下的请求不携带此 Header,只能访问公开端点。

GET 请求

Future<ApiResponse> get(
  String path, {
  Map<String, String>? queryParams,
}) async {
  final uri = _buildUri(path, queryParams);
  final response = await _httpClient.get(uri, headers: _headers);
  return _processResponse(response);
}

POST 请求

Future<ApiResponse> post(
  String path, {
  Map<String, dynamic>? body,
}) async {
  final uri = _buildUri(path);
  final response = await _httpClient.post(
    uri,
    headers: _headers,
    body: body != null ? jsonEncode(body) : null,
  );
  return _processResponse(response);
}

URI 构建

Uri _buildUri(String path, Map<String, String>? queryParams) {
  final baseUrl = ApiConstants.baseUrl; // https://api.atomgit.com/api/v5
  final uri = Uri.parse('$baseUrl$path');

  if (queryParams != null && queryParams.isNotEmpty) {
    return uri.replace(queryParameters: queryParams);
  }
  return uri;
}

使用 Uri.parse + replace(queryParameters:) 而非手动拼接字符串。Uri 类自动处理特殊字符的编码,避免手动 Uri.encodeComponent 的遗漏。

响应处理

信封解包

AtomGit API 使用统一信封格式包裹响应数据:

{
  "data": { "id": 1, "name": "example" },
  "code": 200,
  "message": "success"
}

_unwrapEnvelope 方法自动识别并解包:

dynamic _unwrapEnvelope(dynamic body) {
  if (body is Map<String, dynamic>) {
    if (body.containsKey('data') &&
        (body.containsKey('code') ||
         body.containsKey('message'))) {
      return body['data'];
    }
  }
  return body; // 不是信封格式,原样返回
}

解包条件:body 是 Map、包含 data 键、包含 codemessage 键。三个条件都满足时,提取 data 字段内容。否则原样返回。

注意判断逻辑的演变。早期版本要求 datacodemessage 三个键全部存在才解包。但 AtomGit API 的实际返回中,codemessage 不一定同时出现。放宽条件为 codemessage 任一存在即可,提高了兼容性。

完整响应处理流程

ApiResponse _processResponse(http.Response response) {
  _updateRateLimit(response);

  if (response.statusCode >= 200 && response.statusCode < 300) {
    final body = _tryParseBody(response.body);
    return ApiResponse(
      data: _unwrapEnvelope(body),
      statusCode: response.statusCode,
      rateLimitRemaining: rateLimitRemaining,
      rateLimitReset: rateLimitReset,
    );
  }

  throw _mapError(response);
}

成功(2xx)→ 解析 body → 解包信封 → 返回 ApiResponse。
失败(4xx/5xx)→ 映射为类型化 ApiException → 抛出异常。

JSON 解析防护

dynamic _tryParseBody(String body) {
  try {
    return jsonDecode(body);
  } catch (_) {
    return body; // 解析失败时返回原始字符串
  }
}

如果服务器返回的不是合法 JSON(例如 HTML 错误页面),jsonDecode 会抛异常。_tryParseBody 捕获异常并返回原始字符串,避免整个请求因解析失败而崩溃。

频率限制追踪

void _updateRateLimit(http.Response response) {
  final remaining =
      response.headers['x-ratelimit-remaining'];
  final reset =
      response.headers['x-ratelimit-reset'];

  if (remaining != null) {
    rateLimitRemaining =
        int.tryParse(remaining) ?? rateLimitRemaining;
  }
  if (reset != null) {
    final ts = int.tryParse(reset);
    if (ts != null) {
      rateLimitReset =
          DateTime.fromMillisecondsSinceEpoch(ts * 1000);
    }
  }
}

每次 API 响应后自动更新限流状态。rateLimitRemaining 可在设置页面展示给用户,也可用于内部限流预警(在达到阈值前主动降低请求频率)。

AtomGit 的限流机制

  • 未认证用户:60 次/小时(基于 IP)
  • 认证用户:5000 次/小时(基于 Token)
  • 超限后 API 返回 403,x-ratelimit-remaining 为 0

重置时间x-ratelimit-reset 是一个 Unix 时间戳(秒级),表示当前限流窗口重置的时间。前端可以根据这个时间展示"X 分钟后恢复"。

错误映射

ApiException _mapError(http.Response response) {
  final body = _tryParseBody(response.body);
  String? msg;
  if (body is Map) {
    msg = (body['error_message'] ??
           body['message'])
          ?.toString();
  }

  switch (response.statusCode) {
    case 401:
      return ApiException.unauthorized(
          msg ?? '认证失效,请重新登录');
    case 403:
      if (rateLimitRemaining <= 0) {
        return ApiException.rateLimited(
            '请求频率超限,请稍后重试');
      }
      return ApiException.forbidden(
          msg ?? '无权限访问该资源');
    case 404:
      return ApiException.notFound(
          msg ?? '资源不存在');
    case 422:
      return ApiException.validationError(
          msg ?? '请求参数错误');
    default:
      if (response.statusCode >= 500) {
        return ApiException.serverError(
            msg ?? '服务器错误,请稍后重试');
      }
      return ApiException.unknown(
          msg ?? '未知错误 (${response.statusCode})');
  }
}

错误消息的优先级

错误消息来源按优先级依次为:

  1. API 返回的 error_message:AtomGit 特定的详细错误描述
  2. API 返回的 message:通用错误消息
  3. 默认中文描述:兜底消息,确保用户始终看到可读的中文提示

error_message 优先于 message,因为 AtomGit 的 error_message 通常包含更具体的原因(如"Token 已过期"而非通用的"认证失败")。

403 的特殊处理

403 有两种可能原因,通过 rateLimitRemaining 区分:

  • 限流触发(rateLimitRemaining <= 0):提示"请求频率超限"
  • 权限不足:提示"无权限访问该资源"

这个区分很重要——如果用户被限流,提示应引导用户等待而非重复请求。

ApiException 类型体系

enum ApiErrorType {
  unauthorized,    // 401 — Token 失效或未提供
  forbidden,       // 403 — 权限不足
  notFound,        // 404 — 资源不存在
  rateLimited,     // 429 — 被限流
  serverError,     // 5xx — 服务器内部错误
  validationError, // 422 — 请求参数校验失败
  networkError,    // 网络连接失败(客户端侧)
  unknown,         // 其他未分类错误
}

class ApiException implements Exception {
  final String message;
  final ApiErrorType type;
  final int? statusCode;

  const ApiException({
    required this.message,
    required this.type,
    this.statusCode,
  });

  // 工厂构造函数,简化创建
  factory ApiException.unauthorized(String msg) =>
      ApiException(
          message: msg,
          type: ApiErrorType.unauthorized,
          statusCode: 401);

  factory ApiException.notFound(String msg) =>
      ApiException(
          message: msg,
          type: ApiErrorType.notFound,
          statusCode: 404);

  // ... 其他工厂构造函数

  
  String toString() => message;
}

使用工厂构造函数为每种错误类型提供标准创建方式。Provider 层可以根据 type 做差异化处理(例如,401 错误触发自动登出,429 错误展示"等待 X 分钟后重试")。

分页加载(getAllPages)

对于需要获取全部数据的场景,封装了自动翻页方法:

Future<List<Map<String, dynamic>>> getAllPages(
  String path, {
  int perPage = 30,
  int maxPages = 10,
  Map<String, String>? extraParams,
}) async {
  final allItems = <Map<String, dynamic>>[];
  var page = 1;

  while (page <= maxPages) {
    final params = {
      'page': page.toString(),
      'per_page': perPage.toString(),
      ...?extraParams,
    };
    final response = await get(path, queryParams: params);

    if (response.data is List) {
      final items =
          (response.data as List).cast<Map<String, dynamic>>();
      if (items.isEmpty) break;  // 无更多数据,停止
      allItems.addAll(items);
    } else {
      break;  // 非列表数据,停止(可能已经是最后一页)
    }
    page++;
  }

  return allItems;
}

安全机制:

  • maxPages(默认 10):防止无限循环。即使 API 持续返回数据,最多请求 10 页(300 条),保护客户端和服务器
  • items.isEmpty 检查:数据为空时停止翻页
  • response.data is List 检查:响应格式异常时停止

在 Provider 中的典型使用

Provider 是 ApiClient 的主要消费者,典型的使用模式:

class SomeProvider extends ChangeNotifier {
  final AtomGitApiClient _apiClient;

  Future<void> load() async {
    try {
      final response = await _apiClient.get(
        '/some/endpoint',
        queryParams: {'sort': 'updated'},
      );

      // 安全提取数据
      final data = parseList<dynamic>(response.data) ?? [];
      _items = data
          .whereType<Map<String, dynamic>>()
          .map(Item.fromJson)
          .toList();
    } on ApiException catch (e) {
      // API 层已翻译为用户友好的消息
      _error = e.message;
    } catch (e) {
      // 网络层未捕获的异常(如 jsonDecode 失败)
      _error = '加载失败,请检查网络连接';
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

为什么外层还需要 try-catch?ApiClient 可能抛出非 ApiException 的异常——例如 DNS 解析失败时的 SocketException、请求超时的 TimeoutException。这些异常不是 HTTP 错误(没有 statusCode),不能被 _mapError 捕捉。外层 catch 确保这些异常也不会让应用崩溃。

请求/响应的完整链路

Provider.load()
  ↓
ApiClient.get('/repos/owner/name')
  ↓ _buildUri() — 拼接 baseUrl + path + queryParams
  ↓ _headers — 添加 Accept, Content-Type, X-Api-Version, Authorization
  ↓ _httpClient.get(uri, headers: _headers)
  ↓ HTTP 请求发送到 https://api.atomgit.com/api/v5/repos/owner/name
  ↓ DNS 解析 → TCP 连接 → TLS 握手 → 发送 HTTP 请求
  ↓
服务器处理
  ↓
HTTP 响应(状态码 + Header + Body)
  ↓ _updateRateLimit() — 从 Header 读取限流信息
  ↓ _processResponse()
  ↓   statusCode 2xx?
  │   ├─ YES → _tryParseBody → _unwrapEnvelope → return ApiResponse
  │   └─ NO  → _mapError → throw ApiException
  ↓
Provider 收到 ApiResponse
  ↓ parseList / parseMap 提取数据
  ↓ Model.fromJson 转换
  ↓ notifyListeners() 通知 UI

设计决策

为什么不用 Dio 或 Chopper?

Dio 和 Chopper 是 Dart 社区流行的 HTTP 库,提供拦截器、请求重试、文件上传等高级功能。但本项目选择了最基础的 http 包,原因:

  1. 需求简单。应用只做 GET 和 POST,不需要文件上传、下载进度、WebSocket 等高级功能。http 包完全满足需求。

  2. 保持轻量。dart:http 是 Dart 内置库,零额外依赖。Dio 和 Chopper 各自有复杂的依赖树,增加应用体积和潜在兼容性问题。

  3. 完全控制。信封解包、错误映射、限流追踪都是 AtomGit 特有的需求。使用 Dio 的拦截器机制同样需要自定义代码,与手写的复杂度相当。

  4. HarmonyOS 兼容。HarmonyOS 的 Flutter 引擎对原生网络 API 的支持最稳定,而 Dio 的底层平台通道可能在 HarmonyOS 上存在未测试的边缘情况。

为什么使用 http.Client 而非静态方法?

注入 http.Client 实例(而非使用全局静态方法)使得测试时可以注入 Mock Client:

// 测试代码
final mockClient = MockHttpClient();
final apiClient = AtomGitApiClient(httpClient: mockClient);

如果使用 http.get() 静态方法,无法在测试中替换为 mock,因为静态方法调用在编译器就绑定了。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐