MiniSpring 手写教程:MyBatis SQL语句配置化

引言

在上一节课中,我们基于JDBC Template对数据库操作进行了进一步的拆解,包括数据源DataSource、参数处理ArgumentPreparedStatementSetter以及结果转换RowMapper和RowMapperResultSetExtractor。为了提升性能,我们还引入了简单的数据库连接池。然而,当前的SQL语句仍然是硬编码在程序中。本节课,我们将模仿MyBatis,将SQL语句配置化,使其更加灵活和可维护。

MyBatis简介

MyBatis是一个一流的持久层框架,支持自定义SQL、存储过程和高级映射。它消除了几乎所有的JDBC代码和手动设置参数及检索结果的工作。MyBatis可以使用简单的XML或注解进行配置,并将原始类型、Map接口和Java POJOs映射到数据库记录。

SQL语句配置化的目的

将SQL语句配置化的主要目的是为了:

  1. 解耦SQL语句与Java代码:使SQL语句可以从程序代码中分离出来,便于管理和修改。

  2. 提高代码的可读性和可维护性:通过外部配置文件管理SQL语句,使得代码更加清晰,易于理解和维护。

  3. 便于团队协作:不同的开发人员可以专注于代码和SQL语句的开发,提高团队协作效率。

实现SQL语句配置化的步骤

1. 创建配置文件

首先,我们需要创建一个外部配置文件(通常是XML格式),用于存放SQL语句。这个文件将包含所有与数据库交互的SQL语句。

2. 解析配置文件

接着,我们需要解析这个配置文件,将SQL语句加载到程序中,以便在执行数据库操作时使用。

3. 集成到JDBC Template

最后,我们将配置化的SQL语句集成到JDBC Template中,使得在执行数据库操作时,可以动态地从配置文件中读取SQL语句。

结语

通过将SQL语句配置化,我们可以使数据库操作更加灵活和可维护。这不仅提高了代码的质量,也为后续的数据库操作提供了便利。在接下来的课程中,我们将继续深入探讨MyBatis的其他高级特性,如ORM和缓存等。

1
2
3
4
try (SqlSession session = sqlSessionFactory.openSession()) {
    Blog blog = session.selectOne(
             "org.mybatis.example.BlogMapper.selectBlog", 101);
}

上面代码的大意是先用SqlSessionFactory创建一个SqlSession,然后把要执行的SQL语句的id(org.mybatis.example.BlogMapper.selectBlog)和SQL参数(101),传进session.selectOne()方法,返回查询结果对象值Blog。

凭直觉,这个session应当是包装了数据库连接信息,而这个SQL id应当是指向的某处定义的SQL语句,这样就能大体跟传统的JDBC代码对应上了。

我们就再往下钻研一下。先看这个SqlSessionFactory是怎么来的,一般在应用程序中这么写。

1
2
3
4
String resource = "org/mybatis/example/mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory =
          new SqlSessionFactoryBuilder().build(inputStream);

可以看出,它是通过一个配置文件由一个SqlSessionFactoryBuider工具来生成的,我们看看配置文件的简单示例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
  "https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
  <environments default="development">
    <environment id="development">
      <transactionManager type="JDBC"/>
      <dataSource type="POOLED">
        <property name="driver" value="${driver}"/>
        <property name="url" value="${url}"/>
        <property name="username" value="${username}"/>
        <property name="password" value="${password}"/>
      </dataSource>
    </environment>
  </environments>
  <mappers>
    <mapper resource="org/mybatis/example/BlogMapper.xml"/>
  </mappers>
</configuration>

没有什么神奇的地方,果然就是数据库连接信息还有一些mapper文件的配置。用这些配置信息创建一个Factory,这个Factory就知道该如何访问数据库了,至于具体执行的SQL语句,则是放在mapper文件中的,你可以看一下示例。

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.mybatis.example.BlogMapper">
  <select id="selectBlog" resultType="Blog">
    select * from Blog where id = #{id}
  </select>
</mapper>

