资讯动态

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

发布时间:2026/10/9 17:36:03 来源:尧图企业网站定制
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、OrderDODTO服务层/接口层承载业务数据传输不暴露数据库结构UserDTO、OrderDTOVO接口返回层只包含前端需要的字段格式化后的数据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 接口用 JSONJackson 或 GsonRPC 框架按需选择。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 实体里有个ListString字段数据库里存的是逗号分隔字符串这种情况你可以在 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)要么全局配置 ObjectMapperBean 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 小时。排查发现数据库连接参数没有配置serverTimezoneMyBatis 把LocalDateTime直接写入 DATETIME 字段时走的是数据库服务器时区。正确的做法是数据库连接 URL 加上serverTimezoneAsia/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 引用 BB 又引用 A用JsonIgnoreProperties或JsonManagedReference/JsonBackReference切断循环或改用 DTO 扁平结构反序列化 JSON 到 List 报类型转换错误使用List.class接收泛型丢失用TypeReferenceListOrderDTO或JavaTypeMyBatis 查询结果字段全部为 null数据库下划线字段未映射到驼峰属性检查map-underscore-to-camel-case配置或手动加TableField前端收到的时间差 8 小时数据库时区与应用时区不一致数据库连接加serverTimezone统一 UTC 存储接口返回的数字精度丢失Long 类型超过 JS 安全整数范围对主键添加JsonSerialize(using ToStringSerializer.class)输出为字符串数据量大的 List 转换后内存溢出一次性加载全部数据使用分页查询或使用流式查询Cursor这里我给三个具体的处理示例。第一反序列化泛型丢失问题。你写mapper.readValue(json, List.class)得到的其实是ListLinkedHashMap后面取字段时类型转换异常。正确写法ListOrderDTO orderList mapper.readValue( json, new TypeReferenceListOrderDTO() {} );第二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); ListUserVO toVOList(ListUserDO userDOList); } // 调用 UserVO userVO UserConvertMapper.INSTANCE.toVO(userDO);编译后生成的代码就是一堆userVO.setId(userDO.getId())没有任何运行时反射性能极高。如果是更复杂的转换比如 UserDO 里嵌套了 ListOrderDO也要一起转成 UserVO 里的 ListOrderVOMapStruct 同样可以做映射你只需要在接口里定义目标方法MapStruct 会自动递归调用内层转换方法。有些人觉得 MapStruct 也是个框架不想引入额外依赖。那么退一步至少不要在大循环里用反射拷贝。可以用手写 setter或者用 Apache Commons 的PropertyUtils它支持级联属性但还是反射。我个人的底线是循环内禁止反射拷贝接口入口处可以适当使用。5.3 大对象转换与流式处理当数据量上来以后转换操作本身也可能成为瓶颈。比如导出报表时一次性查 10 万条订单在内存里构建 ListOrderExportVO你会发现 GC 压力骤增搞不好 OOM。解决办法之一是 MyBatis 游标查询当年我用这种方式做过一次导出优化Mapper public interface OrderMapper { CursorOrderDO 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 并转成 DTOPostMapping(/users) public ApiResponseUserVO 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 转 DOUid 由分布式 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 用返回 true128 返回 falseInteger 缓存序列化与反序列化为什么需要 serialVersionUID用于校验序列化前后的类版本一致性版本号不匹配会抛什么异常JSON 序列化 Long 丢失精度JS 无法精确表示超过 2^53 的整数需要转字符串还有哪些类型在 JSON 传输中需要注意BigDecimal、Datetransient关键字的作用序列化时忽略该字段它和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 的配置、统一好日期与枚举的处理方式然后在代码评审时严格把关数据传输类的问题会减少九成以上。这些规范本身不复杂拷到任何项目都能落地关键在于是否有人愿意把这些“小事”当成“大事”来对待。如果你正被数据传输的某些问题困扰不妨从上面的自查清单开始逐个核对一遍大概率能发现自己项目里隐藏着的隐患。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