四,手写自己的MyBatis框架

1.MyBatis应用分析与实践
2.MyBatis体系结构与工作原理
3.MyBatis插件原理及Spring集成
4.手写自己的MyBatis框架
本节目标:

1、 实现 1.0 版本,掌握 MyBatis 的本质、核心功能、核心对象、执行流程

2、 通过分析 2.0 版本,体验框架的演进过程,理解 MyBatis

一,需求分析

假如你在一家软件公司的研发部工作,有一天技术总监老王想让你负责开发一个项 目,你要做的第一件事情是什么?

确定需求。

那我们要开发这个项目,需求从哪里来? 我们要跟老王沟通下。

1、项目目标:为什么要做这个项目?做成什么样?

老王说:我发现在业务复杂的项目中,开发的兄弟们用 JDBC 操作数据库太麻烦了, 想要把一些基础的操作做一个封装和提取,让开发的兄弟们更加专注于业务的开发,这样就可以提升开发效率,远离 996。

原来是一个操作数据库的框架。

那么我要问一下老王:这个项目要做什么,才简化我们对数据库的操作呢?或者说, 在业务复杂的项目中使用 JDBC

2、核心功能:这个框架需要解决什么问题?

老王给我看了一段 JDBC 的代码:

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

    rs.close();
    stmt.close();
    conn.close();
    
  2. 它需要把 SQL 语句抽离出来实现集中管理,开发人员不用在业务代码里面写 SQL 语句。

    String sql = "SELECT bid, name, author_id FROM blog where bid = 1";
    ResultSet rs = stmt.executeQuery(sql);
    
  3. 它需要实现对结果集的转换,也就是我们指定了映射规则之后,这个框架会自动 帮我们把 ResultSet 映射成实体类对象。

    Integer bid = rs.getInt("bid");
    String name = rs.getString("name");
    Integer authorId = rs.getInt("author_id");
    blog.setAuthorId(authorId);
    blog.setBid(bid);
    blog.setName(name);
    
  4. 做了这些事以后,这个框架需要提供一个 API 来给我们操作数据库,这里面封装 了对数据库的操作的常用的方法。

3、功能分解:这个框架要怎么解决这些问题?

老王的需求我已经了解了,这个框架应该怎么解决这些问题呢? 我们先来分析一下需要哪些核心对象:

  1. 核心对象

    1. 存放参数和结果映射关系、存放 SQL 语句,我们需要定义一个配置类;
    2. 执行对数据库的操作,处理参数和结果集的映射,创建和释放资源,我们需要定 义一个执行器;
    3. 有了这个执行器以后,我们不能直接调用它,而是定义一个给应用层使用的 API, 它可以根据 SQL 的 id 找到 SQL 语句,交给执行器执行;
    4. 直接使用 id 查找 SQL 语句太麻烦了,我们干脆把存放 SQL 的命名空间定义成一 个接口,把 SQL 的 id 定义成方法,这样只要调用接口方法就可以找到要执行的 SQL。这 个时候我们需要引入一个代理类。

    核心对象有了,接下来我们分析一下这个框架操作数据库的主要流程,先从单条查询入手。

  2. 操作流程(绘图)

MeBatis.png
  1. 定义接口 Mapper 和方法,用来调用数据库操作。 Mapper 接口操作数据库需要通过代理类。
  2. 定义配置类对象 Configuration。
  3. 定义应用层的 API SqlSession。它有一个 getMapper()方法,我们会从配置类 Configuration 里面使用 Proxy.newProxyInatance()拿到一个代理对象 MapperProxy。
  4. 有了代理对象 MapperProxy 之后,我们调用接口的任意方法,就是调用代理对 象的 invoke()方法。
  5. 代理对象 MapperProxy 的 invoke()方法调用了 SqlSession 的 selectOne()。
  6. SqlSession 只是一个 API,还不是真正的 SQL 执行者,所以接下来会调用执行器 Executor 的 query()方法。
  7. 执行器 Executor 的 query()方法里面就是对 JDBC 底层的 Statement 的封装, 最终实现对数据库的操作,和结果的返回。

