五、Mybatis学习实践-SpringBoot整合Mybatis实现原理

仓库

文章涉及的代码都将统一存放到此仓库,本章节使用SpringBoot,因此启动类为com.hzchendou.blog.demo.SpringBootApp

代码地址:Gitee

分支:lesson5

简介

Mybatis是一款优秀的ORM框架,使用Mybatis可以降低开发成本,将开发人员从繁琐的的JDBC操作中解放出来,把更多的注意力聚焦于SQL编写。在Java开发中,我们通常使用SpringBoot作为基础开发框架,SpringBoot + Mybatis的组合是目前主流开发模式。

本文将介绍SpringBoot整合Mybatis的实现原理。

SpringBoot整合Mybatis配置

困惑

对于SpringBoot整合Mybatis,我一直有一个疑问,这个疑问驱动着我去了解实现原理。SpringBoot中的Bean默认都是单例,那么创建的Mapper也是单例,Mapper底层是委托SqlSession来执行SQL语句操作的,SqlSession同时管理着事务,在多线程环境下难道使用的是同一个SqlSession来执行的吗,这个问题的答案很明显,不可能由同一个SqlSession来执行。那么SpringBoot到底是如何处理的呢?

整合配置

引入SpringBoot依赖



   org.springframework.boot
   spring-boot-starter-parent
   2.5.6


   org.springframework.boot
   spring-boot-starter

我们不需要Web容器,因此不需要引入Spring MVC模块。

引入mybatis-springboot start模块


  org.mybatis.spring.boot
  mybatis-spring-boot-starter
  2.1.3

添加springboot配置文件application.yaml(数据源使用SpringBoot方式创建)

spring:
  datasource:
    username: ""
    password: ""
    url: jdbc:sqlite:src/main/resources/database/sqlite.db
    driver-class-name: org.sqlite.JDBC
