本章希望通过手写mybatis框架的核心部分,帮助加深对mybatis框架底层原理的理解,主要包括框架的执行流程,动态sql的解析流程,框架中各个组件和类的作用,以及他们之间是如何交互的。
手写之前,我们需要对mybatis的架构和流程有大致的掌握。
1. mybatis的架构和流程分析
先直接来一张mybatis的架构图,看下mybatis按照架构整体是怎么设计的:
- 接口层
接口层是MyBatis提供给开发人员的一套API,主要表现在SqlSession接口,通过SqlSession接口开发人员就可以完成对数据库CRUD。这层主要考虑的就是使用人的需求,用起来怎么方便接口就怎么设计,属于对外的接口。 - 数据处理层
数据处理层就是具体解析和执行sql的地方,是MyBatis框架内部的核心,底层最终还是调用JDBC代码来执行的。这层就是通过对JDBC执行sql的步骤进行了拆解,划分了不同模块,同时抽象出了很多组件,每个组件各司其职,通过协作让整个sql执行更加灵活。
主要责任:
(1)参数的解析与绑定
(2)SQL的解析
(3)SQL的执行
(4)结果集映射的解析与处理 - 基础支撑层
支撑层用来支撑整个框架运行的,主要负责:
(1)对数据库连接的支持与管理
(2)对事务的支持与管理
(3)对配置信息的支持与管理
(4)对查询缓存的支持与管理
接着我们看下执行流程图,看下配置文件是如何结合Mybatis中各个组件进行整个程序执行的。
说明:
- 1.mybatis配置文件
所有的配置文件在框架运行的一开始就会被解析,并将所有的配置信息封装到Configuration对象中。
- SqlMapConfig.xml,全局配置文件,配置了mybatis的运行环境等信息。
- Mapper.xml,映射文件,配置了sql相关信息。
- 2.SqlSessionFactory
通过mybaris环境等配置信息构造SqlSessionFactory,即会话工厂。(创建过程主要是通过SqlSessionFactoryBuilder对象加载配置文件,将解析的配置信息封装成Configuration对象后,再将Configuration对象传递给SqlSessionFactory对象完成构建) - 3.sqlSession
通过上面的会话工厂创建sqlSession会话,程序员就是通过sqlsession会话接口对数据库进行增删改查操作。这个是mybatis对外提供的操作接口。 - 4.Executor执行器
mybatis底层定义了Executor执行器接口来具体操作数据库, 而上面的对外接口sqlsession底层就是通过executor接口操作数据库的。Executor接口有两个实现,一个是基本执行器BaseExecutor(是一个抽象类,采用模板方法设计模式,基本执行器下面又分为简单执行器、可重用执行器、批量执行器…)、一个是缓存执行器CachingExecutor,Executor是mybatis内部的执行接口,四大组件之一。
(四大组件:Exucutor、StatementHandler、ParameterHandler、ResultSetHandler,各司其职,让SQL执行流程更加灵活) - 5.MappedStatement
它也是mybatis一个底层封装对象,它包装了mybatis配置信息及sql映射信等。映射文件中每一个select\insert\update\delete标签都对应一个Mapped Statement对象,select\insert\update\delete标签的id即是Mapped statement的id,此外还封装了标签中的sql语句(SqlSource),参数类型,结果集类型,statment类型等。(JDBC中,有三种Statement类型,Statement,PreparedStatment,CallableStatment)
- MappedStatement对sql执行的输入参数进行定义,支持HashMap、基本类型、pojo,Executor在MappedStatemen执行sql前将输入的java对象映射至sql中,输入参数映射过程相当于jdbc编程中对preparedStatement设置参数。
- MappedStatement对sql执行的输出结果进行定义,支持HashMap、基本类型、pojo,Executor在MappedStatemen执行sql后将输出结果映射至java对象中,输出结果映射过程相当于jdbc编程中对结果的解析处理过程。
接下来看第三张图,主要讲解的是SqlSession水平面下的调用过程,展示了整个调用过程中各个组件的职责,以及组件间是如何交互,如何和JDBC的代码进行交互的:
- SqlSession:可以看到SqlSession作为MyBatis工作的顶层API接口,是对外提供的操作接口,而底层是调用Executor执行器。
- Executor:执行器是Mybatis的核心,是内部的执行接口,同时还提供了查询缓存的功能。
- SqlSource:MappedStatment中的SqlSource具有解析sql的功能,通过SqlSource可以并获得BoundSql,它维护了可执行的Sql语句和对应的参数映射信息。
- StatementHandler:StatementHandler是用来处理和JDBC中的Statement进行交互的,例如对statment设置入参、执行查询、执行更新、结果集封装等。
- ParameterHandler:StatementHandler对statment设置入参,底层主要依靠这个接口。
- ResultSetHandler:StatementHandler对statment的结果集封装,底层主要依靠这个接口。
通过上面的分析,我们进行一个总结:
- SqlSession
mybatis对外提供的程序员使用的接口,通过接口完成CRUD - Executor
执行器,mybatis内部执行的接口,是MyBatis 调度的核心,负责SQL语句的生成和查询缓存的维护 - StatementHandler
封装了JDBC Statement操作,负责对JDBC statement 的操作,如设置参数、将Statement结果集转换成List集合。 - ParameterHandler
负责对用户传递的参数转换成JDBC Statement 所需要的参数 - ResultSetHandler
负责将JDBC返回的ResultSet结果集对象转换成List类型的集合 - TypeHandler
负责java数据类型和jdbc数据类型之间的映射和转换 - MappedStatement
维护了一条<select|update|delete|insert>节点的封装 - SqlSource
负责根据用户传递的parameterObject,动态地生成SQL语句,将信息封装到BoundSql对象中,并返回BoundSql,表示动态生成的SQL语句以及相应的参数信息 - Configuration
MyBatis所有的配置信息都维持在Configuration对象之中。
2.Mybatis中的SQL解析
上面已经提到过SqlSource具有解析sql的功能,那么解析流程到底是什么样的呢?我们举个例子:
之前说过MappedStatement对象维护了一条<select|update|delete|insert>节点的封装,上面的例子中,整个<select>就对应一个MappedStatement对象,里面维护了id,parameterType,resultType,statmentType和标签内的sql语句。
那么标签内的sql语句具体是以什么样的数据结构存储的呢?
首先分析一下<select>标签,对于xml来说,它是一个混合元素,可以包含子标签,并且子标签之间可以穿插文本内容,dom解析<select>标签就可以看成一个Node节点,它有很多子节点,子节点中既有文本节点,也有元素节点,元素节点又有可能有子节点…而在mybatis中把这样的节点,定义成了【SqlNode】,直译为Sql节点。【SqlNode】它是一个接口,按照节点包含的内容不同,实现类分为:
- StaticTextSqlNode:封装的是仅带有#{}的文本节点
- TextSqlNode:封装的是带有${}的文本节点
- IfSqlNode:封装的是if动态标签的混合节点
- …
- MixedSqlNode:混合节点,代表一组节点。使用组合设计模式,以集合方式存储子节点,使得用户可以使用一致的方法操作单个对象和组合对象。
所以【SqlNode】主要就是用来存储<select>标签下一个个sql节点的。除了存储以外,<select>标签所代表的sql语句还需要根据用户传递的参数对其包含的一个个sql节点进行选择拼接,最终拼成一个完整的sql语句。
因此【SqlNode】接口定义了一个方法apply(DynamicContext context),其中DynamicContext里面维护了用户传递的入参,和一个StringBuilder变量,用来存储拼接的sql语句,该方法会根据当前节点的逻辑对节点中的信息进行处理,然后拼接到StringBuilder中,注意此时的处理,只是根据动态标签的逻辑,完成了字符串的拼接,或者替换了字符串中的${},并不会处理#{}。当调用顶层节点的apply方法的时候,会依次遍历树中每个节点,所以每个节点都有机会并且按照顺序依次执行自己的apply方法对StringBuilder进行拼接,最终从StringBuilder中方可以得到一个完整的sql语句,当然得到的是一个最多只包含#{}的sql语句。
上面的过程我们可以称为拼接过程!
只包含#{}的sql语句,还不能被JDBC直接执行,所以mybatis定义了一个SqlSource接口,其实现类会包含SqlNode,并提供了一个获取可执行SQL的方法getBoundSql(Object parameterObject),该方法底层实现,会调用解析器sqlSourceParser,它会根据用户传递的入参,将SqlNode拼接完整的语句进行一次解析,把#{}替换为?占位符,同时将#{}中的参数信息封装成了【ParameterMapping】对象,组成一个集合按照顺序作为该sql语句占位符对应的参数映射信息,最后将sql和参数信息封装到了【BoundSql】对象返回。
这个过程我们可以称为解析过程!
根据【SqlSource】实现类中包含的【SqlNode】类型不同,也有不同实现类:
- DynamicSqlSource:包含了带有${}或者动态SQL标签的SqlNode
- RawSqlSource:仅包含只带有#{}的SqlNode
为什么这么分?因为只包含#{}的sql语句,是不需要经历拼接过程的(没有${}或者动态标签需要处理),只需要解析一次以后,就可以使用占位符来长期使用。而${}和动态标签的sql,每次用户传递的入参不同,整个语句拼接后的结果都不一样,所以每次调用都需要重新拼接并解析。
所以往往RawSqlSource在构造的时候拼接解析就可以了,而DynamicSqlSource要在每次调用方法的时候重新拼接在解析
- StaticSqlSource:上面两个SqlSource解析完成以后的结果都会封装到该对象中,这里面的sql是可以直接使用的。
总结SQL解析:
SQL解析可以分成两个阶段:
- 拼接阶段:拼接sql节点,只处理动态标签和${},最终返回只包含#{}的sql语句
- 解析阶段:处理#{},解析成占位符,最终返回可执行的sql和参数映射信息
涉及的接口和类:
- SqlSource接口:
第一、实现类要封装未解析的SQL信息(SqlNode集合)
第二、要提供对未解析的SQL信息,进行解析的功能
- DynamicSqlSource:封装的SqlNode集合中含有"带有${}或者动态SQL标签的SqlNode"
解析工作是发生在每一次调用getBoundSql方法的时候 - RawSqlSource:封装的SqlNode集合中只含有"只带有#{}的SqlNode"
解析工作是发生在第一次构造RawSqlSource的时候,只需要被解析一次 - StaticSqlSource:当上面两个SqlSource解析后,都会把解析结果封装到该对象中,通过该SqlSource获取到BoundSql
- SqlNode接口:
1、封装SQL节点信息
2、提供对封装的SQL节点的拼接解析功能,此处解析指替换#{}
- MixedSqlNode:主要起到封装集合节点作用,并且进行一些操作,代表的含有子节点的节点
- TestSqlNode:封装的是带有${}的文本字符串,并提供根据参数替换掉${}功能
- StaticTextSqlNode:封装的是带有#{}的文本字符串,不用特殊处理
- IfSqlNode:封装的是if动态标签的文本字符串,根据参数决定是否拼接
- …
元素:我们认为动态标签和${}都是字符串拼接的操作,对于这种操作都认为是动态的sql信息,#{}或者没有占位符都属于静态,对应sql处理"拼接"和"解析"两个流程
- DynamicContext:动态上下文,就是封装的入参信息,解析过程中的SQL信息
- BoundSql:封装解析之后可以执行的sql语句,以及解析占位符?时产生的参数映射信息
- ParameterMapping:从#{}中解析出来的参数信息,包括参数名称和类型
SqlSourece、SqlNode、BoundSql这几个接口在整个解析阶段和执行阶段都非常重要!也是最难理解的部分。
3.分析JDBC的执行步骤
mybatis底层都是对JDBC的封装,接下来我们在JDBC的执行步骤基础上简单分析一下如何手写mybatis,主要以面向过程方式分析:
@Test
public void test() {
Connection conn = null;
PreparedStatement stmt = null;
ResultSet rs = null;
try {
// 1 注册驱动
Class.forName("com.mysql.jdbc.Driver");
// 2 获得连接
String url = "jdbc:mysql://rm-bp......nm1ao.mysql.rds.aliyuncs.com:3306/yth";
conn = DriverManager.getConnection(url, "xiaoy1995", "Xiaoy9502");
// 3 获取sql语句
String sql = "select * from user where name = ?";
// 4 获取预处理 statement
stmt = conn.prepareStatement(sql);
// 5 设置参数,序号从1开始
stmt.setString(1, "刘备");
// 6 执行SQL语句
rs = stmt.executeQuery();
// 7 处理结果集
while (rs.next()) {
// 获得一行数据
System.out.println(rs.getString("name") + ", " + rs.getString("gender") + "," + rs.getString("phone")
+ "," + rs.getString("address"));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 8释放资源
if (rs != null) {
try {
rs.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (stmt != null) {
try {
stmt.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
使用JDBC方式编程,缺点很多就不说了。我们直接思考可以从哪些方面进行优化。
- 首先里面的数据源配置信息和sql信息是经常变化的,可以放到配置文件中
- 每次JDBC执行SQL的步骤都是相似的,可以对各步骤所代表的功能进行分析,划分不同模块,抽象出通用的组件。
针对上面两点,我们可以把JDBC按照下面两个流程进行分析
- 解析流程
- 执行流程
解析流程(从配置文件中获取JDBC需要的数据信息)
1.加载全局配置文件,最终将解析出来的信息,封装到【Configuration】对象中
- a)解析运行时环境信息,这边主要指的是DataSource的配置信息,然后将DataSource封装到Configuration对象中存储。
- b)当加载到mappers元素的时候,就会触发映射文件的加载。
- 映射文件的加载,先解析select/update/insert/delete标签,获取id值(statementId)、SQL语句、参数类型、结果类型、statement类型,最终将解析出来的信息,封装到【MappedStatement】对象中,再将该对象封装到Map集合中,key为statementId,value就是该对象,然后将map集合存储到【Configuration】对象中
- MappedStatement对象中存储Sql信息,是通过【SqlSource】进行存储的。SqlSource对象,不只是存储Sql信息,而且还提供对存储的SQL信息进行处理的功能。
- SqlSource是通过一个SqlNode集合数据来封装的SQL信息。
- 解析映射文件其他元素…
执行流程(使用JDBC代码从配置文件中读取SQL相关信息完成CRUD)
1.获取连接(需要driverclassname、url、username、password)
- a)获取Configuration对象,从该对象中获取DataSource对象
- b)有了DataSource对象,就可以从该对象中获取Connection对象
对应的JDBC执行步骤:
// 1 注册驱动
Class.forName("com.mysql.jdbc.Driver");
// 2 获得连接
String url = "jdbc:mysql://rm-bp186l387tovm7nm1ao.mysql.rds.aliyuncs.com:3306/yth";
conn = DriverManager.getConnection(url, "xiaoy1995", "Xiaoy9502");
2.获取JDBC可以执行的SQL语句
- 其实此处获取的是BoundSql,就是调用的【SqlSource】的SQL解析功能得到的
- 从BoundSql中获取SQL语句
对应的JDBC执行步骤:
// 3 获取sql语句
String sql = "select * from user where name = ?";
3.获取statement对象
- 从MappedStatement对象中获取statement的类型:simple、prepared、callable根据不同类型去创建不同的Statement对象
对应的JDBC执行步骤:
// 4 获取预处理 statement
stmt = conn.prepareStatement(sql);
4.从BoundSql中获取参数映射信息List<ParameterMapping>
- 遍历给参数赋值,先需要读取ParameterMapping中的属性名称,再从入参对象中获取指定属性的值
- 调用JDBC代码,完成属性设置
对应的JDBC执行步骤:
// 5 设置参数,序号从1开始
stmt.setString(1, "刘备");
5.执行statemement,并获取结果集ResultSet
对应的JDBC执行步骤:
// 6 执行SQL语句
rs = stmt.executeQuery();
6.处理结果集
- 从MappedStatement对象中获取输出结果类型,也就是结果要映射的类型
- 遍历结果集,获取结果集中每一行的数据
- 获取结果集中每一行每一列的数据,获取列的名称
- 根据列的名称,通过反射,去设置要映射的java类型的指定属性值
对应的JDBC执行步骤:
// 7 处理结果集
while (rs.next()) {
// 获得一行数据
System.out.println(rs.getString("name") + ", " +.....);
}