MyBatis 是一款优秀的持久层框架,它支持定制化 SQL、存储过程以及高级映射。MyBatis 避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。MyBatis 可以使用简单的 XML 或注解来配置和映射原生类型、接口和 Java 的 POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。
Mybatis 是我们 Web 项目中最常用的开源框架之一,起初只要学会如何使用,但后面还是有必要了解一下 Mybatis 的底层设计实现,这对提升自己的内功还是挺重要的。既要知其然,也要知其所以然。
经过源码探查,我们发现,Mybatis 的初始化过程也就是 Configuration 对象构建的过程,Mybatis 采用 All-In-One
的方式,将全部的配置信息全部存放到 Configuration 对象中。
下面就以 Java 解析 Mybatis 的 XML 配置为例,带大家分析下 Mybatis 的初始化过程。
Mybatis 版本基于 3.0.6
,各版本之间略有差异,但总体流程是一致的。
首先需要准备一下Mybatis的配置文件mybatis-config.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<properties resource="config.properties" />
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
<setting name="cacheEnabled" value="false"/>
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="multipleResultSetsEnabled" value="true"/>
<setting name="useColumnLabel" value="true"/>
<setting name="useGeneratedKeys" value="false"/>
<setting name="defaultExecutorType" value="REUSE"/>
<setting name="defaultStatementTimeout" value="20000"/>
</settings>
<typeAliases>
<typeAlias alias="Menu" type="com.wanghu.mybatis.domain.Menu"/>
<typeAlias alias="UrlAuth" type="com.wanghu.mybatis.domain.UrlAuth"/>
</typeAliases>
<plugins>
<plugin interceptor="day_8_mybatis.util.ExamplePlugin">
<property name="someProperty" value="100"/>
</plugin>
</plugins>
<objectWrapperFactory type="tk.mybatis.MapWrapperFactory"/>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="${driverClasss}"/>
<property name="url" value="${jdbcUrl}"/>
<property name="username" value="${username}"/>
<property name="password" value="${password}"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="mapper/MenuMapper.xml"/>
</mappers>
<typeHandlers>
<typeHandler javaType="" handler=""/>
<typeHandler javaType="" jdbcType="" handler=""/>
</typeHandlers>
</configuration>
准备配置文件 config.properties
driverClasss=com.mysql.jdbc.Driver
jdbcUrl=jdbc:mysql://localhost:3306/springMVC?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull
username=root
password=123456
#定义初始连接数
initialSize=0
#定义最大连接数
maxActive=20
#定义最大空闲
maxIdle=20
#定义最小空闲
minIdle=1
#定义最长等待时间
maxWait=60000
客户端测试程序
public static void main(String[] args) throws IOException {
String config = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(config);
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(inputStream);
SqlSession session = factory.openSession();
List<Object> result = session.selectList("com.wanghu.mybatis.domain.mapper.MenuMapper.listAuthUrls");
System.out.println(result.toString());
}
准备工作完成,我们先从时序图上来看下大致的加载过程:
时序图解析:
1.调用 Resources 类中的 getResourceAsStream 方法来加载 mybatis-config.xml 文件;
2.Resources 类中 getResourceAsStream 返回配置文件字节输入流;
3.调用 SqlSessionFactoryBuilder 对象的build方法,并将步骤2中的字节输入流作为参数;
4.build 方法的新建 XMLConfigBuilder 对象,并调用 XMLConfigBuilder 的 parse 方法;
5.parse 方法中调用 parseConfiguration
来构建 Configuration 对象,这里是初始化的核心;
6.parseConfiguration 方法返回构建完成的 Configuration 对象;
7.build 方法返回 SqlSessionFactory 对象,默认返回的是实现类 DefaultSqlSessionFactory 对象;
8.拿到 SqlSessionFactory 对象后,调用 openSession 方法就可以创建 SqlSession 对象,执行具体业务SQL;
9.openSession 方法返回具体的 SqlSession 对象,默认返回的是实现类DefaultSqlSession 对象。
上面就是 Mybatis 从加载到获取SqlSession对象的过程,具体里面是如何实现的呢?请继续查看下面的源码分析及注意点。
如何获取 mybatis-config.xml 的字节输入流不是我们今天关注的重点,我们重点来看SqlSessionFactoryBuilder 对象的 build 方法实现,从源码的角度来看 Mybatis 的初始化。
先来看下 SqlSessionFactoryBuilder 的 build 重载方法:
public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
try {
//获取XML配置构建对象
XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
//构建SqlSessionFactory对象
return build(parser.parse());
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error building SqlSession.", e);
}
//此处省略 finally 语句块
......
}
public SqlSessionFactory build(Configuration config) {
//构建默认SqlSession工厂
return new DefaultSqlSessionFactory(config);
}
首先会进入第一个 build 方法,在 try 块中,先是通过 new 一个 XMLConfigBuilder 对象来接收客户端传过来的字节输入流对象,接着在 build 重载方法中传入 parser.parse() 方法来构建 Configuration 对象,然后通过 DefaultSqlSessionFactory 构建SqlSessionFactory 对象。
上面 build 方法中重点逻辑是 XMLConfigBuilder 中的 parse 方法。
下面来看下 XMLConfigBuilder 中的 parse 方法是如何构建 Configuration 对象的。
public Configuration parse() {
//第一次初始化时parsed为false,防止配置文件被多次解析,导致配置重复异常
if (parsed) {
throw new BuilderException("Each MapperConfigParser can only be used once.");
}
//解析之前将parsed设置为true。
/*注意:这里可能有多线程并发问题,当两个线程在上一个 if 那都判断 parsed 为 false,
那么接着都会执行 parseConfiguration 方法,同样会出现配置重复异常。
所以,如果程序中有并发解析配置文件代码,记得要加锁,然后让程序直接抛出 BuilderException 运行时异常。*/
parsed = true;
//解析 XM L文件,获取 configuration 标签里的内容
parseConfiguration(parser.evalNode("/configuration"));
return configuration;
}
从 parse 方法中,首先对是否多次解析做了一下校验,然后把具体构建 configuration 对象过程放在了 parseConfiguration 方法中,下面我们继续看 parseConfiguration 方法中的实现:
private void parseConfiguration(XNode root) {
try {
// 解析配置文件中的typeAliases标签,
// 将对象的别名和对象的类型注册到TypeAliasRegistry类的TYPE_ALIASES(HashMap)中
typeAliasesElement(root.evalNode("typeAliases"));
// 解析配置文件中的plugins标签,插件主要是自定义的拦截器,用于对某种方法进行拦截调用
// 可拦截Executor、ParameterHandler、ResultSetHandler、StatementHandler这些方法
// 主要用于日志记录、鉴权、缓存等场景
pluginElement(root.evalNode("plugins"));
//解析配置文件中的objectFactory标签
//默认使用的是DefaultObjectFactory,如果想自定义,可继承DefaultObjectFactory类实现自己的objectFactory,
// 然后配置到Mybatis配置文件中
objectFactoryElement(root.evalNode("objectFactory"));
//解析配置文件中的objectWrapperFactory标签,用于自定义的对象包装器,如返回对象的下划线转驼峰式
objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
//解析配置文件中的properties标签,用于引入项目中的properties,
// 如把db账号密码、url等配置在另外的properties中,然后Mybatis配置文件只需要引用即可,实现数据源的解耦和切换
propertiesElement(root.evalNode("properties"));
//解析配置文件中的settings标签,用于修改和设置Configuration中的全局设置
settingsElement(root.evalNode("settings"));
//解析配置文件中的environments标签,设置数据源
environmentsElement(root.evalNode("environments"));
//解析配置文件中的typeAliases标签,用于自定义类型处理器,如处理执行类对象的类型处理器
typeHandlerElement(root.evalNode("typeHandlers"));
//解析配置文件中的mappers标签,将mapper映射的XML文件解析转化,并存储到Configuration类对象的各个属性中
mapperElement(root.evalNode("mappers"));
} catch (Exception e) {
throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
}
}
该方法就是对 Mybatis 配置文件进行解析并将相关配置设置到 Configuration 对象中的。下面选举两个常用的配置,分析配置的转化过程。
typeAliasesElement 处理别名和对象类型映射
先看下 typeAliasesElement 方法是如何设置对象的别名和对象的类型,并注册到TypeAliasRegistry 类的 TYPE_ALIASES(HashMap)中的。我们看下typeAliasesElement 方法的实现:
private void typeAliasesElement(XNode parent) {
//父节点 typeAliases 不能为空,否则配置无效,不进行解析
if (parent != null) {
//遍历 typeAliases 中的子节点,即查找 typeAlias 节点
for (XNode child : parent.getChildren()) {
//从 typeAlias 节点中获取 alias 属性和 type 属性, alias 为别名,type 是类的全限定名
String alias = child.getStringAttribute("alias");
String type = child.getStringAttribute("type");
try {
//通过 type 获取类对象
Class<?> clazz = Resources.classForName(type);
//如果未设置别名 alias,则先要检查是否从注解中设置了别名或者获取类的简单名称
if (alias == null) {
typeAliasRegistry.registerAlias(clazz);
} else {
//alias不为空,则直接进入registerAlias方法,进行注册
typeAliasRegistry.registerAlias(alias, clazz);
}
} catch (ClassNotFoundException e) {
throw new BuilderException("Error registering typeAlias for '" + alias + "'. Cause: " + e, e);
}
}
}
}
typeAliasesElement 方法主要是来解析配置文件中的 typeAliases 节点,并准备好别名和对象类型的映射信息。下面看下当 alias 为空时的处理方式:
public void registerAlias(Class<?> type) {
//获取类的简单名称,如 java.lang.String,则简单名称为 String
String alias = type.getSimpleName();
//检查类是否通过注解 @Alias 设置别名
Alias aliasAnnotation = type.getAnnotation(Alias.class);
//如果存在通过注解设置的别名,则使用注解中设置的别名,否则使用类的简单名称作为别名
if (aliasAnnotation != null) {
alias = aliasAnnotation.value();
}
//将别名和类类型传入重载方法registerAlias进行注册
registerAlias(alias, type);
}
这里做了进一步的查找,如果在配置文件中没有设置别名,则继续检查类中是否通过注解 @Alias
的方式设置别名。接着调用重载方法 registerAlias(alias,type) 进行别名注册:
//TypeAliasRegistry 类中的全局对象,用于存放别名和类型的映射
private final HashMap<String, Class<?>> TYPE_ALIASES = new HashMap<String, Class<?>>();
public void registerAlias(String alias, Class<?> value) {
//断言别名不能为null
assert alias != null;
//将别名转化为小写
String key = alias.toLowerCase();
//别名检查,校验别名对应的类类型是否匹配
if (TYPE_ALIASES.containsKey(key) && !TYPE_ALIASES.get(key).equals(value.getName()) && TYPE_ALIASES.get(alias) != null) {
//如果同一个别名被设置为多个映射(即 1-N),则抛异常
if (!value.equals(TYPE_ALIASES.get(alias))) {
throw new TypeException("The alias '" + alias + "' is already mapped to the value '" + TYPE_ALIASES.get(alias).getName() + "'.");
}
}
//为类类型设置别名
TYPE_ALIASES.put(key, value);
}
注意
:上面 if 判断条件 TYPE_ALIASES.get(key).equals(value.getName())
肯定是false,因为 TYPE_ALIASES.get(key)
返回 Class
类型和 value.getName
返回字符串类型相比较肯定是 false
,所以 !TYPE_ALIASES.get(key).equals(value.getName())
恒等于 true
。这里是个 bug
, 在高版本中已经修复。
到这里来,别名注册流程就已经全部结束了。我们在 mapper.xml 中的 parameterType或是 resultType 中就可以用别名代替类全限定名称。
再来看一个 Mybatis 是如何解析各个 mapper 文件的,在方法mapperElement(root.evalNode(“mappers”)) 中,具体解释了转化过程。
mapperElement 解析转化 XML 文件,并存储到Configuration 对象的属性中
看下 mapperElement 方法的代码:
private void mapperElement(XNode parent) throws Exception {
//获取 mappers 节点
if (parent != null) {
for (XNode child : parent.getChildren()) {
//使用相对于类路径的资源引用 如:mapper/MenuMapper.xml
String resource = child.getStringAttribute("resource");
//使用完全限定资源定位符(URL)如:file:///var/mapper/MenuMapper.xml
String url = child.getStringAttribute("url");
InputStream inputStream;
//当只有 resource 属性存在时,解析资源引用
if (resource != null && url == null) {
//ErrorContext 用于存储异常信息,resource存储异常发生所处的文件,用于精准定位问题
ErrorContext.instance().resource(resource);
//获取 resource 的字节流
inputStream = Resources.getResourceAsStream(resource);
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
//执行转化操作
mapperParser.parse();
} else if (url != null && resource == null) {
ErrorContext.instance().resource(url);
inputStream = Resources.getUrlAsStream(url);
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
mapperParser.parse();
} else {
// url 和 resource 属性,两者只能配置其中一个
throw new BuilderException("A mapper element may only specify a url or resource, but not both.");
}
}
}
}
从 mapperElement 方法中,我们可以看到 url 和 resource 属性在同一个 mapper 节点中只能存在其中一个,并且根据路径的不同,构造不同的 XMLMapperBuilder 对象。
在将文件转化为字节输入流之后,执行了 mapperParser.parse() 方法,这个方法就是用来解析 XML 文件并将节点数据存储到 configuration 对象中的。来看下 parse 方法代码:
public void parse() {
//首先检查 configuration 对象是否已加载过该资源,如果未加载则进行加载
if (!configuration.isResourceLoaded(resource)) {
// parser.evalNode("/mapper") 获取 XML 文件的 mapper 节点,
// 接着 configurationElement 方法开始解析 XML 中具体子节点和属性
configurationElement(parser.evalNode("/mapper"));
configuration.addLoadedResource(resource);
//绑定命名空间
bindMapperForNamespace();
}
//解析未完成的 resultMap
parsePendingResultMaps();
//解析未完成的 cache-Ref
parsePendingChacheRefs();
//解析未完成的 statements,如:select|insert|update|delete
parsePendingStatements();
}
该方法中首先获取 XML 文件中 mapper 根节点,然后调用 configurationElement 方法进行节点解析,接下来看下 configurationElement 方法代码:
private void configurationElement(XNode context) {
try {
//获取 mapper 节点中的命名空间 如:获取<mapper namespace="com.xx.mapper.AuthorityMapper">中的namespace属性值
String namespace = context.getStringAttribute("namespace");
//设置当前命名空间
builderAssistant.setCurrentNamespace(namespace);
//解析 cache-ref 节点,获取 cache-ref 中的属性 namespace,从其他命名空间引用的缓存配置
cacheRefElement(context.evalNode("cache-ref"));
//解析 cache 节点,开启二级缓存时用到。
// 二级缓存的作用域是同一个 namespace 下的 mapper 映射文件内容,多个SqlSession共享
cacheElement(context.evalNode("cache"));
//解析 parameterMap 节点,存储到 configuration 对象的 parameterMaps中, key 为 currentNamespace + "." + id,value为ParameterMap类型
parameterMapElement(context.evalNodes("/mapper/parameterMap"));
//解析 resultMap节点,存储到 configuration 对象的 resultMaps 中,key 为 currentNamespace + "." + id
resultMapElements(context.evalNodes("/mapper/resultMap"));
//解析 sql 节点,存储到 sqlFragments 中,key 为 currentNamespace + "." + id,value 为 XNode
sqlElement(context.evalNodes("/mapper/sql"));
//解析select|insert|update|delete节点,存储到mappedStatements中,key为全限定名,value为MappedStatement
buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
} catch (Exception e) {
throw new RuntimeException("Error parsing Mapper XML. Cause: " + e, e);
}
}
这个方法就是将 mapper 文件的节点进行抽取、解析到 configuration 对象的过程。当然,在 configurationElement 方法中又调用了各个封装的方法进行具体的节点解析,如 parameterMap、resultMap、sql 等节点。过程包括节点属性的抽取、数据的封装、以及最终将封装好的 Map 对象保存到 Configuration 对象中。至此,Mybatis 的初始化过程就已经完成了。