1. 为什么我建议你把动态SQL当一门“小语言”来学
很长一段时间里,我对 MyBatis 的印象停留在“写 SQL 比 JDBC 舒服一点”这个层面。直到我接手一个老合同管理系统,里面每个列表查询都手写了一堆重复的条件判断,改一个字段要连着改三四个 XML,我才意识到:动态 SQL 不是花活,而是 MyBatis 能不能用好的一道分水岭。今天想聊的,就是两个被讨论最多也最容易用歪的东西——动态 SQL 和 MyBatis Generator。前者决定你写 Mapper 的能力上限,后者决定你入职新项目后第一周是花在复制 CRUD 上,还是花在真正理解业务上。
先说动态 SQL。很多教程喜欢把<if>、<where>、<foreach>单独拎出来做个 demo,你照着抄一遍感觉会了,到了真实项目里又不知道该怎么组合。原因很简单:动态 SQL 其实是一套小型模板语言,它有变量、有判断、有循环、有复用片段。你要是只背标签不建立整套组合思维,写出来的 SQL 依然会在第一个复杂查询面前崩掉。
1.1 一个200行SQL的真实教训
那个合同系统里的列表查询,我印象很深。页面上一共有七个筛选条件:合同编号、客户名称、签订日期起止、合同状态、经办人、金额上下限、是否包含作废。对应到 Mapper XML 里,早期同事的做法是where 1=1打底,后面跟十多个<if>。你能想象吗,一个查询方法里同时堆了订单子查询、客户子查询、金额区间判断、日期格式化函数,还有三套不同的排序逻辑,整个 SQL 加起来超过 200 行。
当时最痛苦的不是 SQL 长,而是没人敢动它。你想加一个“仅查看自己负责的合同”条件,必须先读懂里面哪一段是干什么的;你想把一个相等判断改成IN判断,可能牵扯到两处<if>共用变量;你甚至不小心动了一个空格的顺序,拼接出来的 SQL 就直接语法错误。后来我花了整整一个下午,把这段 SQL 按业务语义拆成了四个<sql>片段,用<include>引用,再配合<where>和<trim>把条件按模块分组,整个方法直接从 200 行降到 90 行,而且后续加需求再也没出过拼接问题。
这个经历给我最大的触动是:动态 SQL 的核心不是“能拼”,而是“拼得可维护”。条件多了以后,谁能让 XML 像积木一样一块块拼装,谁才是真正入了门。
1.2 动态SQL能解决什么,不能解决什么
动态 SQL 能解决的是“同一套查询骨架,在不同入参下产生不同 SQL”的问题。比如列表查询,客户可能什么都不填直接点搜索,也可能只填一个名称模糊搜索,还可能带时间范围搜索。如果不用动态 SQL,你就得写三个方法、三个 SQL,或者用 Java 代码拼 SQL 字符串——前者冗余,后者不仅容易注入,还很难统一优化。MyBatis 的动态 SQL 让你在 XML 里做条件分支,参数缺了就少拼一段,参数全了就拼全,逻辑清清楚楚。
但它不能解决所有事情。第一,它不能替代数据库设计,关联表过多、索引缺失,动态 SQL 写得再漂亮也是慢查询;第二,它不擅长处理极其复杂的动态表名、动态字段名,这类需求硬要用${}去拼,往往会把安全性拖垮;第三,它替代不了代码评审,因为动态 SQL 写多了以后,肉眼很难看出最终生成的 SQL 长什么样,所以“能少拼就少拼、能拆就拆”反而成了最重要的原则。
一句话总结:动态 SQL 是让你在“条件不确定”的场景下保持 SQL 可读性的工具,不是让你把所有逻辑都塞进 XML 的理由。
2. 动态SQL核心标签拆解:从if到foreach的完整实操
我按实际项目里出现的频率,把高频标签逐个过一遍。下面的例子尽量用真实的业务场景,而不是教科书式的select * from user where id = #{id}。
2.1 if + where:别再用 where 1=1 硬扛
<if>是最基础的判断标签,语法很直白:test里写判断表达式,成立就拼接内部 SQL。但光有<if>不够,因为多个条件组合时,第一个成立的条件前面要加WHERE,后面的加AND。老写法是where 1=1 and xxx,能用但很丑,而且让数据库的谓词分析变得不那么直观。
更好的写法是<where>标签:它会在内部有条件成立时自动插入WHERE,并且会自动去掉第一个条件前缀的AND或OR。来看一个典型的条件查询:
<select id="listContracts" resultType="com.example.entity.Contract"> SELECT * FROM contract <where> <if test="contractNo != null and contractNo != ''"> AND contract_no = #{contractNo} </if> <if test="customerName != null and customerName != ''"> AND customer_name LIKE CONCAT('%', #{customerName}, '%') </if> <if test="statusList != null and statusList.size() > 0"> AND status IN <foreach collection="statusList" item="st" open="(" separator="," close=")"> #{st} </foreach> </if> <if test="startDate != null"> AND sign_date >= #{startDate} </if> <if test="endDate != null"> AND sign_date <= #{endDate} </if> </where> </select>这里有几个细节要提醒你。<where>虽然能去掉开头多余的AND,但它是靠“匹配”实现的,如果你的第一个<if>里面写了括号嵌套或者其他奇怪的前缀,偶尔会判断不准确。所以我的习惯是:即使有<where>,<if>内部也尽量用AND开头,不要交叉混用OR。
另外,判断空字符串要记得把“空格”也考虑进去,<if test="customerName != null and customerName != ''">里的空串检查对表单默认值很有用。如果你用了 MyBatis 3.5 以上的版本,还可以直接用test="customerName != null and customerName.trim() != ''",但会影响一点性能,量大的话尽量在 Java 层就处理掉。
2.2 choose/when/otherwise:处理多选一的业务分支
<if>是“每个条件独立判断”,谁成立谁拼。但很多业务是“排他”的:比如列表页的排序规则,可能是按时间、按金额、按状态,三选一,而不是同时生效。这时候如果你用多个<if>,就会拼出多个ORDER BY,直接语法错误。正确的做法是<choose>,它类似 Java 里的switch:
<choose> <when test="sortType == 'DATE'"> ORDER BY sign_date DESC </when> <when test="sortType == 'AMOUNT'"> ORDER BY amount DESC </when> <otherwise> ORDER BY id DESC </otherwise> </choose><choose>从上到下匹配,匹配到第一个成立的<when>后就不再看后面的了,所以天然是互斥的。项目里我经常把它用在“筛选模式”上,比如“全部 / 本月 / 自定义区间”这样的三个 tab,前端只传一个queryType,后端用一个<choose>就处理干净,比在 Java 层 if-else 里拼 SQL 优雅得多。
有个容易踩的坑:<when>的test表达式里,字符串比较一定要加引号,注意外层单引号、内层双引号的写法。test="sortType == 'DATE'"是常见的正确形式,写反了 MyBatis 会把'DATE'当成字符解析失败,报错还比较隐晦。
2.3 set + trim:让更新语句不再胆战心惊
做UPDATE的时候,动态更新是最刚需的场景。前端表单可能只改了备注,也可能改了金额和状态,你不能用一个写死全部字段的UPDATE把所有列都覆盖一遍——那样会把没传的字段更新成null。最稳妥的方案是用<set>标签:
<update id="updateContract"> UPDATE contract <set> <if test="amount != null"> amount = #{amount}, </if> <if test="status != null"> status = #{status}, </if> <if test="remark != null and remark != ''"> remark = #{remark}, </if> <if test="updateTime != null"> update_time = #{updateTime}, </if> </set> WHERE id = #{id} </update><set>会自动在内部有条件成立时插入SET,并且会去掉最后一个多余的逗号。这是它最友好的地方,因为新手手写时经常会留下一个尾逗号,数据库直接报错。
但你要注意两个问题。第一,如果所有<if>都不成立,最终生成的 SQL 是UPDATE contract WHERE id = ?,这在 MySQL 里会直接报语法错误。所以务必在 Java 层或 SQL 层兜底,要么保证至少一个字段有值,要么先查一次再决定是否更新。第二,<set>不会管字段本身的业务约束,比如某个列在数据库里是NOT NULL,但你表单里没传,这个字段就不会出现在 SQL 里,数据库不会报错,但业务上可能不符合预期。所以动态更新适合“允许部分字段更新”的场景,如果业务要求“必须更新完整”,那应该用固定的UPDATE。
<trim>是比<set>更底层的通用方案。它的四个属性prefix、suffix、prefixOverrides、suffixOverrides可以干很多事。比如手动实现一个<set>:
<trim prefix="SET" suffixOverrides=","> <if test="amount != null"> amount = #{amount}, </if> <if test="status != null"> status = #{status}, </if> </trim><trim>的语义是:如果内部有内容,就在整体前面加prefix,整体后面加suffix;prefixOverrides会去掉内容开头匹配的字符串,suffixOverrides会去掉内容结尾匹配的字符串。理解了这四个属性,你就能写出各种自定义拼接规则,比如动态GROUP BY、动态ORDER BY组合。
2.4 foreach:批量操作与 IN 列表的正确姿势
<foreach>用得非常频繁,最常见的两个场景是IN查询和批量插入。先看IN查询:
<select id="listByIds" resultType="com.example.entity.Contract"> SELECT * FROM contract WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>这里的collection取值要注意:如果 Mapper 方法只有一个参数,且参数是List,默认名字是list或collection;如果参数是数组,默认是array。如果不想记这些默认名,最稳妥的是在 Mapper 接口上用@Param("ids") List<Integer> ids明确指定名字。我几乎每个集合参数都会加@Param,因为默认命名规则在不同版本、不同场景下容易记混,一旦写错就直接报There is no getter for property named 'xxx',排查起来很烦。
批量插入是<foreach>的另一个高频场景:
<insert id="batchInsert"> INSERT INTO contract (contract_no, customer_name, amount, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.contractNo}, #{item.customerName}, #{item.amount}, #{item.status}) </foreach> </insert>这里有几个性能和安全细节。MySQL 默认单条 SQL 有max_allowed_packet限制,一般默认 4MB 或 64MB。批量插入的数据量太大时,整个 SQL 字符串可能超过限制,数据库直接拒绝。我实践下来的分片经验是:每批 500 到 1000 条比较稳妥。如果你用的连接池是 Druid,还要注意maxStatementLength之类的参数,必要时在 JDBC URL 上把rewriteBatchedStatements=true打开,虽然它影响的是addBatch模式,但配合批量插入能显著减少网络往返。网上很多人问“java mybatis mybatis-plus 批量怎么做”,其实 MyBatis 底层就靠这个<foreach>,MyBatis-Plus 的saveBatch也只是帮你把这个循环生成了而已。
另外注意,<foreach>也可以配合动态拼接做“批量更新”,比如UPDATE ... SET field = CASE id WHEN ...,但那样的 SQL 可读性很差,我一般建议拆成多条更新放在事务里执行,不要非把复杂逻辑压成一条 SQL。
2.5 bind + sql/include:复用是动态SQL的隐藏价值
<bind>是个容易被忽略但很实用的小标签。典型场景是模糊查询:MySQL 可以CONCAT('%', #{name}, '%'),但如果你要兼容 Oracle,或者你在 PostgreSQL 里用||拼接,不同数据库语法不一样。用<bind>可以把参数处理统一放到 XML 里:
<select id="listByName" resultType="com.example.entity.Contract"> <bind name="pattern" value="'%' + customerName + '%'"/> SELECT * FROM contract WHERE customer_name LIKE #{pattern} </select><bind>的value里写的是一个 OGNL 表达式,它会在执行前把计算结果绑定到pattern变量上。这样做的好处是:Mapper 接口入参不需要额外处理,所有拼接逻辑留在 XML,后续切换数据库方言时只需要改这一处。
<sql>和<include>是复用片段的重要手段。前面说的那个 200 行 SQL,拆解后可以抽象出公共片段:
<sql id="contractColumns"> id, contract_no, customer_name, amount, status, sign_date </sql> <sql id="baseWhere"> <where> <if test="customerName != null and customerName != ''"> AND customer_name LIKE CONCAT('%', #{customerName}, '%') </if> <if test="status != null"> AND status = #{status} </if> </where> </sql>然后不同查询方法里这样引用:
<select id="listContracts" resultType="com.example.entity.Contract"> SELECT <include refid="contractColumns"/> FROM contract <include refid="baseWhere"/> </select><include>还可以配合<property>给片段传变量。比如你想让同一个baseWhere在不同方法里作用到不同表,可以写成<include refid="baseWhere"><property name="tableAlias" value="c"/></include>,然后在片段里用${tableAlias}.status = #{status}。但这里要用${},注意别把这部分暴露给外部输入,否则会有注入风险。我的建议是:<sql>片段适合复用字段列表、公共条件、排序规则,但不适合过度抽象,因为片段一旦嵌套多层,XML 可读性会急剧下降,到时候维护的成本比复制粘贴还高。
2.6 每个标签背后的OGNL表达式
你说<if test="customerName != null and customerName != ''">里的and、!=、null这些是哪来的?答案是 OGNL,一个 Java 里的表达式语言。MyBatis 动态 SQL 的test、bind的value都会交给 OGNL 求值,所以它的语法基本和 Java 表达式类似:==、!=、&&、||、!、方法调用list.size()、三目运算等都能用。
明白这一点后,很多坑就能解释了。比如你写<if test="status == 1">,当参数status是Integer时没问题;但如果参数是String,你得写'1'。比如你用list.size() > 0判断集合非空,这是 OGNL 在调用 List 的方法;如果你直接写list != null and list != '',对集合来说''的比较可能不生效。再比如某些版本下test="ids.size() > 1"里的>在 XML 里最好写成>,否则 XML 解析器会报错。
我自己写动态 SQL 时,test表达式遵守三个原则:第一,能用简单的!= null判断就不用复杂表达式,降低出错面;第二,文本值统一加引号,数字值不加,保持和 Java 字面量一致;第三,集合判空写成xxx != null and xxx.size() > 0,不写成xxx != null and xxx != ''。这些规则你在面试时随口说出来,通常会让面试官觉得你是踩过坑的。
3. 动态SQL的底层逻辑:从参数绑定到BoundSql
不少人写完动态 SQL 能跑,但一旦报错就慌了,因为不清楚 MyBatis 到底是怎么把 XML 变成数据库能执行的 SQL 的。这一节我从参数和执行链路两个角度拆一下,理解了这两块,很多诡异的报错都能迎刃而解。
3.1 #{} 和 ${} 的区别,与 param index 的关系
#{}最终会被 MyBatis 替换成?(预处理参数占位符),然后通过 JDBC 的PreparedStatement设置参数。这样做的好处有两个:一是防止 SQL 注入,二是让数据库可以复用执行计划。而${}是直接做字符串替换,把变量内容原样拼进 SQL 字符串,所以它天生有注入风险,也容易因为引号问题出语法错误。
我在网上看热词里一直有人搜“mybatis param index”,其实就是#{}里的参数引用机制。默认情况下,如果你的 Mapper 方法只有一个参数,MyBatis 会把这个参数包装成map,可以用_parameter来引用它;如果有多个参数,又不加@Param,MyBatis 会生成param1、param2这样的名字。用@Param("xxx")后,XML 里就可以直接用xxx了。所以你会发现很多老代码里写的是#{param1}、#{param2},这就是没加注解时的默认行为。
在动态 SQL 里,test表达式引用参数时遵循同样的规则。假设方法签名是List<Contract> list(@Param("query") ContractQuery query),那么 XML 里写<if test="query.customerName != null">,#{}里写#{query.customerName}。如果你用了_parameter这种默认名,可读性会很差,所以再次建议:多参数的 Mapper 方法一定显式加@Param。
至于什么时候能用${},我的底线是:只用于非用户控制的静态片段,比如<sql>片段里的表别名、固定的排序列名,或者在生成器里用来拼接数据库分页方言。凡是可能被外部输入影响的值,一律走#{}。别为了省事把用户输入的排序字段直接${sortField}拼进去,那是典型的注入入口。
3.2 一条动态SQL的执行链路
在 MyBatis 里,XML 中的<select>节点会被解析成一个MappedStatement,它内部维护的 SQL 信息是一个SqlSource。如果这个 SQL 包含了动态标签,MyBatis 会把它包成DynamicSqlSource;如果不含动态标签,直接用RawSqlSource。
执行的时候,DynamicSqlSource.getBoundSql(parameterObject)会把参数对象传入,通过 XMLScriptBuilder 递归解析各种动态节点。这个过程有点像模板引擎渲染:遇到<if>就调用 OGNL 判断test表达式,成立就把子节点内容拼进去;遇到<foreach>就遍历集合,按open、separator、close生成列表;遇到<sql>就去查找对应的SqlNode解析。最后拼出一个BoundSql,里面包含了完整的 SQL 字符串和参数映射关系。
理解这条链路以后,有几个排查问题的思路就很清楚了。比如你改了 XML 但没生效,多半是没重新编译或 MyBatis 缓存了 metaObject;比如你发现生成的 SQL 里AND多了或者少了,问题一定出在动态节点的prefixOverrides或<where>的处理逻辑上。我之前排查过一个莫名其妙多出一个WHERE的问题,最后发现是<sql>片段里已经写了<where>,外层又套了一个<where>,两段逻辑叠加把 SQL 结构搞乱了。这种问题如果不理解拼接顺序,光盯着报错看是找不出答案的。
3.3 动态SQL的性能与缓存边界
动态 SQL 每次拼接时都要走一遍 OGNL 求值和 SQL 字符串拼接,所以相比固定 SQL 会有一定的 CPU 开销,但这个开销通常很小,不至于成为瓶颈。真正要关注的是“生成的 SQL 是否还走得上索引”。比如你对一个可空字段写<if test="status != null"> AND status = #{status}</if>,条件是可选时没问题;但如果很多条件都是可选,用户却一个都不选,SQL 就变成SELECT * FROM contract,全表扫描是必然的。所以动态 SQL 写不写是一回事,查询计划能不能用好是另一回事,配合EXPLAIN检查每个分支的索引命中情况,是我每次上线前都会做的事。
至于缓存,很多人在搜“mybatis缓存”“mybatis二级缓存实现”。我的经验是:动态 SQL 和缓存的交集很容易被误解。一级缓存是 SqlSession 级别的,默认开启,同一个 SqlSession 内执行相同 SQL 会命中缓存;二级缓存是 Mapper 级别的,需要手动开启。如果你给某个 Mapper 开了二级缓存,而它内部又有很多动态 SQL,缓存命中率会因为 SQL 字符串不同而大幅下降,因为动态条件一变,SQL 就不是同一个了。所以实务里我只建议对基础字典表、静态配置表开二级缓存,复杂的列表查询宁可走数据库或加 Redis 缓存,也别指望 MyBatis 二级缓存兜底。面试时如果你能把“动态 SQL 导致缓存 key 差异大”这个点说出来,比单纯背一级、二级缓存的区别有说服力得多。
4. MyBatis Generator:从零配置到生成代码落地
动态 SQL 本身能让你把 Mapper 写明白,而 MyBatis Generator(MBG)解决的是另一件事:项目里的基础 CRUD 代码谁来写。我见过很多团队还在手动复制粘贴selectByPrimaryKey、updateByPrimaryKeySelective,写十张表就开始烦躁,写五十张表就开始出 bug。MBG 是 MyBatis 官方提供的代码生成器,连表结构都不用手敲,直接生成实体类、Mapper 接口和 XML,能省下大量机械劳动。
4.1 为什么你值得花半天把 MBG 用起来
先摆几个我真实的感受。第一,手动写 CRUD 的出错率比想象中高:字段漏了、类型映射错了、XML 里 resultMap 的column和实体属性对不上,这些错误在编译期根本发现不了,只能运行时报错。MBG 从数据库表反推代码,字段名、类型、主键规则全部以元数据为准,生成结果一致性极高。第二,生成器让你把规范固化下来:比如所有实体类继承同一个基类,所有字段都加注释,所有 Mapper 都遵循同样的命名风格,这些一致性靠人肉保证很难,但生成器天然就可以。
第三,也是最重要的,MBG 生成的代码是“可丢弃”的。这句话怎么理解?它不像某些代码生成工具那样生成一堆没法改的代码,恰恰相反,MBG 标准做法是:生成的基础 CRUD 不手动修改,后续要改生成的代码就重新生成覆盖;你的业务查询另外写在扩展接口或扩展 XML 里。这样每次数据库变更,你重新跑一遍生成器,基础层自动跟着变,手写部分不会受影响。这个工作流一旦跑起来,维护成本会直线下降。
4.2 generatorConfig.xml 关键节点逐个拆解
MBG 的入口是一个generatorConfig.xml,第一次配置会觉得节点很多,其实核心就五个部分:jdbcConnection、javaModelGenerator、sqlMapGenerator、javaClientGenerator、table。下面是完整的最小配置:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE generatorConfiguration PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN" "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd"> <generatorConfiguration> <context id="mysqlContext" targetRuntime="MyBatis3Simple" defaultModelType="flat"> <jdbcConnection driverClass="com.mysql.cj.jdbc.Driver" connectionURL="jdbc:mysql://localhost:3306/contract_db?useUnicode=true&characterEncoding=utf8" userId="root" password="123456"> </jdbcConnection> <javaTypeResolver> <property name="forceBigDecimals" value="false"/> </javaTypeResolver> <javaModelGenerator targetPackage="com.example.entity" targetProject="src/main/java"> <property name="enableSubPackages" value="true"/> <property name="trimStrings" value="true"/> <property name="immutable" value="false"/> </javaModelGenerator> <sqlMapGenerator targetPackage="mappers" targetProject="src/main/resources"> <property name="enableSubPackages" value="true"/> </sqlMapGenerator> <javaClientGenerator type="XMLMAPPER" targetPackage="com.example.mapper" targetProject="src/main/java"> <property name="enableSubPackages" value="true"/> </javaClientGenerator> <table tableName="contract" domainObjectName="Contract" enableCountByExample="false" enableDeleteByExample="false" enableUpdateByExample="false" enableSelectByExample="false"/> </context> </generatorConfiguration>逐个讲关键配置。第一,targetRuntime我推荐根据团队情况二选一:MyBatis3Simple生成的代码最简单,只包含单表主键 CRUD,适合你打算自己写复杂查询的情况;MyBatis3会额外生成大量XXXExample类,能把单表查询做成“动态条件模板”,但代码量巨大,类数量直接翻倍。我个人的倾向是MyBatis3Simple,因为它生成的代码容易读懂,Example 类那种设计对于多人协作来说学习成本偏高。
第二,defaultModelType="flat"非常重要。如果不设置或设置成默认的conditional,MBG 可能会根据表的主键结构生成主键类、BLOB 类等多个类,导致实体类数量膨胀。flat模式下每张表只生成一个实体类,简单直接,绝大多数业务场景都够用。
第三,javaModelGenerator里的trimStrings属性建议设为true,这样生成实体的 setter 会自动 trim 掉字段值两端的空格,减少表单数据脏值入库的概率。
第四,table节点按需配置。如果只填tableName,生成器会使用数据库表名反推实体类名,如果你有表名前缀比如t_contract,想生成Contract,可以加<property name="domainObjectName" value="Contract"/>;这里也可以直接用上面示例里的domainObjectName属性。还有更细的列映射,比如某列的generatedKey="true"表示数据库自增主键,某个字段不想生成,可以用<ignoreColumn>排除。表特别多的时候,可以用<table tableName="%">通配所有表,再配合过滤规则来批量生成。
4.3 三种运行方式:命令行、Maven插件、Java代码
MBG 的运行方式有几种,我按实际使用频率从低到高说。
命令行方式:下载mybatis-generator-core的 jar 包,执行:
java -jar mybatis-generator-core-1.4.2.jar -configfile generatorConfig.xml这种方式适合临时跑一次,缺点是要手动下载 jar、手动维护版本。
Maven 插件方式是我最推荐、也是项目里最常用的。在pom.xml里加插件:
<plugin> <groupId>org.mybatis.generator</groupId> <artifactId>mybatis-generator-maven-plugin</artifactId> <version>1.4.2</version> <configuration> <configurationFile>src/main/resources/generatorConfig.xml</configurationFile> <overwrite>true</overwrite> <verbose>true</verbose> </configuration> <dependencies> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies> </plugin>然后执行业务模块下的 Maven 命令:
mvn mybatis-generator:generate注意overwrite设为true时,MBG 会覆盖同名文件。这个设计配合“生成代码不手工修改”的策略非常安全,因为基础 CRUD 永远不会被业务改动污染。
第三种是 Java 代码方式,适合写进自动化构建脚本或者 IDE 工具里。核心代码只有几行:
List<String> warnings = new ArrayList<>(); Configuration config = new ConfigurationParser(warnings).parseConfiguration( new File("src/main/resources/generatorConfig.xml")); ShellCallback callback = new DefaultShellCallback(true); MyBatisGenerator generator = new MyBatisGenerator(config, callback, warnings); generator.generate(null);实际项目里我见过有人把这段代码写成一个单元测试,数据库 schema 变更后直接跑一下测试就重新生成了代码,配合 CI 里的定时任务也算一种团队基建。不过大多数时候 Maven 插件已经够用。
4.4 生成的代码长什么样
以MyBatis3Simple为例,表contract会生成三个文件:
Contract.java:实体类,字段对应表的列,包含 getter/setter,如果开启了toString等选项还会生成对应方法。ContractMapper.java:Mapper 接口,默认包含deleteByPrimaryKey、insert、selectByPrimaryKey、updateByPrimaryKey、updateByPrimaryKeySelective等几个方法。ContractMapper.xml:Mapper XML,包含对应的动态 SQL 和固定的resultMap。
生成的 XML 会自动带一个BaseResultMap,它会将表的每一列映射到实体字段:
<resultMap id="BaseResultMap" type="com.example.entity.Contract"> <id column="id" property="id" jdbcType="INTEGER"/> <result column="contract_no" property="contractNo" jdbcType="VARCHAR"/> <result column="amount" property="amount" jdbcType="DECIMAL"/> ... </resultMap>这个resultMap是基础层和手写层共用的核心资产。你在扩展查询 SQL 时,直接<select id="listByCustomer" resultMap="BaseResultMap">就可以了,不需要每个查询都重新定义一遍字段映射。
另外,MBG 会根据列的 JDBC 类型为#{}自动生成jdbcType。这块有个容易遇到的问题会在后面的常见问题里专门讲,比如 Oracle 的DATE类型映射到 Java 的Date时,时间精度是否会丢。
5. Generator 进阶改造与生产经验
基础用法学会后,真正决定 MBG 好不好用的是后续的改造和工程管理。这一节我集中写生产里会遇到的硬核细节。
5.1 自定义注释生成器:别再让生成的代码带着丑注释
MBG 默认生成的文件顶部会有“This class was generated by MyBatis Generator...”这种注释,看起来很丑,而且如果你重新生成文件,注释还会说“do not modify”。想要生成干净、带上自己团队风格注释的代码,最靠谱的做法是实现一个注释生成器插件。
核心是利用 MBG 的插件扩展点,重写addModelClassComment、addMapperComment、addComment等方法。下面这段代码是我项目里精简约好的版本:
public class CustomCommentGenerator extends DefaultCommentGenerator { @Override public void addModelClassComment(XmlElement xmlElement, IntrospectedTable introspectedTable) { // 不生成默认注释 } @Override public void addComment(XmlElement xmlElement) { // 跳过 XML 节点的默认注释 } @Override public void addConfigurationProperties(Properties properties) { super.addConfigurationProperties(properties); } }然后在generatorConfig.xml的<context>里通过<plugin>注册:
<plugin type="com.example.generator.CustomCommentGenerator" />你还可以根据自己的需要给实体类字段加上业务说明。比如希望在实体类每个字段上生成“数据库字段的注释”,可以在addFieldComment方法里从IntrospectedColumn.getRemarks()拿数据库注释,拼到 Javadoc 上。这样生成的代码等于自带文档,团队成员看实体类就能明白每个字段的含义。
还有个小技巧:你可以在插件里统一给实体类加上@Data注解(如果你项目用 Lombok),加@EqualsAndHashCode(callSuper = true)等类注解,这样生成出来的实体类更贴合团队规范。但注意,生成代码被反复覆盖后,Lombok 注解也会被重新生成,所以插件逻辑必须稳定,否则每次生成完都要手动改。
5.2 生成代码与手写SQL如何分层管理
这是 MBG 使用中最核心的工程问题。我的方案是:生成层和业务层分离,物理上就分开。
具体做法是,MBG 生成的ContractMapper.java只负责基础 CRUD,你不允许在它上面加任何自定义方法。业务查询、复杂动态 SQL 全部写在另一个扩展接口里,比如ContractExtMapper.java,配套的 XML 是ContractExtMapper.xml。然后通过 Spring 装配或手动注入,在 Service 层同时注入两个 Mapper:
@Autowired private ContractMapper contractMapper; @Autowired private ContractExtMapper contractExtMapper;这样做的好处非常明显。第一,数据库表结构变了,你重新跑 MBG,ContractMapper和Contract.java被覆盖,但ContractExtMapper完全不受影响,手写 SQL 的稳定性有保障。第二,基础 CRUD 和维护代码分开后,代码评审和排查问题都能快速定位:主键查询、根据主键更新这种通用操作走ContractMapper,业务查询找ContractExtMapper。
如果你不想多建接口,也可以采用另一种做法:MBG 生成所有基础方法,然后你用<sql>片段给 XML 追加自定义查询。这时要注意<mapper>的 namespace 一定不能动,XML 文件也尽量保持和ContractMapper.java同一个命名空间下。但我的经验是这样做有隐患,因为overwrite=true时,MBG 会直接覆盖整个 XML 文件,你手动追加的查询会被冲掉。所以我强烈建议走“扩展接口”这条路线,从物理上隔离生成代码和手写代码。
5.3 常见问题速查表
我整理了生产环境里遇到频率最高的几个问题,直接给出解决方案:
| 问题现象 | 原因 | 解决办法 |
|---|---|---|
| 生成的实体字段少了一个 | 表结构未刷新,或列被ignoreColumn排除了 | 检查generatorConfig.xml的table配置,确认没有把列排除,重新生成前先刷新数据库连接 |
生成时间类型是Date,但数据库是datetime,查询结果少了时分秒 | JDBC 驱动返回的类型映射问题 | 检查实体字段类型是否用了java.time.LocalDateTime;必要时在<javaTypeResolver>里配置自定义类型映射 |
| 重新生成后手写 XML 被覆盖 | overwrite=true覆盖了整个文件 | 把自定义 SQL 放到独立的扩展 Mapper 文件;或者调整overwrite=false并手动合并,不推荐 |
| Maven 插件生成时报“does not exist” | MBG 插件找不到数据库驱动 | 在插件<dependencies>里显式添加数据库驱动依赖 |
生成的 XML 里#{}多了jdbcType=OTHER | 某些数据库类型无法推断 | 在<table>里用<columnOverride column="xxx" jdbcType="VARCHAR"/>指定类型 |
| 生成时提示主键为 null | 表没有主键或主键配置错误 | MBG 强依赖主键信息生成selectByPrimaryKey,表无主键时建议加上逻辑主键 |
| Oracle 查询时间字段映射不对 | Oracle 的DATE同时包含日期和时间,驱动映射逻辑不同 | 使用rs.setFetchSize相关配置,或在实体类型上做@JsonFormat、类型处理器处理 |
动态 SQL 执行时拼接多了AND | 第一个<if>前面有多余的空格或换行 | 把AND写在<if>内部,并尽量在<where>内保持同一行风格 |
还有一个经常被问到的:“mybatis配置打印”怎么搞。这个和动态 SQL、生成器都相关,因为排查 SQL 问题最需要的就是看到实际执行的 SQL 长什么样。在 Spring Boot 项目里,最简单的方式是在application.yml配置:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl或者针对某个 Mapper 单独开日志等级:
logging: level: com.example.mapper: debug这样你就能在控制台看到动态 SQL 拼接后的完整语句和参数绑定列表。我排查动态 SQL 问题时,第一步永远是打开 SQL 日志,第二步才是看 XML。生成的 SQL 和预想不一致时,日志里的==> Preparing:后面那段就是最直接的证据。
5.4 面试关联:动态SQL和Generator常被问到的点
结合这几年看到的各种“mybatis面试题”整理,我觉得和本文主题最相关的几个高频问题可以这样答。
为什么#{}能防注入?因为它是预处理占位符,参数通过 JDBCsetXXX绑定,和 SQL 语句结构完全分离,用户输入再“坏”也只能当数据,不会成为 SQL 结构的一部分。
动态 SQL 常用标签有哪些,底层怎么实现的?最常用的是if、where、set、foreach、choose、bind、sql/include。底层就是 XML 解析成 SqlNode 树,运行时根据参数递归拼接 SQL,最终生成 BoundSql,交给 Executor 执行。你把DynamicSqlSource、BoundSql这条链路说出来,深度就够了。
where 1=1有什么问题,为什么推荐用<where>?where 1=1虽然能避免没有条件时语法报错,但语义丑陋、数据库优化器对常量条件的处理也没必要,而且第一个条件前面必须写 AND,拼接风格非常容易出错。<where>自动管理 WHERE 和 AND 前缀。
用过 MyBatis Generator 吗?谈谈避免手写重复 CRUD 的思路?用 MBG 生成基础 CRUD,生成代码不手改,业务查询写在扩展 Mapper 里,数据库变更后重新生成基础层,手写部分隔离。这里可以顺带讲一下自定义注释、Lombok 插件、批量生成策略,都是加分项。
还有一个我特别喜欢问自己的问题:如果表字段很多,更新逻辑要求“只更新非空字段”,你怎么设计 SQL?这个就是updateByPrimaryKeySelective的典型场景,本质就是<set>加多个<if>。把生成器生成的方法名和动态 SQL 标签结合起来讲,会让面试官觉得你是真在项目里干过,而不是只会背概念。
最后想补一句关于“mybatis xml高亮”的题外话:很多前端项目里展示 Mapper XML 时没有语法高亮,排查效率很低。如果你用的是 IntelliJ IDEA,装好 MyBatis 相关插件后,XML 里动态标签会有高亮和折叠,Maven 构建时也能提示 XML 语法错误。这个属于开发体验的一部分,但往往被低估了。
写在最后
我自己的经验是,动态 SQL 用得好不好,本质上是“你能不能预判最终生成的 SQL 长什么样”。写每段<if>的时候,大脑里模拟一遍传入参数后拼出的完整 SQL,能显著降低低级错误。MBG 则秉持一个“生成即基础,基础即勿改”的纪律,宁可多花半天做注释生成器和扩展 Mapper 的骨架,也不要图省事直接在生成文件里手写业务逻辑,否则下一次数据库变更时你会同时失去生成代码和手写代码。
如果你正在搭一个新项目,我建议你从第一张表开始就把generatorConfig.xml配好,哪怕项目很小也值得这样做。等到表数量超过十张再回头补,你会发现自己已经在重复 CRUD 上浪费了两个完整的下午。真的,这个工具半天就能上手,但它每年能帮你省下的时间,远远不止半天。