《Mybatis 手擼專欄》第9章:細化XML語句構建器,完善靜態SQL解析

小傅哥發表於2022-05-24

作者:小傅哥
部落格:https://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

你只是在解釋過程,而他是在闡述高度!

如果不是長時間的沉澱、積累和儲備,我一定也沒有辦法用更多的維度和更多的視角來對一個問題進行多方面闡述。就像你我;越過峭壁山川,才知枕蓆還師的通達平坦。領略過雷聲千嶂落,雨色萬峰來,才聞到八表流雲澄夜色,九霄華月動春城的寧靜。

所以引申到程式設計開發,往簡單了說就是寫寫程式碼,改改bug。但如果就侷限在只是寫寫程式碼,其實很難領略到那些眾多設計思想和複雜問題中,庖丁解牛般的酣暢淋漓。而這些酣暢的體驗,都需要你對技術的擴充學習和深度探索,從眾多的優秀原始碼框架中吸收經驗。反覆揣摩、反覆嘗試,終有那麼一個時間點,你會有種悟了的感覺。而這些一個個感覺的積累,就能幫助你以後在面試、述職、答辯、分享、彙報等場景中,說出更有深度的技術思想和類比設計對照,站在更高的角度俯視業務場景的走向和給出長遠的架構方案。

二、目標

實現到本章節前,關於 Mybatis ORM 框架的大部分核心結構已經逐步體現出來了,包括;解析、繫結、對映、事務、執行、資料來源等。但隨著更多功能的逐步完善,我們需要對模組內的實現進行細化處理,而不單單只是完成功能邏輯。這就有點像把 CRUD 使用設計原則進行拆分解耦,滿足程式碼的易維護和可擴充套件性。而這裡我們首先著手要處理的就是關於 XML 解析的問題,把之前粗糙的實現進行細化,滿足我們對解析時一些引數的整合和處理。

圖 9-1 ORM框架XML解析對映關係

  • 這一部分的解析,就是在我們本章節之前的 XMLConfigBuilder#mapperElement 方法中的操作。看上去雖然能實現功能,但總會讓人感覺它不夠規整。就像我們平常開發的 CRUD 羅列到一塊的邏輯一樣,什麼流程都能處理,但什麼流程都會越來越混亂。
  • 就像我們在 ORM 框架 DefaultSqlSession 中呼叫具體執行資料庫操作的方法,需要進行 PreparedStatementHandler#parameterize 引數時,其實並沒有準確的定位到引數的型別,jdbcType和javaType的轉換關係,所以後續的屬性填充就會顯得比較混亂且不易於擴充套件。當然,如果你硬寫也是寫的出來的,不過這種就不是一個好的設計!
  • 所以接下來小傅哥會帶著讀者,把這部分解析的處理,使用設計原則將流程和職責進行解耦,並結合我們的當前訴求,優先處理靜態 SQL 內容。待框架結構逐步完善,再進行一些動態SQL和更多引數型別的處理,滿足讀者以後在閱讀 Mybatis 原始碼,以及需要開發自己的 X-ORM 框架的時候,有一些經驗積累。

三、設計

參照設計原則,對於 XML 資訊的讀取,各個功能模組的流程上應該符合單一職責,而每一個具體的實現又得具備迪米特法則,這樣實現出來的功能才能具有良好的擴充套件性。通常這類程式碼也會看著很乾淨 那麼基於這樣的訴求,我們則需要給解析過程中,所屬解析的不同內容,按照各自的職責類進行拆解和串聯呼叫。整體設計如圖 9-2

