手写 SQL 被一次 mvn generate 抹掉之后:我用一个 extension/ 子目录躲开 MyBatis 两个坑

手写 SQL 被一次 mvn generate 抹掉之后:我用一个 extension/ 子目录躲开 MyBatis 两个坑

本文基于我自己跟着机构写的一个 Spring Boot 论坛项目(Spring Boot 3.2.5 + mybatis-spring-boot-starter 3.0.3 + MyBatis Generator 1.4.2),以一次真实事故为线索讲透两个机制:overwrite=true 如何无声抹掉手写 SQL,以及 classpath:mapper/**/*.xml 为什么能把子目录里的 XML 一个不漏地扫进来。文中所有配置和代码片段均来自项目真实文件,关键结论附 spring-core 6.1.6 源码行号验证,顺带辟谣一个流传很广的"classpath: 自动升级"误区。(图片由AI生成)

引子:一次"能编译、能启动、运行期才炸"的事故

故事发生在迁移之前。那时 increaseArticleCountdecreaseArticleCount 这些手写 SQL 还躺在 BoardMapper.xml 的下半部分,和 MBG 生成的 CRUD 住在一个文件里。某次改表结构后跑了一遍 mvn mybatis-generator:generate,重新生成的 BoardMapper.xml 干干净净------手写 SQL 整段消失,没有警告,没有提示。

最阴的地方在后面:代码照样能编译 (DAO 接口没变),应用照样能启动 (XML 还在、namespace 还在),直到运行期调用到那个 statement,才抛 Invalid bound statement (not found)。编译期零信号、启动期零信号、运行期才炸,排查成本极高。

这次事故的根因是 pom.xml 里的一行配置,而解决方案是一个子目录:extension/。本文就沿着这条链讲下去:坑的根因 → 隔离方案 → 验证方案可行性 → 为新方案付出的新代价。


上篇:坑的根因------overwrite=true 与生成器领地

1.1 配置原文

pom.xml 第 168--184 行:

xml 复制代码
<plugin>
    <groupId>org.mybatis.generator</groupId>
    <artifactId>mybatis-generator-maven-plugin</artifactId>
    <version>1.4.2</version>
    <configuration>
        <configurationFile>src/main/resources/mybatis/generatorConfig.xml</configurationFile>
        <verbose>true</verbose>
        <overwrite>true</overwrite>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>${mysql-connector.version}</version>
        </dependency>
    </dependencies>
</plugin>

第 175 行的 <overwrite>true</overwrite> 就是事故的题眼。

1.2 overwrite=true 到底意味着什么

一句话:每次执行 mvn mybatis-generator:generate,生成目标里已存在的同名文件会被整文件覆盖重写,你在里面写的每一行手写内容都会被抹掉,不做任何保留。

对照 overwrite=false(默认值)的行为是"尝试合并":MBG 用 XmlFileMergerJAXP 把新生成的 XML 与已有文件做 DOM 合并,尽量保留用户手工添加的节点。听起来很体贴,但这个合并机制对 XML 并不可靠------节点顺序、重复片段、手工修改的格式化都可能被搅乱,社区里"合并后文件面目全非"的抱怨并不少见。overwrite=true 则是另一种哲学:不跟你谈合并,全盘重写,行为完全可预测。代价很明确------生成文件里不许住手写代码

1.3 生成器领地:generatorConfig.xml 划定的范围

MBG 的 XML 生成目标由 generatorConfig.xml 第 37--40 行指定:

xml 复制代码
<!-- mapper.xml生成位置 -->
<sqlMapGenerator targetPackage="mapper"
                 targetProject="src/main/resources">
    <property name="enableSubPackages" value="true"/>
</sqlMapGenerator>

表清单在第 50--94 行,生成器只认这 5 张表:

xml 复制代码
<!-- 配置生成表与实例(generatorConfig.xml 第 50--94 行) -->
<table tableName="t_article" domainObjectName="Article">
    <property name="useActualColumnNames" value="true"/>
