问题的根源

在对接 AtomGit v5 API 的过程中,项目遇到了一个棘手的问题:API 返回的 JSON 数据类型不完全一致。同一个字段在某些响应中返回整数,在另一些响应中返回字符串。典型例子包括:

// 正常情况——仓库 ID 是整数
{ "id": 12345, "stargazers_count": 1500 }

// 异常情况——仓库 ID 成了字符串
{ "id": "12345", "stargazers_count": "1500" }

如果在代码中使用 Dart 的 as 强制转换:

final id = json['id'] as int;

json['id'] 实际是 "12345" 这个字符串时,Dart 运行时会抛出异常:

type 'String' is not a subtype of type 'int' in type cast

这个错误会导致整个页面崩溃。对于一个需要展示大量数据的客户端应用来说,因为一个字段的类型异常就让整个页面不可用,这是不可接受的。

为什么会出现类型不一致

类型不一致在 REST API 中并不罕见,主要有几个原因:

1. 后端语言特性。AtomGit 的后端可能使用 Ruby on Rails(GitLab 的技术栈)。Ruby 是弱类型语言,JSON 序列化时数字类型可能被转换为字符串,尤其当数字超过 JavaScript 的安全整数范围(2^53)时。

2. 数据库迁移。随着数据库从 MySQL 迁移到 PostgreSQL 或引入分库分表,某些 ID 字段从 INT 变为 BIGINT。中间件为了兼容性,可能将 BIGINT 转为字符串传输。

3. 不同版本的 API。某些字段在 API 演进过程中改变了类型。旧版客户端依赖整数,新版 API 可能返回字符串。

4. 字段语义变化stargazers_count 超过一定阈值后,后端可能切换到估算值,并返回字符串以区分精确值和估算值。

解决方案的架构设计

面对类型不确定的 JSON 数据,项目创建了一个集中式的安全解析工具模块 json_parser.dart。所有 Model 的 fromJson 方法统一使用这个模块的解析函数,不再直接使用 as 类型转换。
在这里插入图片描述

// 改造前:不安全
factory Repository.fromJson(Map<String, dynamic> json) {
  return Repository(
    id: json['id'] as int,                    // 可能崩溃
    stargazersCount: json['stargazers_count'] as int,  // 可能崩溃
    name: json['name'] as String,             // 可能崩溃
  );
}

// 改造后:安全
factory Repository.fromJson(Map<String, dynamic> json) {
  return Repository(
    id: parseInt(json['id']),
    stargazersCount: parseInt(json['stargazers_count']),
    name: parseString(json['name']),
  );
}

parseInt

int parseInt(dynamic value, [int defaultValue = 0]) {
  if (value is int) return value;
  if (value is String) return int.tryParse(value) ?? defaultValue;
  return defaultValue;
}

处理三种情况:

  • value 已是 int → 直接返回(最常见的情况,性能最优)
  • valueString → 尝试 int.tryParse,失败返回 defaultValue
  • valuenull 或其他类型(doubleboolList 等)→ 返回 defaultValue

为什么用 int.tryParse 而不是 int.parse

int.parse 在解析失败时抛出 FormatException,需要额外的 try-catch。int.tryParse 失败时返回 null,配合 ?? 即可得到默认值,代码更简洁。

为什么 defaultValue 是 0?

对于大部分整数字段(id、计数类),0 是合理的兜底值。调用方可以传入自定义默认值:

// 使用自定义默认值
parseInt(json['age'], -1)  // 如果解析失败,返回 -1 表示未知

parseInt 的边界情况测试

parseInt(42)        → 42      // 标准 int
parseInt("42")      → 42      // 数字字符串
parseInt("3.14")    → 0       // 浮点数字符串,int.tryParse 返回 null
parseInt("not_num") → 0       // 非数字字符串
parseInt(null)      → 0       // null
parseInt(3.14)      → 0       // double 类型
parseInt(true)      → 0       // bool 类型
parseInt([])        → 0       // List 类型
parseInt("99999999999999999999") → 0  // 超出 int 范围

