如何 DIY 一个 ORM 持久层框架

最近在准备找工作的事,为了强化对 MyBatis 实现原理的理解,我决定 DIY 一个与 MyBatis 类似的 ORM 框架。

1 ORM 框架的意义

1.1 原始 JDBC 操作的缺点分析

  1. 使用原始 JDBC 操作数据库,虽也可以完成各种操作,但是需要做很多重复的步骤。
  2. SQL 语句在代码中硬编码,代码不易维护,实际应用中 SQL 变化的可能较大,SQL 变动需要改变 Java 代码。
  3. 使用 PreparedStatement 向占有位符号传参数存在硬编码,因为 SQL 语句的 WHERE 条件不一定,可能增多也可能变少,修改 SQL 依旧需要修改 Java 代码,不易维护。
  4. 对结果集的解析存在硬编码,SQL 变化也导致解析代码变化,不易维护。

1.2 ORM 概念

ORM (Object Relational Mapping) 即对象关系映射

  • O(对象模型)
    • 实体对象,即我们在程序中根据数据库表结构建立的一个个实体 POJO 对象
  • R(关系型数据库的数据结构)
    • 关系数据库领域的 Relational(即数据库表)
  • M(映射)
    • 从 R(数据库)到 O(对象模型)的映射

ORM 框架是面向对象设计语言与关系型数据库发展不同步时的中间解决方案,它可以完成面向对象编程模型到关系型数据库的映射。采用 ORM 框架后,应用程序不再直接访问底层数据库,而是以面向对象的方式来操作持久化对象,ORM 框架再将这些面向对象的操作转换成底层 SQL 操作。

2 设计思路

ORM 框架本质上是一层适配代码,其核心设计理念是为用户提供一组 Java 接口,然后将用户对 Java 接口的调用转化为对 JDBC 的操作。

首先,让我们回顾一下通过 JDBC 操作数据库的步骤。假设有一个名为 user 的表,然后我们通过指定条件来完成对该表的查询:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 1. 获取数据库连接
Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD);

// 2. 通过连接获取 PreparedStatement
String sql = "select * from user where age = ? and name = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setObject(1, 18);
ps.setObject(2, "xiaoming");

//3. 执行查询操作
ResultSet resultSet = preparedStatement.executeQuery();

//4. 封装结果集
while(resultSet.next()) {
    ...
}

而我们的 ORM 框架的目标是通过一个接口方法来实现上述操作。在实现上,我们参照了 MyBatis。用户可以通过核心配置文件 sqlMapConfig.xml 配置数据库连接,并指定 mapper 配置文件的路径。在 mapper 配置文件中,定义需要使用的 SQL 语句。最终,在用户程序中加载并解析配置文件,通过预定义接口完成查询。我们的 ORM 框架会在接口的默认实现中解析 mapper 配置文件,完成 SQL 操作与结果集的装配:

2.1 核心配置文件设计

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<configuration>
    <!--数据库配置信息-->
    <dataSource>
        <property name="driverClass" value="com.mysql.cj.jdbc.Driver"></property>
        <property name="jdbcUrl" value="jdbc:mysql://ip:port/test_db"></property>
        <property name="username" value="mysql_test_user"></property>
        <property name="password" value="ASFH89f73h24r23hs_ksdf"></property>
    </dataSource>

    <!--存放 mapper.xml 的全路径-->
    <mapper resource="UserMapper.xml"></mapper>
</configuration>
  • 核心配置文件的根标签为 configuration
  • 内部包括两部分内容,分别是数据源的配置信息 dataSource 和映射配置文件路径 mapper

2.2 映射配置文件设计

1
2
3
4
5
6
7
8
9
<mapper namespace="user">
    <select id="findAll" resultType="io.maling.test.pojo.User" >
        select * from user
    </select>

    <select id="findByCondition" resultType="io.maling.test.pojo.User" parameterType="io.maling.test.pojo.User">
        select * from user where age = #{age} and name = #{name}
    </select>
</mapper>
  • 映射配置文件的根标签为 mapper
  • 内部定义用于 crud 的 sql,如果是查询语句则使用 select 标签,如果是新增语句则使用 insert 标签,本文只涉及查询操作;
  • 通过 mapper -> namespaceselect -> id 这两个属性,可以定位到唯一的 SQL,使用方法为 $namespace.$d

