AtomGit Flutter鸿蒙客户端:安全JSON解析
在对接 AtomGit v5 API 的过程中,项目遇到了一个棘手的问题:API 返回的 JSON 数据类型不完全一致。同一个字段在某些响应中返回整数,在另一些响应中返回字符串。
问题的根源
在对接 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→ 直接返回(最常见的情况,性能最优)value是String→ 尝试int.tryParse,失败返回 defaultValuevalue是null或其他类型(double、bool、List等)→ 返回 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;
}
两个处理分支:
- data 已是
Map<String, dynamic>→ 如果有 key 就提取内层 Map,否则直接返回 - 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,提供默认值,永远不抛异常。
更多推荐


所有评论(0)