通过源码理解手写简单版本MyBatis框架(十)

一、需求分析

1.1项目需求

通过原始的JDBC代码来操作数据库非常的麻烦,里面存在着太多的重复代码和低下的开发效率,针对这种情况需要提供一个更加高效的持久层框架。

1.2 核心功能

首先来看下JDBC操作查询的代码。

public class JdbcTest {

    public static void main(String[] args) {
        new JdbcTest().queryUser();
         new JdbcTest().addUser();
    }

    /**
     *
     * 通过JDBC查询用户信息
     */
    public void queryUser(){
        Connection conn = null;
        Statement stmt = null;
        User user = new User();
        try {
            // 注册 JDBC 驱动
            // Class.forName("com.mysql.cj.jdbc.Driver");
            // 打开连接
            conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8&serverTimezone=UTC", "root", "root");
            // 执行查询
            stmt = conn.createStatement();
            String sql = "SELECT id,user_name,real_name,password,age,d_id from t_user where id = 1";
            ResultSet rs = stmt.executeQuery(sql);
            // 获取结果集
            while (rs.next()) {
                Integer id = rs.getInt("id");
                String userName = rs.getString("user_name");
                String realName = rs.getString("real_name");
                String password = rs.getString("password");
                Integer did = rs.getInt("d_id");
                user.setId(id);
                user.setUserName(userName);
                user.setRealName(realName);
                user.setPassword(password);
                user.setDId(did);

                System.out.println(user);
            }
            rs.close();
            stmt.close();
            conn.close();
        } catch (SQLException se) {
            se.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (stmt != null) stmt.close();
            } catch (SQLException se2) {
            }
            try {
                if (conn != null) conn.close();
            } catch (SQLException se) {
                se.printStackTrace();
            }
        }
    }

    /**
     * 通过JDBC实现添加用户信息的操作
     */
    public void addUser(){
        Connection conn = null;
        Statement stmt = null;
        try {
            // 注册 JDBC 驱动
            // Class.forName("com.mysql.cj.jdbc.Driver");
            // 打开连接
            conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8&serverTimezone=UTC", "root", "root");
            // 执行查询
            stmt = conn.createStatement();
            String sql = "INSERT INTO T_USER(user_name,real_name,password,age,d_id)values('ww','王五','111',22,1001)";
            int i = stmt.executeUpdate(sql);
            System.out.println("影响的行数:" + i);
            stmt.close();
            conn.close();
        } catch (SQLException se) {
            se.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (stmt != null) stmt.close();
            } catch (SQLException se2) {
            }
            try {
                if (conn != null) conn.close();
            } catch (SQLException se) {
                se.printStackTrace();
            }
        }
    }
}

通过上面的代码,可以发现问题还是比较多的。

1.2.1 资源管理

它需要实现对连接资源的自动管理,也就是把创建Connection、创建Statement、关闭Connection、关闭Statement这些操作封装到底层的对象中,不需要在应用层手动调用。

rs.close();
stmt.close();
conn.close();

1.2.2 SQL语句

在代码中我们直接将SQL语句和业务代码写在了一起,耦合性太高了,我们需要把SQL语句抽离出来实现集中管理,开发人员不用在业务代码里面写SQL语句

1.2.3 结果集映射

在上面的代码中我们需要根据字段取出值,然后把值设置到对应对象的属性中,这个操作也是很繁琐的,所以我们也希望框架能够自动帮助我们实现结果集的转换,也就是我们指定了映射规则之后,这个框架会自动帮我们把ResultSet映射成实体类对象。

            while (rs.next()) {
                Integer id = rs.getInt("id");
                String userName = rs.getString("user_name");
                String realName = rs.getString("real_name");
                String password = rs.getString("password");
                Integer did = rs.getInt("d_id");
                user.setId(id);
                user.setUserName(userName);
                user.setRealName(realName);
                user.setPassword(password);
                user.setDId(did);

                System.out.println(user);
            }

1.2.4 对外API

实现了上面的功能以后,这个框架需要提供一个API来给我们操作数据库,这里面定义了对数据库的操作的常用的方法。

1.3 功能分解

项目的需求我们也已经清楚了,那么我们应该要怎么来解决这些问题呢?,我们先来分析下需要哪些核心对象

1.3.1 核心对象

