news 2026/10/9 17:35:45

Java后端数据传输与转换:从DTO到数据库的完整链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java后端数据传输与转换:从DTO到数据库的完整链路

Java 数据传输与转换详解:从 DTO 到数据库的完整链路

做 Java 后端这几年,我最大的感受是:真正让项目出问题的,往往不是那些花哨的高并发方案,而是每天都要碰无数次的数据传输与数据转换。从 Controller 接收 JSON,到 Service 层的 DTO 流转,再到 MyBatis 把对象映射成 SQL 参数,最后结果集再映射回 VO 返回给前端——这条链路任何一个环节出问题,都够你排查半天。这篇笔记我就把 Java 数据传输和转换这条主链路完整梳理一遍,结合我实际项目中踩过的坑,把 DTO/VO/DO 的划分、JSON 序列化、MyBatis-Plus 实体类生成建表 SQL、分布式场景下的数据一致性、编码与时间处理这些关键点一次说透。适合准备面试的初级工程师,也适合正在做 Spring Boot 项目、天天和数据转换打交道的同学参考。

1. 先理清 Java 后端数据传输的三个核心场景

不管项目大小,Java 后端的传输与转换基本离不开三个场景:进程内对象转换、进程间序列化传输、持久层数据映射。这三个场景的处理方式完全不同,很多新手上来就把它们搅在一起,结果用错工具,埋下隐患。

1.1 进程内传输:DTO/VO/DO 的划分与职责

先看一张我整理的对照表:

对象类型使用位置核心职责典型命名后缀
DO数据访问层与数据库表结构一一对应UserDO、OrderDO
DTO服务层/接口层承载业务数据传输,不暴露数据库结构UserDTO、OrderDTO
VO接口返回层只包含前端需要的字段,格式化后的数据UserVO、OrderVO

为什么非要拆成三套?我直接说结论:如果你图省事,把 DO 直接返给前端,那你迟早要为这个决定买单。

举一个真实例子。数据库 user 表里有个字段叫password_salt,这是给密码加密用的盐值,只存在于后端。如果你直接把 UserDO 序列化后返回给前端,盐值就泄露了。你当然可以说“那我加 @JsonIgnore 注解不就行了”,但问题在于:DO 是承接持久层的,它应该对持久层语义负责,而不是混入接口语义。当接口要返回createTime格式化后的字符串、DO 里却存的是LocalDateTime时,你会发现自己要么在 DO 上堆注解,要么在 Service 里做大量手工赋值——代码就这样烂掉的。

正确的做法是:Service 层把 DO 转成 DTO,再交给 Controller 层转成 VO 返回。转换可以手工写 setter,也可以用 BeanUtils.copyProperties 或 MapStruct。MapStruct 是编译期生成转换代码,性能最好,也没有反射开销,我个人的建议是:核心链路用 MapStruct,非核心链路用 BeanUtils.copyProperties 省事。

1.2 进程间传输:JSON 序列化与二进制序列化的选型

进程间传输,最常见的就是 HTTP 接口间传 JSON,以及 RPC 框架(如 Dubbo、gRPC)里传二进制。这里有一个很多人都会踩的坑:JDK 自带 Serializable 序列化只适合做内存间临时传输,千万别用在跨语言、跨系统的接口通信上。

为什么?JDK 序列化的产物是二进制对象流,里面包含了完整的类结构描述,体积大、性能差,而且 Java 类一旦变更序列版本号,两端就无法反序列化。更麻烦的是,别的语言根本读不懂这个二进制流。所以我见过很多团队,内部服务间为了“省事”用 JDK 序列化传对象,最后要接一个 Python 写的算法服务时,全都得推倒重来。

现在的标准做法是:HTTP/REST 接口用 JSON(Jackson 或 Gson),RPC 框架按需选择。gRPC 强制用 Protobuf,性能好、跨语言,但是要写 .proto 文件并生成代码,团队需要学习成本。Dubbo 则支持多种序列化协议,包括 Hessian2、Kryo、JSON。

JSON 的好处是直观、可读、调试方便,适合对外接口;二进制序列化适合内部 RPC 的场景。选型原则就一条:对外接口必须用 JSON 这类有自描述能力的文本格式,内部高频调用且对性能敏感的,考虑 Protobuf 或 Kryo。