基于我们总结的这个框架的主要工作流程,接下来我们就要动手去写这个框架了。 我们先给它起个名字叫 MyBatis-Custom

二,V1.0 的实现

创建一个全新的 maven 工程,命名为 mebatis,引入 mysql:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.javacoo</groupId>
    <artifactId>MyBatis-Custom</artifactId>
    <version>1.0-SNAPSHOT</version>
    <properties>
        <maven.test.skip>true</maven.test.skip>
        <maven.test.failure.ignore>true</maven.test.failure.ignore>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.21</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <defaultGoal>package</defaultGoal>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-javadoc-plugin</artifactId>
                <version>2.9.1</version>
            </plugin>
        </plugins>
    </build>
</project>

1、SqlSession

我们已经分析了 MyBatis-Custom的主要对象和操作流程,应该从哪里入手?

当我们在 psvm 操作的时候,第一个需要的对象是 SqlSession。所以我们从应用层的接口 SqlSession 入手。

那么我们先来创建一个 package,它是我们手写的 MyBatis-Custom,我们建一个包。 首先我们创建一个自己的 SqlSession,叫 JCSqlSession。

根据我们刚才总结的流程图,SqlSession 需要有一个获取代理对象的方法,那么这 个代理对象是从哪里获取到的呢?是从我们的配置类里面获取到的,因为配置类里面有接口和它要产生的代理类的对应关系。

所以,我们要先持有一个 Configuration 对象,叫 JCConfiguration,我们也创建这个类。除了获取代理对象之外,Configuration 里面还存储了我们的接口方法(也就是 statementId)和 SQL 语句的绑定关系。

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

public class JCSqlSession {
    private JCConfiguration configuration;
    private JCExecutor executor;
    ...
}

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

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

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

public class JCSqlSession {
    ...
    public <T> T selectOne(String statementId, Object paramater){
            // 根据statementId拿到SQL
            String sql = JCConfiguration.sqlMappings.getString(statementId);
            if(null != sql && !"".equals(sql)){
                return executor.query(sql, paramater );
            }
            return null;
    }
}

JCExecutor.java

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

第二个方法是获取代理对象的方法,我们通过这种方式去避免了 statementId 的硬 编码。

我们在 SqlSession 中创建一个 getMapper()的方法,由于可以返回任意类型的代理类,所以我们把返回值也定义成泛型 T。我们是根据接口类型获取到代理对象的,所以传入参数要用类型 Class。

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

2、Configuration

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

public class JCSqlSession {
    ...
    public <T> T getMapper(Class clazz){
        return (T)configuration.getMapper(clazz);
    }
}

我们先在 Configuration 创建这个方法,返回类型一样是泛型 T,先返回空。

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

3、MapperProxy

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

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

invoke()的实现我们先留着,先返回 null。MapperProxy 已经有了,我们回到 Configuration.getMapper()完成获取代理对象的逻辑。

返回代理对象,直接使用 JDK 的动态代理:第一个参数是类加载器,第二个参数是 被代理类,第三个参数是代理类。 把返回结果强转为(T):

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

获取代理类的逻辑已经实现完了,我们可以在 SqlSession 中通过 getMapper()拿到代理对象了,也就是可以调用 invoke()方法了。

接下来去完成 MapperProxy 的 invoke() 方法。 在 MapperProxy 的 invoke()方法里面又调用了 SqlSession 的 selectOne()方法。 一个问题出现了:在 MapperProxy 里面根本没有 SqlSession 对象?

这两个对象的关系怎么建立起来?MapperProxy 怎么拿到一个 SqlSession 对象? 很简单,我们可通过构造函数传入它。

先定义一个属性,然后在 MapperProxy 的构造函数里面赋值:

public class JCMapperProxy implements InvocationHandler {
    private JCSqlSession sqlSession;

    public JCMapperProxy(JCSqlSession sqlSession){
        this.sqlSession = sqlSession;
    }
    ...
}

