DTO / Model 转换策略与类型安全:别让 Map 和 dynamic 统治你的业务层
系列:网络与数据篇(2/6)|
FlutterDartJSONDTO类型安全架构
业务接口一多,团队里最容易出现两种极端:要么全程 Map<String, dynamic> 随手取值,线上才爆 null/类型错;要么一个接口五个层层嵌套的 fromJson,改个字段全仓库爆红。
本篇聚焦一件事:数据从「线的边界」进 App 之后,怎样转成类型安全、可演进的领域表示,以及 DTO 与 Model 怎么分工。
1. 问题背景:业务场景 + 现象
- 场景:多端协议、列表嵌套、枚举/状态码字符串、老接口 nullable 不一致、新接口加字段。
- 常见现象:
- UI 或 Repository 里直接
data['user']?['profile']?['nick'],重命名就炸,IDE 也没法重构。 fromJson里塞默认业务逻辑:if (type == 1) ... else ...,解析和规则搅在一起。- 同一实体在网络层叫
UserDto,业务层还叫UserDto,边界消失,改 DTO 等于改全栈心智。 json_serializable生成代码和手写混用,null 安全策略不统一(required/defaultValue各写各的)。- 线上 Crash:
int下发了"1"、空串、null,没有在边界拦住。
- UI 或 Repository 里直接
2. 原因分析:核心原理 + 排查过程
2.1 核心原理:类型的「责任边界」
- JSON/DTO:反映「线上真实形状」,允许丑、允许历史包袱,尽量贴近响应体。
- Domain Model(实体 / 值对象):反映「业务上可靠的概念」,字段语义明确、约束清晰。
- UI Model(可选):列表项、表单等展示态,可与 Domain 分离,避免把排序/选中塞进实体。
类型安全的收益不在「少写几行」,而在:编译期拦住一批错误、重构可机械推进、无效态在类型里不可表示(尽量)。
2.2 排查过程(团队自检)
- 全局搜
Map<String, dynamic>、as dynamic、深层[],看是否越过 Repository。 - 看
fromJson是否包含分支业务规则;若有,多半该下沉为 Mapper 或纯函数。 - 列一张表:同一概念网络名 vs 代码名(如
nick_namevsnickname),是否有多套并存。 - 对典型接口做 fixture JSON(含 null、缺字段、类型漂移)跑一次解析测试。
3. 解决方案:方案对比 + 最终选择
3.1 几种策略对比
| 策略 | 优点 | 缺点 |
|---|---|---|
A. 全 Map + 运行时取值 | 写得快 | 无类型、难重构、错误在运行时 |
| B. 单类贯穿「Dto = Model」 | 文件少 | 边界模糊,接口抖动直接冲击业务 |
| C. DTO + 显式映射到 Domain | 边界清、Domain 稳定 | 多一层映射代码(可用代码生成减轻) |
| D. freezed + union 表联合状态 | 适合强状态机 | 学习成本、生成代码管理要规范 |
3.2 推荐默认(多数业务项目)
- 网络响应 → DTO(可
json_serializable/freezed);禁止在 Widget 层手 parse。 - Repository 出口只暴露 Domain Model(或
Result/Either包装的 Model)。 - 映射集中写:
UserMapper.toDomain(UserDto dto),单元测试只打 Mapper + fixture。 - 可空与默认值策略在 DTO 层统一:
@JsonKey(defaultValue: …)、自定义fromJson的小函数,不要散在 UI。 - 「脏数据」在边界消化:类型不一致时
tryParse、clamp、fallback,进入 Domain 后不再猜。
3.3 与「第一篇网络封装」的衔接
- 拦截器把 HTTP + 业务 code 变成统一的
ApiException/Failure。 - DTO 只关心 body 内的 data 形状;登录态、traceId、toast 文案不在 DTO 里处理。
这样分层是:网络层管通信与错误码,数据层管形状与映射,业务层管规则。
4. 关键代码:最小必要代码片段
以下示例刻意保持短小,说明「分层 + 映射」骨架(命名可按项目调整)。
4.1 DTO:贴近 JSON,容忍可空
import 'package:json_annotation/json_annotation.dart';
part 'user_dto.g.dart';
@JsonSerializable()
class UserDto {
const UserDto({
required this.id,
this.nickName,
this.ageRaw,
});
final String id;
@JsonKey(name: 'nick_name')
final String? nickName;
/// 线上有时是 String/int/null
@JsonKey(name: 'age')
final Object? ageRaw;
factory UserDto.fromJson(Map<String, dynamic> json) => _$UserDtoFromJson(json);
}
4.2 Domain:业务可靠类型
class User {
const User({
required this.id,
required this.displayName,
required this.age,
});
final String id;
final String displayName;
final int age; // Domain 层「必须有年龄策略」时可改为 int? 并配套 UI
}
4.3 Mapper:脏数据在边界挡住
class UserMapper {
static User toDomain(UserDto dto) {
final nick = (dto.nickName ?? '').trim();
final displayName = nick.isEmpty ? '未命名用户' : nick;
final age = _parseAge(dto.ageRaw);
return User(
id: dto.id,
displayName: displayName,
age: age,
);
}
static int _parseAge(Object? raw) {
if (raw == null) return 0;
if (raw is int) return raw;
if (raw is double) return raw.toInt();
if (raw is String) return int.tryParse(raw) ?? 0;
return 0;
}
}
4.4 Repository:对外只返回 Domain
class UserRepository {
UserRepository(this._client);
final ApiClient _client;
Future<User> me() async {
final json = await _client.get<Map<String, dynamic>>('/user/me');
final dto = UserDto.fromJson(json['data'] as Map<String, dynamic>);
return UserMapper.toDomain(dto);
}
}
4.5 列表 DTO:List 映射保持纯函数
List<Order> mapOrders(List<dynamic>? raw) {
if (raw == null) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(OrderDto.fromJson)
.map(OrderMapper.toDomain)
.toList(growable: false);
}
要点:whereType / 显式 cast 在边界做掉,避免 map 中间出现 null 元素拖进 Domain。
5. 效果验证:数据 / 截图 / 日志
可从这几项验收(不要求一次上齐):
- 单测:
test/fixtures/user/normal.json、user_bad_age.json、user_missing_nick.json三套输入,断言User字段与边界行为。 - 静态分析:业务目录禁止
Map<String, dynamic>(可用dart_code_metrics/ 自定义 lint 约定)。 - 重构:改
nick_name→nickname,只动 DTO + Mapper,Domain 与 UI 可保持不变(若对外语义未变)。 - 线上:解析阶段日志(仅 debug)记录 丢弃字段 / 异常类型,快速定位后端放量问题。
6. 可复用结论:通用经验 + 避坑清单
经验小结
- DTO 是「 wire 形状」,Domain 是「业务形状」;二者合一适合小项目,长生命周期项目迟早付费。
- 映射是纯函数最好测:比测 Widget、测网络都便宜。
- 可空策略要成文:哪些字段「缺了当 0」、哪些「缺了必须报错」,写进 Mapper 注释或团队文档,避免每人猜。
- 代码生成只解决样板,不解决语义;
@JsonKey改名、默认值仍要人决策。
避坑清单
- Widget / Provider 里手写
json['a']['b']。 -
fromJson里调用全局Toast、埋点、导航。 - Domain 直接使用
Dto类型字段名(如仍叫nick_name)。 - 对「可能为 int 也可能是 String」的字段在多层复制粘贴
_parseXxx。 - 没有 fixture,单靠真机连调才发现解析问题。
下期预告
- 第 3 篇:缓存策略(内存、磁盘、失效机制)
- 第 4 篇:列表分页与并发请求优化