圖 9-2 XML 配置構建器解析過程

  • 與之前的解析程式碼相對照,不在是把所有的解析都在一個迴圈中處理,而是在整個解析過程中,引入 XMLMapperBuilder、XMLStatementBuilder 分別處理對映構建器語句構建器,按照不同的職責分別進行解析。
  • 與此同時也在語句構建器中,引入指令碼語言驅動器,預設實現的是 XML語言驅動器 XMLLanguageDriver,這個類來具體操作靜態和動態 SQL 語句節點的解析。這部分的解析處理實現方式很多,即使自己使用正則或者 String 擷取也是可以的。所以為了保持與 Mybatis 的統一,我們直接參照原始碼 Ognl 的方式進行處理。對應的類是 DynamicContext
  • 這裡所有的解析鋪墊,通過解耦的方式實現,都是為了後續在 executor 執行器中,更加方便的處理 setParameters 引數的設定。後面引數的設定,也會涉及到前面我們實現的元物件反射工具類的使用。

四、實現

1. 工程結構

mybatis-step-08
└── src
    ├── main
    │   └── java
    │       └── cn.bugstack.mybatis
    │           ├── binding
    │           ├── builder
    │           │   ├── xml
    │           │   │   ├── XMLConfigBuilder.java
    │           │   │   ├── XMLMapperBuilder.java
    │           │   │   └── XMLStatementBuilder.java
    │           │   ├── BaseBuilder.java
    │           │   ├── ParameterExpression.java
    │           │   ├── SqlSourceBuilder.java
    │           │   └── StaticSqlSource.java
    │           ├── datasource
    │           ├── executor
    │           │   ├── resultset
    │           │   │   ├── DefaultResultSetHandler.java
    │           │   │   └── ResultSetHandler.java
    │           │   ├── statement
    │           │   │   ├── BaseStatementHandler.java
    │           │   │   ├── PreparedStatementHandler.java
    │           │   │   ├── SimpleStatementHandler.java
    │           │   │   └── StatementHandler.java
    │           │   ├── BaseExecutor.java
    │           │   ├── Executor.java
    │           │   └── SimpleExecutor.java
    │           ├── io
    │           ├── mapping
    │           │   ├── BoundSql.java
    │           │   ├── Environment.java
    │           │   ├── MappedStatement.java
    │           │   ├── ParameterMapping.java
    │           │   ├── SqlCommandType.java
    │           │   └── SqlSource.java
    │           ├── parsing
    │           │   ├── GenericTokenParser.java
    │           │   └── TokenHandler.java
    │           ├── reflection
    │           ├── session
    │           │   ├── defaults
    │           │   │   ├── DefaultSqlSession.java
    │           │   │   └── DefaultSqlSessionFactory.java
    │           │   ├── Configuration.java
    │           │   ├── ResultHandler.java
    │           │   ├── SqlSession.java
    │           │   ├── SqlSessionFactory.java
    │           │   ├── SqlSessionFactoryBuilder.java
    │           │   └── TransactionIsolationLevel.java
    │           ├── transaction
    │           └── type
    │               ├── JdbcType.java
    │               ├── TypeAliasRegistry.java
    │               ├── TypeHandler.java
    │               └── TypeHandlerRegistry.java
    └── test
        ├── java
        │   └── cn.bugstack.mybatis.test.dao
        │       ├── dao
        │       │   └── IUserDao.java
        │       ├── po
        │       │   └── User.java
        │       └── ApiTest.java
        └── resources
            ├── mapper
            │   └──User_Mapper.xml
            └── mybatis-config-datasource.xml

工程原始碼https://github.com/fuzhengwei/small-mybatis

XML 語句解析構建器,核心邏輯類關係,如圖 9-3 所示

圖 9-3 XML 語句解析構建器,核心邏輯類關係

  • 解耦原 XMLConfigBuilder 中對 XML 的解析,擴充套件對映構建器、語句構建器,處理 SQL 的提取和引數的包裝,整個核心流圖以 XMLConfigBuilder#mapperElement 為入口進行串聯呼叫。
  • 在 XMLStatementBuilder#parseStatementNode 方法中解析 <select id="queryUserInfoById" parameterType="java.lang.Long" resultType="cn.bugstack.mybatis.test.po.User">...</select> 配置語句,提取引數型別、結果型別,而這裡的語句處理流程稍微較長,因為需要用到指令碼語言驅動器,進行解析處理,建立出 SqlSource 語句資訊。SqlSource 包含了 BoundSql,同時這裡擴充套件了 ParameterMapping 作為引數包裝傳遞類,而不是僅僅作為 Map 結構包裝。因為通過這樣的方式,可以封裝解析後的 javaType/jdbcType 資訊