2.3 查询 API 设计

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 1. 加载核心配置文件
InputStream resourceAsStream = Resources.getResourceAsStream("sqlMapConfig.xml");
// 2. 通过工厂模式创建 SqlSession
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(resourceAsStream);
SqlSession sqlSession = sqlSessionFactory.openSession();
// 3. 构造查询参数
User userParam = new User();
userParam.setId(1L);
userParam.setName("xiaoming");
// 4. 通过接口完成查询
User user = sqlSession.selectOne("user.findByCondition", userParam);
System.out.println(user);
  • 定义 Resources 类,用于加载配置文件到输入流;
  • 定义 SqlSession 类,里边提供默认的 CRUD 方法,例如 selectOneselectList 等;
  • 利用工厂模式和构建者模式,创建 SqlSession 类。

下面我们着手实现这个 ORM 框架。

3 基础代码实现

3.1 引入 maven 依赖

首先是引入依赖,我们的 ORM 框架需要解析 XML 配置文件,这里我们选择 dom4j 库实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<dependency>
    <groupId>dom4j</groupId>
    <artifactId>dom4j</artifactId>
    <version>1.6.1</version>
</dependency>
<dependency>
    <groupId>jaxen</groupId>
    <artifactId>jaxen</artifactId>
    <version>1.2.0</version>
</dependency>

然后为了提升框架性能,还需要引入连接池,这里我们选择 C3P0 连接池:

1
2
3
4
5
<dependency>
    <groupId>c3p0</groupId>
    <artifactId>c3p0</artifactId>
    <version>0.9.1.2</version>
</dependency>

3.2 XML 配置文件的加载与解析实现

3.2.1 核心配置文件加载

根据上文的 查询接口设计,首先我们需要通过 Resources 类将配置文件加载为 InputStream 输入流:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class Resources {
    /**
     * 根据配置文件路径,将资源加载成字节输入楼,并返回
     * @param path 资源路径
     * @return 字节输入流
     */
    public static InputStream getResourceAsStream(String path) {
        // 利用类加载器,直接加载即可
        return Resources.class.getClassLoader()
            .getResourceAsStream(path);
    }
}

这里我们直接通过 ClassLoader 中的 getResourceAsStream() 加载即可,其中 path 需要传入核心配置文件路径。

3.2.2 配置文件解析

首先我们设计一组 POJO 类,用于存储解析后的配置文件。首先是存储核心配置文件的 Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package io.maling.pojo;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

public class Configuration {

    // 保持配置文件中设置的数据源
    private DataSource dataSource;

    /**
     * 当前数据源对应的所有 Sql 信息
     * key: Statement Id
     * value: 封装好的 MappedStatement 对象
     */
    private Map<String, MappedStatement> mappedStatementMap = new HashMap<>();

    // getter & setter ...
}
  • dataSource:用于存储核心配置文件中指定的数据库连接信息;
  • mappedStatementMap:存储映射配置文件中所有的 SQL 条目:
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    package io.maling.pojo;
    
    public class MappedStatement {
    
        // ID 标识
        private String id;
    
        // 参数类型
        private String resultType;
    
        // 返回值类型
        private String parameterType;
    
        // SQL 语句
        private String sql;
    
        // getter & setter ...
    }
    • id:存储每个 SQL 标签的 namespace + id
    • resultType & parameterType:存储 SQL 需要的查询参数类型名称和结果集类型名称;
    • sql:查询被 SQL 标签包裹的原始 SQL 语句。

然后创建 XmlConfigBuilder 类,利用构建者模式解析核心配置文件,将其封装为一个 Configuration 对象:

 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
package io.maling.conf;

import com.mchange.v2.c3p0.ComboPooledDataSource;
import io.maling.io.Resources;
import io.maling.pojo.Configuration;
import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;

import java.beans.PropertyVetoException;
import java.io.InputStream;
import java.util.List;
import java.util.Properties;

public class XmlConfigBuilder {

    private Configuration configuration;
    public XmlConfigBuilder() {
        this.configuration = new Configuration();
    }