1.3 持久层映射:ORM 框架如何完成对象到 SQL 的转换

持久层映射,最主流的方案就是 MyBatis/MyBatis-Plus 和 JPA/Hibernate。这两派我都有过实际项目经验,说说我的体会。

JPA/Hibernate 是“全自动”派:你把实体类定义好,它自动生成 SQL、自动管理关联关系,理论上你可以完全不用写 SQL。但实际项目里,一旦查询复杂起来,自动生成的 SQL 就达不到想要的性能,你得去研究它的查询方法命名规则、Criteria API,反而比写 SQL 更费劲。

MyBatis 是“半自动”派:SQL 你写,结果集映射交给框架。这个理念我比较认同——数据库查询本身是复杂多变的,程序员掌控 SQL 能保证可预测性。MyBatis-Plus 在 MyBatis 之上做了增强,内置了通用 CRUD 方法,而且还能根据实体类自动生成建表 SQL,这个能力我后面专门讲。

说回数据传输与转换,ORM 框架的核心工作就是两件事:把 Java 对象转换成 SQL 参数(写操作),以及把 SQL 结果集转换成 Java 对象(读操作)。MyBatis 通过TypeHandler处理 Java 类型与 JDBC 类型的映射,比如LocalDateTime对应 JDBC 的TIMESTAMP,这个映射关系是框架内置的。你需要在意的反而是:什么时候用框架自动映射,什么时候自定义 TypeHandler。

例如,Java 实体里有个List<String>字段,数据库里存的是逗号分隔字符串,这种情况你可以在 TypeHandler 里完成转换。但别把所有逻辑都塞进 TypeHandler,我见过有人把“对象转 JSON 字符串”这个操作也做成 TypeHandler,导致查询出来直接得到一个 Map,代码可读性极差。TypeHandler 应该只做类型转换,不要做业务处理。

2. MyBatis-Plus 根据实体类生成建表 SQL,到底怎么配置

热词里有“mybatisplus根据java实体类生成创建表的sql语句”,这个功能很多人不知道,但确实是 MyBatis-Plus 3.5.3+ 版本提供的一个重要能力。它解决什么问题?微服务拆分成几十个模块后,每个模块都有自己的数据库表。如果手工写建表 SQL,实体类改了字段,SQL 没同步更新,上线时就会出现字段缺失的报错。这个功能允许你在应用启动时,根据实体类自动比对数据库表结构,如果表不存在则自动建表。

2.1 实体类规范:注解驱动表结构

要用这个功能,实体类必须规范标注元信息。我用一个实际例子说明:

@Data @TableName("t_user") public class UserDO { @TableId(type = IdType.AUTO) private Long id; @TableField(value = "user_name", fill = FieldFill.INSERT) private String userName; @TableField("phone") private String phone; @TableField("email") private String email; @Version private Integer version; @TableLogic @TableField("deleted") private Integer deleted; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }

这里几个注解的作用要说清楚:

  • @TableName指定表名,如果不写,默认用类名转下划线,也就是user_d_o——肯定不是你想要的,所以务必写。
  • @TableId指定主键,IdType.AUTO表示数据库自增;如果是分布式 ID(比如雪花算法),用IdType.ASSIGN_ID。
  • @TableField指定字段映射,如果实体属性是驼峰命名、数据库字段是下划线命名,理论上可以不写,但写上更稳妥,也便于阅读。
  • @Version是乐观锁版本字段,更新时会自动带上where version = #{旧值}并在 set 中version = version + 1。
  • @TableLogic是逻辑删除字段,有了它,MyBatis-Plus 所有查询都会自动加上deleted = 0条件。
  • @TableField(fill = ...)是自动填充字段,配合MetaObjectHandler实现插入/更新时自动写入当前时间。

这一点必须提醒:数据库字段默认建议用下划线命名,实体属性用驼峰,MyBatis-Plus 默认开启下划线转驼峰映射,这是它帮我省下大量重复配置的核心原因。

2.2 启动时自动执行:DbScript 的 createTable 方法