因为修改了代理类的构造函数,这个时候 Configuration 创建代理类的方法 getMapper()也要修改。

问题:Configuration 的 getMapper()方法参数中也没有 SqlSession,没办法传给 MapperProxy 的构造函数。怎么拿到 SqlSession 呢?是直接 new 一个吗?

不需要,可以在 SqlSession 调用它的时候直接把自己传进来:

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

那么 SqlSession 的 getMapper()方法也要修改:

public class JCSqlSession {
    ...
    public <T> T getMapper(Class clazz){
        return configuration.getMapper(clazz, this);
    }
}

现在在 MapperProxy 里面已经就可以拿到 SqlSession 对象了,在 invoke()方法里面我们会调用 SqlSession 的 selectOne()方法。

我们继续来完成 invoke()方法。 selectOne()方法有两个参数, statementId 和 paramater,这两个我们怎么拿到 呢?

statementId 其实就是接口的全路径+方法名,中间加一个英文的点。

paramater 可以从方法参数中拿到,这里我们只传了一个参数,用 args[0]。

它要把 statementId 和参数传给 SqlSession:

public class JCMapperProxy implements InvocationHandler {
    private JCSqlSession sqlSession;

    public JCMapperProxy(JCSqlSession 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]);
    }
}

4、Executor

到了 sqlSession 的 selectOne()方法,这里我们要去调用 Executor 的 query()方法, 这个时候我们必须传入 SQL 语句和参数(根据 statementId 获取)。

问题来了:我们怎么根据 StatementId 找到我们要执行的 SQL 语句呢?他们之间的 绑定关系我们配置在哪里?

为了简便,免去读取文件流和解析 XML 标签的麻烦,我们把我们的 SQL 语句放在 Properties 文件里面。

我们在 resources 目录下创建一个 v1sql.properties 文件。key 就是接口全路径+ 方法名称,SQL 是我们的查询 SQL。

参数这里,因为我们要传入一个整数,所以先用一个%d 的占位符代

com.javacoo.mybatis.v1.mapper.BlogMapper.selectBlogById=select * from blog where bid = %d

这个绑定关系是放在配置类 Configuration 里面的。

为了避免重复解析,我们在 Configuration 创建一个静态属性和静态方法,直接解 析 v1sql.properties 文件里面的所有 KV 键值对:

public class JCConfiguration {
    public static final ResourceBundle sqlMappings;

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

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

这样就可以通过 Configuration 拿到 SQL 了。

如果 SQL 语句拿不到,说明不存在映射关系(或者不是接口中定义的操作数据的方 法,比如 toString()),我们返回空。

public class JCSqlSession {
    ...
    public <T> T selectOne(String statementId, Object paramater){
        // 根据statementId拿到SQL
        String sql = JCConfiguration.sqlMappings.getString(statementId);
        if(null != sql && !"".equals(sql)){
            return executor.query(sql, paramater );
        }
        return null;
    }
}

SQL 语句已经拿到了,接下来就是 Executor 类的 query()方法,Executor 是数据库 操作的真正执行者。它里面应该做什么事情?

我们干脆直接把 JDBC 的代码全部复制过来,职责先不用细分。

参数用传入的参数替换%d 占位符,需要 format 一下。

ResultSet rs = stmt.executeQuery(String.format(sql, paramater));

最后我们把结果强转一下。

return (T)blog

写一个测试类:

public class MyBatisTest {
    public static void main(String[] args) {
        JCSqlSession sqlSession = new JCSqlSession(new JCConfiguration(), new JCExecutor());
        BlogMapper blogMapper = sqlSession.getMapper(BlogMapper.class);
        blogMapper.selectBlogById(1);
    }
}

测试通过,1.0 的版本完成了:

Blog{bid=1, name='MyBatis 源码分析', authorId='1001}

三,1.0 的不足

1.0 的功能完成了,在拿给老王看之前,我抽了根烟思考了一下:

V1.0 的不足

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

V1.0 的优化目标

  • 支持参数预编译;
  • 支持结果集的自动处理(通过反射);
  • 对 Executor 的职责进行细化。

V1.0 的功能增强目标

  • 在方法上使用注解配置 SQL;
  • 查询带缓存功能;
  • 支持自定义插件。

四、V2.0 的实现

1、配置文件

创建了全局配置文件 mybatis.properties,存放 SQL 连接信息、缓存开关、插件地 址、Mapper 接口地址。 全局配置文件在 Configuration 配置类的构造器中解析。

2、参数处理

创建 ParameterHandler,调用 psmt 的 set 方法。propertie 文件中 SQL 语句的%d 占位符改成?。

3、结果集处理

创建 ResultSetHandler,在其中创建 pojo 对象,获取 ResultSet 值,通过反射给 pojo 对象赋值。

实 体 类 的 转 换 关 系 通 过 @Entity 注 解 ( 保 存 在 MapperRegistry 中 ) , 从 MapperProxyFactory(构造函数)——MapperProxy 一路传递到 ResultSetHandler 中。

4、语句执行处理

创建 StatementHandler,在 Executor 中调用。封装获取连接的方法。

执行查询前调用 ParameterHandler,执行查询后调用 ResultSetHandler

5、支持注解配置 SQL

定义了一个@Select 注解,加在方法上。

在 Configuration 构 造 函 数 中 的 parsingClass() 中解析, 保存在mappedStatements 中(一个 HashMap)。

注意:在 properties 中和注解上同时配置 SQL 语句,注解会覆盖 properties。 properties 中对表达三个对象的映射关系并不适合,所以暂时用--分隔。注意类型 前面不能有空格。

6、支持查询缓存

定 义 了 一 个 CachingExecutor , 当 全 局 配 置 中 的 cacheEnabled=true 时 , Configuration 的 newExecutor()方法会对 SimpleExecutor 进行装饰,返回被装饰过的 Executor。CachingExecutor 中用 HashMap 维护缓存。 在 DefaultSqlSession 调用 Executor 时,会先走到装饰器 CachingExecutor。 定义了一个 CacheKey 用于计算缓存 Key,主要根据 SQL 语句和参数计算。

7、支持插件

定义了一个@Intercepts 注解,目前还只能拦截 Executor 的方法,所以属性只要配置方法名称。

定义 Interceptor 接口,是所有自定义插件必须实现的接口。

定义 InterceptorChain 容器,用来存放解析过的拦截器。在 Configuration 中创建 Executor 的时候,会调用它的 pluginAll()方法,对 Executor 循环代理。

定义 Invocation 包装类,用于在执行完自定义插件逻辑后调用 Executor 的原方法。

定义 Plugin 代理类,提供了一个 wrap()方法用于产生代理对象。当 Executor 被代 理后,所有的方法都会走到 invoke()方法中,进一步调用自定义插件的 intercept()方法。

完成了这些功能,我觉得应该可以拿给老王看了。

五、V2.0 可优化之处

老王看了2.0 的代码以后,点了一根烟,提了一些建议:

1 、在 ResultSetHandler 中 , 类 型 处 理 都 是 写 死 的 , 能 不 能 创 建 一 个 TypeHandler,把这些关系维护起来,处理所有类型的转换关系和自定义类型; 2、只实现了@Select 的注解,插入、删除、修改的注解呢?参数能不能用@Param 传入类型?

3、插件只能拦截 Executor,能不能实现对其他核心对象的方法的拦截?插件可 以支持配置参数么?

4、缓存只有一级,不能在单个方法上关闭(properties 不够用了),能不能实 现多级的缓存?

5、异常处理有点粗暴,都是直接 catch,没有细化;

…… 小哥,接下来拯救世界的任务就交给你了……

工程源码:https://gitee.com/javacoo/my-batis-custom

一些信息
路漫漫其修远兮,吾将上下而求索
码云:https://gitee.com/javacoo
QQ群:164863067
作者/微信:javacoo
邮箱:xihuady@126.com
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341

推荐阅读更多精彩内容