    /**
     * 将 xml 解析为 Configuration
     * @param in 输入流
     * @return 配置对象
     */
    public Configuration parseConfig(InputStream in) throws DocumentException, PropertyVetoException {
        // 1. 将核心配置文件输入流转换为 Document
        Document document = new SAXReader().read(in);
        // 2. 获取 根标签
        Element rootElement = document.getRootElement();
        // 3. 利用 XPATH 表达式,获取 property 标签
        List<Element> list = rootElement.selectNodes("//property");
        Properties properties = new Properties();
        for (Element element : list) {
            String name = element.attributeValue("name");
            String value = element.attributeValue("value");
            properties.setProperty(name, value);
        }
        // 4. 根据配置信息,初始化 c3p0 连接池
        ComboPooledDataSource dataSource = new ComboPooledDataSource();
        dataSource.setDriverClass(properties.getProperty("driverClass"));
        dataSource.setJdbcUrl(properties.getProperty("jdbcUrl"));
        dataSource.setUser(properties.getProperty("username"));
        dataSource.setPassword(properties.getProperty("password"));
        dataSource.setInitialPoolSize(3);
        dataSource.setMaxIdleTime(60);
        dataSource.setMaxPoolSize(20);
        dataSource.setMinPoolSize(5);
        configuration.setDataSource(dataSource);

        // mapper.xml 加载
        // 5. 从 mapper 标签中拿到路径
        list = rootElement.selectNodes("//mapper");
        for (Element element : list) {
            String xmlPath = element.attributeValue("resource");
            // 6. 拿到 mapper xml 输入流
            InputStream resourceAsStream = Resources.getResourceAsStream(xmlPath);
            // 7. 解析 mapper xml,最麻烦
            XmlMapperBuilder xmlMapperBuilder = new XmlMapperBuilder(configuration);
            xmlMapperBuilder.parse(resourceAsStream);
        }

        return this.configuration;
    }
}

XmlConfigBuilder 首先在构造方法中创建了一个 Configuration 对象,用于保存系统配置信息。然后在 parseConfig() 方法中,通过 dom4j 的 SAX 解析器将输入流成功转换为 Document 对象。再然后,通过 XPath 表达式提取文档中 property 标签配置的数据库连接信息,并利用这些属性信息成功完成了 C3P0 连接池的初始化。

此外,代码还顺带加载了映射配置文件:首先通过 XPath 表达式遍历 XML 文档中的 mapper 标签,获取 resource 属性表示的 Mapper XML 文件路径。然后,读取该文件的输入流,并使用另外的 XmlMapperBuilder 类进行解析。

解析映射配置文件的 XmlMapperBuilder 实现如下:

 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 io.maling.conf;

import io.maling.pojo.Configuration;
import io.maling.pojo.MappedStatement;
import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;

import java.io.InputStream;
import java.util.List;
import java.util.Map;

public class XmlMapperBuilder {

    private Configuration configuration;

    public XmlMapperBuilder(Configuration configuration) {
        this.configuration = configuration;
    }

    public void parse(InputStream inputStream) throws DocumentException {
        // 1. 将核心配置文件输入流转换为 Document
        Document document = new SAXReader().read(in);
        // 2. 获取 根标签
        Element rootElement = document.getRootElement();
        // 3. 先获取 namespace 属性,用于组成 statement id
        String namespace = rootElement.attributeValue("namespace");
        // 4. 利用 XPATH 表达式,获取 select 标签
        List<Element> selectList = rootElement.selectNodes("//select");
        for (Element element : selectList) {
            // 5. 获取 sql id,参数类型、结果集类型等信息
            String id = element.attributeValue("id");
            String resultType = element.attributeValue("resultType");
            String parameterType = element.attributeValue("parameterType");
            // 6. 拿到 xml 标签包裹的 text,即原始 sql
            String sqlText = element.getTextTrim();
            // 7. 将这些信息封装为一个 MappedStatement 对象
            MappedStatement mappedStatement = new MappedStatement();
            mappedStatement.setId(id);
            mappedStatement.setSql(sqlText);
            mappedStatement.setParameterType(parameterType);
            mappedStatement.setResultType(resultType);
            // 8. 然后放到 mappedStatementMap 容器
            Map<String, MappedStatement> mappedStatementMap = configuration.getMappedStatementMap();
            mappedStatementMap.put(namespace + "." + id, mappedStatement);
        }
        // 9. 利用 XPATH 表达式,获取 insert、delete、update 等标签,这里省略。..
    }
}

XmlMapperBuilder 的实现与 XmlConfigBuilder 类似,同样采用 dom4j 解析映射配置文件中的内容,然后将每个 SQL 语句封装为一个 MappedStatement 对象,并将其存储在 ConfigurationmappedStatementMap 中。由于这两个 Builder 构造时传入的是同一个 Configuration 对象,因此最终,C3P0 连接池的数据源和 SQL 语句都会被添加到这个 Configuration 对象中。

3.3 SqlSession 实现