</table>

<table tableName="t_article_reply" domainObjectName="ArticleReply">
    <property name="useActualColumnNames" value="true"/>
</table>

<table tableName="t_board" domainObjectName="Board">
    <property name="useActualColumnNames" value="true"/>
</table>

<table tableName="t_message" domainObjectName="Message">
    <property name="useActualColumnNames" value="true"/>
</table>

<table tableName="t_user" domainObjectName="User">
    <property name="useActualColumnNames" value="true"/>
</table>

注意:t_article_liket_notice 根本不在配置里 。两个事实合起来就是事故的完整解释:BoardMapper.xml 在生成器领地内,而 overwrite=true 让每次 generate 都是整文件重写------手写 SQL 住在里面,等于把心血放在随时会被推土机推平的地方。

事故时间线与隔离方案的完整对照见图

上篇一句话速记overwrite=true = generate 时整文件覆盖、不留手写内容;生成器只认 sqlMapGenerator 配置的目标路径,领地内的文件永远处于"可能被推平"状态。


中篇:解决方案------机区 / 人区物理隔离

2.1 迁移后的现状:一块地划成两区

躲坑的思路不是"求生成器手下留情",而是物理隔离。迁移后的目录结构:

text 复制代码
resources/
└── mapper/
    ├── extension/                  ← "人区":7 个纯手写 XML
    │   ├── ArticleExtMapper.xml
    │   ├── ArticleLikeExtMapper.xml
    │   ├── ArticleReplyExtMapper.xml
    │   ├── BoardExtMapper.xml
    │   ├── MessageExtMapper.xml
    │   ├── NoticeExtMapper.xml
    │   └── UserExtMapper.xml
    ├── ArticleMapper.xml           ← "机区":5 个 MBG 生成
    ├── ArticleReplyMapper.xml
    ├── BoardMapper.xml
    ├── MessageMapper.xml
    ├── NoticeMapper.xml            ← 特例:t_notice 未托管,手写但不会被覆盖
    └── UserMapper.xml