配置好实体类后,生成建表 SQL 的核心 API 是DbScript.createTable()。调用方式如下:

@Service public class TableInitializationService { @PostConstruct public void init() { // 通过 DbScript 根据实体类生成建表 SQL 并执行 DbScript.createTable(UserDO.class); DbScript.createTable(OrderDO.class); } }

为什么它是先“生成 SQL 再执行”,而不是直接调用 JDBC 的 create table 语句?我的理解是:这个 API 内部会读取实体类的注解元数据,解析出表名、字段名、字段类型、字段长度、索引、主键策略等信息,拼接出一条完整的CREATE TABLE IF NOT EXISTS语句,然后在当前数据源上执行。

实际使用中有一点很重要:实体类的字段类型需要能被正确推导为数据库类型。比如 Java 的String默认会被映射为数据库的VARCHAR(255),如果userName字段超过 255 个字符,你就需要在@TableField中显式指定columnDefinition来控制长度:

@TableField(value = "user_name", columnDefinition = "VARCHAR(64) NOT NULL COMMENT '用户名'") private String userName;

我不建议所有字段都写到columnDefinition里,那样实体类会变得非常臃肿,重点给长度特殊、有注释需求的字段标注即可。

2.3 生产环境建表的最佳实践

虽然这是“自动建表”功能,但我的经验是:把它用在开发环境和测试环境,可以极大提升联调效率;生产环境,建表脚本还是应该纳入版本控制,走 DBA 审批流程。

原因很现实:生产环境的表结构变更涉及数据迁移、索引优化、分表分库,这些是纯建表 SQL 搞不定的。自动建表功能只保证“表能建出来”,不保证“建出来的表性能最优”。比如索引设计,实体类注解只能表示字段,但联合索引、唯一索引的创建策略,通常需要 DBA 根据查询模式来定。

所以我在项目里的做法是双轨制:开发环境开启DbScript.createTable(),数据库结构随时跟随实体类演进;生产环境有专门的 SQL 脚本目录,由 DBA 审核执行。这个方案在几个项目里都验证过,开发效率和上线安全都保住了。

3. 分布式场景下的数据传输与一致性保障

Java 数据传输走到分布式阶段,复杂度陡然上升。热搜词里“java怎么保证数据一致性”这个疑问,我从实际项目出发,说说最常见的两种解法:幂等设计与分布式事务。

3.1 服务间数据传递:回调、幂等与重试

服务间传递数据,最常见的就是支付回调、消息队列消费、开放平台回调这类场景。它们都有一个共同特征:对方系统会重试发送,我们的接收方必须做到幂等。

我第一次对接第三方支付时,犯过一个错:回调进来后直接更新订单状态,没做幂等。结果对方因为网络超时重发了两次回调,订单状态被更新了两次,虽然两次都更新成“已支付”,表面上没问题,但如果这个订单状态是要被记入账户流水、触发后续发货流程的,流水就重复了。

正确的幂等处理长这样:

@Transactional(rollbackFor = Exception.class) public void handlePaymentCallback(PaymentCallbackDTO callback) { // 1. 防重:基于业务唯一键查幂等表 Integer count = idempotentMapper.selectCount( callback.getMerchantOrderId(), callback.getTransactionId() ); if (count > 0) { log.warn("重复回调,忽略:{}", callback.getTransactionId()); return; } // 2. 记录回调流水(唯一键约束兜底) idempotentMapper.insert(callback); // 3. 目标表更新:乐观锁版本号控制,防止并发更新 int updated = orderMapper.updateStatusWithVersion( callback.getMerchantOrderId(), OrderStatus.PAID, OrderStatus.WAIT_PAY, currentVersion ); if (updated == 0) { throw new BusinessException("订单状态变更失败,可能已被并发处理"); } }

这里的关键点:幂等表除了用程序判断,数据库层面也要建唯一索引兜底。消息队列消费场景同理,消费端要记住已经被处理过的消息 ID。

3.2 分布式事务:Seata 与本地消息表

分布式的数据一致性,还要处理“跨库写操作”。比如那个热词里的多商户跨境商城项目,用户下单要扣库存、加订单、减余额,这三个操作分散在三个微服务,涉及三个数据库,如何保证要么都成功要么都失败?

业界方案不少,最常见的是 Seata AT 模式。它的原理说起来也不复杂:事务发起方开启全局事务,注册分支事务;分支事务执行本地 SQL,同时记录 undo_log;事务结束时,如果全部成功则删除 undo_log,如果有一个失败则根据 undo_log 反向补偿回滚。

我在一个订单系统里用过 Seata,说说实际体验。接入成本不算高,无非是在需要分布式事务的方法上加@GlobalTransactional注解,然后配置 TC 服务端。但要注意一点:AT 模式的性能开销不低,因为它要解析 SQL、生成 undo_log,还要持有全局锁。所以不要对普通接口都用分布式事务,只有真正跨库操作的场景才用。

如果不想引入 Seata 这类重量级框架,本地消息表也够用:事务操作业务表的同时,往本地消息表插入一条消息,然后有一个定时任务把消息发到 MQ,消费端处理成功后回调确认。这种“最终一致性”方案,在大多数电商场景都够用,而且实现起来可控性更强。

3.3 数据传输中的状态机与版本控制

分布式场景下还有一块很隐蔽的坑:数据会在多个服务间传递,状态字段也在流转,如果每个服务都随意修改状态,会导致状态混乱。我们内部的做法是:为订单这种有状态流转的对象定义状态机,每个状态只允许特定的流转方向,并且状态更新统一走 Service 方法,不允许直接 update 整行。

在实际转换中,我还遇到过这样一个问题:上游服务传来的 DTO 里有一个status字段,但我们数据库里存的是status加一个status_history字段记录变更历史。如果直接把 DTO 覆盖到 DO 上,历史记录就丢了。所以我在转换层做了一个专门的状态变更方法,先把旧状态写入历史表,再更新当前状态。这个设计救过我们一次——有一次线上出问题,靠历史记录定位到了是哪个环节改了状态,省去了大量排查时间。

4. 编码、时间、枚举:数据传输中三个最容易翻车的点

数据转换的坑,很多不在复杂的架构上,而在最基础的类型细节。编码、时间、枚举这三样,可以说是数据传输中翻车率最高的三个点。

4.1 字符编码:中文乱码、ISO-8859-1 与 HTTP 头

先看编码。我接手过一个老项目,接口返回中文正常,但把数据推送到下游系统时,中文全变成了问号。排查到最后发现,是 HTTP 客户端发送请求时没有显式设置Content-Type的charset,服务器端用默认的 ISO-8859-1 去解析了 UTF-8 的字节流,自然乱码。

这个问题的根治方案是:发送 HTTP 请求时,显式指定编码。用 Spring 的RestTemplate或WebClient,在请求头里加:

HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); // 使用 UTF-8 字符集 headers.set("Accept-Charset", "UTF-8");

另外,JVM 默认字符集也经常坑人。比如File.readAllLines(path)不指定字符集,在不同环境得到的结果可能不同。所有涉及文本读写的地方,一律显式指定StandardCharsets.UTF_8,不要依赖默认值。这是我在代码评审里一定会检查的一条。

还有java.net.URLEncoder的坑:它默认按application/x-www-form-urlencoded编码,空格会变+,提交 JSON 到GET请求参数里时,经常出问题。我推荐用 Spring 的UriComponentsBuilder来做 URL 参数拼接,它会处理好编码问题。

4.2 时间与时区:LocalDateTime 在 JSON 中的往返

时间是个大坑,坑在两方面:一是 JSON 序列化时LocalDateTime的格式,二是时区偏移。

先看第一个问题。LocalDateTime本身没有时区概念,Jackson 默认序列化它时输出的是数组格式([2025, 1, 15, 14, 30, 0]),或者一串时间戳数字,前端根本没法直接使用。要么在 POJO 字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),要么全局配置 ObjectMapper:

@Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai")); return mapper; }

全局配置好后,所有LocalDateTime字段都输出成"2025-01-15 14:30:00"这种格式,后端不用在每个字段上重复注解,清爽很多。

时区问题更隐蔽。我们有一次做跨境商城,服务器部署在海外可用区,结果用户下单时间在数据库里显示比本地时间早了 8 小时。排查发现:数据库连接参数没有配置serverTimezone,MyBatis 把LocalDateTime直接写入 DATETIME 字段时走的是数据库服务器时区。正确的做法是:数据库连接 URL 加上serverTimezone=Asia/Shanghai,并且统一约定应用中只存 UTC 时间,展示时才转本地时区。

关于为什么建议存 UTC:如果你的应用有海外用户,你把 UTC 存进去,在每个用户请求时根据他的时区做转换展示,是最灵活的方案。如果一开始就存本地时间,将来做全球化就麻烦了。

4.3 枚举与状态码:不落库、不跨系统传字面量

最后说枚举。Java 里的enum在序列化时默认输出的是name()字符串(比如OrderStatus.PAID序列化成"PAID")。这有两个问题:一是数据库里存"PAID"比存1、2占空间且不可读;二是前后端约定通常是数字状态码,不是英文字符串。

我的推荐做法是:在枚举里加一个code字段,用@EnumValue注解标注,这样 MyBatis-Plus 存数据库时取code值,Jackson 序列化时也输出code值:

@Getter public enum OrderStatus { WAIT_PAY(1, "待支付"), PAID(2, "已支付"), SHIPPED(3, "已发货"), FINISHED(4, "已完成"); @EnumValue private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } }

这里的原则是:代码里用枚举保证类型安全,数据库里存数字,接口传输用数字,只有展示层才转成描述文本。别把desc直接传到前端去,因为前端要显示什么语言、什么文案,是前端自己的事。

我还见过一个常见错误:有人为了方便,在接口入参里直接接收OrderStatus,让 Jackson 把字符串转成枚举。这个功能默认按name()匹配,前端如果传"paid"就转不了,而且枚举的name()一旦重构改名,接口兼容性就崩了。建议接口入参也用 Integer,在 Service 层做转发校验,这样最稳妥。

5. 数据转换的排查技巧与性能优化建议

这部分算是真正的实战经验了。前面讲的都是方案,这里说几个我实际踩过、调试过的坑,以及怎么把转换性能提上去。

5.1 常见问题速查表

我把高频问题整理成一张表,遇到类似情况可以对照排查:

现象可能原因解决办法
JSON 序列化时报 “No serializer found”目标对象有字段为 null,且类没有默认无参构造添加@JsonInclude(Include.NON_NULL),确保类有默认构造
JSON 序列化循环引用报错对象 A 引用 B,B 又引用 A用@JsonIgnoreProperties或@JsonManagedReference/@JsonBackReference切断循环;或改用 DTO 扁平结构
反序列化 JSON 到 List 报类型转换错误使用List.class接收泛型丢失用TypeReference<List<OrderDTO>>或JavaType
MyBatis 查询结果字段全部为 null数据库下划线字段未映射到驼峰属性检查map-underscore-to-camel-case配置,或手动加@TableField
前端收到的时间差 8 小时数据库时区与应用时区不一致数据库连接加serverTimezone,统一 UTC 存储
接口返回的数字精度丢失Long 类型超过 JS 安全整数范围对主键添加@JsonSerialize(using = ToStringSerializer.class),输出为字符串
数据量大的 List 转换后内存溢出一次性加载全部数据使用分页查询,或使用流式查询Cursor

