這個問題發生的原因比較複雜,主要條件有4個:
- 使用Spring Boot,並使用Spring Boot的Maven插件打包
- 使用MyBatis(目前最新的
3.3.1
版本仍有這個問題) - 將Domain配置在單獨的Jar包中(例如Maven多模塊)
- 使用
SqlSessionFactoryBean.setTypeAliasesPackage
指定包掃描Domain
然後你會發現:在開發時直接使用IDEA執行main方法運行時一切正常,但是打成Jar包後使用java -jar啓動時配置的Domain別名均會失效。
例如我有一個Spring Boot項目,其中分爲三個Maven模塊:
scienjus
—-scienjus-domain
——–com.scienjus.domain.User
—-scienjus-mapper
——–com.scienjus.mapper.UserMapper
——–UserMapper.xml
—-scienjus-web
——–SqlSessionFactoryConfig
在SqlSessionFactoryConfig中配置SqlSesstionFactory:
@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
final SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
sqlSessionFactory.setDataSource(dataSource());
//配置別名
sqlSessionFactory.setTypeAliasesPackage("com.scienjus.domain");
return sqlSessionFactory.getObject();
}
在UserMapper.xml使用別名:
<select id="get" resultType="User">
select * from user u where id = #{id}
</select>
開發時使用IDEA啓動一切都會正常運行,但是如果等到運行時通過命令行啓動,將會出現以下錯誤信息:
org.apache.ibatis.builder.BuilderException: Error resolving class. Cause:
org.apache.ibatis.type.TypeException: Could not resolve type alias 'User'. Cause:
java.lang.ClassNotFoundException: Cannot find class: User
這個錯誤的大概意思是生成Mapper時出錯了,原因是無法識別 User
這個別名,也找不到 User
這個class。可以看出之前配的包掃描根本沒有掃描到 com.scienjus.domain.User
這個類。
爲了證明這點,我翻了一下MyBatis的源碼,然後在 org.apache.ibatis.type.TypeAliasRegistry
的 registerAliases(String packageName, Class superType)
方法中發現了MyBatis是如何通過包名掃描別名類的。直接將這部分邏輯搬到 main
方法中執行試試:
public static void main(String[] args) {
ResolverUtil resolverUtil = new ResolverUtil();
resolverUtil.find(new IsA(Object.class), "com.scienjus.domain");
Set typeSet = resolverUtil.getClasses();
Iterator i$ = typeSet.iterator();
while(i$.hasNext()) {
Class type = (Class)i$.next();
if(!type.isAnonymousClass() && !type.isInterface() && !type.isMemberClass()) {
System.out.println(type.getName());
}
}
}
分別在IDEA和Jar中執行,會發現前者將會打印出 com.scienjus.domain.User
,後者卻無任何輸出結果,說明問題出在這裏。
既然鎖定了問題出現的地方,就可以仔細看看這是如何發生的了。查看 ResolverUtil.find
方法,其通過 VFS.getInstance().list(path)
方法獲得Class文件,而 VFS.getInstance()
默認情況下返回的是 DefaultVFS
也就是說原因是這個類的 list
方法無法掃描到 Spring Boot
依賴Jar包中的類。
再細化一下調用邏輯,就可以準備斷點調試了:
public static void main(String[] args) throws IOException {
DefaultVFS defaultVFS = new DefaultVFS();
List children = defaultVFS.list("com/scienjus/domain");
for (String child : children) {
System.out.println(child);
}
}
斷點調試使用 java -jar
啓動的程序並沒有想象中困難,IDEA和Eclipse都內置了非常優秀的調試工具,略微介紹一下IDEA中的使用方法:
開啓Debug模式運行Jar包,並且監聽一個特定的端口:
java -Xdebug -Xrunjdwp:transport=dt_socket,address=5005,server=y,suspend=y -jar scienjus-web.jar
IDEA端在Run -> Edit Configurations中創建一個Remote應用,填寫IP和監聽的端口號,然後啓動就可以了。
通過斷點調試我在 findJarForResource
發現了一塊比較有意思的代碼:
// If the file part of the URL is itself a URL, then that URL probably points to the JAR
try {
for (;;) {
url = new URL(url.getFile());
if (log.isDebugEnabled()) {
log.debug("Inner URL: " + url);
}
}
} catch (MalformedURLException e) {
// This will happen at some point and serves as a break in the loop
}
這是一個死循環,唯有拋出 MalformedURLException
異常時纔會跳出循環,根據上面的註釋我們可以得知,這件事是必然發生的,且會將 url
指向一個想要的結果。對比一下兩種方式運行時 url
最後的結果:
IDEA中直接運行:
scienjus-domain/target/classes/com/scienjus/domain
命令行運行:
scienjus-web/target/scienjus-web.jar!/lib/scienjus-domain.jar!/com/scienjus/domain
之後將變量 jarUrl
的值賦爲 scienjus-web/target/scienjus-web.jar!/lib/scienjus-domain.jar
,但是最後 listResources
方法會返回 null
。
而調用這個方法時的註釋則是這樣說的:
// First, try to find the URL of a JAR file containing the requested resource. If a JAR
// file is found, then we'll list child resources by reading the JAR.
也就是說,如果掃描的文件確實在一個Jar包中,這個方法應該返回這個Jar包的URL,於是嘗試一個比較粗暴的改進:
public static void main(String[] args) throws IOException {
DefaultVFS defaultVFS = new DefaultVFS() {
@Override
protected URL findJarForResource(URL url) throws MalformedURLException {
String urlStr = url.toString();
if (urlStr.contains("jar!")) {
return new URL(urlStr.substring(0, urlStr.lastIndexOf("jar") + "jar".length()));
}
return super.findJarForResource(url);
}
};
List children = defaultVFS.list("com/scienjus/domain");
for (String child : children) {
System.out.println(child);
}
}
如果這個URL中含有 jar!
的標識,就直接返回這個Jar包的地址。我不太確定這樣做是否有隱患,不過我只有在掃描Domain別名時會用到這個類,並且這時候是正常工作的。
既然這個類可以正常工作了,只需要將它設爲默認的VFS。在MyBatis的文檔中寫着可以通過配置文件更改 vfsImpl
屬性更換VFS實現類,我這裏用這個配置沒有效果,原因是Spring的配置會在MyBatis配置文件之前執行,所以在讀取這個配置之前 VFS.getInstall()
已經實例化了。然後我給MyBatis提了個Issue,順道還發現這個掃描不到類的Bug早在去年10月就有人提出了,也早就有解決辦法了,只是需要到 3.4.1
版本纔會發佈。
MyBatis官方的解決辦法首先是推薦使用mybatis-spring-boot的 1.0.1
版本,默認已經配置了一個兼容Spring Boot的VFS實現類。或是將這個實現類添加到你的項目中,並手動配置。
爲了不讓這些瞎折騰白費,我決定將這整個過程發佈出來,教各位在使用開源項目遇到bug時如何定(zuo)位(si),這可能也是本文的僅剩的一點價值了。
以上篇幅來自: MyBatis無法掃描Spring Boot別名的Bug
我自己使用的是 mybatis-spring-boot-starter 的 1.2.0 版本,它包含的 mybatis 版本是 3.4.2。
然後,我通過下面的方式來聲明瞭一個 SessionFactory:
@Configuration
@AutoConfigureAfter(DataSourceConfig.class)
@MapperScan(basePackages = {"org.rainbow.persistence.mysql"}, sqlSessionFactoryRef = "mysqlSqlSessionFactory")
public class MyBatisConfigForMysql {
@Bean("mysqlSqlSessionFactory")
@Autowired
public SqlSessionFactory mysqlSqlSessionFactory(@Qualifier("mysqlDataSource") DataSource dataSource) throws Exception {
SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
sqlSessionFactory.setDataSource(dataSource);
// IMPORTANT: we MUST set the 'VFS',
// otherwise if you run this project as a 'executable jar', then mybatis will throw an exception saying that it can not find java POJO
sqlSessionFactory.setVfs(SpringBootVFS.class);
sqlSessionFactory.setTypeAliasesPackage("org.rainbow.model.mysql");
return sqlSessionFactory.getObject();
}
}
可以發現,我根據上文提到的這個實現類,添加了一行代碼:
sqlSessionFactory.setVfs(SpringBootVFS.class);
這樣也可以解決問題。
後話
如果使用 mybatis autoconfigure生成的 SessionFactory可能就沒有這個問題了,具體的可以查看: MybatisAutoConfiguration.java