ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MyBatis-Plus实体类字段忽略:@TableField(exist=false)用法与避坑指南

MyBatis-Plus实体类字段忽略:@TableField(exist=false)用法与避坑指南 作为一个整天和 MyBatis-Plus 打交道的后端开发我第一次遇到实体类加字段导致 SQL 报错是在一个周四下午。当时订单列表接口突然全部 500日志里冒出一句SQLSyntaxErrorException: Unknown column role_names in field list。我第一反应是数据库少列了查了 DDL 才发现数据库里压根没有role_names这一列问题出在我刚在实体类里加的一个roleNames上。很多人第一次接触TableField(exist false)都以为它是个“不起眼的小注解”实际排查起来却能让新手折腾半天。这篇内容会从 MyBatis-Plus 的字段映射机制讲起把exist false的含义、使用场景、常见坑以及和 Spring Boot 其他常用注解的区别一次说清楚。适合刚接触 MyBatis-Plus 的初学者也适合被“实体类多字段导致 SQL 报错”折磨过的中级开发者。1. 从MyBatis-Plus的自动SQL拼接逻辑说起1.1 它凭什么“默认”每个实体类字段都要映射到表字段MyBatis-Plus 的核心卖点就是“简单 CRUD 不用写 SQL”它能做到这一点靠的是对实体类和数据库表之间的默认映射假设。默认情况下除了static和transient修饰的字段实体类里的所有实例字段都会被当成数据库表字段来处理。字段名采用驼峰转下划线规则roleNames会转成role_names然后被拼接到自动生成的 INSERT、UPDATE、SELECT 语句里。这套机制在“实体类和表结构完全一致”的时候非常好用因为开发者完全不需要关心列名转换、结果映射这些琐事。但它有一个致命前提实体类的字段必须和数据库表的列严格对得上。一旦你在实体类里多加了一个“表里不存在”的字段MyBatis-Plus 并不知道这个字段是临时的还是查漏补缺的它只会机械地把它当成表列去拼接 SQL结果就是运行时报Unknown column更隐蔽的情况下不会报错但查询结果里会多出一列 null前端拿到的 JSON 莫名其妙多一个字段。我后来建过一个对照表来梳理这种“字段类型差异”你感受一下实体类字段状态MyBatis-Plus 行为后果字段与表列完全一致正常映射无字段是多余的 Java 属性拼接成表列名SQL 报错或结果多出 null表有列但实体没字段自动生成的 SQL 不查它无大碍但自定义 SQL 可能映射不上字段标记了TableField(exist false)自动 SQL 完全忽略它正常一旦想清楚这个映射假设你就明白为什么TableField(exist false)是必学注解了它是专门用来打破“默认假设”的声明告诉 MyBatis-Plus 这个字段与表列无关自动 SQL 生成时请把我摘出去。1.2 注解出现之前团队是怎么撑过来的在 MyBatis-Plus 早期或团队对注解不熟悉的时候解决“实体类多字段导致 SQL 报错”最常见的土办法是给字段加transient关键字。因为 MyBatis-Plus 默认跳过transient字段所以加了transient的字段不会被当成表列拼接 SQL问题确实能解决。但transient的隐藏副作用非常大。举个例子你在 Spring Boot 项目里把实体类通过 Jackson 转成 JSON 返回给前端时Jackson 默认不忽略transient字段只在配置了特定 Mapper 特性时才忽略所以前端照样能看到这个字段。反过来如果你的实体类通过 Java 原生序列化放进 Redistransient字段会直接丢失缓存读出来变成 null这种问题排查起来极其隐蔽。后来还有人用DTO或VO把入参、返回值和实体类隔离开这确实是更规范的做法。但很多老业务接口多、改动面大不是每个团队都有精力为每个表单单独建一个 DTO更多时候只是在实体类上直接加字段。这时候官方提供的TableField(exist false)就成了最合理的选择它的语义是“这不是数据库列自动 SQL 生成时忽略我”不涉及 Java 序列化不涉及 JSON 序列化该赋值赋值、该返回返回唯一管的只有 MyBatis-Plus 的 SQL 拼接。1.3 一句话说透注解的本质如果把实体类比作“内存中的对象”把表比作“外存中的关系”TableField(exist false)等于在两者之间画了一条线。普通字段是“对象和表都能看到”的角色加了它之后字段变成“只存在于 Java 对象中”的角色。一个“只存在于 Java 对象中”的字段常用于三件事接收查询条件、存放查询结果里的冗余列、作为业务流程中的临时状态标记。下一节就逐个说。2. 实际开发中什么样的字段最需要标注 exist false2.1 列表页搜索条件时间范围、关键字、状态数组做后台管理系统时订单列表页最常见的搜索条件是startTime、endTime、keyword和statusList。它们都不是order表里的列也不可能成为表的一列。我见过很多人把这些字段直接写在实体类上public class Order { TableId private Long id; private String orderNo; private BigDecimal amount; private LocalDateTime createTime; TableField(exist false) private String startTime; TableField(exist false) private String endTime; TableField(exist false) private String keyword; TableField(exist false) private ListInteger statusList; }这样写最直接的好处是查询时只需要传一个Order对象就能把时间范围、关键字一起带给 Mapper自定义 SQL 里直接#{startTime}、#{endTime}取值不用额外再造一个 Query 类来装搜索条件。对于中小型项目这种“实体类兼任查询条件对象”的做法维护成本最低。但这里有一个容易误解的边界exist false只解决“MyBatis-Plus 自动 SQL 不拼接这些字段”的问题它不保证你在自定义 XML 里能直接用这些字段。能取到值是因为 MyBatis 的参数绑定机制认 Java 对象属性和TableField没有关系。换句话说这个注解对自定义 SQL 里的#{}取值完全不起作用也完全不需要起作用。2.2 关联查询后的冗余展示字段另一种高频场景是列表要显示“用户名”但order表里只有user_id。SQL 联查后需要一个userName字段来承接查询结果。这个字段不可能、也不应该加入到order表里它只是查询场景下的展示字段。public class Order { TableId private Long id; private Long userId; private BigDecimal amount; TableField(exist false) private String userName; }当查询 SQL 是SELECT o.*, u.name AS user_name FROM order o LEFT JOIN user u ...时MyBatis 结果映射层会尝试把user_name映射到userName属性上这时exist false不参与 SQL 生成参与的是结果映射。但如果你不加这个注解下次某个insert或updateById调用时MyBatis-Plus 会把user_name误当成真实列去拼 SQL于是你又遇到“未知列”异常。我自己的习惯是凡是联表查询后需要承接的“额外列”只要被塞进实体类就一律加exist false。如果不承接那就不要加这个字段减少实体类被“撑大”的概率。2.3 聚合统计字段统计需求也是重灾区。比如查询“每个客服的今日成单金额”SQL 通常是SELECT user_id, SUM(amount) AS total_amount FROM order WHERE create_time #{startTime} GROUP BY user_idSUM(amount)的结果字段totalAmount不是任何表的列但它也需要一个 Java 字段来接收。在实体类里加public class UserStat { private Long userId; TableField(exist false) private BigDecimal totalAmount; }这样写没问题如果统计字段少的话直接往实体类上一挂就行。不过我想提醒一句当聚合字段比较多查询结果和表结构差异太大时我更推荐单独建一个VO类而不是在实体类上疯狂堆exist false。exist false的本质是“让一个实体类同时扮演多种角色”偶尔客串一下很实用长期把实体类当垃圾桶就会让代码变得难维护。我的平衡点是三五个字段以内用实体类加注解超过这个量就单独建类。2.4 流程状态标记与导入场景还有一类字段既不是查询条件也不是展示列而是业务流程里的临时状态标记。典型场景是 Excel 批量导入的用户校验public class User { private Long id; private String name; TableField(exist false) private Boolean valid; TableField(exist false) private String errorMsg; }导入时逐行校验用户数据校验失败就把原因写进errorMsg。这个字段不可能存到user表但它跟着业务对象走同一个方法里使用起来特别顺手。加了exist false后MyBatis-Plus 的批量插入 SQL 不会包含valid和errorMsg导入完成后又能把这些字段原样返回给调用方让前端看到每一行错在哪里。如果不用exist false你只能把错误信息收集到一个Map里或者建一个错误收集辅助类。不是不行但代码可读性会变差尤其当团队其他人不知道你那个Map的 key 是什么约定的时候。3. 和注解家族里其他兄弟一起用属性搭配与边界3.1 和 updateStrategy、insertStrategy 有什么不同很多人看到TableField就以为它只包含exist一个参数实际上它是一个很丰富的注解。除了exist常用的还有value、updateStrategy、insertStrategy、whereStrategy、fill等。我按自己的理解简单梳理一下参数作用典型取值value指定实体字段对应的数据库列名TableField(u_id)exist该字段是否属于数据库表列false表示不是表列insertStrategy插入时是否拼接该字段及空值策略FieldStrategy.NOT_NULLupdateStrategy更新时是否拼接该字段及空值策略FieldStrategy.NOT_NULLwhereStrategy该字段能否作为查询条件参与拼接FieldStrategy.NOT_NULLfill自动填充策略FieldFill.INSERTjdbcType指定 JDBC 类型JdbcType.VARCHAR需要区分的是updateStrategy FieldStrategy.NOT_NULL和exist false是两个维度。前者表示“只有当字段值非空时才参与更新”后者表示“自动 SQL 生成时谁也别动我”。如果你只是希望“更新时忽略这个字段但查询时它仍然出现在 MyBatis-Plus 默认查询列清单里”那应该用updateStrategy NOT_NULL而不是exist false。两个注解对应两种不同的业务意图用错会导致 SQL 拼接结果不符合预期。3.2 和 TableId 的分工主键字段一般用TableId标注比如TableId(type IdType.ASSIGN_ID)。有些刚接触的人会想既然主键也要告诉 MyBatis-Plus “这是特殊字段”那是不是也要加TableField(exist false)千万别这么干。TableId的作用是“这个字段就是表里的主键列”而exist false表示“表里没有这列”二者语义完全相反。硬加在一起主键会被当作忽略列导致查询构造不了主键条件插入拿不到主键回填问题比不加注解还多。这也是理解所有 Java 注解的一个通用角度注解就是给框架看的元数据。每个框架按自己的规则解释它。我们要按语义使用而不是“哪个注解报错就再叠加一个”。3.3 和 static / transient 的关系MyBatis-Plus 默认忽略static字段和transient字段所以这两个修饰符的字段天然不参与自动 SQL。但我不建议用它们替代exist false原因前面提过transient会影响 Java 原生序列化static字段是所有实例共享的多线程环境下极容易造成临时状态串数据。exist false没有这些副作用它只影响 MyBatis-Plus 的 SQL 生成不碰 Java 序列化和 JSON 序列化。3.4 3.x 版本的细节差异MyBatis-Plus 3.x 系列解析实体字段时会递归查找父类字段。老版本在不同场景下对父类字段的注解处理存在些许差异我遇到的真实案例是写在父类上的TableField(exist false)在某个版本里没有被子类继承解析直到升级到 3.5.x 才稳定。所以如果你们项目还停留在 3.x 早期版本遇到“父类字段加注解没生效”的问题优先考虑升级依赖不要浪费时间怀疑自己的代码。4. 我踩过的坑只加注解并不一定万事大吉4.1 父类字段上的 TableField(exist false) 不生效怎么办我在做一个权限系统时定义了一个BaseEntity放了一些通用字段public class BaseEntity { TableField(exist false) private String currentUserId; private LocalDateTime createTime; private LocalDateTime updateTime; }然后让多个业务实体继承它。结果在某个业务实体执行insert时报了Unknown column current_user_id。我当时的反应和大多数人一样不是已经加了exist false吗为什么还会拼接这个字段排查后定位到问题出在 MyBatis-Plus 解析父类字段的方式上。实体类字段解析顺序是先当前类、后父类不同版本对父类字段注解的继承处理有差异尤其是升级或降级后行为会变化。我最终的解决办法是升级 MyBatis-Plus 到 3.5.x并顺手把currentUserId从基类挪到了具体的查询 DTO 里。后来的经验告诉我涉及继承关系时不要过度依赖注解在父类上的表现能放在当前类就放在当前类能在 DTO/VO 里解决就不要硬塞进实体基类。4.2 自定义 SQL 里写 SELECT *这个注解根本不拦着有一个坑特别容易“误导后人”。使用 MyBatis-Plus 自动生成的selectList查询时exist false的字段不会出现在查询列清单里一切正常。可一旦你手写 XML 里的SELECT * FROM user WHERE id #{id}然后映射到带TableField(exist false)字段的实体类时只要结果集里存在和目标字段同名的列MyBatis 照样会把它映射进实体。这里要明确边界exist false只控制 MyBatis-Plus 自动生成的 CRUD SQL管不了你手写的 XML 或注解 SQL。如果你在自定义 SQL 里写的列名和实体字段对不上你需要通过map-underscore-to-camel-case或resultMap来处理这和TableField无关。4.3 QueryWrapper 里写 Java 属性名也一样会踩到列名不存在的错这是另一个高频误用。有同事问我“我这个字段明明加了TableField(exist false)为什么在QueryWrapper.eq(roleNames, admin)里还是报列不存在”这里必须分清两个概念QueryWrapper里传入的字符串是数据库列名不是 Java 属性名它会被直接拼到 SQL 的 WHERE 条件里。TableField(exist false)只影响 MyBatis-Plus 在解析实体字段时是否把该字段加入“列清单”它不会“翻译”你传给 QueryWrapper 的列名字符串。所以如果你传入的是roleNames最终 SQL 会变成WHERE roleNames ?数据库当然报错。想让非表字段作为查询条件正确做法是写自定义 SQL 并用#{}绑定参数或者干脆不要把它标记为exist false而是用updateStrategy NOT_NULL等方式保留为“SQL 可用”状态。4.4 序列化给前端时字段“消失”的误会还有同事问加了TableField(exist false)后前端还能看到这个字段吗答案是能看到如果赋值了就会正常出现在 JSON 中如果没赋值就是 null。它和 Jackson 序列化没有关系。TableField是 MyBatis-Plus 的专属注解只管数据库映射。Jackson 序列化只看实体类有没有可访问的 getter或者字段有没有被JsonIgnore等 Jackson 注解标记。所以如果你不想让某个exist false字段返回给前端正确做法是加JsonIgnore或JsonProperty(access Access.WRITE_ONLY)或者干脆把这个字段放到 DTO 里而不是指望 MyBatis-Plus 的注解帮你挡 JSON 序列化。5. 一个实用的判断清单什么字段该加什么不该加5.1 新增实体字段前的四连问我在团队里立了一个简单的检查清单每次在实体类里新增字段之前按顺序过一遍数据库表 DDL 里有没有这一列如果没有进入下一步。这个字段会被 MyBatis-Plus 自动生成的insert/updateById用到吗如果不会就必须考虑加TableField(exist false)。这个字段只是查询条件或查询结果吗如果是要么加exist false放在实体类里要么单独放进 DTO。这个字段将来有没有可能变成表列如果有可能先在实体里加上注解等 DDL 变更后去掉注解即可改动范围最小。这个清单本质上解决了“实体类和数据库表不一致该如何显式标记”的问题而不是靠运气规避。项目越大这种显式标记越重要。5.2 和 Spring Boot 其他常用注解放在一起看经常有人对Component、Transactional、RequestBody、TableField(exist false)这些注解产生“到处是魔法”的错觉。其实它们只是不同框架读取的元数据ComponentSpring 容器通过类路径扫描把标注类注册为 Bean。TransactionalSpring 通过 AOP 拦截调用链在方法前后开启和提交事务。RequestBodySpring MVC 用 Jackson 把请求体 JSON 绑定到方法参数对象上。TableField(exist false)MyBatis-Plus 在初始化实体元数据时把该字段从“自动 SQL 列”中剥离。用这个视角看问题就不会因为 IDEA 里输入小写字母不联想注解而焦虑。注解本质上是元数据重点不是你记住了多少拼写而是理解“哪个框架会读取它、在什么阶段读取、读取后的效果是什么”。IDEA 的补全能力只是编辑工具层面的事不影响代码运行效果。5.3 给还在犹豫“要不要加”的你一个建议一个典型反例是为了省事把startTime、endTime全部塞进实体类并加exist false但同一个实体类又要序列化给前端。时间一长前端开发者看到实体类里明明不是表字段的属性会误以为它们是表列接口文档都会产生误导。我的平衡点是临时查询条件时间范围、关键字、分页信息优先放到 query 对象或 DTO 里联表展示字段、导入错误标记这类跟实体生命周期强相关的字段才放实体类并使用exist false。这不算什么金科玉律但至少能让代码可读性保持在一个中等偏上的水平也减少后端在“实体类到底该不该承担多种角色”这个问题上的精神内耗。最后再分享一个我坚持了很久的小习惯写完实体类我会跑一次mapper.selectById的单元测试看打印出来的 SQL 里是否多出一些预期之外的列名。MyBatis-Plus 默认会把 SQL 打在日志里即使不打只要TableField(exist false)加对了insert 语句里就不会出现那个列名。多花十秒钟看一眼日志能拦住大部分“字段映射错误”导致的上线事故。遇到复杂的多表关联我也会先看日志里的 PreparedStatement 参数确认列名后再提交代码。这个习惯帮我少加了很多次夜班也让我对这套自动 SQL 生成机制的理解越来越深。
返回列表