这里我给三个具体的处理示例。第一,反序列化泛型丢失问题。你写mapper.readValue(json, List.class),得到的其实是List<LinkedHashMap>,后面取字段时类型转换异常。正确写法:

List<OrderDTO> orderList = mapper.readValue( json, new TypeReference<List<OrderDTO>>() {} );

第二,Long 转 JS 精度丢失。数据库自增主键到了 19 位,JS 的 Number 只能精确表达 16 位。前端拿到的 ID 会变成123456789012345680这种舍入过的值,传给后端再查,数据就查不出来了。全局配置:

mapper.registerModule(new SimpleModule() .addSerializer(Long.class, ToStringSerializer.instance) .addSerializer(Long.TYPE, ToStringSerializer.instance));

第三,BeanUtils.copyProperties的 null 覆盖问题。Spring 的BeanUtils会把源对象里为 null 的字段也拷贝到目标对象,导致更新接口时用户没传的字段被清空。要么先查库、再手工忽略 null 字段,要么用BeanWrapper写个工具方法只在非 null 时拷贝。这是线上出过事故的操作,务必小心。

5.2 转换性能优化:从反射到编译期生成

转换性能方面,最有争议的就是BeanUtils.copyProperties到底慢不慢。我的结论是:慢,而且慢得没有必要。它是基于反射实现的,每次拷贝都要做类元信息扫描、方法调用。在低并发接口里感知不到,但到了每秒几百上千次的业务量,这个开销就是实打实的 CPU 损耗。