最后一个边界情况值得注意。如果 API 返回了一个超过 Dart int 范围(64 位有符号整数)的数字字符串,int.tryParse 返回 null,最终得到 defaultValue。对于仓库 ID 这类实际值不会超过 64 位范围的字段,这不是问题。

parseString

在这里插入图片描述

String parseString(dynamic value, [String defaultValue = '']) {
  if (value is String) return value;
  if (value == null) return defaultValue;
  return value.toString();
}

处理路径:

  • 已经是 String → 直接返回(零开销)
  • null → 返回 defaultValue
  • 其他类型 → 调用 toString() 兜底

toString() 的兜底行为在不同类型上的表现:

  • 123.toString()"123"(合理的转换)
  • 3.14.toString()"3.14"(保留小数)
  • true.toString()"true"(布尔变字符串)
  • ["a","b"].toString()"[a, b]"(不理想但不会崩溃)

最后一种情况在正常的 API 数据中极少出现。但即使出现,toString() 至少保证了不会崩溃,这是设计的第一原则。

parseDateTime

在这里插入图片描述

DateTime? parseDateTime(dynamic value) {
  if (value is String) return DateTime.tryParse(value);
  if (value is DateTime) return value;
  return null;
}

返回类型是 DateTime?(可空),与 parseInt 的 int(非空)不同。这是因为时间字段在业务上常常不存在(例如仓库可能从未 push 过,pushed_at 为空)。

两种输入类型:

  • 字符串:ISO 8601 格式,如 "2024-01-15T10:30:00Z""2024-01-15T10:30:00+08:00"
  • DateTime 对象:某些内部序列化场景可能直接传入

DateTime.tryParse 支持多种 ISO 8601 变体:

"2024-01-15"                    → DateTime(2024, 1, 15)
"2024-01-15T10:30:00Z"         → DateTime(2024, 1, 15, 10, 30, 0)
"2024-01-15T10:30:00+08:00"    → DateTime(2024, 1, 15, 2, 30, 0) UTC
"invalid"                       → null

parseList

这是最复杂的解析函数,处理三种不同的数据结构:

List<T>? parseList<T>(
  dynamic data, [
  String? key,
]) {
  // 策略 1:data 本身就是 List
  if (data is List) {
    try {
      return data.cast<T>();
    } catch (_) {
      return null;
    }
  }

  // 策略 2:data 是 Map,从指定 key 提取 List
  if (data is Map<String, dynamic>) {
    if (key != null && data.containsKey(key)) {
      final v = data[key];
      if (v is List) {
        try {
          return v.cast<T>();
        } catch (_) {
          return null;
        }
      }
    }

    // 策略 3:遍历 Map 的值,找第一个 List
    for (final v in data.values) {
      if (v is List) {
        try {
          return v.cast<T>();
        } catch (_) {
          return null;
        }
      }
    }
  }

  return null;
}

策略 1:直接列表

API 直接返回 JSON 数组时使用:

["item1", "item2", "item3"]

/user/repos 端点返回这种格式。

策略 2:从指定 key 提取

API 返回信封或分页结构时使用:

{
  "data": {
    "total_count": 150,
    "items": [{...}, {...}]
  }
}

调用 parseList<dynamic>(response.data, 'items') 提取 items 数组。

策略 3:自动发现

当不确定 key 名称时(例如不同的 API 版本可能用不同的 key 名包装列表),遍历 Map 的 values 寻找第一个 List。这是一种智能的兜底策略,减少了代码中对 API 响应结构的硬编码假设。

泛型约束

parseList<T> 是泛型方法,T 由调用方指定。但 Dart 的泛型在运行时是擦除的(与 Java 相同),所以 cast<T>() 在运行时实际上不做类型检查——它只是返回原始 List。真正的类型安全来自后续的 whereType<Map<String, dynamic>>()

final items = parseList<dynamic>(response.data, 'items') ?? [];
_repos = items
    .whereType<Map<String, dynamic>>()
    .map(Repository.fromJson)
    .toList();

whereType<Map<String, dynamic>>() 在运行时真正过滤元素类型,非 Map 元素会被静默丢弃。