1、存放参数和结果映射关系、存放SQL语句,我们需要定义一个配置类;
2、执行对数据库的操作,处理参数和结果集的映射,创建和释放资源,需要定义一个执行器;
3、有了这个执行器以后,我们不能直接调用它,而是定义一个给应用层使用的API,它可以根据SQL的id找到SQL语句,交给执行器执行;
4、如果由用户直接使用id查找SQL语句太麻烦了,干脆把存放SQL的命名空间定义成一个接口,把SQL的id定义成方法,这样只要调用接口方法就可以找到要执行的SQL。刚好动态代理可以实现这个功能。这个时候需要引入一个代理类。
核心对象有了,第二个:分析一下这个框架操作数据库的主要流程,先从单条查询入手。

1.3.2 操作流程

通过源码理解手写简单版本MyBatis框架(十)

1、定义配置类对象Configuration。里面要存放SQL语句,还有查询方法和结果映射的关系。
2、定义应用层的API SqlSession。在SqlSession里面封装增删改查和操作事务的方法(selectOne())。
3、如果直接把Statement ID传给SqlSession去执行SQL,会出现硬编码,所以决定把SQL语句的标识设计成一个接口+方法名(Mapper接口),调用接口的方法就能找到SQL语句。
4、这个需要代理模式实现,所以要创建一个实现了InvocationHandler的触发管理类MapperProxy。代理类在Configuration中通过JDK动态代理创建。
5、有了代理对象之后,调用接口方法,就是调用触发管理器MapperProxy的invoke()方法。
6、代理对象的invoke()方法调用了SqlSession的selectOne()。
7、SqlSession只是一个API,还不是真正的SQL执行者,所以接下来会调用执行器Executor的query()方法。
8、执行器Executor的query()方法里面就是对JDBC底层的Statement的封装,最终实现对数据库的操作,和结果的返回。

二、代码实现

2.1 SqlSession

针对不同用户的请求操作可以通过SqlSession来处理,在SqlSession中可以提供基础的操作API,我定义的名称为GhySqlSession,暂时不需要考虑其他的实现,所以先不用创建接口,直接写类。
通过源码理解手写简单版本MyBatis框架(十)

根据刚才总结的流程图,SqlSession需要有一个获取代理对象的方法,那么这个代理对象是从哪里获取到的呢?是从我们的配置类里面获取到的,因为配置类里面有接口和它要产生的代理类的对应关系。
所以,要先持有一个Configuration对象,叫GhyConfiguration,我们也创建这个类。除了获取代理对象之外,它里面还存储了接口方法(也就是statementId)和SQL语句的绑定关系。

        在SqlSession中定义的对外的API,最后都会调用Executor去操作数据库,所以还要持有一个Executor对象,叫GhyExecutor,我们也创建它

public class GhySqlSession {
    private GhyConfiguration configuration;
    private GhyExecutor executor;
}

除了这两个属性之外,我们还要定义SqlSession的行为,也就是它的主要的方法。

第一个方法是查询方法,selectOne(),由于它可以返回任意类型(List、Map、对象类型),我们把返回值定义成 T泛型。selectOne()有两个参数,一个是String类型的statementId,我们会根据它找到SQL语句。一个是Object类型的parameter参数(可以是Integer也可以是String等等,任意类型),用来填充SQL里面的占位符。

    /**
     * 对外提供的查询的方法
     * @param <T>
     * @return
     */
    public <T> T selectOne(String statementId,Object parameter){
        String sql=statementId; //先用statementId代替SQL
        
        return executor.query(sql,parameter);
    }

它会调用Executor的query()方法,所以创建Executor类,传入这两个参数,一样返回一个泛型。Executor里面要传入SQL,但是我们还没拿到,先用statementId代替。

/**
 * SQL语句的执行器
 */
public class GhyExecutor {

    public <T> T query(String sql ,Object parameter) {
        return null;
    }
}

SqlSession的第二个方法是获取代理对象的方法,通过这种方式去避免了statementId的硬编码。我们在SqlSession中创建一个getMapper()的方法,由于可以返回任意类型的代理类,所以我们把返回值也定义成泛型 T。我们是根据接口类型获取到代理对象的,所以传入参数要用类型Class。

    //获取代理对象
    public <T> T getMapper(Class clazz){
        return configuration.getMapper(clazz );
    }

 