根据设计,我们需要为用户提供执行 Sql 的接口 —— sqlSession。这里我们参考 MyBatis,采用面向抽象编程的方式,提供 sqlSession 接口以及默认的实现类(方便用户扩展):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package io.maling.sqlSession;

import java.beans.IntrospectionException;
import java.lang.reflect.InvocationTargetException;
import java.sql.SQLException;
import java.util.List;

public interface SqlSession {

    // 查询所有
    <E> List<E> selectList(String statementId, Object... params) throws SQLException, IntrospectionException, NoSuchFieldException, ClassNotFoundException, InvocationTargetException, IllegalAccessException, InstantiationException;

    // 根据条件查询单个
    <T> T selectOne(String statementId, Object... params) throws SQLException, IntrospectionException, NoSuchFieldException, ClassNotFoundException, InvocationTargetException, IllegalAccessException, InstantiationException;

    // 新增、删除、修改等操作,省略。..
}
  • SqlSession 中的每个方法的第一个参数都是 statementId,这个 ID 用于定位映射配置文件中的 SQL 条目,由映射配置文件的 namespace 与 SQL 条目的 ID 一起组成(映射配置文件可能由多个,所以需要引入 namespace)。
 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 io.maling.sqlSession;

import io.maling.pojo.Configuration;

import java.beans.IntrospectionException;
import java.lang.reflect.InvocationTargetException;
import java.sql.SQLException;
import java.util.List;

public class DefaultSqlSession implements SqlSession{

    private Configuration configuration;
    public DefaultSqlSession(Configuration configuration) {
        this.configuration = configuration;
    }

    // 查询多个
    @Override
    public <E> List<E> selectList(String statementId, Object... params) throws SQLException, IntrospectionException, NoSuchFieldException, ClassNotFoundException, InvocationTargetException, IllegalAccessException, InstantiationException {
        Executor executor = new SimpleExecutor();
        List<Object> query = executor.query(configuration,
                configuration.getMappedStatementMap().get(statementId),
                params);
        return (List<E>) query;
    }
    // 查询单个
    @Override
    public <T> T selectOne(String statementId, Object... params) throws SQLException, IntrospectionException, NoSuchFieldException, ClassNotFoundException, InvocationTargetException, IllegalAccessException, InstantiationException {
        List<Object> objects = selectList(statementId, params);
        if (objects.size() == 1) {
            return (T) objects.get(0);
        } else if (objects.isEmpty()){
            throw new RuntimeException("查询结果为空");
        } else {
            throw new RuntimeException("返回结果过多");
        }
    }
}

这里的 DefaultSqlSession 仅展示了两个查询实现(其他的被省略),并且最终都通过 selectList 方法完成。值得注意的是,DefaultSqlSession 并未直接操作 JDBC,而是进一步封装了一个 Executor 层。

3.4 Executor 实现

基于分层设计的思想,我们将对 JDBC 的操作再封装一层执行层,接口如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package io.maling.sqlSession;

import io.maling.pojo.Configuration;
import io.maling.pojo.MappedStatement;

import java.beans.IntrospectionException;
import java.lang.reflect.InvocationTargetException;
import java.sql.SQLException;
import java.util.List;

public interface Executor {
    <E>List<E> query(Configuration configuration, MappedStatement mappedStatement, Object... params) throws SQLException, ClassNotFoundException, NoSuchFieldException, IllegalAccessException, IntrospectionException, InstantiationException, InvocationTargetException;
}

这里依旧只演示查询方法。然后是框架提供的默认执行器实现 SimpleExecutor,这里只给出其 query() 方法实现:

 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
