MyBatis無法掃描Spring Boot別名的Bug

這個問題發生的原因比較複雜,主要條件有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.TypeAliasRegistryregisterAliases(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-boot1.0.1 版本,默認已經配置了一個兼容Spring Boot的VFS實現類。或是將這個實現類添加到你的項目中,並手動配置。

爲了不讓這些瞎折騰白費,我決定將這整個過程發佈出來,教各位在使用開源項目遇到bug時如何定(zuo)位(si),這可能也是本文的僅剩的一點價值了。

以上篇幅來自: MyBatis無法掃描Spring Boot別名的Bug


我自己使用的是 mybatis-spring-boot-starter1.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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章