在🔗上一篇文章中,咱们由一个快速案例剖析了 MyBatis 的总体架构与总体运行流程,在本篇文章中笔者会根据 MyBatis 的运行流程手写一个自定义 MyBatis 简单框架,在实践中加深对 MyBatis 框架运行流程的理解。本文涉及到的项目代码能够在 GitHub 上下载: 🔗my-mybatis 。html
话很少说,如今开始!🔛🔛🔛java
首先经过下面的流程结构图回顾 MyBatis 的运行流程。在 MyBatis 框架中涉及到的几个重要的环节包括配置文件的解析、 SqlSessionFactory 和 SqlSession 的建立、 Mapper 接口代理对象的建立以及具体方法的执行。mysql
经过回顾 MyBatis 的运行流程,咱们能够看到涉及到的 MyBatis 的核心类包括 Resources、Configuration、 XMLConfigBuilder 、 SqlSessionFactory 、 SqlSession 、 MapperProxy 以及 Executor 等等。所以为了手写本身的 MyBatis 框架,须要去实现这些运行流程中的核心类。git
本节中仍然是以学生表单为例,会手写一个 MyBatis 框架,并利用该框架实如今 xml 以及注解两种不一样配置方式下查询学生表单中全部学生信息的操做。学生表的 sql 语句以下所示:github
CREATE TABLE `student` ( `id` int(10) NOT NULL AUTO_INCREMENT COMMENT '学生ID', `name` varchar(20) DEFAULT NULL COMMENT '姓名', `sex` varchar(20) DEFAULT NULL COMMENT '性别', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8; insert into `student`(`id`,`name`,`sex`) values (1,'张三','男'), (2,'托尼·李四','男'), (3,'王五','女'), (4,'赵六','男');
学生表对应的 Student 实体类以及 StudentMapper 类可在项目的 entity 包和 mapper 包中查看,咱们在 StudentMapper 只定义了 findAll() 方法用于查找学生表中的全部学生信息。sql
下面准备自定义 MyBatis 框架的配置文件,在 mapper 配置时咱们先将配置方式设置为指定 xml 配置文件的方式,整个配置文件以下所示:数据库
<?xml version="1.0" encoding="UTF-8"?> <configuration> <!-- 配置环境--> <environments default="development"> <!-- 配置MySQL的环境--> <environment id="development"> <!-- 配置事务类型--> <transactionManager type="JDBC"/> <!-- 配置数据源--> <dataSource type="POOLED"> <!-- 配置链接数据库的四个基本信息--> <property name="driver" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/mybatis_demo"/> <property name="username" value="root"/> <property name="password" value="admin"/> </dataSource> </environment> </environments> <!-- 指定映射配置文件的位置,映射配置文件的时每一个dao独立的配置文件--> <mappers> <!-- 使用xml配置文件的方式:resource标签 --> <mapper resource="mapper/StudentMapper.xml"/> <!-- 使用注解方式:class标签 --> <!--<mapper class="cn.chiaki.mapper.StudentMapper"/>--> </mappers> </configuration>
本文在编写配置文件时仍按照真正 MyBatis 框架的配置方式进行,这里无需加入配置文件的头信息,同时将数据库的相关信息直接写在配置文件中以简化咱们的解析流程。数组
在真正的 MyBatis 框架中对 Java 的原生反射机制进行了相应的封装获得了 ClassLoaderWrapper 这样一个封装类,以此实现更简洁的调用。本文在自定义时就直接采用原生的 Java 反射机制来获取配置文件并转换为输入流。自定义的 Resources 类以下所示:缓存
// 自定义Resources获取配置转换为输入流 public class Resources { /** * 获取配置文件并转换为输入流 * @param filePath 配置文件路径 * @return 配置文件输入流 */ public static InputStream getResourcesAsStream(String filePath) { return Resources.class.getClassLoader().getResourceAsStream(filePath); } }
在真正的 MyBatis 框架中, MappedStatement 是一个封装了包括 SQL语句、输入参数、输出结果类型等在内的操做数据库配置信息的类。所以本小节中也须要自定义这样一个类,在本文的案例中只须要定义与 SQL 语句和输出结果类型相关的变量便可。代码以下:session
// 自定义MappedStatement类 @Data public class MappedStatement { /** SQL语句 **/ private String queryString; /** 结果类型 **/ private String resultType; }
上一篇文章中已经介绍过,在 MyBatis 框架中对于配置文件的解析都会设置到 Configuration 对象中,而后根据该对象去构建 SqlSessionFactory 以及 SqlSession 等对象,所以 Configuration 是一个关键的类。在本节开头中自定义的配置文件中,真正重要的配置对象就是与数据库链接的标签以及 mapper 配置对应标签下的内容,所以在 Configuration 对象中必须包含与这些内容相关的变量,以下所示:
// 自定义Configuration配置类 @Data public class Configuration { /** 数据库驱动 **/ private String driver; /** 数据库url **/ private String url; /** 用户名 **/ private String username; /** 密码 **/ private String password; /** mappers集合 **/ private Map<String, MappedStatement> mappers = new HashMap<>(); }
这里定义一个工具类用于根据 Configuration 对象中与数据库链接有关的属性获取数据库链接的类,编写 getConnection() 方法,以下所示:
// 获取数据库链接的工具类 public class DataSourceUtil { public static Connection getConnection(Configuration configuration) { try { Class.forName(configuration.getDriver()); return DriverManager.getConnection(configuration.getUrl(), configuration.getUsername(), configuration.getPassword()); } catch (Exception e) { throw new RuntimeException(e); } } }
进一步自定义解析配置文件的 XMLConfigBuilder 类,根据真正 MyBatis 框架解析配置文件的流程,这个自定义的 XMLConfigBuilder 类应该具有解析 mybatis-config.xml 配置文件的标签信息并设置到 Configuration 对象中的功能。对于 xml 文件的解析,本文采用 dom4j + jaxen 来实现,首先须要在项目的 pom.xml 文件中引入相关依赖。以下所示:
<dependency> <groupId>dom4j</groupId> <artifactId>dom4j</artifactId> <version>1.6.1</version> </dependency> <dependency> <groupId>jaxen</groupId> <artifactId>jaxen</artifactId> <version>1.2.0</version> </dependency>
引入依赖后,咱们在 XMLConfigBuilder 类中定义 parse() 方法来解析配置文件并返回 Configuration 对象,以下所示:
public static Configuration parse(InputStream in) { try { Configuration configuration = new Configuration(); // 获取SAXReader对象 SAXReader reader = new SAXReader(); // 根据输入流获取Document对象 Document document = reader.read(in); // 获取根节点 Element root = document.getRootElement(); // 获取全部property节点 List<Element> propertyElements = root.selectNodes("//property"); // 遍历节点进行解析并设置到Configuration对象 for(Element propertyElement : propertyElements){ String name = propertyElement.attributeValue("name"); if("driver".equals(name)){ String driver = propertyElement.attributeValue("value"); configuration.setDriver(driver); } if("url".equals(name)){ String url = propertyElement.attributeValue("value"); configuration.setUrl(url); } if("username".equals(name)){ String username = propertyElement.attributeValue("value"); configuration.setUsername(username); } if("password".equals(name)){ String password = propertyElement.attributeValue("value"); configuration.setPassword(password); } } // 取出全部mapper标签判断其配置方式 // 这里只简单配置resource与class两种,分别表示xml配置以及注解配置 List<Element> mapperElements = root.selectNodes("//mappers/mapper"); // 遍历集合 for (Element mapperElement : mapperElements) { // 得到resource标签下的内容 Attribute resourceAttribute = mapperElement.attribute("resource"); // 若是resource标签下内容不为空则解析xml文件 if (resourceAttribute != null) { String mapperXMLPath = resourceAttribute.getValue(); // 获取xml路径解析SQL并封装成mappers Map<String, MappedStatement> mappers = parseMapperConfiguration(mapperXMLPath); // 设置Configuration configuration.setMappers(mappers); } // 得到class标签下的内容 Attribute classAttribute = mapperElement.attribute("class"); // 若是class标签下内容不为空则解析注解 if (classAttribute != null) { String mapperClassPath = classAttribute.getValue(); // 解析注解对应的SQL封装成mappers Map<String, MappedStatement> mappers = parseMapperAnnotation(mapperClassPath); // 设置Configuration configuration.setMappers(mappers); } } //返回Configuration return configuration; } catch (Exception e) { throw new RuntimeException(e); } finally { try { in.close(); } catch (Exception e) { e.printStackTrace(); } } }
能够看到在 XMLConfigBuilder#parse() 方法中对 xml 配置文件中与数据库链接相关的属性进行了解析并设置到 Configuration 对象,同时最重要的是对 mapper 标签下的配置方式也进行了解析,而且针对指定 xml 配置文件以及注解的两种状况分别调用了 parseMapperConfiguration() 方法和 parseMapperAnnotation() 两个不一样的方法。
针对 xml 配置文件,实现 XMLConfigBuilder#parseMapperConfiguration() 方法来进行解析,以下所示:
/** * 根据指定的xml文件路径解析对应的SQL语句并封装成mappers集合 * @param mapperXMLPath xml配置文件的路径 * @return 封装完成的mappers集合 * @throws IOException IO异常 */ private static Map<String, MappedStatement> parseMapperConfiguration(String mapperXMLPath) throws IOException { InputStream in = null; try { // key值由mapper接口的全限定类名与方法名组成 // value值是要执行的SQL语句以及实体类的全限定类名 Map<String, MappedStatement> mappers = new HashMap<>(); // 获取输入流并根据输入流获取Document节点 in = Resources.getResourcesAsStream(mapperXMLPath); SAXReader saxReader = new SAXReader(); Document document = saxReader.read(in); // 获取根节点以及namespace属性取值 Element root = document.getRootElement(); String namespace = root.attributeValue("namespace"); // 这里只针对SELECT作处理(其它SQL类型同理) // 获取全部的select节点 List<Element> selectElements = root.selectNodes("//select"); // 遍历select节点集合解析内容并填充mappers集合 for (Element selectElement : selectElements){ String id = selectElement.attributeValue("id"); String resultType = selectElement.attributeValue("resultType"); String queryString = selectElement.getText(); String key = namespace + "." + id; MappedStatement mappedStatement = new MappedStatement(); mappedStatement.setQueryString(queryString); mappedStatement.setResultType(resultType); mappers.put(key, mappedStatement); } return mappers; } catch (Exception e){ throw new RuntimeException(e); } finally { // 释放资源 if (in != null) { in.close(); } } }
在实现 parseMapperConfiguration() 方法时,仍然是利用 dom4j + jaxen 对 Mapper 接口的 xml 配置文件进行解析,遍历 selectElements 集合,获取 namespace 标签以及 id 标签下的内容进行拼接组成 mappers 集合的 key 值,获取 SQL 语句的类型标签(select)以及具体的 SQL 语句封装成 MappedStatement 对象做为 mappers 集合的 value 值,最后返回 mappers 对象。
要实现对注解的解析,首先必需要定义注解,这里针对本案例的查询语句,实现一个 Select 注解,以下所示。
// 自定义Select注解 @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Select { String value(); }
而后就是实现 parseMapperAnnotation() 对 Select 注解的解析,实现代码以下。
/** * 解析mapper接口上的注解并封装成mappers集合 * @param mapperClassPath mapper接口全限定类名 * @return 封装完成的mappers集合 * @throws IOException IO异常 */ private static Map<String, MappedStatement> parseMapperAnnotation(String mapperClassPath) throws Exception{ Map<String, MappedStatement> mappers = new HashMap<>(); // 获取mapper接口对应的Class对象 Class<?> mapperClass = Class.forName(mapperClassPath); // 获取mapper接口中的方法 Method[] methods = mapperClass.getMethods(); // 遍历方法数组对SELECT注解进行解析 for (Method method : methods) { boolean isAnnotated = method.isAnnotationPresent(Select.class); if (isAnnotated) { // 建立Mapper对象 MappedStatement mappedStatement = new MappedStatement(); // 取出注解的value属性值 Select selectAnnotation = method.getAnnotation(Select.class); String queryString = selectAnnotation.value(); mappedStatement.setQueryString(queryString); // 获取当前方法的返回值及泛型 Type type = method.getGenericReturnType(); // 校验泛型 if (type instanceof ParameterizedType) { ParameterizedType parameterizedType = (ParameterizedType) type; Type[] types = parameterizedType.getActualTypeArguments(); Class<?> clazz = (Class<?>) types[0]; String resultType = clazz.getName(); // 给Mapper赋值 mappedStatement.setResultType(resultType); } // 给key赋值 String methodName = method.getName(); String className = method.getDeclaringClass().getName(); String key = className + "." + methodName; // 填充mappers mappers.put(key, mappedStatement); } } return mappers; }
在实现 parseMapperAnnotation() 方法时,根据 Mapper 接口的全限定类名利用反射机制获取 Mapper 接口的 Class 对象以及 Method[] 方法数组,而后遍历方法数组其中的注解相关方法并对注解进行解析,最后完成对 mappers 集合的填充并返回。
在前期准备中,咱们围绕 Configuration 类的配置自定义了 Resource 类、 MappedStatement 类以及 XMLConfiguration 类。接下来根据 MyBatis 的执行流程,须要建立一个 SqlSessionFactory 会话工厂类用于建立 SqlSession 。 所谓工欲善其事,必先利其器。所以首先要自定义一个会话工厂的构建者类 SqlSessionFactoryBuilder ,并在类中定义一个 build() 方法,经过调用 build() 方法来建立 SqlSessionFactory 类,以下所示。
// 会话工厂构建者类 public class SqlSessionFactoryBuilder { /** * 根据参数的字节输入流构建一个SqlSessionFactory工厂 * @param in 配置文件的输入流 * @return SqlSessionFactory */ public SqlSessionFactory build(InputStream in) { // 解析配置文件并设置Configuration对象 Configuration configuration = XMLConfigBuilder.parse(in); // 根据Configuration对象构建会话工厂 return new DefaultSqlSessionFactory(configuration); } }
在这个类中咱们定义了 build() 方法,入参是 MyBatis 配置文件的输入流,首先会调用 XMLConfigBuilder#parse() 方法对配置文件输入流进行解析并设置 Configuration 对象,而后会根据 Configuration 对象构建一个 DefaultSqlSessionFactory 对象并返回。上篇文章中已经介绍了在 MyBatis 中 SqlSessionFactory 接口有 DefaultSqlSessionFactory 这样一个默认实现类。所以本文也定义 DefaultSqlSessionFactory 这样一个默认实现类。
会话工厂类 SqlSessionFactory 是一个接口,其中定义了一个 openSession() 方法用于建立 SqlSession 会话,以下所示:
// 自定义SqlSessionFactory接口 public interface SqlSessionFactory { /** * 用于打开一个新的SqlSession对象 * @return SqlSession */ SqlSession openSession(); }
该接口有一个 DefaultSqlSessionFactory 默认实现类,其中实现了 openSession() 方法,以下所示:
// 自定义DefaultSqlSessionFactory默认实现类 public class DefaultSqlSessionFactory implements SqlSessionFactory { // Configuration对象 private final Configuration configuration; // 构造方法 public DefaultSqlSessionFactory(Configuration configuration) { this.configuration = configuration; } /** * 用于建立一个新的操做数据库对象 * @return SqlSession */ @Override public SqlSession openSession() { return new DefaultSqlSession(configuration); } }
能够看到在实现 openSession() 方法中涉及到了 SqlSession 接口以及 SqlSession 接口的 DefaultSqlSession 默认实现类。
在自定义 SqlSession 接口时,先思考该接口中须要定义哪些方法。在 MyBatis 执行流程中,须要使用 SqlSession 来建立一个 Mapper 接口的代理实例,所以必定须要有 getMapper() 方法来建立 MapperProxy 代理实例。同时,还会涉及到 SqlSession 的释放资源的操做,所以 close() 方法也是必不可少的。所以自定义 SqlSession 的代码以下:
// 自定义SqlSession接口 public interface SqlSession { /** * 根据参数建立一个代理对象 * @param mapperInterfaceClass mapper接口的Class对象 * @param <T> 泛型 * @return mapper接口的代理实例 */ <T> T getMapper(Class<T> mapperInterfaceClass); /** * 释放资源 */ void close(); }
进一步建立 SqlSession 接口的 DefaultSqlSession 默认实现类,并实现接口中的 getMapper() 和 close() 方法。
public class DefaultSqlSession implements SqlSession { // 定义成员变量 private final Configuration configuration; private final Connection connection; // 构造方法 public DefaultSqlSession(Configuration configuration) { this.configuration = configuration; // 调用工具类获取数据库链接 connection = DataSourceUtil.getConnection(configuration); } /** * 用于建立代理对象 * @param mapperInterfaceClass mapper接口的Class对象 * @param <T> 泛型 * @return mapper接口的代理对象 */ @Override public <T> T getMapper(Class<T> mapperInterfaceClass) { // 动态代理 return (T) Proxy.newProxyInstance(mapperInterfaceClass.getClassLoader(), new Class[]{mapperInterfaceClass}, new MapperProxyFactory(configuration.getMappers(), connection)); } /** * 用于释放资源 */ @Override public void close() { if (connection != null) { try { connection.close(); } catch (Exception e) { e.printStackTrace(); } } } }
与真正的 MyBatis 实现流程同样,本文在 getMapper() 方法的实现过程当中也采用动态代理的方式返回 Mapper 接口的代理实例,其中包括了构建 MapperProxyFactory 类。在调用 Proxy#newProxyInstance() 方法时,包括的入参以及含义以下:
而后 DefaultSqlSession#close() 方法的实现主要就是调用数据库链接的 close() 方法。
为了实现动态代理,须要自定义 MapperProxyFactory 类用于建立 Mapper 接口的代理实例,其代码以下:
// 自定义MapperProxyFactory类 public class MapperProxyFactory implements InvocationHandler { // mappers集合 private final Map<String, MappedStatement> mappers; private final Connection connection; public MapperProxyFactory(Map<String, MappedStatement> mappers, Connection connection) { this.mappers = mappers; this.connection = connection; } // 实现InvocationHandler接口的invoke()方法 @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 获取方法名 String methodName = method.getName(); // 获取方法所在类的名称 String className = method.getDeclaringClass().getName(); // 组合key String key = className + "." + methodName; // 获取mappers中的Mapper对象 MappedStatement mappedStatement = mappers.get(key); // 判断是否有mapper if (mappedStatement != null) { // 调用Executor()工具类的query()方法 return new Executor().query(mappedStatement, connection); } else { throw new IllegalArgumentException("传入参数有误"); } } }
建立 Mapper 接口的代理对象后,下一步就是执行代理对象的相关方法,这里须要实现 Executor 类用于执行 MapperedStatement 对象中的封装的 SQL 语句并返回其中指定输出类型的结果, 在 Executor 类中定义查询全部相关的 selectList() 方法,以下所示:
// 自定义Executor类 public class Executor { // query()方法将selectList()的返回结果转换为Object类型 public Object query(MappedStatement mappedStatement, Connection connection) { return selectList(mappedStatement, connection); } /** * selectList()方法 * @param mappedStatement mapper接口 * @param connection 数据库链接 * @param <T> 泛型 * @return 结果 */ public <T> List<T> selectList(MappedStatement mappedStatement, Connection connection) { PreparedStatement preparedStatement = null; ResultSet resultSet = null; try { // 取出SQL语句 String queryString = mappedStatement.getQueryString(); // 取出结果类型 String resultType = mappedStatement.getResultType(); Class<?> clazz = Class.forName(resultType); // 获取PreparedStatement对象并执行 preparedStatement = connection.prepareStatement(queryString); resultSet = preparedStatement.executeQuery(); // 从结果集对象封装结果 List<T> list = new ArrayList<>(); while(resultSet.next()) { //实例化要封装的实体类对象 T obj = (T) clazz.getDeclaredConstructor().newInstance(); // 取出结果集的元信息 ResultSetMetaData resultSetMetaData = resultSet.getMetaData(); // 取出总列数 int columnCount = resultSetMetaData.getColumnCount(); // 遍历总列数给对象赋值 for (int i = 1; i <= columnCount; i++) { String columnName = resultSetMetaData.getColumnName(i); Object columnValue = resultSet.getObject(columnName); PropertyDescriptor descriptor = new PropertyDescriptor(columnName, clazz); Method writeMethod = descriptor.getWriteMethod(); writeMethod.invoke(obj, columnValue); } // 把赋好值的对象加入到集合中 list.add(obj); } return list; } catch (Exception e) { throw new RuntimeException(e); } finally { // 调用release()方法释放资源 release(preparedStatement, resultSet); } } /** * 释放资源 * @param preparedStatement preparedStatement对象 * @param resultSet resultSet对象 */ private void release(PreparedStatement preparedStatement, ResultSet resultSet) { if (resultSet != null) { try { resultSet.close(); } catch (Exception e) { e.printStackTrace(); } } if (preparedStatement != null) { try { preparedStatement.close(); } catch (Exception e) { e.printStackTrace(); } } } }
在 Executor 类中最为核心的就是 selectList() 方法,该方法的实现逻辑在于从 MappedStatement 对象中取出 SQL 语句以及结果集类型,而后根据 SQL 语句信息构建 PreparedStatement 对象并执行返回 ResultSet 对象,而后将 ResultSet 中的数据转换为 MappedStatement 中指定的结果集类型 ResultType 的数据并返回。
至此,一个手写 MyBatis 简单框架就搭建完成了,其搭建过程彻底遵循原生 MyBatis 框架对 SQL 语句的执行流程,现对上述过程作下小结:
为了测试前文中手写的 MyBatis 简单框架,定义以下的测试方法:
// MyBatisTest测试类 public class MybatisTest { private InputStream in; private SqlSession sqlSession; @Before public void init() { // 读取MyBatis的配置文件 in = Resources.getResourcesAsStream("mybatis-config.xml"); // 建立SqlSessionFactory的构建者对象 SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder(); // 使用builder建立SqlSessionFactory对象 SqlSessionFactory factory = builder.build(in); // 使用factory建立sqlSession对象 sqlSession = factory.openSession(); } @Test public void testMyMybatis() { // 使用SqlSession建立Mapper接口的代理对象 StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class); // 使用代理对象执行方法 List<Student> students = studentMapper.findAll(); System.out.println(students); } @After public void close() throws IOException { // 关闭资源 sqlSession.close(); in.close(); } }
首先在配置文件中将 mapper 的配置方式设置为指定 xml 文件,其中 StudentMapper 接口的 xml 文件以下所示:
<?xml version="1.0" encoding="UTF-8"?> <mapper namespace="cn.chiaki.mapper.StudentMapper"> <select id="findAll" resultType="cn.chiaki.entity.Student"> SELECT * FROM student </select> </mapper>
运行测试方法获得的结果以下所示,验证了手写框架的正确性。
此外,咱们修改 mybatis-config.xml 配置文件的 mapper 配置方式为注解配置,同时在 StudentMapper 接口上加入注解,以下所示。
<mappers> <!-- 使用xml配置文件的方式:resource标签 --> <!--<mapper resource="mapper/StudentMapper.xml"/>--> <!-- 使用注解方式:class标签 --> <mapper class="cn.chiaki.mapper.StudentMapper"/> </mappers>
@Select("SELECT * FROM STUDENT") List<Student> findAll();
再次运行测试方法能够获得相同的运行结果,以下图所示。
经过运行测试方法验证了本文手写的 MyBatis 简单框架的正确性。
本文根据原生 MyBatis 框架的运行流程,主要借助 dom4j 以及 jaxen 工具,逐步实现了一个自定义的 MyBatis 简易框架,实现案例中查询全部学生信息的功能。本文的实现过程相对简单,仅仅只是涉及到了 select 类型的 SQL 语句的解析,不涉及其它查询类型,也不涉及到 SQL 语句带参数的状况,同时也没法作到对配置文件中与数据库相关的缓存、事务等相关标签的解析,总而言之只是一个玩具级别的框架。然而,本文实现这样一个简单的自定义 MyBatis 框架的目的是加深对 MyBatis 框架运行流程的理解。所谓万丈高楼平地起,只有先打牢底层基础,才能进一步去实现更高级的功能,读者能够自行尝试。
浅析MyBatis(一):由一个快速案例剖析MyBatis的总体架构与运行流程
dom4j 官方文档:https://dom4j.github.io/
jaxen 代码仓库:https://github.com/jaxen-xpath/jaxen
《互联网轻量级 SSM 框架解密:Spring 、 Spring MVC 、 MyBatis 源码深度剖析》
以为有用的话,就点个推荐吧~ 🔚🔚🔚