更优的方案是 MapStruct。它在编译期读取源类与目标类,生成纯 setter/getter 的转换代码,性能与手写代码等同,还没有反射。用法很简单:

@Mapper public interface UserConvertMapper { UserConvertMapper INSTANCE = Mappers.getMapper(UserConvertMapper.class); UserVO toVO(UserDO userDO); List<UserVO> toVOList(List<UserDO> userDOList); } // 调用 UserVO userVO = UserConvertMapper.INSTANCE.toVO(userDO);

编译后生成的代码就是一堆userVO.setId(userDO.getId()),没有任何运行时反射,性能极高。

如果是更复杂的转换,比如 UserDO 里嵌套了 List<OrderDO>,也要一起转成 UserVO 里的 List<OrderVO>,MapStruct 同样可以做映射:你只需要在接口里定义目标方法,MapStruct 会自动递归调用内层转换方法。

有些人觉得 MapStruct 也是个框架,不想引入额外依赖。那么退一步,至少不要在大循环里用反射拷贝。可以用手写 setter,或者用 Apache Commons 的PropertyUtils(它支持级联属性,但还是反射)。我个人的底线是:循环内禁止反射拷贝,接口入口处可以适当使用。

5.3 大对象转换与流式处理

当数据量上来以后,转换操作本身也可能成为瓶颈。比如导出报表时,一次性查 10 万条订单,在内存里构建 List<OrderExportVO>,你会发现 GC 压力骤增,搞不好 OOM。

解决办法之一是 MyBatis 游标查询,当年我用这种方式做过一次导出优化:

@Mapper public interface OrderMapper { Cursor<OrderDO> scanAll(@Param("startTime") LocalDateTime start, @Param("endTime") LocalDateTime end); }

游标查询不是一次性加载所有结果,而是分批从数据库读取,每行数据处理完就释放。

然后配合流式处理,比如用try-with-resources遍历游标,一行一行转成导出对象,再写入 Excel 的SXSSFWorkbook(这个组件本身也支持内存上限设定,超出部分自动写磁盘)。改造后,导出 10 万条数据的耗时反而比原先直接炸内存的方案快了三成——不是查询快了,是省去了 GC 的停顿。

这个经验我再多延伸一点:Java 里处理大数据传输,核心思路永远是能分批尽量分批,能流式尽量流式,千万别把全量数据一次性堆进内存。哪怕你用的是 java 8 的 Stream,也是全量加载后再处理,不是真正意义上的流。要“流”,就要用数据库游标 + 迭代器的组合。

6. 一串代码看全转换链路:从 HTTP JSON 到数据库字段

前面部分较多,用一个完整的小例子把整条链路串起来,加深理解。假设我们要做一个用户注册接口:前端传 JSON,后端转成 DO 存库,返回 VO 给前端。

6.1 接口层:接收 JSON 并转成 DTO

@PostMapping("/users") public ApiResponse<UserVO> createUser(@RequestBody @Validated UserCreateDTO dto) { UserDO userDO = createUserService.create(dto); return ApiResponse.success(UserConvertMapper.INSTANCE.toVO(userDO)); }

UserCreateDTO的字段一般比 DO 少,比如没有id、createTime。此时@Validated的入参校验也要在这里做,比如手机号格式、密码长度。注意 DTO 里不要出现数据库相关的字段,否则网络安全上就是个隐患。

6.2 Service 层:DTO 转 DO,补全业务字段

