AtomGit Flutter鸿蒙客户端:安全JSON解析

问题的根源

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

json 复制代码
// 正常情况------仓库 ID 是整数
{ "id": 12345, "stargazers_count": 1500 }

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

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

dart 复制代码
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 类型转换。

dart 复制代码
// 改造前:不安全
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

dart 复制代码
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 是合理的兜底值。调用方可以传入自定义默认值:

dart 复制代码
// 使用自定义默认值
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

dart 复制代码
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

dart 复制代码
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

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

dart 复制代码
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 数组时使用:

json 复制代码
["item1", "item2", "item3"]

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

策略 2:从指定 key 提取

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

json 复制代码
{
  "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>>()

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

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

parseMap

dart 复制代码
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 响应中提取数据:

dart 复制代码
// 提取仓库列表(数据可能在 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 使用的深入,可能需要新的解析函数。例如解析布尔值:

dart 复制代码
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:

dart 复制代码
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,提供默认值,永远不抛异常。

相关推荐
坚果派·白晓明20 小时前
【鸿蒙PC】SDL3 适配:AtomCode + Skills 快速集成 NAPI 测试工具
c++·华为·ai编程·harmonyos·atomcode
2601_9619633821 小时前
技术解剖:哈希值、区块链与CA认证如何守护电子合同安全?
网络·人工智能·安全·区块链·智能合约·政务
科技林总21 小时前
解决vllm服务漏扫问题
python·安全
YM52e21 小时前
男孩子在外自我保护指南——用鸿蒙 ArkTS 构建交互式安全教育应用
学习·安全·华为·harmonyos·鸿蒙·鸿蒙系统
祭曦念1 天前
古诗小集开发实战:从零开发一款 HarmonyOS 古诗鉴赏应用
pytorch·深度学习·harmonyos
Par@ish1 天前
【网络安全】Web安全扫描工具Nikto安装和使用详细教程
安全·web安全·ubuntu
namexingyun1 天前
拆解Fable 5三重安全护栏:模型路由、蒸馏防护与生物安全分类器的技术原理 - 微元算力(weytoken)
java·人工智能·python·安全·架构·ai编程
全栈若城1 天前
HarmonyOS AppUtil 应用配置控制:颜色模式/灰度/字体/语言/键盘避让详解
华为·harmonyos·arkts·harmonyos6·键盘避让·字体缩放
FrameNotWork1 天前
HarmonyOS 6.1 Lottie动画集成完全指南:从踩坑到精通
华为·harmonyos