2.2 Configuration

代理对象我们不是在SqlSession里面获取到的,要进一步调用Configuration的getMapper()方法。返回值需要强转成(T)。

/**
 * 用来保存相关的配置信息
 */

public class GhyConfiguration {

    
    public <T> T getMapper(Class clazz){
        return null;
    }

}

2.3 MapperProxy

我们要在Configuration中通过getMapper()方法拿到这个代理对象,必须要有一个实现了InvocationHandler的代理类(触发管理器)。我们来创建它:GhyMapperProxy。实现invoke()方法。

public class GhyMapperProxy  implements InvocationHandler {
 
    @Override
    public Object invoke(Object proxy,Method method,Object[] args) throws Throwable {
      return null;
    }
}

invoke()的实现我们先留着。MapperProxy已经有了,回到Configuration.getMapper()完成获取代理对象的逻辑。返回代理对象,直接使用JDK的动态代理:第一个参数是类加载器,第二个参数是被代理类实现的接口(这里没有被代理类),第三个参数是H(触发管理器)。把返回结果强转为(T):

    public <T> T getMapper(Class clazz,GhySqlSession sqlSession){
        return (T) Proxy.newProxyInstance(this.getClass().getClassLoader(),
                new Class[]{clazz},
                new GhyMapperProxy());
    }

获取代理类的逻辑已经实现完了,我们可以在SqlSession中通过getMapper()拿到代理对象了,也就是可以调用invoke()方法了。接下来去完成MapperProxy 的invoke()方法。在MapperProxy的invoke()方法里面又调用了SqlSession的selectOne()方法。一个问题出现了:在MapperProxy里面根本没有SqlSession对象?这两个对象的关系怎么建立起来?MapperProxy怎么拿到一个SqlSession对象?很简单,我们可通过构造函数传入它。先定义一个属性,然后在MapperProxy的构造函数里面赋值

public class GhyMapperProxy  implements InvocationHandler {

    private GhySqlSession sqlSession;

    public GhyMapperProxy(GhySqlSession sqlSession) {
        this.sqlSession = sqlSession;
    }

    @Override
    public Object invoke(Object proxy,Method method,Object[] args) throws Throwable {
        return null;
    }
}

因为修改了代理类的构造函数,这个时候Configuration创建代理类的方法getMapper()也要修改。问题:Configuration也没有SqlSession,没办法传入MapperProxy的构造函数。怎么拿到SqlSession呢?是直接new一个吗?不需要,可以在SqlSession调用它的时候直接把自己传进来(修改的地方:MapperProxy的构造函数添加了sqlSession,getMapper()方法也添加了SqlSession):

/**
 * 用来保存相关的配置信息
 */

public class GhyConfiguration {

   
    public <T> T getMapper(Class clazz,GhySqlSession sqlSession){
        return (T) Proxy.newProxyInstance(this.getClass().getClassLoader(),
                new Class[]{clazz},
                new GhyMapperProxy(sqlSession));
    }

}

那么SqlSession的getMapper()方法也要修改(红色是修改的地方):

    //获取代理对象
    public <T> T getMapper(Class clazz){
        return configuration.getMapper(clazz,this);
    }

现在在MapperProxy里面已经就可以拿到SqlSession对象了,在invoke()方法里面会调用SqlSession的selectOne()方法。我们继续来完成invoke()方法。selectOne()方法有两个参数, statementId和paramater,这两个怎么拿到呢?statementId其实就是接口的全路径+方法名,中间加一个英文的点。paramater可以从方法参数中拿到(args[0])。因为我们定义的是String,还要把拿到的Object强转一下。把statementId和parameter传给SqlSession:

public class GhyMapperProxy  implements InvocationHandler {

    private GhySqlSession sqlSession;

    public GhyMapperProxy(GhySqlSession sqlSession) {
        this.sqlSession = sqlSession;
    }

    @Override
    public Object invoke(Object proxy,Method method,Object[] args) throws Throwable {
        String mapperInterface = method.getDeclaringClass().getName();
        String methodName = method.getName();
        String statementId = mapperInterface +"." +methodName;

        return sqlSession.selectOne(statementId,args[0]);
    }
}