@Override
public <E> List<E> query(Configuration configuration, MappedStatement mappedStatement, Object... params) throws SQLException, ClassNotFoundException, NoSuchFieldException, IllegalAccessException, IntrospectionException, InstantiationException, InvocationTargetException {
    // 1. 从 c3p0 数据源中获取连接
    DataSource dataSource = configuration.getDataSource();
    Connection connection = dataSource.getConnection();
    // 2. 获取 SQL 语句
    String sql = mappedStatement.getSql();
    BoundSql boundSql = getBoundSql(sql);
    // 3. 获取 jdbc PreparedStatement
    PreparedStatement preparedStatement = connection.prepareStatement(boundSql.getSqlText());
    // 4. 设置参数
    String parameterType = mappedStatement.getParameterType();
    Class<?> parameterClass = Class.forName(parameterType);
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    for (int i = 0; i < parameterMappings.size(); i++) {
        ParameterMapping parameterMapping = parameterMappings.get(i);
        String content = parameterMapping.getContent();
        // 反射获取
        Field contentField = parameterClass.getDeclaredField(content);
        contentField.setAccessible(true);
        Object o = contentField.get(params[0]);
        preparedStatement.setObject(i+1, o);
    }
    // 5. 执行 sql
    ResultSet resultSet = preparedStatement.executeQuery();
    // 6. 封装结果集
    String resultType = mappedStatement.getResultType();
    Class<?> resultTypeClass = Class.forName(resultType);
    List<Object> objects = new ArrayList<>();
    while (resultSet.next()) {
        Object instance = resultTypeClass.newInstance();
        ResultSetMetaData metaData = resultSet.getMetaData();
        for (int i = 1; i <= metaData.getColumnCount(); i++) {
            String columnName = metaData.getColumnName(i);
            Object value = resultSet.getObject(columnName);
            PropertyDescriptor propertyDescriptor = new PropertyDescriptor(columnName, resultTypeClass);
            Method writeMethod = propertyDescriptor.getWriteMethod();
            writeMethod.invoke(instance, value);
        }
        objects.add(instance);
    }
    // 返回结果集
    return (List<E>) objects;
}

上述代码中的 query 方法即为 ORM 框架的核心。该方法内部调用了 JDBC 代码:

  1. 首先从 Configuration 中获取连接池中的连接;
  2. 然后从 Configuration 中获取原始 SQL,这里需要将 ORM 格式的 SQL 替换为 JDBC 支持的格式,实现如下:
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    /**
     * 将 #{} 替换为 ?,解析出 #{} 中的值,并存储
     * @param sql sql
     * @return BoundSql
     */
    private BoundSql getBoundSql(String sql) {
        ParameterMappingTokenHandler tokenHandler = new ParameterMappingTokenHandler();
        GenericTokenParser genericTokenParser = new GenericTokenParser("#{", "}", tokenHandler);
        // 将所有 #{xxx} 替换为 ?
        String parseSql = genericTokenParser.parse(sql);
        // 将所有 #{xxx} 中的 xxx 名称,作为参数列表返回
        List<ParameterMapping> parameterMappings = tokenHandler.getParameterMappings();
        return new BoundSql(parseSql, parameterMappings);
    }
    • getBoundSql 内部解析字符串的过程直接利用了 MyBatis 源码中的 ParameterMappingTokenHandlerGenericTokenParser 等工具,详情可访问 Github
    • 原始 SQL 最终会被封装到 BoundSql 中:
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      
      public class BoundSql {
          String sqlText;
          List<ParameterMapping> parameterMappings;
      
          public BoundSql(String sqlText, List<ParameterMapping> parameterMappings) {
              this.sqlText = sqlText;
              this.parameterMappings = parameterMappings;
          }
          // getter & setter ...
      }
      • 其中,sqlTextPreparedStatement 支持的的格式,即 ? 做占位符的 SQL;
      • parameterMappings 中依次存储每个占位符处的参数名称:
        1
        2
        3
        4
        5
        6
        7
        8
        9
        
        package io.maling.utils;
        
        public class ParameterMapping {
            private String content; // 参数名称,例如 name,age
            public ParameterMapping(String content) {
                this.content = content;
            }
            // getter & setter ...
        }
  3. 接着,通过反射利用 BoundSql -> ParameterMapping 获取 param[0] 实例中对应的参数,并将其填入 PreparedStatement
  4. 最后执行 SQL,利用反射 + 内省将结果写入 resultType 类型的对象中。

3.5 SqlSessionFactory 实现

到了这一步,ORM框架的核心基本完成。最后,我们只需通过工厂模式来创建 SqlSession 即可:

1
2
3
4
5
package io.maling.sqlSession;

public interface SqlSessionFactory {
    SqlSession openSession();
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package io.maling.sqlSession;

import io.maling.pojo.Configuration;

public class DefaultSqlSessionFactory implements  SqlSessionFactory{

    private Configuration configuration;
    public DefaultSqlSessionFactory(Configuration configuration) {
        this.configuration = configuration;
    }

    // 直接创建一个 DefaultSqlSession 实现
    @Override
    public SqlSession openSession() {
        return new DefaultSqlSession(configuration);
    }
}

4 代理功能实现

上文我们完成了自定义框架的基础代码实现,但当前的实现避免了 JDBC API 的同时,又引入了一组新的框架 API,


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容