我们看这个mapper文件,里面就是包含了SQL语句,给了select语句一个namespace(org.mybatis.example.BlogMapper)以及id(selectBlog),它们拼在一起就是上面程序中写的SQL语句的sqlid(org.mybatis.example.BlogMapper.selectBlog)。我们还要注意这个SQL的参数占位符 #{id} 以及返回结果对象Blog,它们的声明格式是MyBatis自己规定的。

转换成JDBC的语言,这里定义了这个SQL语句是一个select语句,命令是select * from Blog where id = #{id},参数是id,返回结果对象对应的是Blog,这条SQL语句有一个唯一的sqlid来代表。

现在我们几乎能想象出应用程序执行下面这行的时候在做什么了。

1
2
    Blog blog = session.selectOne(
                    "org.mybatis.example.BlogMapper.selectBlog", 101);

实现自定义的mBatis框架的过程可以分为以下几个步骤:

1. 理解MyBatis工作原理

在开始编写自己的mBatis之前,首先要理解MyBatis的工作原理。MyBatis通过使用一个唯一的ID来定位mapper文件中的SQL语句,替换参数,执行SQL,并将数据库记录转换为对象。这个过程与JdbcTemplate的工作方式非常相似。

2. 配置Mapper文件

为了模仿MyBatis,我们将SQL语句存放在外部的配置文件中。首先,在项目的resources目录下创建一个名为mapper的文件夹,然后将所有的SQL语句配置在这个目录下的XML文件中,例如User_Mapper.xml

3. 编写Mapper配置文件

在mapper文件夹中创建XML文件,例如User_Mapper.xml,用于存储SQL语句。这些SQL语句将与Java代码中的接口方法相对应。

4. 替换参数并执行SQL

在Java代码中,通过传递参数到mapper接口的方法中,框架将这些参数替换到对应的SQL语句中,并执行这些SQL语句。

5. 将结果转换为对象

执行SQL后,数据库返回的结果集需要按照一定的规则转换成Java对象,以便在应用程序中使用。 通过以上步骤,我们就能够实现一个简化版的mBatis框架,从而更好地理解MyBatis的内部工作机制。

1
2
3
4
5
6
7
8
	<?xml version="1.0" encoding="UTF-8"?>
	<mapper namespace="com.test.entity.User">
	    <select id="getUserInfo" parameterType="java.lang.Integer" resultType="com.test.entity.User">
	        select id, name,birthday
	        from users
	        where id=?
	    </select>
	</mapper>

这个配置中,也同样有基本的一些元素:SQL类型、SQL的id、参数类型、返回结果类型、SQL语句、条件参数等等。

自然,我们需要在内存中用一个结构来对应上面的配置,存放系统中的SQL语句的定义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.minis.batis;

public class MapperNode {
    String namespace;
    String id;
    String parameterType;
    String resultType;
    String sql;
    String parameter;

	public String getNamespace() {
		return namespace;
	}
	public void setNamespace(String namespace) {
		this.namespace = namespace;
	}
	public String getId() {
		return id;
	}
	public void setId(String id) {
		this.id = id;
	}
	public String getParameterType() {
		return parameterType;
	}
	public void setParameterType(String parameterType) {
		this.parameterType = parameterType;
	}
	public String getResultType() {
		return resultType;
	}
	public void setResultType(String resultType) {
		this.resultType = resultType;
	}
	public String getSql() {
		return sql;
	}
	public void setSql(String sql) {
		this.sql = sql;
	}
	public String getParameter() {
		return parameter;
	}
	public void setParameter(String parameter) {
		this.parameter = parameter;
	}
	public String toString(){
		return this.namespace+"."+this.id+" : " +this.sql;
	}
}

对它们的处理工作,我们仿照MyBatis,用一个SqlSessionFactory来处理,并默认实现一个DefaultSqlSessionFactory来负责。

你可以看一下SqlSessionFactory接口定义。

1
2
3
4
5
6
package com.minis.batis;

public interface SqlSessionFactory {
	SqlSession openSession();
	MapperNode getMapperNode(String name);
}

同时,我们仍然使用IoC来管理,将默认的DefaultSqlSessionFactory配置在IoC容器的applicationContext.xml文件里。

1
2
3
    <bean id="sqlSessionFactory" class="com.minis.batis.DefaultSqlSessionFactory" init-method="init">
        <property type="String" name="mapperLocations" value="mapper"></property>
    </bean>