机区 mapper/ 根目录 = 生成器领地 。对 5 张托管表而言,根目录文件就是纯生成:看迁移后的 BoardMapper.xml,全部内容就是 MBG 的标准产物------全文如下,一个字的手写都没有

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.rz.forum.dao.BoardMapper">
  <resultMap id="BaseResultMap" type="com.rz.forum.model.Board">
    <id column="id" jdbcType="BIGINT" property="id" />
    <result column="name" jdbcType="VARCHAR" property="name" />
    <result column="articleCount" jdbcType="INTEGER" property="articleCount" />
    <result column="sort" jdbcType="INTEGER" property="sort" />
    <result column="state" jdbcType="TINYINT" property="state" />
    <result column="deleteState" jdbcType="TINYINT" property="deleteState" />
    <result column="createTime" jdbcType="TIMESTAMP" property="createTime" />
    <result column="updateTime" jdbcType="TIMESTAMP" property="updateTime" />
  </resultMap>
  <sql id="Base_Column_List">
    id, name, articleCount, sort, state, deleteState, createTime, updateTime
  </sql>
  <select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
    select 
    <include refid="Base_Column_List" />
    from t_board
    where id = #{id,jdbcType=BIGINT}
  </select>
  <insert id="insert" parameterType="com.rz.forum.model.Board" useGeneratedKeys="true" keyProperty="id">
    insert into t_board (id, name, articleCount, sort, state, deleteState, createTime, updateTime)
    values (#{id,jdbcType=BIGINT}, #{name,jdbcType=VARCHAR}, #{articleCount,jdbcType=INTEGER}, ...)
  </insert>
  <!-- ... insertSelective ... updateByPrimaryKeySelective ... updateByPrimaryKey ... -->
</mapper>

只有 BaseResultMapBase_Column_ListselectByPrimaryKeyinsertinsertSelectiveupdateByPrimaryKeySelectiveupdateByPrimaryKey 这 7 个节点,被覆盖一万次也是零损失。

人区 mapper/extension/ = 纯手写BoardExtMapper.xml 全文如下,三个 statement 全是业务手写 SQL:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.rz.forum.dao.BoardMapper">
  <!-- 查询前 n 条有效板块记录 -->
  <select id="selectByNum" resultMap="BaseResultMap" parameterType="java.lang.Integer">
    SELECT
    <include refid="Base_Column_List"/>
    FROM t_board
    WHERE state = 0 AND deleteState = 0
    ORDER BY sort ASC
    LIMIT #{num,javaType=INTEGER};
    <!-- 根据板块 id 查询所有未被删除的帖子。不包含 content -->
  </select>

  <!-- 板块文章数自增 -->
  <update id="increaseArticleCount">
    update t_board
    set articleCount = articleCount + 1
    where id = #{id,jdbcType=BIGINT}
  </update>

  <!-- 板块文章数自减(不低于 0) -->
  <update id="decreaseArticleCount">
    update t_board
    set articleCount = case when articleCount > 0 then articleCount - 1 else 0 end
    where id = #{id,jdbcType=BIGINT}
  </update>
</mapper>

2.2 为什么 extension/ 绝对安全

因为 sqlMapGenerator 只声明了 targetPackage="mapper",MBG 永远只往自己配置里写的目标路径写东西,mapper/extension/ 不在其中------生成器不认识这个目录。于是形成稳定分工:生成器管根目录,人管 extension ,跑一万次 generate 也碰不到手写 SQL。这招的本质是"不依赖工具的自觉,靠物理隔离保证安全",比指望 MBG 的 merge 机制可靠一个数量级。

顺带解释目录树里埋的两个细节。其一,extension/ 里还有 ArticleLikeExtMapper.xmlNoticeExtMapper.xml,而 t_article_liket_notice 根本不在 generatorConfig 的 5 张表清单里,所以 extension/ 实际承担双重职责:一是保护"被生成器托管的表"的手写 SQL 不被 overwrite 抹掉;二是收纳"生成器未托管的表"的纯手写 Mapper。一个目录,两种身份。

其二,根目录并不 100% 是机区NoticeMapper.xml 就躺在根目录、却是手写的------全文对比如下

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.rz.forum.dao.NoticeMapper">
  <resultMap id="BaseResultMap" type="com.rz.forum.model.Notice">
    <id column="id" jdbcType="BIGINT" property="id" />
    <result column="user_id" jdbcType="BIGINT" property="userId" />
    <result column="notice_type" jdbcType="INTEGER" property="noticeType" />
    <!-- ↑ notice_type 这种手写注释说明这是手写文件 -->
    <result column="content" jdbcType="VARCHAR" property="content" />
    <result column="is_read" jdbcType="TINYINT" property="isRead" />
    <result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
  </resultMap>

  <insert id="insert" parameterType="com.rz.forum.model.Notice" useGeneratedKeys="true" keyProperty="id">
    insert into t_notice (user_id, notice_type, content, is_read, create_time)
    values (#{userId}, #{noticeType}, #{content}, #{isRead}, #{createTime})
    <!-- ← 只有这两个节点,明显不是 MBG 产物 -->
  </insert>
</mapper>

它敢躺在那儿,是因为 t_notice 本就不在 5 张表清单里------生成器永远不会产出同名文件,"被覆盖"无从谈起。这说明划界的真正判据不是目录,而是是否在生成器的表清单里:托管表的手写 SQL 必须进 extension/,未托管表的文件放哪里都安全;把未托管表的 Ext 文件也统一收进 extension/,只是让约定更整齐。

中篇一句话速记:机区纯生成、人区纯手写,靠目录隔离对抗工具覆盖;划界判据是"是否在生成器表清单里"而非目录本身。


下篇:验证方案可行性------子目录里的 XML 扫得到吗

隔离方案成立的前提是:MyBatis 必须能扫到 extension/ 子目录。如果 mapper-locations 只扫根目录,这方案就是自杀。所以下篇回答第二个问题------也才是这次事故的完整闭环。

3.1 配置原文

application.yml 第 29--35 行:

yaml 复制代码
# MyBatis 配置
mybatis:
  mapper-locations: classpath:mapper/**/*.xml
  type-aliases-package: com.rz.forum.model
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

第 31 行的信息量拆开看是三层:classpath: 是资源前缀,mapper/ 是目录前缀,**/*.xml 是 Ant 风格通配符模式。"能不能扫到子目录"的第一层答案藏在 ** 里。

3.2 第一层:*** 的语义差异

Spring 的资源模式用的是 Ant 风格通配符,三种符号语义完全不同:

写法 通配符语义 能否匹配 extension/ 下的 XML
mapper/*.xml * 只匹配一个 路径片段,不能跨 / 否。只命中 mapper/UserMapper.xml 这类根目录文件
mapper/**/*.xml ** 匹配零个或多个目录层 是。根目录和 mapper/extension/BoardExtMapper.xml 都命中
mapper/**/*ExtMapper.xml ** 管深度,* 管文件名 是。各层里 Ext 命名的文件都收(本项目恰好全在 extension/)

这里有一个比"能跨目录"更重要的细节:** 匹配的是零层或多层,不是"恰好一层" 。所以 mapper/*.xml 匹配不到 mapper/extension/BoardExtMapper.xml(隔了两层),而 mapper/**/*.xml 既能匹配只隔零层的 mapper/BoardMapper.xml,也能匹配隔一层的 mapper/extension/BoardExtMapper.xml------甚至哪天你在 extension/ 下再建一个 deep/ 子目录,mapper/extension/deep/XxxMapper.xml 照样命中。"任意深度"四个字是理解整个配置的钥匙。

但通配符语义只回答了"模式写得对不对",没有回答"是谁、按什么流程把这个字符串变成一个个 Resource 的"。后者才是机制层。

3.3 第二层:源码解析链条

mapper-locations 的值最终由 MyBatis-Spring 的 SqlSessionFactoryBean 交给 Spring 的 PathMatchingResourcePatternResolver 解析成 Resource[]完整源码请见码云项目 /spring-core-6.1.6-sources.jar,以下标注关键行号佐证(Spring Boot 3.2.5 对应 spring-core 6.1.6):

整个解析分四步:

第 1 步 · 入口分流(L324--356)classpath: + 通配符 → L349 findPathMatchingResources;无通配符 → L353 getResource 单资源查找。

java 复制代码
// PathMatchingResourcePatternResolver.java L324-356 (见码云/spring-core)
public Resource[] getResources(String locationPattern) {
    if (isPattern(locationPattern.substring(prefixEnd))) {
        return findPathMatchingResources(locationPattern);  // L349
    } else {
        return new Resource[] {getResource(locationPattern)};  // L353
    }
}

第 2 步 · 截断根目录(L568)determineRootDir() 从尾部往前退直到不再是模式 → classpath:mapper/ + 子模式 **/*.xml

java 复制代码
// PathMatchingResourcePatternResolver.java L568, L608-618
String rootDirPath = determineRootDir(locationPattern);  // L568
return location.substring(0, rootDirEnd);  // 返回 classpath:mapper/

第 3 步 · 递归遍历(L570--590)getResources(rootDirPath) 递归解析根目录;文件系统 → L589 doFindPathMatchingFileResources 全量扫描。

java 复制代码
// PathMatchingResourcePatternResolver.java L570, L589
Resource[] rootDirResources = getResources(rootDirPath);  // L570
result.addAll(doFindPathMatchingFileResources(...));  // L589 递归扫描 extension/

第 4 步 · Ant 匹配(L571) 。逐文件与 **/*.xml 匹配,收进 LinkedHashSet 去重。

java 复制代码
// PathMatchingResourcePatternResolver.java L571
Set<Resource> result = new LinkedHashSet<>(64);
return result.toArray(new Resource[0]);
复制代码
**第 2 步 · 截断根目录(L568)**。`determineRootDir()` 从尾部往前退直到不再是模式 → `classpath:mapper/` + 子模式 `**/*.xml`。

```java
// PathMatchingResourcePatternResolver.java L568, L608-618 (见码云/spring-core)
String rootDirPath = determineRootDir(locationPattern);  // L568
return location.substring(0, rootDirEnd);  // 返回 classpath:mapper/

第 3 步 · 递归遍历(L570--590)getResources(rootDirPath) 递归解析根目录;文件系统 → L589 doFindPathMatchingFileResources 全量扫描。

java 复制代码
// PathMatchingResourcePatternResolver.java L570, L589 (见码云/spring-core)
Resource[] rootDirResources = getResources(rootDirPath);  // L570
result.addAll(doFindPathMatchingFileResources(...));  // L589 递归扫描 extension/

第 4 步 · Ant 匹配(L571) 。逐文件与 **/*.xml 匹配,收进 LinkedHashSet 去重。

java 复制代码
// PathMatchingResourcePatternResolver.java L571 (见码云/spring-core)
Set<Resource> result = new LinkedHashSet<>(64);
return result.toArray(new Resource[0]);

实际验证:应用启动时的日志会打印解析到的 mapper...

复制代码
[MyBatis] Loaded mapper: classpath:mapper/extension/BoardExtMapper.xml
[MyBatis] Loaded mapper: classpath:mapper/UserMapper.xml
[MyBatis] Loaded mapper: classpath:mapper/extension/UserExtMapper.xml
...

解析流程全景图如下

文字版流程:

text 复制代码
classpath:mapper/**/*.xml
        │ ① getResources 入口:前缀后含通配符 → findPathMatchingResources
        ▼
② determineRootDir 截断 ──→ 根目录 classpath:mapper/  +  子模式 **/*.xml
        ▼
③ 定位根目录 → target/classes/mapper/(物理目录)
        ▼  doFindPathMatchingFileResources:目录树全量递归遍历
   mapper/UserMapper.xml ...... mapper/extension/BoardExtMapper.xml ......(全部看见)
        ▼
④ AntPathMatcher 逐文件匹配 **/*.xml → 命中的 13 个 XML 收进 Resource[]
        ▼
SqlSessionFactory 加载全部 statement

所以"能扫到子目录"的完整链条是三个齿轮咬合的结果:** 通配符提供"任意深度"的匹配语义 + 解析器对根目录做递归目录遍历 + AntPathMatcher 逐文件匹配 。缺任何一个都不成立:把 ** 换成 *,遍历照样递归但匹配不中;没有递归遍历,** 写得再对也看不见子目录里的文件。隔离方案在这个机制下被验证为可行。

3.4 辟谣专场:classpath: 不会"自动升级"成 classpath*:

讲到这里必须插一段辟谣,因为网上流传着一个非常诱人的说法:"classpath: 一旦搭配通配符,解析器会自动升级为 classpath*: 的行为,扫描所有 classpath 根。"------这个说法是错的,而且错得很隐蔽。把源码摆出来:

两种前缀最终都汇进 findPathMatchingResources,递归遍历和 Ant 匹配是共用的,真正的差异只在第 570 行"根目录怎么取":

text 复制代码
classpath:mapper/**/*.xml
  → 根目录 classpath:mapper/(不含通配符)
  → 递归回 getResources 的 else 分支 → 第 353 行 getResource 单资源查找
  → 单根:classpath 上第一个命中的 mapper/ 目录

classpath*:mapper/**/*.xml
  → 根目录 classpath*:mapper/(带 classpath*: 前缀)
  → 第 326 行 classpath*: 分支、无通配符 → findAllClassPathResources
  → ClassLoader.getResources("mapper/") → 所有根:每个 jar、每个 classes 目录里的 mapper/

换句话说,classpath: 配通配符时,递归扫描的范围仍然只有一个根 ------classpath 上第一个解析到 mapper/ 的位置。它和 classpath*: 共享"递归遍历 + Ant 匹配"的能力,但不共享"全根发现"的能力。

那为什么本项目写 classpath: 完全没问题?因为本项目的 13 个 mapper XML 全部位于应用自身的 target/classes一个 classpath 根下,单根递归遍历就足以覆盖 extension/classpath:classpath*: 的分歧只在"mapper 分散在多个 jar / 多个 classpath 根"的场景才会暴露:比如你把 UserMapper.xml 打进了一个公共依赖 jar,应用自己的 target/classes 里也有 mapper/ 目录,那么 classpath: 只会扫到第一个根,另一个根里的 XML 静默丢失------不报错、不警告,调用时才有 Invalid bound statement。这种场景才必须写 classpath*:

所以准确的记忆姿势是:"扫不扫得到子目录"由 ** + 递归遍历决定,与前缀无关;"扫不扫得到别的 jar"才由 classpath: / classpath*: 决定。 把这两件事分开记,就不会被"自动升级"这类顺口溜带偏。

并且,在源码注释直接描述了 classpath: 配 Ant 模式的边界:

"may exist in only one class path location, but when a location pattern such as classpath:com/example/**/service-context.xml is used to try to resolve it, the resolver will work off the (first) URL returned by getResource("com/example"). If the com/example base package node exists in multiple class path locations, the actual desired resource may not be present under the com/example base package in the first URL. Therefore, preferably, use classpath*: with the same Ant-style pattern in such a case, which will search all class path locations that contain the base package."

作者(Juergen Hoeller 团队)白纸黑字:classpath: + 通配符,是基于 getResource() 返回的"第一个 URL"在工作,多根场景必须 classpath*: 才能搜全。所谓"自动升级"在源码里不存在。

机制证据:getResources 的分支结构(第 323--356 行)

java 复制代码
public Resource[] getResources(String locationPattern) {
    if (locationPattern.startsWith("classpath*:")) {
        ...
        if (isPattern(去前缀后的部分)) {
            return findPathMatchingResources(locationPattern);      // ① classpath*: + 通配符
        }
        return findAllClassPathResources(...);                       // ② classpath*: 无通配符(全根)
    }
    else {
        int prefixEnd = locationPattern.indexOf(':') + 1;
        if (isPattern(locationPattern.substring(prefixEnd))) {
            return findPathMatchingResources(locationPattern);      // ③ classpath: + 通配符
        }
        return new Resource[]{ getResourceLoader().getResource(locationPattern) };  // ④ 单资源
    }
}

①②③ 三个分支汇进同一个 findPathMatchingResources------所以"递归遍历 + Ant 匹配"确实是共用的(这就是误读的温床)。

分歧点:findPathMatchingResources 第 570 行的递归调用

java 复制代码
protected Resource[] findPathMatchingResources(String locationPattern) {
    String rootDirPath = determineRootDir(locationPattern);   // 截到第一个通配符前
    String subPattern = locationPattern.substring(rootDirPath.length());
    Resource[] rootDirResources = getResources(rootDirPath);  // ← 关键!递归调用 getResources(rootDir)
    for (Resource rootDirResource : rootDirResources) {
        ... doFindPathMatchingFileResources / JarResources 递归遍历 + AntPathMatcher 匹配 ...
    }
}

注意第 570 行:根目录的发现方式又递归回了 getResources(rootDirPath),而 rootDirPath 此时不含通配符,于是再次命中分支判定:

原始写法 determineRootDir 结果 递归 getResources(rootDirPath) 走的分支 根目录发现能力
classpath:mapper/**/*.xml classpath:mapper/ 分支 ④ → getResourceLoader().getResource(...)ClassLoader.getResource 单根:第一个命中的根(源码第 353 行)
classpath*:mapper/**/*.xml classpath*:mapper/ 分支 ② → findAllClassPathResourcesClassLoader.getResources 全根:所有 classpath 根(源码第 387 行)

所以两种前缀共享"递归遍历 + Ant 匹配"(doFindPathMatchingFileResources / doFindPathMatchingJarResources ),不共享"全根发现"(getResource vs getResources)。分歧藏在 findPathMatchingResources 对根目录的递归解析里,不把 getResources 的分支结构看全,容易被"汇进同一个方法"误导。

3.5 交叉验证:实际扫到了什么

按上面的机制推演,本配置加载的 XML 清单应该是 6 + 7 = 13 个,与目录树逐一对照:

根目录 6 个:其中 UserMapper.xmlBoardMapper.xmlArticleMapper.xmlArticleReplyMapper.xmlMessageMapper.xml 是 MBG 生成;NoticeMapper.xml 是根目录里唯一的手写文件------t_notice 不在生成器的表清单里,generate 永远不会覆盖它,放根目录也安全(中篇 2.2 已展开)。

人区 7 个(手写):UserExtMapper.xmlBoardExtMapper.xmlArticleExtMapper.xmlArticleLikeExtMapper.xmlArticleReplyExtMapper.xmlMessageExtMapper.xmlNoticeExtMapper.xml

实际验证 :应用启动时的日志会打印解析到的 mapper(log-impl: StdOutImpl 生效了),你可以看到类似这样的输出:

复制代码
==> Preparing: select * from t_board where id = ?
==> Parameters: 1(Long)
...
[MyBatis] Loaded mapper: classpath:mapper/extension/BoardExtMapper.xml
[MyBatis] Loaded mapper: classpath:mapper/UserMapper.xml
[MyBatis] Loaded mapper: classpath:mapper/extension/UserExtMapper.xml
...

只要 13 个 xml 都在列表里,隔离方案闭环成立。你也可以直接检查 target/classes/mapper/ 目录结构,确认 13 个文件确实存在。

下篇一句话速记** 匹配任意深度目录,PathMatchingResourcePatternResolver 截断出根目录后递归遍历 + AntPathMatcher 逐文件匹配,所以 extension/ 一个不漏;classpath: 只解析单根、classpath*: 才全根,本项目单根足够。


尾篇:新代价------同 namespace 下 id 全局唯一

目录隔离解决了"被覆盖",扫描机制验证了"能加载",但这个故事还有最后一个代价。注意中篇 BoardExtMapper.xml 的第 3 行:

xml 复制代码
<mapper namespace="com.rz.forum.dao.BoardMapper">

BoardMapper.xml 第 3 行完全相同。MyBatis 启动解析时,会把同一 namespace 下所有文件的全部节点汇总进同一个"命名空间桶",桶内规则是 id 全局唯一,覆盖三类节点:

第一类是 statement id 。假设迁移时在 BoardMapper.xml 里忘了删 increaseArticleCount,Ext 文件里又写了一份,启动直接抛:

text 复制代码
Mapped Statements collection already contains value for
com.rz.forum.dao.BoardMapper.increaseArticleCount

应用起不来。这是最直观的"同 namespace 限制",也是迁移操作的正确姿势检查项:搬过去之前,必须确认机区文件里没有同名 id 残留

第二类是 resultMap id ,第三类是 <sql> 片段 id 。这两类反而体现了同 namespace 的好处:BoardExtMapper.xml 里的 selectByNum 直接写了 resultMap="BaseResultMap"<include refid="Base_Column_List"/>,而这两个节点定义在 BoardMapper.xml 里------跨文件引用之所以成立,前提就是两个文件共享同一个命名空间 。好处和约束是一枚硬币的两面:既然共享,就不能在 Ext 文件里再定义一遍 BaseResultMapBase_Column_List,否则同样启动即炸。

最后补一个让这次迁移显得优雅的点:DAO 接口一行不用改 。MyBatis 的绑定规则是"接口全限定名 = namespace,方法名 = statement id",只要这两个对应关系在,SQL 写在哪个 XML 文件里无所谓。所以这次手写 SQL 搬家是纯粹的配置文件迁移,BoardMapper.java 里的方法签名原封不动,Service 层调用也原封不动。

尾篇一句话速记 :多文件共享同一 namespace 后,statement id、resultMap id、<sql> 片段 id 都必须全局唯一,否则启动报 "already contains value" 直接挂;好处是跨文件引用免费,接口零改动。


结语:一个子目录,两个机制,三个收获

回看整个设计,会发现上篇和下篇其实是同一个故事的两半:下篇的 ** 保证了"人区无论藏在哪层子目录都能被加载",上篇的目录隔离保证了"人区永远不会被推土机推平"------加载机制和覆盖机制在 extension/ 这个目录上达成了和解:生成器不写它,解析器却扫得到它。而和解的代价是尾篇的 id 唯一性约束,写 Ext 文件时心里要始终装着那张"命名空间桶"的清单。

三个可以带走的收获。其一,事故复盘要追到配置语义层 :这次事故的根因不是"谁跑错了命令",而是 overwrite=true 的整文件覆盖语义与"手写 SQL 住在生成文件里"的布局冲突,改布局才能根治。其二,当两个工具会写同一块地时,最可靠的协调方式不是约定和自觉,而是划界 :MBG 与手写 SQL 的冲突用目录划界解决;同理,代码生成与手写逻辑在 Java 侧也可以用包划界(generated 包 vs custom 包),CI 里的自动格式化与人工精调可以用文件白名单划界。物理隔离之所以可靠,是因为它把"希望对方别碰"变成了"对方根本没有碰的入口"。其三,机制层的知识要敢用源码校准 :"** 任意深度 + 递归遍历 + Ant 匹配"和"classpath: 单根 / classpath*: 全根"这两条结论,都是对着 spring-core 源码一行行核出来的,比任何顺口溜都牢靠。

能够把这条链条完整说出来基本就到位了:事故的运行期特征 → overwrite=true 的整文件覆盖语义 → 目录隔离方案与划界判据 → ** 的任意深度语义与解析四步链 → classpath: / classpath*: 的真实差异(顺带辟谣"自动升级")→ 同 namespace 的 id 唯一性约束。事故、机制、方案、代价四层都齐了。

项目完整代码在 Gitee 链接,一个还在持续重构优化的学习阶段练手项目,代码里肯定还有没打磨到的地方(海量 bug 待修复 ing)。文中所有配置都可以在码云中找到;如果你对照代码发现问题,欢迎指出~

相关推荐
雨晨源码(同名B站)3 小时前
基于深度学习YoloV11农业病害虫害检测系统 智慧农业信息化综合管理平台 (附源码+lw文档+ppt)
数据库·人工智能·深度学习·yolo·信息可视化
盗理者4 小时前
AI Agent 技能分享|SQL 性能诊断与优化
java·sql·spring·skill
DBA小马哥4 小时前
向量数据库入门到进阶:Embedding、ANN算法与RAG落地的关键术语
数据库·算法·embedding
云和数据.ChenGuang4 小时前
fastapi项目拆分实战数据模型
java·服务器·数据库·人工智能·深度学习·fastapi·强化学习
祈禾4 小时前
Redis三大特殊数据类型
运维·服务器·数据库·redis·笔记·缓存
东方护航数据恢复(深圳)4 小时前
MySQL_Oracle数据库崩溃修复全攻略_东方护航数据恢复深圳店
数据库·mysql·oracle
y = xⁿ5 小时前
一文掌握Redis常见八股
数据库·redis·缓存
Cloud云卷云舒5 小时前
HaishanDB(海山)|磐维数据库|YashanDB(崖山)深度对比分析
数据库·人工智能·海山数据库·haishandb·移动云海山数据库
weixin_460443565 小时前
企业考试系统如何对接OA、钉钉和企业微信?SSO单点登录、组织同步与权限一致性设计
java·开发语言·数据库
xywww1685 小时前
真实后台页实测:Opus 5 看图写前端的可用边界在哪
linux·服务器·前端·数据库·人工智能·gpt