今天写个简单版V1.0版本的mybatis,可以其实就是在jdbc的基础上一步步去优化的,网上各种帖子都是照着源码写,各种抄袭,没有自己的一点想法,写代码前要先思考,如果是你,你该怎么写?怎么去实现,为什么要这样写?而不是照着源码依葫芦画瓢。
在手写mybatis之前,我们先来手写个jdbc,看看jdbc和mybatis有哪些不同,mybatis能解决哪些jdbc不能解决的问题?如果让你写,你应该从哪里开始写呢?
CREATE TABLE `user` (`id` bigint(32) NOT NULL AUTO_INCREMENT,`name` varchar(40) DEFAULT NULL COMMENT '用户名',`age` tinyint(3) DEFAULT NULL COMMENT '年龄',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COMMENT='用户表';
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('1', '张三', '12');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('2', '李四', '33');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('3', '王五', '44');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('4', '陈贺', '88');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('5', '刘磊', '34');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('6', '刘磊', '86');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('7', '22', '22');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('8', '王子睿', '22');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('9', '陈陈陈', '22');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('10', '刘德华', '66');
INSERT INTO `user` (`id`, `name`, `age`) VALUES ('11', '周杰伦', '77');
新建一个web工程,里面只要引入mysql,不需要spring
mysql mysql-connector-java 8.0.18
public static void main(String[] args) {Connection conn = null;PreparedStatement stmt = null;ResultSet resultSet = null;try {//加载驱动Class.forName("com.mysql.cj.jdbc.Driver");// 获取数据库连接conn = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/eom?useUnicode=true&characterEncoding=utf8&autoReconnect=true&serverTimezone=UTC","root","password");//定义SQL语句String sql = "select * from user where name = ?";//获取执行SQL对象stmt = conn.prepareStatement(sql);//参数赋值,注意jdbc的下标 都是从1开始stmt.setString(1,"刘磊");//执行SQL语句stmt.execute();//获取结果resultSet = stmt.getResultSet();//结果集List list = new ArrayList<>();//遍历结果while (resultSet.next()){//封装对象User user = new User();user.setId(resultSet.getInt("id"));user.setName(resultSet.getString("name"));user.setAge(resultSet.getInt("age"));list.add(user);}System.out.println("-------------" + list);}catch (Exception e){}finally {//释放资源if(conn != null){try {conn.close();} catch (SQLException e) {e.printStackTrace();}}if(stmt != null){try {stmt.close();} catch (SQLException e) {e.printStackTrace();}}if(resultSet != null){try {resultSet.close();} catch (SQLException e) {e.printStackTrace();}}}}
执行结果如下,可以获取查询结果
可以看到jdbc的执行过程就是:
jdbc的不足
带着这几个问题,我们来一步一步解决这些问题,看看mybatis是如何解决的
首先我们准备一个接口,里面有一些增删改查方法,而且返回类型有List,有User对象,也有返回单个值的例如String,int这些,基本包含的大部分场景,这个写法就是按照mybatis的方式写的接口,往下看如何一步步实现这些方法
public interface UserMapper {//这里为什么要搞2个name 参数呢?这是因为在处理获取参数,并赋值的时候,需要考虑这种情况public List getUserNameAndAge(String name, Integer age);public List getUserName(String name);public User getUserById(Integer id);public String getUserNameById(Integer id);public int insertUser(String name,Integer age);public int updateUserById(String name,Integer age,Integer id);public int deleteUserById(Integer id);
}
那么有没有想过,在spring中我们是通过注入的方式来实现接口的调用
例如通过注解@Resource或者@Autowired来实现注入,因为这些接口都是依赖于spring,被spring统一管理的,但是我们这里是没有spring环境的,那么应该怎么去调用这些接口呢?
通过jdk的动态代理,来生成一个UserMapper 的代理对象,通过代理对象来执行接口里面的方法
public class MapperProxyFactory {static {//注册驱动器try {Class.forName("com.mysql.cj.jdbc.Driver");} catch (ClassNotFoundException e) {e.printStackTrace();}}public static T getMapper(Class mapper){//JDK动态代理,生成代理对象,也就是usermapper这个对象Object proxyInstance = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{mapper}, new InvocationHandler() {@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {}});return (T) proxyInstance;
}
有了这个代理工厂就可以拿到UserMapper这个代理对象,然后调用他里面的方法了
public class MybatisApplication {public static void main(String[] args) {UserMapper userMapper = MapperProxyFactory.getMapper(UserMapper.class);List list = userMapper.getUserName("刘磊");System.out.println("--------sql查询1返回--------" + list);User user = userMapper.getUserById(2);System.out.println("--------sql查询2返回--------" + user);String name = userMapper.getUserNameById(2);System.out.println("--------sql查询3返回--------" + name);int insertResult = userMapper.insertUser("周杰伦",77);System.out.println("--------sql新增1返回--------" + insertResult);int updateResult = userMapper.updateUserById("陈陈陈",22,9);System.out.println("--------sql修改1返回--------" + updateResult);int deleteResult = userMapper.deleteUserById(18);System.out.println("--------sql删除1返回--------" + deleteResult);}
}
现在去执行这些方法,是无法实现的,因为这个代理对象里面执行方法还是空的,所有也就无法执行这些方法,那现在我们要怎么办呢?我们需要在代理对象中的invoke方法里面去实现我们的具体业务逻辑代码。
在getMapper的invoke方法中来封装一个方法叫做 doInvoke
public static T getMapper(Class mapper){//JDK动态代理,生成代理对象,也就是usermapper这个对象Object proxyInstance = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{mapper}, new InvocationHandler() {@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {return doInvoke(proxy, method, args);}});return (T) proxyInstance;}
我们在doInvoke方法里面来一步一步的实现业务逻辑,首先我们还是安装JDBC的方法来把整个流程先写出来,前面几步跟jdbc一样注册驱动,获取连接,但是这里的sql就不是我们手动写死了,而是需要获取接口上面注解@Select(只实现简单的注解方式,xml配置方式其实逻辑类似,但是过于复杂这里就按注解的方式来实现),以及@Param来实现参数的复制
由于时间关系这里只考虑select注解的方式,其他注解类似
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Select {String value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Param {String value();
}
有了这2个注解,我们就可以在接口方法上加上注解了
public interface UserMapper {//这里为什么要搞2个name 参数呢?这是因为在处理获取参数,并赋值的时候,需要考虑这种情况@Select("SELECT * FROM `user` WHERE name = #{name} and age = #{age}")public List getUserNameAndAge(@Param("name") String name, @Param("age") Integer age);@Select("SELECT * FROM `user` WHERE name = #{name}")public List getUserName(@Param("name") String name);@Select("SELECT * FROM `user` WHERE id = #{id}")public User getUserById(@Param("id") Integer id);@Select("SELECT name from `user` WHERE id = #{id}")public String getUserNameById(@Param("id") Integer id);@Select("INSERT INTO `user` (NAME, age) VALUES (#{name}, #{age})")public int insertUser(@Param("name") String name,@Param("age") Integer age);@Select("UPDATE USER SET `name` = #{name}, age = #{age} WHERE id = #{id}")public int updateUserById(@Param("name") String name,@Param("age") Integer age,@Param("id") Integer id);@Select("DELETE FROM `user` WHERE id = #{id}")public int deleteUserById(@Param("id") Integer id);}
那么怎么拿到方法上的sql呢?
可以通过Method对象来获取注解上的值,注意下面代码都在doInvoke方法中
//通过method参数拿到select注解上的 sqlSelect annotation = method.getAnnotation(Select.class);//sql = select * from user where name = #{name} and age = #{age} and id = #{name}String sql = annotation.value();
此时拿到的sql是这样的
select * from user where name = #{name} and age = #{age} and id = #{name}
很明显,这样的sql是无法执行的,我们需要把#{xxx}这样的变量替换成?,statement才能进行预编译执行sql,也就是变成下面这样的sql
select * from user where name = ? and age = ? and id = ?
那么应该怎么搞?大家想一想,如果是你写,你会怎么入手呢?
我们可以通过method 来获取参数,然后遍历赋值,可以定义一个map,里面存放参数名和参数值
例如:
key=name,value=刘磊
key=age,value=66
key=id,value=刘磊
这里我为什么要搞2个#{name}呢?有没有想过呢?
//用来存放参数名和参数值
Map paramValueMapping = new HashMap<>();
//通过方法拿到入参
Parameter[] parameters = method.getParameters();
//遍历参数
for (int i = 0; i < parameters.length; i++) {Parameter parameter = parameters[i];//注意这里parameter.getName()拿到的 是arg0,arg1,并不是真正的参数名,这个时候就需要@param注解了paramValueMapping.put(parameter.getName(),args[i]);//通过@param注解的方式拿到参数名称,这里拿到的就是name,age,id的真正参数名String paramName = parameter.getAnnotation(Param.class).value();paramValueMapping.put(paramName,args[i]);
}
现在要做的就是如何把#{xxx}替换成?,来我们一步步实现
我们写个参数处理器接口,然后去实现他
TokenHandler 接口
public interface TokenHandler {String handleToken(String content);
}
ParameterMapping对象,用来存放sql中#{xxx}对应的值
public class ParameterMapping {//sql中 #{name} 的这个值private String property;public ParameterMapping(String property) {this.property = property;}public String getProperty() {return property;}public void setProperty(String property) {this.property = property;}
}
ParameterMappingTokenHandler实现类
public class ParameterMappingTokenHandler implements TokenHandler {//存放sql 中#{} 变量的名称private List parameterMappings = new ArrayList();@Overridepublic String handleToken(String content) {//把变量名称存到list中,然后返回?,这样即拿到了参数名称,也替换了?parameterMappings.add(new ParameterMapping(content));return "?";}public List getParameterMappings() {return parameterMappings;}
}
好,那这个替换的逻辑写好了,我们是不是应该先解析这个sql,把这些#{}的内容找出来,这里节约时间之间把mybatis源码中的解析器拿过来之间用,有个叫GenericTokenParser的解析器,这个类里面逻辑并不复杂就是找到符合条件的字符串,然后替换成?
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;}public String parse(String text) {final StringBuilder builder = new StringBuilder();final StringBuilder expression = new StringBuilder();if (text != null && text.length() > 0) {char[] src = text.toCharArray();int offset = 0;// search open tokenint start = text.indexOf(openToken, offset);while (start > -1) {if (start > 0 && src[start - 1] == '\\') {// this open token is escaped. remove the backslash and continue.builder.append(src, offset, start - offset - 1).append(openToken);offset = start + openToken.length();} else {// found open token. let's search close token.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 {//最最关键的地方是这里,通过刚刚我们定义的方法,把符合条件的替换成?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();}
}
最关键的就是这一行代码
//最最关键的地方是这里,通过刚刚我们定义的方法,把符合条件的替换成?
builder.append(handler.handleToken(expression.toString()));
好了,解析器也有了,替换?的逻辑也有了,我们来看下 如何去使用?
//生成解析器-就是把#{} 改成?
ParameterMappingTokenHandler tokenHandler = new ParameterMappingTokenHandler();
GenericTokenParser parse = new GenericTokenParser("#{","}",tokenHandler);
//解析之后的sql=select * from user where name = ? and age = ? and id = ?
String parseSql = parse.parse(sql);
GenericTokenParser这个方法有3个参数,分别是开始,结束,和要替换的处理器,不难理解
此时解析后的parseSql = select * from user where name = ? and age = ? and id = ?,正是我们想要的结果,好了sql解析完成,那么如何去赋值呢?想一想?
我们知道,statement的赋值方式 是setString,或者setInt,或者set其他类型来实现参数赋值的,那么这里我们是不是需要先拿到参数的类型,我才能知道 到底是setString,还是setInt呢?
刚刚上面的那个ParameterMappingTokenHandler里面的那个list其实已经拿到了参数名,我们来把这个list来遍历,这里面需要考虑一个问题,就是怎么获取参数的类型,到底是string还是int或者其他类型
//这里面就是存放的替换?的 那些变量名name,age,name
List parameterMappings = tokenHandler.getParameterMappings();//遍历赋值
for (int i = 0; i < parameterMappings.size(); i++) {String property = parameterMappings.get(i).getProperty();//注意这里赋值的时候,会有个类型的问题,有可能是字符串,也有可能是数字类型,所以需要知道参数的类型,然后才能给参数赋值,不然会报错//stmt.setString(); ???//stmt.setInt(); ???//通过变量名获取变量的类型,刚刚上面的那个map里面存放的就是参数名和参数值,那么通过获取这个参数值,我们就能拿到这个参数值的类型Object value = paramValueMapping.get(property);Class> type = value.getClass();}
此时这个type就是我们具体的参数类型了,那么拿到这个参数类型了,我是不是就可以赋值了?我们可以根据type来判断,if或者switch都可以实现,但是有个问题,java类型那么多,写那么多if else 可读性可扩展性是不是很差,而且也不符合java的设计模式,这个时候,我们可以使用策略模式来处理这里的逻辑
if(type.equals(String.class)){stmt.setString();
}else if(type.equals(Integer.class)){stmt.setInt();
}
首先我们定义个带泛型的类型处理器接口,里面有个赋值(setParameter)的方法,3个参数分别是statement,第几个参数赋值,要赋值的参数类型
还有个方法getResult是一会处理结果集时,找到对应字段的值
public interface TypeHandler {/*** @param statement st* @param i 第几个参数赋值* @param value 参数值* @return void* @author WangPan* @date 2022/12/7 15:37*/void setParameter(PreparedStatement statement,int i, T value) throws SQLException;/*** @description 在结果集里面获取对应字段的值* @param resultSet* @param columnName* @return T* @author WangPan* @date 2022/12/7 17:45*/T getResult(ResultSet resultSet,String columnName) throws SQLException;
}
然后我们需要写string和int 的2个实现类,去实现这2个方法,这里节约时间只考虑string和int 类型,其他类型类似
IntegerTypeHandler 里面的赋值方法就可以写死setInt,泛型也是Integer
public class IntegerTypeHandler implements TypeHandler{/*** @param statement st* @param i 第几个参数赋值* @param value 参数值* @return void* @author WangPan* @date 2022/12/7 15:37*/@Overridepublic void setParameter(PreparedStatement statement, int i, Integer value) throws SQLException {statement.setInt(i,value);}/*** @param resultSet* @param columnName* @return T* @description 在结果集里面获取对应字段的值* @author WangPan* @date 2022/12/7 17:45*/@Overridepublic Integer getResult(ResultSet resultSet, String columnName) throws SQLException {return resultSet.getInt(columnName);}
}
StringTypeHandler 里面的赋值方法就可以写死setString,泛型也是String
public class StringTypeHandler implements TypeHandler{/*** @param statement st* @param i 第几个参数赋值* @param value 参数值* @return void* @author WangPan* @date 2022/12/7 15:37*/@Overridepublic void setParameter(PreparedStatement statement, int i, String value) throws SQLException {statement.setString(i,value);}/*** @param resultSet* @param columnName* @return T* @description 在结果集里面获取对应字段的值* @author WangPan* @date 2022/12/7 17:45*/@Overridepublic String getResult(ResultSet resultSet, String columnName) throws SQLException {return resultSet.getString(columnName);}
}
好了,然后我们把这个放在static静态块里面去初始化,一会就可以直接使用typeHandlerMap来赋值
//类型处理器,sql给变量赋值的时候,到底是setString,还是setInt,还是别的类型private static Map typeHandlerMap = new HashMap<>();static {typeHandlerMap.put(String.class, new StringTypeHandler());typeHandlerMap.put(Integer.class,new IntegerTypeHandler());}
接下来我们看看如何使用这个,接着上面的for循环写
for (int i = 0; i < parameterMappings.size(); i++) {String property = parameterMappings.get(i).getProperty();//通过变量名获取变量的类型Object value = paramValueMapping.get(property);Class> type = value.getClass();//利用类型处理器,根据字段类型来给变量赋值//通过type类型来获取对应的处理器,如果是string就执行StringTypeHandler这个处理器里面的赋值方法//如果type=Integer,就执行IntegerTypeHandler 这个处理器里面的赋值方法typeHandlerMap.get(type).setParameter(stmt,i+1,value);
}
注意这里jdbc的赋值要从1开始
这样写是不是要优雅的多,逼格一下子就上来了了,不必if else 一目了然,可读性更高吗?
这里有来了个问题,有的结果是返回List,有的是返回String,有的是返回User对象,有的是返回int,那么应该怎么办呢?
首先我们要明白一点的就是只有select查询语句是有结果集的,insert update delete是没有结果集
那么如果是insert update delete就简单了,只需要返回更新的条数就行
那么如果是select怎么办?
我们要考虑这下面3种情况
//查询多条数据返回list
List
//查询单条数据返回对象
User
//查询具体某个字段返回string
String
还有一种情况需要考虑,就是如果不是User对象,例如返回Person对象,里面的字段不一样,那我们应该怎么把结果集转换成对应的java类型返回呢?又怎么去调用User对象里面set方法赋值呢?
这里是本次手写mybatis的最最复杂的地方,如果是你,你有没有思路呢?
//执行SQL语句
stmt.execute();//获取结果
ResultSet resultSet = stmt.getResultSet();//先定义一个要返回的对象,不管是User,或者list或者string最后都赋值给Object返回
Object result = null;
//返回的对象如果是list 就先存到list里面然后在放在Object里面返回
List
这段结果集处理的代码比较复杂,我来把这块逻辑梳理一下
还差最后一步,那么到底是返回List,还是User对象,还是Stirng ,还是Integer呢?
//这里不能直接返回list,需要根据方法返回的类型来判断 到底是返回list,还是对象,还是其他类型
if(method.getReturnType().equals(List.class)){//如果是list,就直接返回result = list;
}else if(method.getReturnType().equals(Object.class)){//如果是对象 就取第一个result = list.get(0);
}else{//如果是String 或者Integer或者其他类型if(list.size() > 0){result = list.get(0);}//其他类型,insert update delete不做处理
}
//释放资源
if(conn != null){try {conn.close();} catch (SQLException e) {e.printStackTrace();}
}
if(stmt != null){try {stmt.close();} catch (SQLException e) {e.printStackTrace();}
}
if(resultSet != null){try {resultSet.close();} catch (SQLException e) {e.printStackTrace();}
}
public static Object doInvoke(Object proxy, Method method, Object[] args) throws Throwable{//要返回的对象Object result = null;//返回的对象如果是list 就先存到list里面然后在放在Object里面返回List
增删改查都没问题
mybatis源码中还用到的设计模式