我们并没有再用一个builder来生成Factory,这是为了简单一点。

这个Bean,也就是这里配置的默认的SqlSessionFactory,它在初始化过程中会扫描这个mapper目录。

1
2
3
	public void init() {
	    scanLocation(this.mapperLocations);
	}

而这个扫描跟以前的Servlet也是一样的,用递归的方式访问每一个文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	private void scanLocation(String location) {
    	String sLocationPath = this.getClass().getClassLoader().getResource("").getPath()+location;
        File dir = new File(sLocationPath);
        for (File file : dir.listFiles()) {
            if(file.isDirectory()){ //递归扫描
            	scanLocation(location+"/"+file.getName());
            }else{ //解析mapper文件
                buildMapperNodes(location+"/"+file.getName());
            }
        }
    }

最后对扫描到的每一个文件,要进行解析处理,把SQL定义写到内部注册表Map里。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
	private Map<String, MapperNode> buildMapperNodes(String filePath) {
        SAXReader saxReader=new SAXReader();
        URL xmlPath=this.getClass().getClassLoader().getResource(filePath);

		Document document = saxReader.read(xmlPath);
		Element rootElement=document.getRootElement();

		String namespace = rootElement.attributeValue("namespace");

        Iterator<Element> nodes = rootElement.elementIterator();;
        while (nodes.hasNext()) { //对每一个sql语句进行解析
        	Element node = nodes.next();
            String id = node.attributeValue("id");
            String parameterType = node.attributeValue("parameterType");
            String resultType = node.attributeValue("resultType");
            String sql = node.getText();

            MapperNode selectnode = new MapperNode();
            selectnode.setNamespace(namespace);
            selectnode.setId(id);
            selectnode.setParameterType(parameterType);
            selectnode.setResultType(resultType);
            selectnode.setSql(sql);
            selectnode.setParameter("");

            this.mapperNodeMap.put(namespace + "." + id, selectnode);
        }
	    return this.mapperNodeMap;
	}

程序的核心功能是读取配置文件中的节点,并提取这些节点的属性来设置到MapperNode结构中。特别地,完整的id是由namespace和id通过’.‘连接而成的,例如com.test.entity.User.getUserInfo。考虑到未来可能支持多种SQL命令,我们在设计时预留了一个属性来区分这条SQL语句是读操作还是写操作。目前,我们仅处理select类型的SQL语句,update等其他类型将在未来扩展。为了实现这一功能,我们需要参考DefaultSqlSessionFactory的完整代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package com.minis.batis;

import java.io.File;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.sql.DataSource;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;
import com.minis.beans.factory.annotation.Autowired;
import com.minis.jdbc.core.JdbcTemplate;

public class DefaultSqlSessionFactory implements SqlSessionFactory{
	@Autowired
	JdbcTemplate jdbcTemplate;

	String mapperLocations;
	public String getMapperLocations() {
		return mapperLocations;
	}
	public void setMapperLocations(String mapperLocations) {
		this.mapperLocations = mapperLocations;
	}
	Map<String,MapperNode> mapperNodeMap = new HashMap<>();
	public Map<String, MapperNode> getMapperNodeMap() {
		return mapperNodeMap;
	}
	public DefaultSqlSessionFactory() {
	}

	public void init() {
	    scanLocation(this.mapperLocations);
	}
    private void scanLocation(String location) {
    	String sLocationPath = this.getClass().getClassLoader().getResource("").getPath()+location;
        File dir = new File(sLocationPath);
        for (File file : dir.listFiles()) {
            if(file.isDirectory()){
            	scanLocation(location+"/"+file.getName());
            }else{
                buildMapperNodes(location+"/"+file.getName());
            }
        }
    }