parseMap

Map<String, dynamic>? parseMap(
  dynamic data, [
  String? key,
]) {
  // 策略 1:data 本身就是 Map<String, dynamic>
  if (data is Map<String, dynamic>) {
    if (key != null && data.containsKey(key)) {
      final v = data[key];
      if (v is Map<String, dynamic>) return v;
      return null;
    }
    return data;
  }

  // 策略 2:data 是泛型 Map(无类型参数)
  if (data is Map) {
    return Map<String, dynamic>.from(data);
  }

  return null;
}

两个处理分支:

  1. data 已是 Map<String, dynamic> → 如果有 key 就提取内层 Map,否则直接返回
  2. data 是未指定类型参数的 Map → 通过 Map.from 安全转换

在 Provider 中的使用

解析函数不仅在 Model 中使用,Provider 也需要安全地从 API 响应中提取数据:

// 提取仓库列表(数据可能在 data 字段或直接是数组)
final reposData = parseList<dynamic>(response.data) ?? [];
_repos = reposData
    .whereType<Map<String, dynamic>>()
    .map(Repository.fromJson)
    .toList();

// 提取用户数据(单对象)
final userData = parseMap(response.data);
if (userData != null) {
  _user = UserProfile.fromJson(userData);
}

// 提取分页列表(从 items 字段)
final items = parseList<dynamic>(response.data, 'items') ?? [];
repos = items
    .whereType<Map<String, dynamic>>()
    .map(Repository.fromJson)
    .toList();

为什么不用第三方库

社区有一些 JSON 解析库(如 json_annotation + json_serializable)提供代码生成和类型安全。但项目选择了手动实现,原因:

1. 零依赖。不引入代码生成工具、build_runner、额外的注解库,减少依赖复杂度和编译时间。

2. API 类型不稳定。代码生成库的强类型假设在类型不一致的 API 面前同样脆弱。如果生成的代码是 json['id'] as int,遇到字符串同样崩溃。修复需要自定义 fromJson,与手动编写没有区别。

3. 代码量小。5 个解析函数合计不到 80 行代码。引入一整套代码生成工具链的复杂度远高于这 80 行代码。

4. 编译速度。不使用 build_runner 意味着开发中不需要等待代码生成步骤,热重载周期更短。

5. 可调试性。手动编写的解析逻辑可以直接打断点调试。代码生成的 .g.dart 文件不方便调试。

防御链的完整层级

整个数据流经过多层防护:

HTTP 响应 (String)
  ↓ jsonDecode
动态 JSON (dynamic)
  ↓ _unwrapEnvelope —— 识别并解包信封
数据体 (dynamic)
  ↓ parseList / parseMap —— 安全提取结构化数据
List / Map (泛型)
  ↓ whereType<Map<String, dynamic>>() —— 过滤非 Map 元素
List<Map<String, dynamic>>
  ↓ Repository.fromJson —— 各字段使用安全解析
Repository (不可变对象)
  ↓ ownerAndName —— 计算属性安全提取导航参数
Route 参数 (Map<String, dynamic>)

每一层都有独立的防护,单层失效不会导致整体崩溃。这种深度防御的设计使应用在面对 API 异常数据时具有很高的韧性。

扩展:添加新的解析函数

随着 API 使用的深入,可能需要新的解析函数。例如解析布尔值:

bool parseBool(dynamic value, [bool defaultValue = false]) {
  if (value is bool) return value;
  if (value is String) {
    final lower = value.toLowerCase();
    if (lower == 'true') return true;
    if (lower == 'false') return false;
  }
  if (value is int) return value != 0;
  return defaultValue;
}

或者解析 double:

double parseDouble(dynamic value, [double defaultValue = 0.0]) {
  if (value is double) return value;
  if (value is int) return value.toDouble();
  if (value is String) return double.tryParse(value) ?? defaultValue;
  return defaultValue;
}

新增函数遵循与现有函数一致的模式:参数类型为 dynamic,提供默认值,永远不抛异常。

Logo

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

更多推荐