2. 解耦對映解析

提供單獨的 XML 對映構建器 XMLMapperBuilder 類,把關於 Mapper 內的 SQL 進行解析處理。提供了這個類以後,就可以把這個類的操作放到 XML 配置構建器,XMLConfigBuilder#mapperElement 中進行使用了。具體我們看下如下程式碼。

原始碼詳見cn.bugstack.mybatis.builder.xml.XMLMapperBuilder

public class XMLMapperBuilder extends BaseBuilder {

    /**
     * 解析
     */
    public void parse() throws Exception {
        // 如果當前資源沒有載入過再載入,防止重複載入
        if (!configuration.isResourceLoaded(resource)) {
            configurationElement(element);
            // 標記一下,已經載入過了
            configuration.addLoadedResource(resource);
            // 繫結對映器到namespace
            configuration.addMapper(Resources.classForName(currentNamespace));
        }
    }

    // 配置mapper元素
    // <mapper namespace="org.mybatis.example.BlogMapper">
    //   <select id="selectBlog" parameterType="int" resultType="Blog">
    //    select * from Blog where id = #{id}
    //   </select>
    // </mapper>
    private void configurationElement(Element element) {
        // 1.配置namespace
        currentNamespace = element.attributeValue("namespace");
        if (currentNamespace.equals("")) {
            throw new RuntimeException("Mapper's namespace cannot be empty");
        }

        // 2.配置select|insert|update|delete
        buildStatementFromContext(element.elements("select"));
    }

    // 配置select|insert|update|delete
    private void buildStatementFromContext(List<Element> list) {
        for (Element element : list) {
            final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, element, currentNamespace);
            statementParser.parseStatementNode();
        }
    }

}

在 XMLMapperBuilder#parse 的解析中,主要體現在資源解析判斷、Mapper解析和繫結對映器到;

  • configuration.isResourceLoaded 資源判斷避免重複解析,做了個記錄。
  • configuration.addMapper 繫結對映器主要是把 namespace cn.bugstack.mybatis.test.dao.IUserDao 繫結到 Mapper 上。也就是註冊到對映器序號產生器裡。
  • configurationElement 方法呼叫的 buildStatementFromContext,重在處理 XML 語句構建器,下文中單獨講解。

配置構建器,呼叫對映構建器,原始碼詳見cn.bugstack.mybatis.builder.xml.XMLMapperBuilder

public class XMLConfigBuilder extends BaseBuilder {

    /*
     * <mappers>
     *	 <mapper resource="org/mybatis/builder/AuthorMapper.xml"/>
     *	 <mapper resource="org/mybatis/builder/BlogMapper.xml"/>
     *	 <mapper resource="org/mybatis/builder/PostMapper.xml"/>
     * </mappers>
     */
    private void mapperElement(Element mappers) throws Exception {
        List<Element> mapperList = mappers.elements("mapper");
        for (Element e : mapperList) {
            String resource = e.attributeValue("resource");
            InputStream inputStream = Resources.getResourceAsStream(resource);

            // 在for迴圈裡每個mapper都重新new一個XMLMapperBuilder,來解析
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource);
            mapperParser.parse();
        }
    }

}
  • 在 XMLConfigBuilder#mapperElement 中,把原來流程化的處理進行解耦,呼叫 XMLMapperBuilder#parse 方法進行解析處理。

3. 語句構建器