@Transactional public UserDO create(UserCreateDTO dto) { // 业务处理:校验唯一性、加密密码 UserAccountDO account = userAccountMapper.selectByLoginName(dto.getLoginName()); if (account != null) { throw new BusinessException("用户名已存在"); } // DTO 转 DO,Uid 由分布式 ID 生成器生成 UserDO userDO = new UserDO(); userDO.setUid(uidGenerator.nextId()); userDO.setLoginName(dto.getLoginName()); userDO.setPasswordHash(passwordEncoder.encode(dto.getPassword())); // createTime 等由 MetaObjectHandler 自动填充 // 插入数据库 userMapper.insert(userDO); return userDO; }

这里我会刻意把“创建用户”和“初始化账户”分开。查询唯一性、加密密码、生成 uid,这些都不是简单的“数据传输”,而是业务规则。不要把业务逻辑全部压到转换层,转换层只负责字段值的搬运与简单映射。

6.3 持久层:DO 到 SQL 参数

MyBatis-Plus 的insert(userDO)会根据实体类元信息自动生成INSERT INTO t_user (uid, login_name, password_hash, ...) VALUES (...)。传参的类型转换由自带的 TypeHandler 完成,LocalDateTime自动对应TIMESTAMP。到这里,一个 JSON 报文里的数据,就完成了从 HTTP 到数据库的完整落地。

6.4 返回层:DO 转 VO,脱敏与格式化

public UserVO toVO(UserDO userDO) { return UserConvertMapper.INSTANCE.toVO(userDO); }

在UserConvertMapper里,我还会加一个自定义方法,把手机号做脱敏处理:

@Mapper public interface UserConvertMapper { UserConvertMapper INSTANCE = Mappers.getMapper(UserConvertMapper.class); @Mapping(target = "phone", expression = "java(DataMaskUtil.maskPhone(userDO.getPhone()))") @Mapping(target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") UserVO toVO(UserDO userDO); }

@Mapping注解的expression可以指定调用某个静态工具方法,dateFormat自动做时间格式化。这样在编译期就确定了脱敏逻辑,代价为零,而且代码可读性很强——一眼就能看出 VO 的 phone 字段是从 DO 的 phone 脱敏得到的。

7. 面试高频题与自查清单

最后的一部分,我把它当作“查漏补缺”清单用。很多 Java 面试题表面考的是“数据传输”,实际上考的是对数据转换细节的理解。

7.1 高频考点速览

考点一句话答案深度追问方向
为什么需要 DTO/VO/DO 分层防止数据库结构外泄、隔离不同层的职责、控制接口出入参什么场景可以不分层?
==与equals在数据传输中的影响包装类型比较用equals,==比较的是引用地址为什么两个值都是 127 的 Integer 用==返回 true,128 返回 false?(Integer 缓存)
序列化与反序列化为什么需要 serialVersionUID用于校验序列化前后的类版本一致性版本号不匹配会抛什么异常?
JSON 序列化 Long 丢失精度JS 无法精确表示超过 2^53 的整数,需要转字符串还有哪些类型在 JSON 传输中需要注意?(BigDecimal、Date)
transient关键字的作用序列化时忽略该字段它和@JsonIgnore的区别是什么?
深拷贝与浅拷贝浅拷贝只复制引用,深拷贝复制对象及其引用的对象用序列化实现深拷贝有什么坑?(性能差、类差异)
为什么 MapStruct 比 BeanUtils 快编译期生成 setter 代码,无反射开销两者适合什么业务场景?
MyBatis-Plus 自动建表的原理读取实体类注解元数据,拼接 DDL 执行生产环境为什么不适合用它建表?

这里我特别提一下 Integer 缓存那个坑。它本质上是Integer.valueOf(127)会走缓存,而Integer.valueOf(128)会 new 一个新对象。在数据传输的 DTO 转换中,如果两个对象的Integer属性被自动装箱,比较时用了==,就会出现“看起来相等,比较结果不相等”的灵异事件。我的建议是:对象属性比较一律用equals或Objects.equals,基本类型才允许用==。

7.2 自查清单

写代码时,我会在脑子里过一遍下面这些问题:

  • 每个接口的出入参,是否都用 DTO/VO 定义,而不是直接暴露 DO?
  • 时间类型是否统一了时区?JSON 格式化是否全局配置过?
  • 所有文本读写的字符集是否显式指定?
  • Long 类型的主键是否做了转字符串处理?
  • 大循环里的对象转换,是否避免了反射?
  • 接收外部回调的接口,是否做了幂等校验?
  • 枚举与数据库之间的映射,是否使用了@EnumValue而不是name()?
  • 序列化配置里,是否处理了循环引用?
  • 敏感字段(密码、盐值、身份证号)是否在 VO 中被过滤或脱敏?
  • 实体类新增字段后,建表 SQL 是否需要同步更新?

这些问题如果在代码评审时能被团队成员主动提出来,说明大家对这个领域的理解就已经到位了。

一些个人的经验体会

最后聊两句。数据传输与转换这件事,看起来像是 Java 开发里最不起眼的“搬砖活”,但恰恰是它决定了系统的稳定性和可维护性。我见过太多项目,架构图无比精细,结果死在 DTO 转 VO 的 NullPointerException 上;也见过性能问题排查到最后,发现是循环里一次不必要的反射拷贝。

这几年我形成的一个工作习惯是:先定规则,再写代码。团队里统一好 DTO/VO/DO 的命名规范、统一好 ObjectMapper 的配置、统一好日期与枚举的处理方式,然后在代码评审时严格把关,数据传输类的问题会减少九成以上。这些规范本身不复杂,拷到任何项目都能落地,关键在于是否有人愿意把这些“小事”当成“大事”来对待。如果你正被数据传输的某些问题困扰,不妨从上面的自查清单开始,逐个核对一遍,大概率能发现自己项目里隐藏着的隐患。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 17:35:05

从零搭建个人网站:云服务器、Nginx与HTTPS全流程实战

1. 个人网站搭建的整体设计与思路拆解1.1 为什么选择云服务器而不是虚拟主机或建站平台很多人第一次动念做个人网站&#xff0c;第一反应是去找那种“一键建站”的平台&#xff0c;拖拖拽拽就能出一个页面。但用过一段时间就会发现&#xff0c;免费套餐限制多、自定义能力弱、数…

作者头像 李华
网站建设 2026/10/9 17:34:54

S7-1500在汽车焊装自动线的应用与调试经验

从S7-300换到西门子1500PLC&#xff0c;是我做汽车焊装自动生产线这些年最明显的一次技术迭代。白车身焊装线上新线和老线改造&#xff0c;控制层基本绕不开1500&#xff0c;主焊线、地板线、侧围线、门盖线&#xff0c;十几台甚至几十台焊接机器人配上中频点焊机、滑橇输送、E…

作者头像 李华
网站建设 2026/10/9 17:34:52

Agent-Reach:面向AI开发者的智能体调用中枢与API编排引擎

1. 项目概述&#xff1a;Agent-Reach 是什么&#xff1f;它解决的不是“能不能用”&#xff0c;而是“怎么用得稳、用得准、用得省”Agent-Reach 这个名字乍看像某个开源模型或框架&#xff0c;但结合 CLI、API、YouTube、Reddit 等高频热词&#xff0c;以及大量围绕 codex cli…

作者头像 李华
网站建设 2026/10/9 17:31:19

JSP+Servlet手写成绩管理系统:从MVC架构到数据库设计与部署避坑指南

简介&#xff1a;一套基于JSP与Servlet的Web成绩管理系统课程设计源码&#xff0c;面向计算机专业学生与Java Web初学者&#xff0c;覆盖学生、教师、管理员三类角色的完整业务闭环。学生端支持个人信息维护与成绩查询&#xff0c;教师端提供成绩录入、修改、查看与分析&#x…

作者头像 李华
网站建设 2026/10/9 17:29:18

数据库物理模型设计实战:字段类型、索引策略与分区方案落地指南

简介&#xff1a;这份资源聚焦数据库物理模型设计&#xff0c;面向数据库设计人员、后端开发与数据建模学习者&#xff0c;帮助读者理解如何将逻辑模型落地到实际存储系统&#xff0c;兼顾性能优化、存储效率与数据管理。内容以四种核心设计模式为线索&#xff0c;重点讲解主扩…

作者头像 李华