在 通过 -Xbootclasspath/a 参数修改 Spring Boot 应用行为实例 的 场景2 中,我们通过 -Xbootclasspath/a
参数,对 Spring Boot 应用 JAR 包中的 Mapper 文件进行了覆盖,但美中不足的是需要将未修改的 Mapper 文件也重新附加进去。
本文将演示两种仅需将改动的 Mapper 文件覆盖进去的方式。
不全部替换会怎么样?
先让我们看一下,在使用 MyBatis 时,如果仅将修改了的 Mapper 文件(即非全部 Mapper 文件)添加到 bootclasspath
时,会发生什么。
还是使用 bootclasspath 中的演示代码:
# 对代码进行编译打包
$ mvn clean package -DskipTests
# 从编译路径删除未修改的 UserMapper 文件
$ rm -f hacked/target/classes/sql/db/mapper/UserMapper.xml
# 按照 Case 2 的语句启动服务
$ java -Xbootclasspath/a:./hacked/target/classes/sql -jar app/target/app-0.0.1-SNAPSHOT.jar
# 访问服务
$ curl localhost:8080
此时在控制台中,可以看到异常信息:
2022-01-30 10:02:19.704 ERROR 17294 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): io.github.alphahinex.bootclasspath.dao.UserDAO.customCount] with root cause
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): io.github.alphahinex.bootclasspath.dao.UserDAO.customCount
at org.apache.ibatis.binding.MapperMethod$SqlCommand.<init>(MapperMethod.java:235) ~[mybatis-3.5.9.jar!/:3.5.9]
at org.apache.ibatis.binding.MapperMethod.<init>(MapperMethod.java:53) ~[mybatis-3.5.9.jar!/:3.5.9]
at org.apache.ibatis.binding.MapperProxy.lambda$cachedInvoker$0(MapperProxy.java:108) ~[mybatis-3.5.9.jar!/:3.5.9]
……
即无法找到 UserDAO 对应的 Mapper 文件。
按照上面的启动命令,此应用中有两个路径都包含 Mapper 文件:
file [./hacked/target/classes/sql/db/mapper]
—— 仅包含修改的 CountryMapper 文件(没有 UserMapper,所以上面报找不到 UserDAO 对应的 Mapper 文件也正常)URL [jar:file:./app/target/app-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/db/mapper]
—— 包含全部 Mapper 文件
如果我们将 mybatis.mapper-locations
参数由 classpath:db/mapper/*Mapper.xml
修改为 classpath*:db/mapper/*Mapper.xml
(classpath
后面加一个 *
) 会怎么样呢?
$ java -Xbootclasspath/a:./hacked/target/classes/sql -jar app/target/app-0.0.1-SNAPSHOT.jar --mybatis.mapper-locations=classpath:db/mapper/*Mapper.xml
应用在启动时,会直接报错:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'demoController' defined in URL [jar:file:/Users/alphahinex/github/origin/bootclasspath/app/target/app-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/io/github/alphahinex/bootclasspath/controller/DemoController.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'userDAO' defined in URL [jar:file:/Users/alphahinex/github/origin/bootclasspath/app/target/app-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/io/github/alphahinex/bootclasspath/dao/UserDAO.class]: Unsatisfied dependency expressed through bean property 'sqlSessionFactory'; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'sqlSessionFactory' defined in class path resource [org/mybatis/spring/boot/autoconfigure/MybatisAutoConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.apache.ibatis.session.SqlSessionFactory]: Factory method 'sqlSessionFactory' threw exception; nested exception is org.springframework.core.NestedIOException: Failed to parse mapping resource: 'URL [jar:file:/Users/alphahinex/github/origin/bootclasspath/app/target/app-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/db/mapper/CountryMapper.xml]'; nested exception is org.apache.ibatis.builder.BuilderException: Error parsing Mapper XML. The XML location is 'URL [jar:file:/Users/alphahinex/github/origin/bootclasspath/app/target/app-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/db/mapper/CountryMapper.xml]'. Cause: java.lang.IllegalArgumentException: Mapped Statements collection already contains value for io.github.alphahinex.bootclasspath.dao.CountryDAO.cc. please check file [/Users/alphahinex/github/origin/bootclasspath/hacked/target/classes/sql/db/mapper/CountryMapper.xml] and URL [jar:file:/Users/alphahinex/github/origin/bootclasspath/app/target/app-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/db/mapper/CountryMapper.xml]
……
Caused by: java.lang.IllegalArgumentException: Mapped Statements collection already contains value for io.github.alphahinex.bootclasspath.dao.CountryDAO.cc. please check file [/Users/alphahinex/github/origin/bootclasspath/hacked/target/classes/sql/db/mapper/CountryMapper.xml] and URL [jar:file:/Users/alphahinex/github/origin/bootclasspath/app/target/app-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/db/mapper/CountryMapper.xml]
at org.apache.ibatis.session.Configuration$StrictMap.put(Configuration.java:1037) ~[mybatis-3.5.9.jar!/:3.5.9]
at org.apache.ibatis.session.Configuration$StrictMap.put(Configuration.java:993) ~[mybatis-3.5.9.jar!/:3.5.9]
at org.apache.ibatis.session.Configuration.addMappedStatement(Configuration.java:791) ~[mybatis-3.5.9.jar!/:3.5.9]
at org.apache.ibatis.builder.MapperBuilderAssistant.addMappedStatement(MapperBuilderAssistant.java:297) ~[mybatis-3.5.9.jar!/:3.5.9]
at org.apache.ibatis.builder.xml.XMLStatementBuilder.parseStatementNode(XMLStatementBuilder.java:113) ~[mybatis-3.5.9.jar!/:3.5.9]
at org.apache.ibatis.builder.xml.XMLMapperBuilder.buildStatementFromContext(XMLMapperBuilder.java:138) ~[mybatis-3.5.9.jar!/:3.5.9]
at org.apache.ibatis.builder.xml.XMLMapperBuilder.buildStatementFromContext(XMLMapperBuilder.java:131) ~[mybatis-3.5.9.jar!/:3.5.9]
at org.apache.ibatis.builder.xml.XMLMapperBuilder.configurationElement(XMLMapperBuilder.java:121) ~[mybatis-3.5.9.jar!/:3.5.9]
... 68 common frames omitted
即两个路径内的 CountryMapper 文件冲突了。
如何精准覆盖?
两种方式可以解决上述问题。
修改 MyBatis 源码
根据异常堆栈(at org.apache.ibatis.session.Configuration$StrictMap.put(Configuration.java:1037) ~[mybatis-3.5.9.jar!/:3.5.9]
),找到报错位置源码:
@Override
@SuppressWarnings("unchecked")
public V put(String key, V value) {
if (containsKey(key)) {
throw new IllegalArgumentException(name + " already contains value for " + key
+ (conflictMessageProducer == null ? "" : conflictMessageProducer.apply(super.get(key), value)));
}
if (key.contains(".")) {
final String shortKey = getShortName(key);
if (super.get(shortKey) == null) {
super.put(shortKey, value);
} else {
super.put(shortKey, (V) new Ambiguity(shortKey));
}
}
return super.put(key, value);
}
可以看到,在加载 Mapper 文件时,如果已经存在了相同的 key,再 put 时就会抛出异常。
因为我们的本意即为覆盖,所以一定会存在 key 相同的情况。根据 Override same class 中描述的类覆盖的先后顺序,通过 -Xbootclasspath/a
参数指定的路径会优先加载,所以可以在出现相同 key 时,直接忽略掉后加载的 Mapper 文件。修改方式如下:
@@ -1013,9 +1013,11 @@ public class Configuration {
@Override
@SuppressWarnings("unchecked")
public V put(String key, V value) {
+ System.out.println("HACKED::Put key [" + key + "] with value [" + (value instanceof MappedStatement ? ((MappedStatement)value).getResource() : value) + "]");
if (containsKey(key)) {
- throw new IllegalArgumentException(name + " already contains value for " + key
+ System.out.println(name + " already contains value for " + key
+ (conflictMessageProducer == null ? "" : conflictMessageProducer.apply(super.get(key), value)));
+ return null;
}
if (key.contains(".")) {
final String shortKey = getShortName(key);
bootclasspath 演示项目的 mybatis-override 分支提供了修改的 Configuration.java
类和可运行的代码,参照 README 中描述,将修改的 Mapper 文件和修改的 MyBatis 代码附加到原始 JAR 包中运行:
$ java -Xbootclasspath/a:./hacked/target/classes/sql:./hacked/target/classes:/Users/alphahinex/.m2/repository/org/mybatis/mybatis/3.5.9/mybatis-3.5.9.jar -jar app/target/app-0.0.1-SNAPSHOT.jar --mybatis.mapper-locations=classpath*:db/mapper/*Mapper.xml
注意替换上面的 mybatis-3.5.9.jar 路径,以及增加
mybatis.mapper-locations
参数指定classpath*:
前缀
应用启动不再报错,之后访问 http://localhost:8080 可以看到,Country count
的值从原 JAR 包中的 151
,变更为了 SQL 修改后的 26
,User count
值未发生改变。
$ curl localhost:8080
User count: 3
Country count: 26
即完成了 Mapper 文件的精准覆盖。
使用 MyBatis-Plus
相比修改 MyBatis 源码,更简单的方式是可以直接引入 MyBatis-Plus。
可修改源码重新打包
能够修改源码重新打包应用时,只需要引入 MyBatis-Plus 的依赖即可,例如在 演示项目 的 main
分支做如下调整并重新打包,即可实现仅从 JAR 包外部加载变更的 Mapper 文件:
diff --git a/app/pom.xml b/app/pom.xml
index f3e5ec4..2b494ab 100644
--- a/app/pom.xml
+++ b/app/pom.xml
@@ -26,6 +26,11 @@
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.1</version>
</dependency>
+ <dependency>
+ <groupId>com.baomidou</groupId>
+ <artifactId>mybatis-plus-boot-starter</artifactId>
+ <version>3.1.2</version>
+ </dependency>
<dependency>
<groupId>com.h2database</groupId>
diff --git a/app/src/main/resources/application.properties b/app/src/main/resources/application.properties
index d8b39d2..ef5435a 100644
--- a/app/src/main/resources/application.properties
+++ b/app/src/main/resources/application.properties
@@ -1,4 +1,4 @@
spring.datasource.url=jdbc:h2:mem:bootclasspath
+mybatis-plus.mapper-locations=classpath:db/mapper/*Mapper.xml
spring.sql.init.schema-locations=classpath:db/sql/*ddl.sql
spring.sql.init.data-locations=classpath:db/sql/*dml.sql
-mybatis.mapper-locations=classpath:db/mapper/*Mapper.xml
在引入了 MyBatis-Plus 后,注意将指定 Mapper 文件路径的参数,由
mybatis.mapper-locations
替换为mybatis-plus.mapper-location
修改后的效果及演示,可在演示项目的 plus 分支查看:
$ mvn clean package -DskipTests
$ java -Xbootclasspath/a:./hacked/target/classes/sql -jar app/target/app-0.0.1-SNAPSHOT.jar --mybatis-plus.mapper-locations=classpath*:db/mapper/*Mapper.xml
$ curl localhost:8080
User count: 3
Country count: 26
无法重新打包
若无法修改源码或重新打包 Spring Boot 应用时,可参照 如何给 Spring Boot 外挂 classpath? 提供的方式,修改启动命令,将 MyBatis-Plus 相关 JAR 包添加进去(不建议使用 -Xbootclasspath/a
参数)。
需添加的 JAR 包如下,以放到 ./libs
路径为例:
$ tree libs
libs
├── mybatis-plus-3.1.2.jar
├── mybatis-plus-annotation-3.1.2.jar
├── mybatis-plus-boot-starter-3.1.2.jar
├── mybatis-plus-core-3.1.2.jar
└── mybatis-plus-extension-3.1.2.jar
0 directories, 5 files
假设使用演示项目 mybatis-override 分支 构建出来的 JAR 包,可使用如下命令启动,并查看效果:
$ java -cp app/target/app-0.0.1-SNAPSHOT.jar -Dloader.path=./hacked/src/main/resources/sql,./libs org.springframework.boot.loader.PropertiesLauncher --mybatis-plus.mapper-locations=classpath*:db/mapper/*Mapper.xml
有两点需要注意:
-Dloader.path
参数中的多个路径使用,
间隔- 因原始 JAR 包中使用的是 MyBatis,通过启动命令动态加入了 MyBatis-plus,故需添加
mybatis-plus.mapper-locations
参数指定 Mapper 文件路径
访问 http://localhost:8080 ,可看到外部挂载的 Mapper 文件内容已生效。
$ curl localhost:8080
User count: 3
Country count: 26