XMLStatementBuilder 語句構建器主要解析 XML 中 select|insert|update|delete 中的語句,當前我們先以 select 解析為案例,後續再擴充套件其他的解析流程。

原始碼詳見cn.bugstack.mybatis.builder.xml.XMLStatementBuilder

public class XMLStatementBuilder extends BaseBuilder {

    //解析語句(select|insert|update|delete)
    //<select
    //  id="selectPerson"
    //  parameterType="int"
    //  parameterMap="deprecated"
    //  resultType="hashmap"
    //  resultMap="personResultMap"
    //  flushCache="false"
    //  useCache="true"
    //  timeout="10000"
    //  fetchSize="256"
    //  statementType="PREPARED"
    //  resultSetType="FORWARD_ONLY">
    //  SELECT * FROM PERSON WHERE ID = #{id}
    //</select>
    public void parseStatementNode() {
        String id = element.attributeValue("id");
        // 引數型別
        String parameterType = element.attributeValue("parameterType");
        Class<?> parameterTypeClass = resolveAlias(parameterType);
        // 結果型別
        String resultType = element.attributeValue("resultType");
        Class<?> resultTypeClass = resolveAlias(resultType);
        // 獲取命令型別(select|insert|update|delete)
        String nodeName = element.getName();
        SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));

        // 獲取預設語言驅動器
        Class<?> langClass = configuration.getLanguageRegistry().getDefaultDriverClass();
        LanguageDriver langDriver = configuration.getLanguageRegistry().getDriver(langClass);

        SqlSource sqlSource = langDriver.createSqlSource(configuration, element, parameterTypeClass);

        MappedStatement mappedStatement = new MappedStatement.Builder(configuration, currentNamespace + "." + id, sqlCommandType, sqlSource, resultTypeClass).build();

        // 新增解析 SQL
        configuration.addMappedStatement(mappedStatement);
    }

}
  • 整個這部分內容的解析,就是從 XMLConfigBuilder 拆解出來關於 Mapper 語句解析的部分,通過這樣這樣的解耦設計,會讓整個流程更加清晰。
  • XMLStatementBuilder#parseStatementNode 方法是解析 SQL 語句節點的過程,包括了語句的ID、引數型別、結果型別、命令(select|insert|update|delete),以及使用語言驅動器處理和封裝SQL資訊,當解析完成後寫入到 Configuration 配置檔案中的 Map<String, MappedStatement> 對映語句存放中。

4. 指令碼語言驅動

在 XMLStatementBuilder#parseStatementNode 語句構建器的解析中,可以看到這麼一塊,獲取預設語言驅動器並解析SQL的操作。其實這部分就是 XML 腳步語言驅動器所實現的功能,在 XMLScriptBuilder 中處理靜態SQL和動態SQL,不過目前我們只是實現了其中的一部分,待後續這部分框架都完善後在進行擴充套件,避免一次引入過多的程式碼。

4.1 定義介面

原始碼詳見cn.bugstack.mybatis.scripting.LanguageDriver

public interface LanguageDriver {

    SqlSource createSqlSource(Configuration configuration, Element script, Class<?> parameterType);

}
  • 定義指令碼語言驅動介面,提供建立 SQL 資訊的方法,入參包括了配置、元素、引數。其實它的實現類一共有3個;XMLLanguageDriverRawLanguageDriverVelocityLanguageDriver,這裡我們只是實現了預設的第一個即可。

4.2 XML語言驅動器實現

原始碼詳見cn.bugstack.mybatis.scripting.xmltags.XMLLanguageDriver

public class XMLLanguageDriver implements LanguageDriver {

    @Override
    public SqlSource createSqlSource(Configuration configuration, Element script, Class<?> parameterType) {
        XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
        return builder.parseScriptNode();
    }

}
  • 關於 XML 語言驅動器的實現比較簡單,只是封裝了對 XMLScriptBuilder 的呼叫處理。

4.3 XML指令碼構建器解析