	private Map<String, MapperNode> buildMapperNodes(String filePath) {
		System.out.println(filePath);
        SAXReader saxReader=new SAXReader();
        URL xmlPath=this.getClass().getClassLoader().getResource(filePath);
        try {
			Document document = saxReader.read(xmlPath);
			Element rootElement=document.getRootElement();

			String namespace = rootElement.attributeValue("namespace");

	        Iterator<Element> nodes = rootElement.elementIterator();;
	        while (nodes.hasNext()) {
	        	Element node = nodes.next();
	            String id = node.attributeValue("id");
	            String parameterType = node.attributeValue("parameterType");
	            String resultType = node.attributeValue("resultType");
	            String sql = node.getText();

	            MapperNode selectnode = new MapperNode();
	            selectnode.setNamespace(namespace);
	            selectnode.setId(id);
	            selectnode.setParameterType(parameterType);
	            selectnode.setResultType(resultType);
	            selectnode.setSql(sql);
	            selectnode.setParameter("");

	            this.mapperNodeMap.put(namespace + "." + id, selectnode);
	        }
	    } catch (Exception ex) {
	        ex.printStackTrace();
	    }
	    return this.mapperNodeMap;
	}

	public MapperNode getMapperNode(String name) {
		return this.mapperNodeMap.get(name);
	}

	@Override
	public SqlSession openSession() {
		SqlSession newSqlSession = new DefaultSqlSession();
		newSqlSession.setJdbcTemplate(jdbcTemplate);
		newSqlSession.setSqlSessionFactory(this);

		return newSqlSession;
	}
}

使用Sql Session访问数据

有了上面的准备工作,上层的应用程序在使用的时候,就可以通过Aurowired注解直接拿到这个SqlSessionFactory了,然后通过工厂创建一个Sql Session,再执行SQL命令。你可以看一下示例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.test.service;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

import com.minis.batis.SqlSession;
import com.minis.batis.SqlSessionFactory;
import com.minis.beans.factory.annotation.Autowired;
import com.minis.jdbc.core.JdbcTemplate;
import com.minis.jdbc.core.RowMapper;
import com.test.entity.User;

public class UserService {
		@Autowired
		SqlSessionFactory sqlSessionFactory;

		public User getUserInfo(int userid) {
			//final String sql = "select id, name,birthday from users where id=?";
			String sqlid = "com.test.entity.User.getUserInfo";
			SqlSession sqlSession = sqlSessionFactory.openSession();
			return (User)sqlSession.selectOne(sqlid, new Object[]{new Integer(userid)},
					(pstmt)->{
						ResultSet rs = pstmt.executeQuery();
						User rtnUser = null;
						if (rs.next()) {
							rtnUser = new User();
							rtnUser.setId(userid);
							rtnUser.setName(rs.getString("name"));
							rtnUser.setBirthday(new java.util.Date(rs.getDate("birthday").getTime()));
						} else {
						}
						return rtnUser;
					}
			);
		}
	}

在分析代码时,我们发现程序的执行方式与之前直接使用JdbcTemplate时相似,主要区别在于现在是通过sqlSession.selectOne方法来执行SQL查询。 SqlSession对象是由SqlSessionFactory生成的,具体代码如下:SqlSession sqlSession = sqlSessionFactory.openSession();。为了深入了解SqlSession的生成过程,我们可以查看DefaultSqlSessionFactory类中的定义。

1. SqlSession的生成

SqlSession对象是通过调用SqlSessionFactory的openSession方法生成的。这表明SqlSession是与数据库交互的一个会话对象,负责执行SQL语句并管理事务。

2. DefaultSqlSessionFactory类

DefaultSqlSessionFactory类是MyBatis框架中用于生成SqlSession的工厂类。通过这个类,我们可以了解SqlSession是如何被创建和管理的。

3. 代码对比

与直接使用JdbcTemplate相比,通过SqlSession执行SQL的优势在于MyBatis提供了更多的灵活性和功能,例如动态SQL、映射结果到对象等。

4. 总结

通过上述分析,我们可以清晰地看到SqlSession在MyBatis框架中的作用和生成方式,以及它与JdbcTemplate的相似之处和差异。

1
2
3
4
5
6
7
	public SqlSession openSession() {
		SqlSession newSqlSession = new DefaultSqlSession();
		newSqlSession.setJdbcTemplate(jdbcTemplate);
		newSqlSession.setSqlSessionFactory(this);

		return newSqlSession;
	}

由上面代码可见,这个Sql Session也就是对JdbcTemplate进行了一下包装。

定义接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package com.minis.batis;