到了sqlSession的selectOne()方法,这里要去调用Executor的query方法,这个时候必须传入SQL语句和parameter(根据statementId获取)。怎么根据StatementId找到我们要执行的SQL语句呢?他们之间的绑定关系我们配置在哪里?为了简便,免去读取文件流和解析XML标签的麻烦,我把SQL语句放在Properties文件里面。在resources目录下创建一个sql.properties文件。key就是接口全路径+方法名称,SQL是我们的查询SQL。参数这里,因为我们要传入一个整数,所以先用一个%d的占位符代替:

com.ghy.versionsone.mapper.UserMapper.selectOne=select * from t_user where id = %d

在sqlSession的selectOne()方法里面,我们要根据StatementId获取到SQL,然后传给Executor。这个绑定关系是放在Configuration里面的。怎么快速地解析Properties文件?为了避免重复解析,我们在Configuration创建一个静态属性和静态方法,直接解析sql.properties文件里面的所有KV键值对:

/**
 * 用来保存相关的配置信息
 */

public class GhyConfiguration {
    // 存储属性文件的信息
    public static final ResourceBundle sqlMappings;

    static {
        sqlMappings = ResourceBundle.getBundle("sql");
    }


    public <T> T getMapper(Class clazz,GhySqlSession sqlSession){
        return (T) Proxy.newProxyInstance(this.getClass().getClassLoader(),
                new Class[]{clazz},
                new GhyMapperProxy(sqlSession));
    }

}
/**
* 对外提供的查询的方法
* @param <T>
* @return
*/
public <T> T selectOne(String statementId,Object parameter){
String sql = GhyConfiguration.sqlMappings.getString(statementId);
// String sql=statementId; //先用statementId代替SQL
System.out.println ("sql:"+sql);
if (null !=sql && !"".equals ( sql )){
return executor.query(sql,parameter);
}
return null;
}

在SqlSession中,SQL语句已经拿到了,接下来就是Executor类的query()方法,Executor是数据库操作的真正执行者。干脆直接把以前文章中写的JDBC的代码全部复制过来,职责先不用细分。参数用传入的参数替换%d占位符,需要format一下。

2.4 Executor

在Executor中我们就可以直接来执行SQL的执行了

/**
 * SQL语句的执行器
 */
public class GhyExecutor {

    public <T> T query(String sql,Object parameter){
        Connection conn = null;
        Statement stmt = null;
        User user = new User();

        try {
            // Class.forName("com.mysql.jdbc.Driver");

            // 打开连接
            conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8&serverTimezone=UTC", "root", "root");

            // 执行查询
            stmt = conn.createStatement();
            ResultSet rs = stmt.executeQuery(String.format(sql,parameter));

            // 获取结果集
            while (rs.next()) {
                user.setId(rs.getInt("id"));
                user.setUserName(rs.getString("user_name"));
                user.setPassword(rs.getString("password"));
                user.setRealName(rs.getString("real_name"));
            }
            System.out.println(user);

            rs.close();
            stmt.close();
            conn.close();
        } catch (SQLException se) {
            se.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (stmt != null) {
                    stmt.close();
                }
            } catch (SQLException se2) {
            }
            try {
                if (conn != null) {
                    conn.close();
                }
            } catch (SQLException se) {
                se.printStackTrace();
            }
        }

        return (T) user;
    }
}

到这儿我们就可以来写个测试类来跑下程序了

public class Test {
public static void main(String[] args) {
GhySqlSession sqlSession = new GhySqlSession();
// sqlSession.selectOne("com.ghy.versionsone.entity.User.selectOne",1);
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectOne(1);
System.out.println(user);
}
}

通过源码理解手写简单版本MyBatis框架(十)

三、不足总结

1、在Executor中,对参数、语句和结果集的处理是耦合的,没有实现职责分离;
2、参数:没有实现对语句的预编译,只有简单的格式化,效率不高,还存在SQL注入的风险;
3、语句执行:数据库连接硬编码;
4、结果集:还只能处理Blog类型,没有实现根据实体类自动映射。


git源码:https://gitee.com/TongHuaShuShuoWoDeJieJu/ljx-my-baits.git

上一篇:在idea中创建maven的工程,搭建mybatis框架完成单表的增删改查


下一篇:Mybatis流程