原始碼詳見cn.bugstack.mybatis.scripting.xmltags.XMLScriptBuilder

public class XMLScriptBuilder extends BaseBuilder {

    public SqlSource parseScriptNode() {
        List<SqlNode> contents = parseDynamicTags(element);
        MixedSqlNode rootSqlNode = new MixedSqlNode(contents);
        return new RawSqlSource(configuration, rootSqlNode, parameterType);
    }

    List<SqlNode> parseDynamicTags(Element element) {
        List<SqlNode> contents = new ArrayList<>();
        // element.getText 拿到 SQL
        String data = element.getText();
        contents.add(new StaticTextSqlNode(data));
        return contents;
    }

}
  • XMLScriptBuilder#parseScriptNode 解析SQL節點的處理其實沒有太多複雜的內容,主要是對 RawSqlSource 的包裝處理。其他小細節可以閱讀原始碼進行學習

4.4 SQL原始碼構建器

原始碼詳見cn.bugstack.mybatis.builder.SqlSourceBuilder

public class SqlSourceBuilder extends BaseBuilder {

    private static final String parameterProperties = "javaType,jdbcType,mode,numericScale,resultMap,typeHandler,jdbcTypeName";

    public SqlSourceBuilder(Configuration configuration) {
        super(configuration);
    }

    public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
        ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
        GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
        String sql = parser.parse(originalSql);
        // 返回靜態 SQL
        return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
    }

    private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler {
       
        @Override
        public String handleToken(String content) {
            parameterMappings.add(buildParameterMapping(content));
            return "?";
        }

        // 構建引數對映
        private ParameterMapping buildParameterMapping(String content) {
            // 先解析引數對映,就是轉化成一個 HashMap | #{favouriteSection,jdbcType=VARCHAR}
            Map<String, String> propertiesMap = new ParameterExpression(content);
            String property = propertiesMap.get("property");
            Class<?> propertyType = parameterType;
            ParameterMapping.Builder builder = new ParameterMapping.Builder(configuration, property, propertyType);
            return builder.build();
        }

    }
    
}
  • 關於以上文中提到的,關於 BoundSql.parameterMappings 的引數就是來自於 ParameterMappingTokenHandler#buildParameterMapping 方法進行構建處理的。
  • 具體的 javaType、jdbcType 會體現到 ParameterExpression 參數列達式中完成解析操作。這個解析過程直接是 Mybatis 自己的原始碼,整個過程功能較單一,直接對照學習即可

5. DefaultSqlSession 呼叫調整

因為以上整個設計和實現,調整了解析過程,以及細化了 SQL 的建立。那麼在 MappedStatement 對映語句中,則使用 SqlSource 替換了 BoundSql,所以在 DefaultSqlSession 中也會有相應的調整。

原始碼詳見cn.bugstack.mybatis.session.defaults.DefaultSqlSession

public class DefaultSqlSession implements SqlSession {

    private Configuration configuration;
    private Executor executor;

    @Override
    public <T> T selectOne(String statement, Object parameter) {
        MappedStatement ms = configuration.getMappedStatement(statement);
        List<T> list = executor.query(ms, parameter, Executor.NO_RESULT_HANDLER, ms.getSqlSource().getBoundSql(parameter));
        return list.get(0);
    }

}
  • 這裡的使用調整也不大,主要體現在獲取SQL的操作;ms.getSqlSource().getBoundSql(parameter) 這樣獲取後,後面的流程就沒有多少變化了。在我們整個解析框架逐步完善後,就會開始對各個欄位的屬性資訊新增進行設定操作。

五、測試

1. 事先準備

1.1 建立庫表

建立一個資料庫名稱為 mybatis 並在庫中建立表 user 以及新增測試資料,如下:

