网络与数据篇(2/6):DTO/Model 转换策略与类型安全

3 阅读5分钟

DTO / Model 转换策略与类型安全:别让 Mapdynamic 统治你的业务层

系列:网络与数据篇(2/6)|Flutter Dart JSON DTO 类型安全 架构

业务接口一多,团队里最容易出现两种极端:要么全程 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没有在边界拦住

2. 原因分析:核心原理 + 排查过程

2.1 核心原理:类型的「责任边界」

  • JSON/DTO:反映「线上真实形状」,允许丑、允许历史包袱,尽量贴近响应体
  • Domain Model(实体 / 值对象):反映「业务上可靠的概念」,字段语义明确、约束清晰
  • UI Model(可选):列表项、表单等展示态,可与 Domain 分离,避免把排序/选中塞进实体。

类型安全的收益不在「少写几行」,而在:编译期拦住一批错误、重构可机械推进、无效态在类型里不可表示(尽量)

2.2 排查过程(团队自检)

  1. 全局搜 Map<String, dynamic>as dynamic、深层 [],看是否越过 Repository。
  2. fromJson 是否包含分支业务规则;若有,多半该下沉为 Mapper 或纯函数
  3. 列一张表:同一概念网络名 vs 代码名(如 nick_name vs nickname),是否有多套并存。
  4. 对典型接口做 fixture JSON(含 null、缺字段、类型漂移)跑一次解析测试。

3. 解决方案:方案对比 + 最终选择

3.1 几种策略对比

策略优点缺点
A. 全 Map + 运行时取值写得快无类型、难重构、错误在运行时
B. 单类贯穿「Dto = Model」文件少边界模糊,接口抖动直接冲击业务
C. DTO + 显式映射到 Domain边界清、Domain 稳定多一层映射代码(可用代码生成减轻)
D. freezed + union 表联合状态适合强状态机学习成本、生成代码管理要规范

3.2 推荐默认(多数业务项目)

  1. 网络响应 → DTO(可 json_serializable / freezed;禁止在 Widget 层手 parse。
  2. Repository 出口只暴露 Domain Model(或 Result/Either 包装的 Model)。
  3. 映射集中写UserMapper.toDomain(UserDto dto),单元测试只打 Mapper + fixture。
  4. 可空与默认值策略在 DTO 层统一@JsonKey(defaultValue: …)、自定义 fromJson 的小函数,不要散在 UI
  5. 「脏数据」在边界消化:类型不一致时 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.jsonuser_bad_age.jsonuser_missing_nick.json 三套输入,断言 User 字段与边界行为。
  • 静态分析:业务目录禁止 Map<String, dynamic>(可用 dart_code_metrics / 自定义 lint 约定)。
  • 重构:改 nick_namenickname,只动 DTO + Mapper,Domain 与 UI 可保持不变(若对外语义未变)。
  • 线上:解析阶段日志(仅 debug)记录 丢弃字段 / 异常类型,快速定位后端放量问题。

6. 可复用结论:通用经验 + 避坑清单

经验小结

  1. DTO 是「 wire 形状」,Domain 是「业务形状」;二者合一适合小项目,长生命周期项目迟早付费。
  2. 映射是纯函数最好测:比测 Widget、测网络都便宜。
  3. 可空策略要成文:哪些字段「缺了当 0」、哪些「缺了必须报错」,写进 Mapper 注释或团队文档,避免每人猜。
  4. 代码生成只解决样板,不解决语义@JsonKey 改名、默认值仍要人决策。

避坑清单

  • Widget / Provider 里手写 json['a']['b']
  • fromJson 里调用全局 Toast、埋点、导航。
  • Domain 直接使用 Dto 类型字段名(如仍叫 nick_name)。
  • 对「可能为 int 也可能是 String」的字段在多层复制粘贴 _parseXxx
  • 没有 fixture,单靠真机连调才发现解析问题。

下期预告

  • 第 3 篇:缓存策略(内存、磁盘、失效机制)
  • 第 4 篇:列表分页与并发请求优化