文章内容输出来源:拉勾教育Java高薪训练营;
系列文章连接:
MyBatis框架全面解析(四)mybatis插件原理+手写自定义插件delete转update
文章目录
前言
在上一篇博文中,我们主要讲述了mybatis的由来以及对jdbc和dbutils的优化方案,在本篇中,我们将根据这些来实现一个自己的mybatis框架
一、自定义框架的设计
教程中采用自顶向下的思路来进行的,但是对于初次开始设计框架来说,很难全盘考虑周全,故本次换种思路,采用自底向上的思路来设计。
配置文件定义
我们先来考虑框架开发出来之后怎么给开发者使用,首先既然我们要解决sql的硬编码,采用xml配置文件的形式,那么就要规定配置文件的格式,由开发者进行自行配置
第一步,规定数据库连接配置信息的格式
从jdbc的使用经验我们可以得出,最少需要四个属性driverClass,jdbcUrl,user,password。如下:
DataSource.xml
<datasource>
<property name="driverClass" value="com.mysql.jdbc.Driver"></property>
<property name="jdbcUrl" value="jdbc:mysql:///zdy_mybatis"></property>
<property name="user" value="admin"></property>
<property name="password" value="admin"></property>
</datasource>
第二步:规定sql语句的格式
这一部分需要考虑的东西较多,参数类型,返回值类型,sql语句,以及用来定位到这个语句的唯一标识,此处不过多分析,直接采用mybatis设计方式
UserMapper.xml
<mapper namespace="User">
<select id="selectOne" parameterType="com.lagou.pojo.User"
resultType="com.lagou.pojo.User">
select * from user where id = #{id} and username =#{username}
</select>
</mapper>
注意:
为了使层次更加清晰,设计采用每个sqlMapper.xml文件对应一个表的操作,以namespec区分,同时namespace+id可以定位到一个唯一sql语句。
第三步:配置文件的读取
两种思路,一种默认读取固定位置固定名称的xml文件,一种由开发者主动传入文件位置。此处为了方便,我们采用第二种方式,同时为了一次性拿到所有的xml文件,我们规定一个总配置文件,该文件可引入其他xml文件的位置,这样开发者只需要传入总配置文件的位置我们就能完成对所有文件的解析。
最终配置文件为:sqlMapConfig.xml
<configuration>
<datasource>
<property name="driverClass" value="com.mysql.jdbc.Driver"></property>
<property name="jdbcUrl" value="jdbc:mysql:///zdy_mybatis"></property>
<property name="user" value="admin"></property>
<property name="password" value="admin"></property>
</datasource>
<!--引入其他sql配置文件-->
<mapper resource="UserMapper.xml"></mapper>
</configuration>
最终这个文件的地址将由使用者传给我们进行解析
对外接口定义
配置文件已经定义完成,此时我们应该提供给开发者一个执行的api,类似Dbutils的QueryRunner(不熟悉者可翻看上篇博文)。为了方便后续对mybatis的阅读,此处的接口名称我们均保持与mybatis一致,其余类同。
此处仅针对查询做实现。
public interface SqlSession {
/**
* 查询
* @param statementId 用来定位sql语句的唯一标识
* @param params sql语句需要的参数值,因不同sql需要的参数不同,故定义为object数组
* @param T 为了支持不同的表查询,此处定义为泛型
* @return
* @throws Exception
*/
<T> T selectOne(String statementId,Object... params) throws Exception;
}
我们接下来的工作就是需要对SqlSession进行实现。
二、自定义框架的实现
解析配置文件并保存
第一步我们需要将用户传递过来的配置文件进行解析,并保存。
我们构建一个Configuration类,用于存储文件解析的结果
public class Configuration {
/**
* 封装成数据源
*/
private DataSource dataSource;
/**
* 将所有的自定义sql语句封装到map中
* key为sql语句的唯一标识,value为封装了sql语句配置信息的对象
* 为什么定义为map?主要是为了方便存取
*/
private Map<String,MapperStatement> mappers = new HashMap<>();
public DataSource getDataSource() {
return dataSource;
}
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public Map<String, MapperStatement> getMappers() {
return mappers;
}
public void setMappers(Map<String, MapperStatement> mappers) {
this.mappers = mappers;
}
}
public class MapperStatement {
/**
* sql唯一标识
*/
private String id;
/**
* 自定义sql语句
*/
private String sqlText;
/**
* 返回值类型,类的全路径
*/
private String resultType;
/**
* 参数值类型,类的全路径
*/
private String parameterType;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getSqlText() {
return sqlText;
}
public void setSqlText(String sqlText) {
this.sqlText = sqlText;
}
public String getResultType() {
return resultType;
}
public void setResultType(String resultType) {
this.resultType = resultType;
}
public String getParameterType() {
return parameterType;
}
public void setParameterType(String parameterType) {
this.parameterType = parameterType;
}
}
类已经定义完成,开始思考实现步骤:
解析xml工具包:dom4j,使用new SaxReader().read(InputStream in)实现
代码架构:
将xml文件解析成io流的工具类,命名为Resources
解析主配置文件的XmlConfigBuilder
解析sqlMapper文件XmlMapperBuilder
开始实现:
/**
* 解析工具类
*/
public class Resources {
/**
* 这里我们直接用java自带的解析方法
* @param path
* @return
*/
public static InputStream getResourceAsStream(String path) {
return Resources.class.getClassLoader().getResourceAsStream(path);
}
}
/**
* @program: MyBatis
* @description: 解析配置文件
**/
public class XmlConfigBuilder {
/**
* 定义configuration变量,是为了方便以后有复用的需求,若不保存,以后每次都要重新解析
*/
private Configuration configuration;
public XmlConfigBuilder() {
this.configuration = new Configuration();
}
public Configuration parse(InputStream inputStream) throws DocumentException, PropertyVetoException {
Document document = new SAXReader().read(inputStream);
//1.解析数据源配置文件,读取property节点
List<Element> list = document.selectNodes("//property");
//2.将property节点的属性名和属性值封装进map集合中
Map<String,String> propertyMap = new HashMap<>();
list.forEach(element -> propertyMap.put(element.attributeValue("name"),element.attributeValue("value")));
//3.根据配置文件的信息封装c3p0数据源,亦可使用druid等更主流的方案
ComboPooledDataSource dataSource = new ComboPooledDataSource();
dataSource.setDriverClass(propertyMap.get("driverClass"));
dataSource.setJdbcUrl(propertyMap.get("jdbcUrl"));
dataSource.setUser(propertyMap.get("user"));
dataSource.setPassword(propertyMap.get("password"));
configuration.setDataSource(dataSource);
//4.读取mapper文件,并进行解析
XmlMapperBuilder mapperBuilder = new XmlMapperBuilder(configuration);
List<Element> mapperElements = document.selectNodes("//mapper");
for (Element element:mapperElements) {
InputStream mapperStream = Resources.getResourceAsStream(element.attributeValue("resource"));
mapperBuilder.parse(mapperStream);
}
return configuration;
}
}
/**
* @program: MyBatis
* @description: 解析sqlMapper文件
**/
public class XmlMapperBuilder {
/**
* 定义configuration变量,是为了方便以后有复用的需求,若不保存,以后每次都要重新解析
*/
private Configuration configuration;
public XmlMapperBuilder(Configuration configuration) {
this.configuration = configuration;
}
public void parse(InputStream inputStream) throws DocumentException {
Document mapperDocument = new SAXReader().read(inputStream);
String nameSpace = mapperDocument.getRootElement().attributeValue("namespace");
//1.这里只解析select节点的语句
List<Element> sqlElements = mapperDocument.selectNodes("//select");
for (Element sqlElement:sqlElements){
MapperStatement statement = new MapperStatement();
//2.注意id的格式,我们一般采用namespace.id的形式
statement.setId(nameSpace+"."+sqlElement.attributeValue("id"));
statement.setParameterType(sqlElement.attributeValue("parameterType"));
statement.setResultType(sqlElement.attributeValue("resultType"));
statement.setSqlText(sqlElement.getTextTrim());
//3.放进configuration对象中的mappers对象里
configuration.getMappers().put(statement.getId(),statement);
}
}
}
到这里我们就解析完了xml文件,获取到了数据库连接信息,以及sql相关信息,为SqlSession的底层执行做好了数据准备
SqlSession的实现
这里教程的讲解路线是
从SqlSessionFactoryBuild——>DefaultSqlSessionFactory——>DefaultSqlSession
自上向下,对mybatis整体框架有了解或者很熟悉设计模式的也许很容易理解,但是初写框架很难理解一开始这样设计的必要性。故本次我们倒过来进行实现,来看这样的实现有什么好处
DefaultSqlSession
区别于new QueryRunner(DataSource source)这种简易的实现,我们优先定义了一个SqlSession的接口,然后实现它。
这样的好处在于可以支持扩展,如下,mybatis提供了两种实现,而我自己项目使用的mybatis-plus框架也提供了一个实现,mybatis整合spring也有一个实现,这也是面向接口编程的优势。
我们自定义一个默认实现DefaultSqlSession
/**
* @program: MyBatis
* @description: 同数据库进行交互
**/
public class DefaultSqlSession implements SqlSession {
/**
* 同数据库交互,必然需要configuration对象,故构造函数里必须传递该值
*/
private Configuration configuration;
public DefaultSqlSession(Configuration configuration) {
this.configuration = configuration;
}
@Override
public <T> T selectOne(String statementId, Object... params) throws Exception{
//todo 具体实现
return null;
}
}
SqlSessionFactory
DefaultSqlSession已经构建完成,那么为了维持扩展性,我们不希望直接new的方式进行创建,故此处使用工厂设计模式,用工厂类来创建,依然面向接口
/**
* @program: MyBatis
* @description: 用于创建sqlSession对象
**/
public interface SqlSessionFactory {
/**
* 返回一个sqlSession
* @return
*/
SqlSession openSession();
}
DefaultSqlSessionFactory
提供一个默认的工厂类实现。
/**
* @program: MyBatis
* @description: 默认工厂实现类
**/
public class DefaultSqlSessionFactory implements SqlSessionFactory {
private Configuration configuration;
public DefaultSqlSessionFactory(Configuration configuration) {
this.configuration = configuration;
}
public SqlSession openSession() {
return new DefaultSqlSession(configuration);
}
}
此时发现还是无法避免创建一个固定的工厂对象,此时若再使用工厂模式似乎陷入了循环。故此时我们使用builder(构建者)设计模式;
这两种设计模式的区别可自行查阅,以下是个人理解:
两种都可以用于类的创建,builder模式注重过程,常用于构建复杂对象,factory模式注重结果,常用于单一对象的构建
Mybatis的初始化相当复杂,包含了我们上面提到的的xml解析(mybatis的xml定义标签要多很多),不能直接用构造函数实现,故用builder模式以下是SqlSessionFactory的构建过程
而有了工厂类,configuration已经封装好,那么可以很轻松的创建SqlSession对象,故采用工厂模式。
SqlSessionFactoryBuilder
/**
* @program: MyBatis
* @description: 用于创建sqlSessionFactory
**/
public class SqlSessionFactoryBuilder {
public static SqlSessionFactory build(InputStream inputStream) throws DocumentException, PropertyVetoException {
XmlConfigBuilder xmlConfigBuilder = new XmlConfigBuilder();
//调用解析配置文件的类
Configuration configuration = xmlConfigBuilder.parse(inputStream);
return new DefaultSqlSessionFactory(configuration);
}
}
至此整个的框架我们已经实现,提供给用户使用就应该是这种形式:
InputStream resourceAsSteam = Resources.getResourceAsStream("SqlMapConfig.xml");
SqlSessionFactory sqlSessionFactory = SqlSessionFactoryBuilder.build(resourceAsSteam);
SqlSession sqlSession = sqlSessionFactory.openSession();
接下来我们就完成DefaultSqlSession的具体实现
Executor
我们可以在DefaultSqlSession里写实现方法,但为了代码复用以及架构清晰,我们将具体的实现再封装一层,交给具体的执行器来执行,SqlSession用来做对结果集的处理
/**
* @program: MyBatis
* @description: 同mysql数据进行交互
**/
public class DefaultSqlSession implements SqlSession {
/**
* 同数据库交互,必然需要configuration对象,故构造函数里必须传递该值
*/
private Configuration configuration;
public DefaultSqlSession(Configuration configuration) {
this.configuration = configuration;
}
/**
* 这里也可以使用工厂模式创建,本次暂且略过
*/
private Executor executor = new SimpleExecutor();
@Override
public <T> T selectOne(String statementId, Object... params) throws Exception{
List<Object> objects = executor.query(configuration,configuration.getMappers().get(statementId),params);
if (objects.size() == 1){
return (T) objects.get(0);
}else if (objects.size() > 1){
throw new RuntimeException("超出一条记录");
}
return null;
}
}
/**
* @program: MyBatis
* @description: 负责解析、执行、封装sql语句
* @author: HanWenTao
* @create: 2020-08-22 20:17
**/
public interface Executor {
/**
* 仅做一个list的query实现
*/
<E> List<E> query(Configuration configuration, MapperStatement mapperStatement, Object... params)throws Exception;
}
public class SimpleExecutor implements Executor {
@Override
public <E> List<E> query(Configuration configuration, MapperStatement mapperStatement, Object... params)throws Exception {
//1.获取连接对象
Connection connection = configuration.getDataSource().getConnection();
//2.解析sql语句,主要是替换占位符#{}为?,同时将要替换的参数名取出
BoundSql boundSql = getBoundSql(mapperStatement.getSqlText());
//3.生成PreparedStatement对象
PreparedStatement preparedStatement = connection.prepareStatement(boundSql.getSql());
//4.获取要替换的参数名
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
//5.获取sqlMapper定义的入参类型
Class<?> paramTypeClass = getClassType(mapperStatement.getParameterType());
for (int i=0;i<parameterMappings.size();i++){
ParameterMapping parameterMapping = parameterMappings.get(i);
//参数名
String content = parameterMapping.getContent();
//6.通过反射的形式,获取入参对象中对应参数名的属性值,并封装到PreparedStatement对象中
Field declaredField = paramTypeClass.getDeclaredField(content);
declaredField.setAccessible(true);
Object o = declaredField.get(params[0]);
preparedStatement.setObject(i+1,o);
}
//7.获取查询结果集
ResultSet resultSet = preparedStatement.executeQuery();
List<Object> resultList = new ArrayList<>();
//8.获取sqlMapper定义的返回值类型
Class<?> resultTypeClass = Class.forName(mapperStatement.getResultType());
while (resultSet.next()){
Object o = resultTypeClass.newInstance();
ResultSetMetaData metaData = resultSet.getMetaData();
int columnCount = metaData.getColumnCount();
for (int i = 1; i <= columnCount; i++) {
String columnName = metaData.getColumnName(i);
Object value = resultSet.getObject(columnName);
//9.java提供的内省工具包,进行对象的复制
PropertyDescriptor propertyDescriptor = new
PropertyDescriptor(columnName, resultTypeClass);
Method writeMethod = propertyDescriptor.getWriteMethod();
writeMethod.invoke(o,value);
}
resultList.add(o);
}
return (List<E>) resultList;
}
private Class<?> getClassType(String parameterType) throws ClassNotFoundException {
if (parameterType == null){
return null;
}
return Class.forName(parameterType);
}
private BoundSql getBoundSql(String sql){
//对该sqlText进行转换,解析占位符,解析的工具类是直接从mybatis中提取出来的,并未进行自己实现,理论上实现一个简易的也并不困难
ParameterMappingTokenHandler tokenHandler = new ParameterMappingTokenHandler();
GenericTokenParser parser = new GenericTokenParser("#{","}",tokenHandler);
String sqlText = parser.parse(sql);
List<ParameterMapping> parameterMappings = tokenHandler.getParameterMappings();
BoundSql boundSql = new BoundSql();
boundSql.setSql(sqlText);
boundSql.setParameterMappings(parameterMappings);
return boundSql;
}
}
注意:
这里涉及到对用户所写的sql语句的解析,主要是替换占位符,获取参数名。实现类用的mybatis工具类的源码, 有兴趣者可以自己尝试:
public interface TokenHandler {
String handleToken(String content);
}
public class ParameterMappingTokenHandler implements TokenHandler {
private List<ParameterMapping> parameterMappings = new ArrayList<ParameterMapping>();
// context是参数名称 #{id} #{username}
public String handleToken(String content) {
parameterMappings.add(buildParameterMapping(content));
return "?";
}
private ParameterMapping buildParameterMapping(String content) {
ParameterMapping parameterMapping = new ParameterMapping(content);
return parameterMapping;
}
public List<ParameterMapping> getParameterMappings() {
return parameterMappings;
}
public void setParameterMappings(List<ParameterMapping> parameterMappings) {
this.parameterMappings = parameterMappings;
}
}
public class GenericTokenParser {
private final String openToken; //开始标记
private final String closeToken; //结束标记
private final TokenHandler handler; //标记处理器
public GenericTokenParser(String openToken, String closeToken, TokenHandler handler) {
this.openToken = openToken;
this.closeToken = closeToken;
this.handler = handler;
}
/**
* 解析${}和#{}
* @param text
* @return
* 该方法主要实现了配置文件、脚本等片段中占位符的解析、处理工作,并返回最终需要的数据。
* 其中,解析工作由该方法完成,处理工作是由处理器handler的handleToken()方法来实现
*/
public String parse(String text) {
// 验证参数问题,如果是null,就返回空字符串。
if (text == null || text.isEmpty()) {
return "";
}
// 下面继续验证是否包含开始标签,如果不包含,默认不是占位符,直接原样返回即可,否则继续执行。
int start = text.indexOf(openToken, 0);
if (start == -1) {
return text;
}
// 把text转成字符数组src,并且定义默认偏移量offset=0、存储最终需要返回字符串的变量builder,
// text变量中占位符对应的变量名expression。判断start是否大于-1(即text中是否存在openToken),如果存在就执行下面代码
char[] src = text.toCharArray();
int offset = 0;
final StringBuilder builder = new StringBuilder();
StringBuilder expression = null;
while (start > -1) {
// 判断如果开始标记前如果有转义字符,就不作为openToken进行处理,否则继续处理
if (start > 0 && src[start - 1] == '\\') {
builder.append(src, offset, start - offset - 1).append(openToken);
offset = start + openToken.length();
} else {
//重置expression变量,避免空指针或者老数据干扰。
if (expression == null) {
expression = new StringBuilder();
} else {
expression.setLength(0);
}
builder.append(src, offset, start - offset);
offset = start + openToken.length();
int end = text.indexOf(closeToken, offset);
while (end > -1) {存在结束标记时
if (end > offset && src[end - 1] == '\\') {//如果结束标记前面有转义字符时
// this close token is escaped. remove the backslash and continue.
expression.append(src, offset, end - offset - 1).append(closeToken);
offset = end + closeToken.length();
end = text.indexOf(closeToken, offset);
} else {//不存在转义字符,即需要作为参数进行处理
expression.append(src, offset, end - offset);
offset = end + closeToken.length();
break;
}
}
if (end == -1) {
// close token was not found.
builder.append(src, start, src.length - start);
offset = src.length;
} else {
//首先根据参数的key(即expression)进行参数处理,返回?作为占位符
builder.append(handler.handleToken(expression.toString()));
offset = end + closeToken.length();
}
}
start = text.indexOf(openToken, offset);
}
if (offset < src.length) {
builder.append(src, offset, src.length - offset);
}
return builder.toString();
}
}
public class ParameterMapping {
private String content;
public ParameterMapping(String content) {
this.content = content;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
public class BoundSql {
private String sql;
private List<ParameterMapping> parameterMappings;
public String getSql() {
return sql;
}
public void setSql(String sql) {
this.sql = sql;
}
public List<ParameterMapping> getParameterMappings() {
return parameterMappings;
}
public void setParameterMappings(List<ParameterMapping> parameterMappings) {
this.parameterMappings = parameterMappings;
}
}
执行测试
public class IPersistenceTest {
@Test
public void test() throws Exception {
InputStream resourceAsSteam = Resources.getResourceAsStream("SqlMapConfig.xml");
SqlSessionFactory sqlSessionFactory = SqlSessionFactoryBuilder.build(resourceAsSteam);
SqlSession sqlSession = sqlSessionFactory.openSession();
//调用
User user = new User();
user.setId(1);
user.setUsername("lucy");
User o = sqlSession.selectOne("User.selectOne", user);
System.out.println(o);
}
}
执行结果:
User{id=1, username='lucy', password='123', birthday='2019-12-12'}
框架结构图:
测试结构图:
至此,自定义的框架已经完成了简易实现
三、自定义框架的优化
虽然目前已经实现了自定义框架,但是再回过头来看,貌似并没有比Dbutils好用多少?虽然sql不硬编码了,但是现在用户需要手动传statementId,使用起来反而复杂了不少,基于这个问题,我们还可以再进行优化
我们希望用户能直接通过接口调用方法的方式来完成指定的操作,例如有个IUserMapper的实现类,调用selectOne方法时,自动找到mapper文件里面selectOne的sql语句并执行。
public interface IUserMapper {
User selectOne(User user);
}
<mapper namespace="User">
<select id="selectOne" parameterType="com.lagou.pojo.User"
resultType="com.lagou.pojo.User">
select * from user where id = #{id} and username =#{username}
</select>
</mapper>
要解决的问题就是我们怎么将方法同mapper里面的sql语句关联起来,我们上面已经确定了一个sql语句是由namespace和id一起确定的,那么我们是否能将这两个属性跟该方法进行映射?
答案是可以的,只要将mapper的namespace映射成类名,id映射成方法名,那么执行方法时,我们就可以轻易的找到对应的sql语句。
修改后如下
<mapper namespace="com.lagou.mapper.IUserMapper">
<select id="selectOne" parameterType="com.lagou.pojo.User"
resultType="com.lagou.pojo.User">
select * from user where id = #{id} and username =#{username}
</select>
</mapper>
然后需要提供一个获取接口是实现类的方法,此时我们采用代理模式
SqlSession添加getMapper方法
public interface SqlSession {
<T> T getMapper(Class<?> mapperClass);
}
实现该方法:
public class DefaultSqlSession implements SqlSession {
/**
* 同数据库交互,必然需要configuration对象,故构造函数里必须传递该值
*/
private Configuration configuration;
public DefaultSqlSession(Configuration configuration) {
this.configuration = configuration;
}
/**
* 这里也可以使用工厂模式创建,本次暂且略过
*/
private Executor executor = new SimpleExecutor();
@Override
public <T> T getMapper(Class<?> mapperClass) {
Object o = Proxy.newProxyInstance(mapperClass.getClassLoader(), new Class[]{mapperClass}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String name = method.getName();
String className = method.getDeclaringClass().getName();
String statementId = className+"."+name;
List<Object> objects = executor.query(configuration,configuration.getMappers().get(statementId),args);
if (objects.size() == 1){
return (T) objects.get(0);
}else if (objects.size() > 1){
throw new RuntimeException("超出一条记录");
}
return null;
}
});
return (T)o;
}
}
注意
这里除了staementId的获取新写的外,其余的都是复用之前的代码,而且并没有针对返回值时集合和单个对象进行区分,仅为了表达设计思想
测试:
public class IPersistenceTest {
@Test
public void test() throws Exception {
InputStream resourceAsSteam = Resources.getResourceAsStream("SqlMapConfig.xml");
SqlSessionFactory sqlSessionFactory = SqlSessionFactoryBuilder.build(resourceAsSteam);
SqlSession sqlSession = sqlSessionFactory.openSession();
//调用
User user = new User();
user.setId(1);
user.setUsername("lucy");
IUserMapper mapper = sqlSession.getMapper(IUserMapper.class);
System.out.println(mapper.selectOne(user));
}
}
执行结果:
User{id=1, username='lucy', password='123', birthday='2019-12-12'}
总结
本文主要手写了一个极为简易的myatis框架,逻辑相对较为简单,但整个的设计思想对于后续理解mybatis整个框架是非常有帮助的。后续将开始介绍mybatis的一些用法以及源码剖析。