CREATE TABLE
    USER
    (
        id bigint NOT NULL AUTO_INCREMENT COMMENT '自增ID',
        userId VARCHAR(9) COMMENT '使用者ID',
        userHead VARCHAR(16) COMMENT '使用者頭像',
        createTime TIMESTAMP NULL COMMENT '建立時間',
        updateTime TIMESTAMP NULL COMMENT '更新時間',
        userName VARCHAR(64),
        PRIMARY KEY (id)
    )
    ENGINE=InnoDB DEFAULT CHARSET=utf8;
    
insert into user (id, userId, userHead, createTime, updateTime, userName) values (1, '10001', '1_04', '2022-04-13 00:00:00', '2022-04-13 00:00:00', '小傅哥');    

1.2 配置資料來源

<environments default="development">
    <environment id="development">
        <transactionManager type="JDBC"/>
        <dataSource type="POOLED">
            <property name="driver" value="com.mysql.jdbc.Driver"/>
            <property name="url" value="jdbc:mysql://127.0.0.1:3306/mybatis?useUnicode=true"/>
            <property name="username" value="root"/>
            <property name="password" value="123456"/>
        </dataSource>
    </environment>
</environments>
  • 通過 mybatis-config-datasource.xml 配置資料來源資訊,包括:driver、url、username、password
  • 在這裡 dataSource 可以按需配置成 DRUID、UNPOOLED 和 POOLED 進行測試驗證。

1.3 配置Mapper

<select id="queryUserInfoById" parameterType="java.lang.Long" resultType="cn.bugstack.mybatis.test.po.User">
    SELECT id, userId, userName, userHead
    FROM user
    where id = #{id}
</select>
  • 這部分暫時不需要調整,目前還只是一個入參的型別的引數,後續我們全部完善這部分內容以後,則再提供更多的其他引數進行驗證。

2. 單元測試

@Test
public void test_SqlSessionFactory() throws IOException {
    // 1. 從SqlSessionFactory中獲取SqlSession
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config-datasource.xml"));
    SqlSession sqlSession = sqlSessionFactory.openSession();
   
    // 2. 獲取對映器物件
    IUserDao userDao = sqlSession.getMapper(IUserDao.class);
    
    // 3. 測試驗證
    User user = userDao.queryUserInfoById(1L);
    logger.info("測試結果:{}", JSON.toJSONString(user));
}
  • 這裡的測試不需要調整,因為我們本章節的開發內容,主要以解耦 XML 的解析,只要能保持和之前章節一樣,正常輸出結果就可以。

測試結果

07:26:15.049 [main] INFO  c.b.m.d.pooled.PooledDataSource - Created connection 1138410383.
07:26:15.192 [main] INFO  cn.bugstack.mybatis.test.ApiTest - 測試結果:{"id":1,"userHead":"1_04","userId":"10001","userName":"小傅哥"}
Disconnected from the target VM, address: '127.0.0.1:54797', transport: 'socket'

Process finished with exit code 0
  • 從測試結果和除錯的截圖可以看到,我們的 XML 解析處理拆解後,已經可以順利的支撐我們的使用。

六、總結

  • 本章節我們就像是去把原本 CRUD 的程式碼,通過設計原則進行拆分和解耦,運用不用的類來承擔不同的職責,完整整個功能的實現。這包括;對映構建器、語句構建器、原始碼構建器的綜合使用,以及對應的引用;指令碼語言驅動和指令碼構建器解析,處理我們的 XML 中的 SQL 語句。
  • 通過這樣的重構程式碼,也能讓我們對平常的業務開發中的大片程式導向的流程程式碼有所感悟,當你可以細分拆解職責功能到不同的類中去以後,你的程式碼會更加的清晰並易於維護。
  • 後續我們將繼續按照現在的擴充套件結構底座,完成其他模組的功能邏輯開發,因為了這些基礎內容的建造,再繼續補充功能也會更加容易。當然這些程式碼還是需要你熟悉以後才能駕馭,在學習的過程中可以嘗試斷點除錯,看看每一個步驟都在完成哪些工作。

相關文章