mybatis:
  mapper-locations: classpath:mapper/*.xml
  configuration:
    default-enum-type-handler: org.apache.ibatis.type.EnumOrdinalTypeHandler
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

添加SpringBoot启动类

@Slf4j
 扫描mapper接口类,需要注册到Spring IOC容器中
@MapperScan(basePackages = "com.hzchendou.blog.demo.mapper")
@SpringBootApplication
public class SpringBootApp {

    public static void main(String[] args) {
        ApplicationContext context = SpringApplication.run(SpringBootApp.class);
        /// 从Spring容器中获取到BlogMapper实例(BlogMapper是接口,获取到的是MapperProxy类型代理对象)
        BlogMapper blogMapper = context.getBean(BlogMapper.class);
        List blogs = blogMapper.selectAll();
        log.info("查询博文记录, {}", blogs);
    }
}

启动程序,运行结果如下:

Parsed mapper file: 'file [/Users/chendou/repo/hzchendou/learning/mybatisdemo/target/classes/mapper/AuthorMapper.xml]'
Parsed mapper file: 'file [/Users/chendou/repo/hzchendou/learning/mybatisdemo/target/classes/mapper/BlogMapper.xml]'
2022-07-10 19:48:20.117  INFO 19316 --- [           main] com.hzchendou.blog.demo.SpringBootApp    : Started SpringBootApp in 0.966 seconds (JVM running for 1.451)
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@12f3afb5] was not registered for synchronization because synchronization is not active
Cache Hit Ratio [com.hzchendou.blog.demo.mapper.BlogMapper]: 0.0
2022-07-10 19:48:20.129  INFO 19316 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
 1.执行SQL查询操作
2022-07-10 19:48:20.301  INFO 19316 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
JDBC Connection [HikariProxyConnection@1158124724 wrapping org.sqlite.SQLiteConnection@273c947f] will not be managed by Spring
==>  Preparing: SELECT id, `title`, `author_id`, `tags`, `status` FROM blog
==> Parameters: 
<==    Columns: id, title, author_id, tags, status
<==        Row: 1, 时间海绵博文, 1, 博文、时间海绵, 1
<==        Row: 2, 时间海绵博文, 1, 博文、时间海绵, 1
<==        Row: 3, 时间海绵博文, 1, 博文、时间海绵, 1
<==        Row: 4, 时间海绵博文, 1, 博文、时间海绵, 1
<==      Total: 4
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@12f3afb5]
/// 2.打印查询结果数据
2022-07-10 19:48:20.331  INFO 19316 --- [           main] com.hzchendou.blog.demo.SpringBootApp    : 查询博文记录, [BlogDO(id=1, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID), BlogDO(id=2, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID), BlogDO(id=3, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID), BlogDO(id=4, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID)]
2022-07-10 19:48:20.333  INFO 19316 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2022-07-10 19:48:20.334  INFO 19316 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

程序运行正常,得到了预期结果

SpringBoot整合Mybatis原理

加载Mybatis配置

SpringBoot基于约定大于配置的原则进行组件整合,从上面整合流程可以看出来,基于SpringBoot只需要少量开发就能完成对Mybatis的配置

@EnableAutoConfiguration是SpringBoot中最重要的配置注解,@SpringBootApplication是一个组合注解,@EnableAutoConfiguration也包含在其中,开启该注解(默认开启,可以通过配置spring.boot.enableautoconfiguration属性进行开关)后会查找所有jar包中的META-INF/spring.factories文件,并查找所有配置在org.springframework.boot.autoconfigure.EnableAutoConfiguration属性下的类(具体逻辑在AutoConfigurationImportSelector类中的getCandidateConfigurations方法),例如上面的SpringBoot整合Mybatis,引入的包中包含spring.factories配置文件。
### 在org.mybatis.spring.boot.mybatis-spring-boot-autoconfigure jar中存在META-INF/spring.factories文件,内容如下
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.mybatis.spring.boot.autoconfigure.MybatisLanguageDriverAutoConfiguration,\
org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
SpringBoot会自动加载MybatisLanguageDriverAutoConfiguration和MybatisAutoConfiguration,其中MybatisAutoConfiguration是完成Mybatis配置的关键类,主要完成了如下功能:
  • 加载配置信息
    • mybatis 配置信息
    • mapper 文件
    • typeAlias
    • .....Mybatis其它配置信息
  • 根据上述加载的配置信息创建Configuration和SqlSessionFactory
  • 使用SqlSessionFactory创建SqlSession
    • 这里创建的SqlSession类型是SqlSessionTemplate,这个类实现了SqlSession接口

上述是SpringBoot自动装配完成的事情,主要是为了创建SqlSessionTemplate这个对象,这个类是SqlSession子类,至于它的重要性将在后面介绍。

到目前为止,还没有涉及到Mapper接口代理实例创建工作(实际上实例化Mapper代理对象功能也可以在MybatisAutoConfiguration完成,前提是在Mapper接口类上使用@Mapper注解,并且没有使用@MapperScan注册,原理是相同的,但是通过源码可以发现使用@MapperScan注解能够更加快捷地实现创建Mapper接口代理对象)。

创建Mapper接口代理实例

还记得我们在SpringBootApp这个启动类上使用的注解吗

@MapperScan(basePackages = "com.hzchendou.blog.demo.mapper")

这个注解帮助我们完成了Mapper接口代理创建工作,下面我们来看一下这个类完成的工作

  • 1、在@MapperScan注解中的@Import引入了MapperScannerRegistrar类
  • 2、MapperScannerRegistrar实现了接口ImportBeanDefinitionRegistrar,Spring会调用该接口中的registerBeanDefinitions方法注册BeanDefinition
  • 3、在MapperScannerRegistrar中的registerBeanDefinitions方法会注册一个MapperScannerConfigurer类型BeanDefinition(通过名字也可以知道这个类会扫描Mapper接口,通过@MapperScan中的注解信息进行扫描)
  • 4、MapperScannerConfigurer实现了BeanDefinitionRegistryPostProcessor接口,Spring会调用这个接口中的postProcessBeanDefinitionRegistry(通过名字就可以知道这个类会在BeanDefinition注册后进行调用)
  • 5、在MapperScannerConfigurer的postProcessBeanDefinitionRegistry方法中会创建一个ClassPathMapperScanner对象,然后调用该对象的scan方法进行包路径扫描,加载Mapper接口类
  • 6、在ClassPathMapperScanner的scan方法中,将扫描到的符合条件的接口类型作为构造参数,定义成MapperFactoryBean类型的BeanDefinition(每一个接口定义一个MapperFactoryBean,MapperFactoryBean是个工厂类,实现了FactoryBean接口,用于创建Mapper接口代理对象)
  • 7、在MapperFactoryBean中的getObject方法(FactoryBean接口方法,Spring会调用FactoryBean接口中的getObject方法创建对象,然后将对象交给容器进行托管)中调用了sqlSession.getMapper(mapperInterface)(这个方法是不是很熟悉,就是之前文章中一直使用的获取Mapper代理对象的方法),用于获取Mapper接口代理对象
  • 8、这里有个关键点,在7中调用的sqlSession是SqlSessionTemplate类型,这就是在加载Mybatis配置中创建的SqlSession实例

上述流程比较繁琐,因为要处理很多情况,如果不理解也没关系,主要记住一点,使用@MapperScan会将Mapper接口包装成MapperFactoryBean这个FactoryBean,在MapperFactoryBean中使用sqlSession.getMapper(mapperInterface)创建Mapper接口代理

通过@MapperScan注解以及MybatisAutoConfiguration配置类将Mybatis组件加载到了Spring容器中主要是两类关键组件

  • Mapper接口代理对象
  • SqlSessionTemplate对象

在业务代码中,我们使用Mapper接口操作SQL语句进行数据库操作时,底层是委托SqlSessionTemplate来进行数据库操作。

疑问解答

我们首先来分析一下Mapper是如何创建的

  1. 在SqlSessionTemplate中的getMapper方法调用Configuration的getMapper(type,sqlSession)(注意这里有两个参数,要创建的Mapper接口类型和SqlSession,SqlSession是SqlSessionTempalte本身,也就是SqlSessionTemplate将自身作为参数创建Mapper代理)
  2. 在Configuration的getMapper方法中调用了MapperRegistry的getMapper(type,sqlSession)方法(也是两个参数,与上面参数一致,)
  3. 在MapperRegistry的getMapper方法中使用了MapperProxyFactory的newInstance(sqlSession)方法(sqlSession是1中传递的SqlSessionTemplate)
  4. 在MapperProxyFactory的newInstance(sqlSession)方法中创建了new MapperProxy<>(sqlSession, mapperInterface, methodCache),然后使用Proxy.newProxyInstance创建代理对象

通过上面分析可以知道,Mapper接口代理实际是通过MapperProxy对象来执行的(MapperProxy实现了InvocationHandler),现在我们来了解一下Mapper是如何执行的(也就是MapperProxy的invoke方法的处理逻辑)

  1. 如果是Object中定义的方法,那么直接执行(比如说hashCode方法,toString方法等)
  2. 如果不是
    1. 创建一个MapperMethod方法new MapperMethod(mapperInterface, method, sqlSession.getConfiguration())
    2. 调用MapperMethod中的execute(SqlSession sqlSession, Object[] args)方法执行SQL操作(这里的SqlSession就是上面创建时传递的SqlSessionTemplate)
    3. 然后按照SQL操作指令进行分类处理(SQL指令分为INSERT、UPDATE、DELETE、SELECT、FLUSH),最后委托给sqlSession执行具体SQL操作(这里的SqlSession就是上面创建时传递的SqlSessionTemplate)

下面我们回归到SqlSessionTemplate查看具体执行逻辑以selectOne方法为例,其它方法类似

public  T selectOne(String statement) {
  /// 委托给sqlSessionProxy进行执行
  return this.sqlSessionProxy.selectOne(statement);
}

查看sqlSessionProxy创建过程

///sqlSessionProxy是一个SqlSession类型,并且是一个final成员变量
private final SqlSession sqlSessionProxy;

public SqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType,
      PersistenceExceptionTranslator exceptionTranslator) {
  ///省略无关代码
  /// 使用JDK代理模式创建,需要注意的是SqlSessionInterceptor是SqlSessionTemplate内部类
  this.sqlSessionProxy = (SqlSession) newProxyInstance(SqlSessionFactory.class.getClassLoader(),
      new Class[] { SqlSession.class }, new SqlSessionInterceptor());
}

从上面可以看出 sqlSessionProxy 是SqlSession接口的代理类,委托给SqlSessionInterceptor执行SqlSession方法,同时SqlSessionInterceptor是SqlSessionTemplate内部类,可以共享SqlSessionInterceptor内部成员变量。

查看SqlSessionInterceptor的invoke(代理执行方法入口)

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
       获取SqlSession对象(这里的getSqlSession是SqlSessionUtils的静态方法)
      SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory,
          SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
      try {
        /// 执行SqlSession对应方法
        Object result = method.invoke(sqlSession, args);
         如果事务没有托管,那么手动执行提交操作
        if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
          sqlSession.commit(true);
        }
        return result;
      } catch (Throwable t) {
         省略无关代码
         异常情况处理逻辑
      } finally {
        if (sqlSession != null) {
          closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
        }
      }
    }

查看SqlSessionUtils.getSqlSession方法

public static SqlSession getSqlSession(SqlSessionFactory sessionFactory, ExecutorType executorType,
      PersistenceExceptionTranslator exceptionTranslator) {
  ///1、从事务管理器中获取SqlSession信息, 类似缓存
  SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);
  SqlSession session = sessionHolder(executorType, holder);
  /// 2、如果获取到了SqlSession直接返回
  if (session != null) {
    return session;
  }
  LOGGER.debug(() -> "Creating a new SqlSession");
  /// 3、如果没有那么创建一个SqlSession,这里的sessionFactory就是加载配置时创建的DefaultSessionFactory
  session = sessionFactory.openSession(executorType);
  /// 4、将session注册到事务管理器中
  registerSessionHolder(sessionFactory, executorType, exceptionTranslator, session);
  return session;
}

我们需要查看两个方法TransactionSynchronizationManager.getResource和registerSessionHolder方法

首先查看TransactionSynchronizationManager.getResource方法

public static Object getResource(Object key) {
    /// 如果key时一个包装类,那么获取实际的对象
	Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
    /// 这个是关键方法,用于获取资源,这里我们要获取的是SqlSession相关资源
	return doGetResource(actualKey);
}

private static Object doGetResource(Object actualKey) {
     需要特别注意这个resources是一个ThreadLocal类型变量,因此与线程绑定,能够实现线程安全
	Map map = resources.get();
	if (map == null) {
		return null;
	}
	Object value = map.get(actualKey);
	 省略无关代码
	return value;
}

从上面可以看出getResource是从线程变量中获取SqlSession信息,也就是说SqlSession与运行线程进行了绑定,接下来我们看一下registerSessionHolder方法

private static void registerSessionHolder(SqlSessionFactory sessionFactory, ExecutorType executorType,
      PersistenceExceptionTranslator exceptionTranslator, SqlSession session) {
  SqlSessionHolder holder;
  ///如果启用了事务模式,那么会将Session与线程进行绑定,方便多线程环境下获取
  if (TransactionSynchronizationManager.isSynchronizationActive()) {
    Environment environment = sessionFactory.getConfiguration().getEnvironment();
     启动事务模式下需要将事务委托给Spring容器进行管理,方便使用Spring事务功能,例如编程式事务和声明式事务
    if (environment.getTransactionFactory() instanceof SpringManagedTransactionFactory) {
      LOGGER.debug(() -> "Registering transaction synchronization for SqlSession [" + session + "]");
      holder = new SqlSessionHolder(session, executorType, exceptionTranslator);
       将SqlSession资源绑定到线程变量中
      TransactionSynchronizationManager.bindResource(sessionFactory, holder);
      TransactionSynchronizationManager
          .registerSynchronization(new SqlSessionSynchronization(holder, sessionFactory));
      holder.setSynchronizedWithTransaction(true);
      holder.requested();
    } else {
      if (TransactionSynchronizationManager.getResource(environment.getDataSource()) == null) {
        LOGGER.debug(() -> "SqlSession [" + session
            + "] was not registered for synchronization because DataSource is not transactional");
      } else {
        throw new TransientDataAccessResourceException(
            "SqlSessionFactory must be using a SpringManagedTransactionFactory in order to use Spring transaction synchronization");
      }
    }
  } else {
    LOGGER.debug(() -> "SqlSession [" + session
        + "] was not registered for synchronization because synchronization is not active");
  }
}

从上面代码可以看出,Spring会将SqlSession资源与线程变量进行绑定,实现在多线程环境下使用SqlSession,但是这里有一个需要注意的地方,那就是执行上述逻辑的前提是要开启事务,那么如何开启事务呢,最简单的方法是使用Spring声明式事务@Transactional

创建一个BlogService类,用于查询博客文章记录

@Service
public class BlogService {

    @Autowired
    private BlogMapper blogMapper;
    这个方法开启了事务
    @Transactional
    public List selectAll() {
        return blogMapper.selectAll();
    }
    /// 这个方法没有开启事务
    public List selectPage() {
        PageVO pageVO = selectPage(1,2);
        return pageVO.getRecords();
    }

    private PageVO selectPage(int page, int size) {
        PageVO param = new PageVO();
        param.setPage(page);
        param.setSize(size);
        List blogs = blogMapper.selectPage(param);
        param.setRecords(blogs);
        param.setPageSize(blogs == null ? 0 : blogs.size());
        return param;
    }
}

修改SpringBootApp启动类:

public static void main(String[] args) {
    ApplicationContext context = SpringApplication.run(SpringBootApp.class);
    BlogService blogService = context.getBean(BlogService.class);
    List blogs = blogService.selectAll();
    log.info("查询博文记录, {}", blogs);
    List pageBlogs = blogService.selectPage();
    log.info("查询博文分页记录, {}", pageBlogs);
}

运行结果如下:

/// 1、创建了一个新的SqlSession
Creating a new SqlSession
/// 2、将SqlSession绑定到当前线程变量中
Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@169da7f2]
Cache Hit Ratio [com.hzchendou.blog.demo.mapper.BlogMapper]: 0.0
JDBC Connection [HikariProxyConnection@2042979183 wrapping org.sqlite.SQLiteConnection@1929425f] will be managed by Spring
==>  Preparing: SELECT id, `title`, `author_id`, `tags`, `status` FROM blog
==> Parameters: 
<==    Columns: id, title, author_id, tags, status
<==        Row: 1, 时间海绵博文, 1, 博文、时间海绵, 1
<==        Row: 2, 时间海绵博文, 1, 博文、时间海绵, 1
<==        Row: 3, 时间海绵博文, 1, 博文、时间海绵, 1
<==        Row: 4, 时间海绵博文, 1, 博文、时间海绵, 1
<==      Total: 4
Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@169da7f2]
Transaction synchronization committing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@169da7f2]
Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@169da7f2]
Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@169da7f2]
2022-07-11 11:25:05.698  INFO 8884 --- [           main] com.hzchendou.blog.demo.SpringBootApp    : 查询博文记录, [BlogDO(id=1, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID), BlogDO(id=2, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID), BlogDO(id=3, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID), BlogDO(id=4, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID)]
/// 3、创建了一个新的SqlSession
Creating a new SqlSession
/// 4、无法将SqlSession绑定到线程变量中,因为没有开启事务
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6f95cd51] was not registered for synchronization because synchronization is not active
Cache Hit Ratio [com.hzchendou.blog.demo.mapper.BlogMapper]: 0.0
JDBC Connection [HikariProxyConnection@209360767 wrapping org.sqlite.SQLiteConnection@1929425f] will not be managed by Spring
==>  Preparing: SELECT id, `title`, `author_id`, `tags`, `status` FROM blog
==> Parameters: 
<==    Columns: id, title, author_id, tags, status
<==        Row: 1, 时间海绵博文, 1, 博文、时间海绵, 1
<==        Row: 2, 时间海绵博文, 1, 博文、时间海绵, 1
<==        Row: 3, 时间海绵博文, 1, 博文、时间海绵, 1
<==        Row: 4, 时间海绵博文, 1, 博文、时间海绵, 1
<==      Total: 4
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6f95cd51]
2022-07-11 11:25:05.702  INFO 8884 --- [           main] com.hzchendou.blog.demo.SpringBootApp    : 查询博文分页记录, [BlogDO(id=1, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID), BlogDO(id=2, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID), BlogDO(id=3, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID), BlogDO(id=4, title=时间海绵博文, authorId=1, tags=博文、时间海绵, status=VALID)]

从上面运行日志可以看出,blogService.selectAll()方法使用了声明式事务,因此会将SqlSession绑定到线程变量中,而blogService.selectPage()没有使用声明式事务,因此不会讲SqlSession绑定到线程变量中。

我们可以来解答这个疑惑了,

  • 如果启用的事务,会将SqlSession绑定到线程变量中实现线程安全,同时实现了缓存功能
  • 如果没有声明事务,那么线程每次运行都将获取新的SqlSession,也是线程安全的

总结

SpringBoot整合Mybatis时,只需要少量配置就能完成Mybatis初始化工作,同时将Mybatis事务交给Spring来管理,从而能够使用Spring方式配置事务

  • 使用MybatisAutoConfiguration配置类来加载Mybatis配置信息从而完成以下操作
    • 创建Mybatis的Configuration对象
    • 创建SqlSessionFactory对象(使用的是DefaultSqlSessionFactory)
    • 创建SqlSessionTemplate对象(这是一个创建SqlSessionTemplate的模版类,底层委托给SqlSessionUtils工具类管理SqlSession,从而接入到Spring事务)
  • 使用@MapperScan注解完成Mapper接口代理对象创建
    • 扫描指定包下的Mapper接口
    • 最后使用MapperProxy实现Mapper接口代理
    • MapperProxy最终委托给SqlSessionTemplate执行SQL操作(本质就是交给SqlSessionUtils来管理SqlSession)
  • 是否启用本地线程变量来保存SqlSession与线程运行方法是否启动了事务有关

联系方式

技术更新换代速度很快,我们无法在有限时间掌握全部知识,但我们可以在他人的基础上进行快速学习,学习也是枯燥无味的,加入我们学习牛人经验:

QQ:901856121

点击:加群讨论 

你可能感兴趣的:(mybatis,java,ORM,Mybatis)