import com.minis.jdbc.core.JdbcTemplate;
import com.minis.jdbc.core.PreparedStatementCallback;

public interface SqlSession {
	void setJdbcTemplate(JdbcTemplate jdbcTemplate);
	void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory);
	Object selectOne(String sqlid, Object[] args, PreparedStatementCallback pstmtcallback);
}

在设计中,我们选择在调用openSession()方法时临时设置JdbcTemplate,而不是在工厂(Factory)层面进行设置。这样的设计赋予了系统更大的灵活性,允许我们在实际执行每一条SQL语句之前,根据需要动态地更换JdbcTemplate实例。这种基于时序的设计为实现动态数据源提供了可能,在诸如读写分离等场景下显得尤为有用。 此外,我们提供了一个默认的实现类DefaultSqlSession,它遵循上述提到的设计原则,确保了架构上的灵活性与实用性。 请注意:此处所有提及的’换行符’均已替换为’ ‘。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.minis.batis;

import javax.sql.DataSource;
import com.minis.jdbc.core.JdbcTemplate;
import com.minis.jdbc.core.PreparedStatementCallback;

public class DefaultSqlSession implements SqlSession{
	JdbcTemplate jdbcTemplate;
	public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
		this.jdbcTemplate = jdbcTemplate;
	}
	public JdbcTemplate getJdbcTemplate() {
		return this.jdbcTemplate;
	}
	SqlSessionFactory sqlSessionFactory;
	public void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory) {
		this.sqlSessionFactory = sqlSessionFactory;
	}
	public SqlSessionFactory getSqlSessionFactory() {
		return this.sqlSessionFactory;
	}
	@Override
	public Object selectOne(String sqlid, Object[] args, PreparedStatementCallback pstmtcallback) {
		String sql = this.sqlSessionFactory.getMapperNode(sqlid).getSql();
		return jdbcTemplate.query(sql, args, pstmtcallback);
	}

	private void buildParameter(){
	}

	private Object resultSet2Obj() {
		return null;
	}
}

极简MyBatis实现

概述

在本节课中,我们通过模仿MyBatis框架,实现了一个简化版的数据库操作工具。这个工具通过配置化SQL语句,使得应用程序可以通过一个唯一的id来执行对应的SQL语句。我们使用了SqlSessionFactory来解析配置文件,并利用SqlSession来执行SQL操作。最终,所有的数据库操作都通过JdbcTemplate来实现。

实现细节

  • 配置化SQL:我们将SQL语句配置在文件中,并通过id来引用这些语句。

  • SqlSessionFactory:负责解析SQL配置文件,创建SqlSession实例。

  • SqlSession:应用程序通过SqlSession来执行SQL语句,目前只提供了selectOne()方法。

  • JdbcTemplate:所有的数据库操作最终都通过JdbcTemplate来执行。

限制与扩展

当前的极简版本存在一些限制:

  • 仅支持select语句,不支持update等其他类型的SQL语句。

  • SqlSession接口较为单薄,仅提供了selectOne()方法。

  • 缺乏SQL数据集缓存,每次操作都需要重新执行SQL语句。

  • 没有实现读写分离的配置。 为了扩展这个框架,可以考虑以下方向:

  • 增加update语句支持:实现update语句的配置化和执行。

  • 接口扩展:丰富SqlSession接口,提供更多的数据库操作方法。

  • 缓存机制:引入SQL数据集缓存,减少数据库访问次数。

  • 读写分离:配置不同的数据库用于读操作和写操作。

学习重点

在学习构建框架的过程中,我们主要关注的是如何拆解问题,让专门的部件处理专门的事情,以及如何使框架具有更好的扩展性。

课后思考题

  • 扩展到update语句:思考如何在我们的极简MyBatis版本中增加对update语句的支持。

  • 实现读写分离:探讨如何实现在执行select操作时从一个数据库读取数据,而在执行update操作时从另一个数据库写入数据。 欢迎在留言区讨论这些问题,或者将这节课分享给需要的朋友。我们下节课再见!

源代码

完整源代码请访问:https://github.com/YaleGuo/minis

交流讨论

欢迎在留言区与我交流讨论。