『手寫Mybatis』實現對映器的註冊和使用

BNTang發表於2024-06-16

前言

如何面對複雜系統的設計?

我們可以把 Spring、MyBatis、Dubbo 這樣的大型框架或者一些公司內部的較核心的專案,都可以稱為複雜的系統。

這樣的工程也不在是初學程式設計手裡的玩具專案,沒有所謂的 CRUD,更多時候要面對的都是對系統分層的結構設計和聚合邏輯功能的實現,再透過層層轉換進行實現和呼叫。

這對於很多剛上道的小碼農來說,會感覺非常難受,不知道要從哪下手,但又想著可以一口吃個胖子。

其實這是不現實的,因為這些複雜系統中的框架中有太多的內容你還沒用和了解和熟悉,越是硬搞越難受,信心越受打擊。

其實對於解決這類複雜的專案問題,核心在於要將分支問題點縮小,突出主幹鏈路,具體的手段包括:分治、抽象和知識。

運用設計模式和設計原則等相關知識,把問題空間合理切割為若干子問題,問題越小也就越容易理解和處理。

就像你可以把很多內容做成單個獨立的案例一樣,最終在進行聚合使用。

目標

在上一章節我們初步的瞭解了怎麼給一個介面類生成對應的對映器代理,並在代理中完成一些使用者對介面方法的呼叫處理。

雖然我們已經看到了一個核心邏輯的處理方式,但在使用上還是有些刀耕火種的,包括:需要編碼告知 MapperProxyFactory 要對哪個介面進行代理,以及自己編寫一個假的 SqlSession 處理實際呼叫介面時的返回結果。

那麼結合這兩塊問題點,我們本章節要對對映器的註冊提供序號產生器處理,滿足使用者可以在使用的時候提供一個包的路徑即可完成掃描和註冊。

與此同時需要對 SqlSession 進行規範化處理,讓它可以把我們的對映器代理和方法呼叫進行包裝,建立一個生命週期模型結構,便於後續的內容的新增。

設計

鑑於我們希望把整個工程包下關於資料庫操作的 DAO 介面與 Mapper 對映器關聯起來,那麼就需要包裝一個可以掃描包路徑的完成對映的註冊器類。

當然我們還要把上一章節中簡化的 SqlSession 進行完善,由 SqlSession 定義資料庫處理介面和獲取 Mapper 物件的操作,並把它交給對映器代理類進行使用。這一部分是對上一章節內容的完善。

有了 SqlSession 以後,你可以把它理解成一種功能服務,有了功能服務以後還需要給這個功能服務提供一個工廠,來對外統一提供這類服務。比如我們在 MyBatis 中非常常見的操作,開啟一個 SqlSession。整個設計圖如下:

  • 以包裝介面提供對映器代理類為目標,補全對映器序號產生器 MapperRegistry,自動掃描包下介面並把每個介面類對映的代理類全部存入對映器代理的 HashMap 快取中。
  • 而 SqlSession、SqlSessionFactory 是在此註冊對映器代理的上層使用標準定義和對外服務提供的封裝,便於使用者使用。我們把使用方當成使用者 經過這樣的封裝就可以以更加方便的方式供我們後續在框架上繼續擴充套件功能了,也希望大家可以在學習的過程中對這樣的設計結構有一些思考,它可以幫助你解決一些業務功能開發過程中的領域服務包裝。

實現

工程結構

step-02
├───src
│   ├───main
│   │   ├───java
│   │   │   └───top
│   │   │       └───it6666
│   │   │           └───mybatis
│   │   │               ├───binding
│   │   │               └───session
│   │   │                   └───defaults
│   │   └───resources
│   └───test
│       └───java
│           └───top
│               └───it6666
│                   └───test
│                       └───dao

工程原始碼:https://github.com/BNTang/Java-All/tree/main/mybatis-source-code/step-02

對映器標準定義實現關係,如下圖:

  • MapperRegistry:提供包路徑的掃描和對映器代理類序號產生器服務,完成介面物件的代理類註冊處理。
  • SqlSession、DefaultSqlSession:用於定義執行 SQL 標準、獲取對映器以及將來管理事務等方面的操作。基本我們平常使用 Mybatis 的 API 介面也都是從這個介面類定義的方法進行使用的。
  • SqlSessionFactory:是一個簡單工廠模式,用於提供 SqlSession 服務,遮蔽建立細節,延遲建立過程。

SqlSession 標準定義和實現

在 top.it6666.mybatis.session:編寫 SqlSession 介面,程式碼如下:

/**
 * @author BNTang
 * @version 1.0
 * @description SqlSession 標準定義和實現
 * @since 2024/6/16 星期日
 **/
public interface SqlSession {
    /**
     * Retrieve a single row mapped from the statement key
     * 根據指定的SqlID獲取一條記錄的封裝物件
     *
     * @param <T>       the returned object type 封裝之後的物件型別
     * @param statement sqlID
     * @return Mapped object 封裝之後的物件
     */
    <T> T selectOne(String statement);

    /**
     * Retrieve a single row mapped from the statement key and parameter.
     * 根據指定的SqlID獲取一條記錄的封裝物件,只不過這個方法容許我們可以給sql傳遞一些引數
     * 一般在實際使用中,這個引數傳遞的是pojo,或者Map或者ImmutableMap
     *
     * @param <T>       the returned object type
     * @param statement Unique identifier matching the statement to use.
     * @param parameter A parameter object to pass to the statement.
     * @return Mapped object
     */
    <T> T selectOne(String statement, Object parameter);

    /**
     * Retrieves a mapper.
     * 得到對映器,這個巧妙的使用了泛型,使得型別安全
     *
     * @param <T>  the mapper type
     * @param type Mapper interface class
     * @return a mapper bound to this SqlSession
     */
    <T> T getMapper(Class<T> type);
}
  • 在 SqlSession 中定義用來執行 SQL、獲取對映器物件以及後續管理事務操作的標準介面。
  • 目前這個介面中對於資料庫的操作僅僅只提供了 selectOne,後續還會有相應其他方法的定義。

在 top.it6666.mybatis.session.defaults:編寫 DefaultSqlSession 實現類,程式碼如下:

/**
 * @author BNTang
 * @version 1.0
 * @description SqlSession 的預設實現
 * @since 2024/6/16 星期日
 **/
public class DefaultSqlSession implements SqlSession {
    /**
     * 對映器序號產生器
     */
    private MapperRegistry mapperRegistry;

    public DefaultSqlSession(MapperRegistry mapperRegistry) {
        this.mapperRegistry = mapperRegistry;
    }

    /**
     * Retrieve a single row mapped from the statement key
     * 根據指定的SqlID獲取一條記錄的封裝物件
     *
     * @param statement sqlID
     * @return Mapped object 封裝之後的物件
     */
    @Override
    public <T> T selectOne(String statement) {
        return (T) ("你的操作被代理了!" + statement);
    }

    /**
     * Retrieve a single row mapped from the statement key and parameter.
     * 根據指定的SqlID獲取一條記錄的封裝物件,只不過這個方法容許我們可以給sql傳遞一些引數
     * 一般在實際使用中,這個引數傳遞的是pojo,或者Map或者ImmutableMap
     *
     * @param statement Unique identifier matching the statement to use.
     * @param parameter A parameter object to pass to the statement.
     * @return Mapped object
     */
    @Override
    public <T> T selectOne(String statement, Object parameter) {
        return (T) ("你的操作被代理了!" + "方法:" + statement + " 入參:" + parameter);
    }

    /**
     * Retrieves a mapper.
     * 得到對映器,這個巧妙的使用了泛型,使得型別安全
     *
     * @param type Mapper interface class
     * @return a mapper bound to this SqlSession
     */
    @Override
    public <T> T getMapper(Class<T> type) {
        return mapperRegistry.getMapper(type, this);
    }
}
  • 透過 DefaultSqlSession 實現類對 SqlSession 介面進行實現。
  • getMapper 方法中獲取對映器物件是透過 MapperRegistry 類進行獲取的,後續這部分會被配置類進行替換。
  • 在 selectOne 中是一段簡單的內容返回,目前還沒有與資料庫進行關聯,這部分在我們漸進式的開發過程中逐步實現。

SqlSessionFactory 工廠定義和實現

在 top.it6666.mybatis.session:編寫 SqlSessionFactory 介面,程式碼如下:

/**
 * @author BNTang
 * @version 1.0
 * @description SqlSessionFactory 工廠定義和實現
 * @since 2024/6/16 星期日
 **/
public interface SqlSessionFactory {
    /**
     * 開啟一個 session
     *
     * @return SqlSession
     */
    SqlSession openSession();
}
  • 這其實就是一個簡單工廠的定義,在工廠中提供介面實現類的能力,也就是 SqlSessionFactory 工廠中提供的開啟 SqlSession 的能力。

在 top.it6666.mybatis.session.defaults:編寫 DefaultSqlSessionFactory 實現類,程式碼如下:

/**
 * @author BNTang
 * @version 1.0
 * @description SqlSessionFactory 工廠定義和實現
 * @since 2024/6/16 星期日
 **/
public class DefaultSqlSessionFactory implements SqlSessionFactory {
    private final MapperRegistry mapperRegistry;

    public DefaultSqlSessionFactory(MapperRegistry mapperRegistry) {
        this.mapperRegistry = mapperRegistry;
    }

    /**
     * 開啟一個 session
     *
     * @return SqlSession
     */
    @Override
    public SqlSession openSession() {
        return new DefaultSqlSession(mapperRegistry);
    }
}
  • 預設的簡單工廠實現,處理開啟 SqlSession 時,對 DefaultSqlSession 的建立以及傳遞 mapperRegistry,這樣就可以在使用 SqlSession 時獲取每個代理類的對映器物件了。

對映器序號產生器

在這段程式碼的實現中,需要用到包掃描的功能,所以我引入了 Hutool 工具包,用於掃描包路徑下的所有類,先新增 pom 依賴:

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.5.0</version>
</dependency>

在進行實現之前需要將之前的 MapperProxyFactory newInstance 方法接收引數進行改改之前是 Map<String, String> sqlSession 現在改為 SqlSession sqlSession,這樣就 MapperProxy 中的 SqlSession 也需要進行更改在 MapperProxy 中就可以透過本次傳遞的 SqlSession 物件進行操作了。

然後在 top.it6666.mybatis.binding:MapperRegistry 類中實現包掃描功能,程式碼如下:

/**
 * @author BNTang
 * @version 1.0
 * @description 對映器序號產生器
 * @since 2024/6/16 星期日
 **/
public class MapperRegistry {
    /**
     * 將已新增的對映器代理加入到 HashMap
     */
    private final Map<Class<?>, MapperProxyFactory<?>> knownMappers = new HashMap<>();

    public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
        final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);
        if (mapperProxyFactory == null) {
            throw new RuntimeException("Type " + type + " is not known to the MapperRegistry.");
        }
        try {
            return mapperProxyFactory.newInstance(sqlSession);
        } catch (Exception e) {
            throw new RuntimeException("Error getting mapper instance. Cause: " + e, e);
        }
    }

    public <T> void addMapper(Class<T> type) {
        // Mapper 必須是介面才會註冊
        if (type.isInterface()) {
            if (hasMapper(type)) {
                // 如果重複新增了,報錯
                throw new RuntimeException("Type " + type + " is already known to the MapperRegistry.");
            }
            // 註冊對映器代理工廠
            knownMappers.put(type, new MapperProxyFactory<>(type));
        }
    }

    public <T> boolean hasMapper(Class<T> type) {
        return knownMappers.containsKey(type);
    }

    public void addMappers(String packageName) {
        Set<Class<?>> mapperSet = ClassScanner.scanPackage(packageName);
        for (Class<?> mapperClass : mapperSet) {
            addMapper(mapperClass);
        }
    }
}
  • MapperRegistry:對映器註冊類的核心主要在於提供了 ClassScanner.scanPackage 掃描包路徑,呼叫 addMapper 方法,給介面類建立 MapperProxyFactory 對映器代理類,並寫入到 knownMappers 的 HashMap 快取中。
  • 另外就是這個類也提供了對應的 getMapper 獲取對映器代理類的方法,其實這步就包裝了我們上一章節手動操作例項化的過程,更加方便在 DefaultSqlSession 中獲取 Mapper 時進行使用。

測試

在同一個包路徑下,提供2個以上的 Dao 介面:

/**
 * @author BNTang
 * @version 1.0
 * @description 學校介面
 * @since 2024/6/16 星期日
 **/
public interface ISchoolDao {
    String querySchoolName(String uId);
    String querySchoolName();
}

/**
 * 使用者介面
 */
public interface IUserDao {
    String queryUserName(String uId);

    Integer queryUserAge(String uId);
}

單元測試

/**
 * @author BNTang
 * @version 1.0
 * @description 測試類
 * @since 2024/4/16 星期二
 **/
public class ApiTest {
    private final Logger logger = LoggerFactory.getLogger(ApiTest.class);

    @Test
    public void test_MapperProxyFactory() {
        // 1. 註冊 Mapper
        MapperRegistry registry = new MapperRegistry();
        registry.addMappers("top.it6666.test.dao");

        // 2. 從 SqlSession 工廠獲取 Session
        SqlSessionFactory sqlSessionFactory = new DefaultSqlSessionFactory(registry);
        SqlSession sqlSession = sqlSessionFactory.openSession();

        // 3. 獲取對映器物件
        ISchoolDao iSchoolDao = sqlSession.getMapper(ISchoolDao.class);

        // 4. 測試驗證
        String res = iSchoolDao.querySchoolName("neo");
        logger.info("測試結果:{}", res);
    }
}
  • 在單元測試中透過序號產生器掃描包路徑註冊對映器代理物件,並把序號產生器傳遞給 SqlSessionFactory 工廠,這樣完成一個連結過程。
  • 之後透過 SqlSession 獲取對應 DAO 型別的實現類,並進行方法驗證。

測試結果

19:48:09.984 [main] INFO  top.it6666.test.ApiTest - 測試結果:你的操作被代理了!方法:querySchoolName 入參:[Ljava.lang.Object;@5fe5c6f
  • 透過測試大家可以看到,目前我們已經在一個有 MyBatis 影子的手寫 ORM 框架中,完成了代理類的註冊和使用過程。

總結

  • 首先要從設計結構上了解工廠模式對具體功能結構的封裝,遮蔽過程細節,限定上下文關係,把對外的使用減少耦合。
  • 從這個過程上讀者夥伴也能發現,使用 SqlSessionFactory 的工廠實現類包裝了 SqlSession 的標準定義實現類,並由 SqlSession 完成對對映器物件的註冊和使用。
  • 本章學習要注意幾個重要的知識點,包括:對映器、代理類、序號產生器、介面標準、工廠模式、上下文。
  • 這些工程開發的技巧都是在手寫 MyBatis 的過程中非常重要的部分,瞭解和熟悉才能更好的在自己的業務中進行使用。

結束語

著急和快,是最大的障礙!慢下來,慢下來,只有慢下來,你才能看到更全的資訊,才能學到更紮實的技術。而那些滿足你快的短篇內容雖然有時候更抓眼球,但也容易把人在技術學習上帶偏,總想著越快越好。

如果您覺得文章對您有所幫助,歡迎您點贊、評論、轉發,也歡迎您關注我的公眾號『BNTang』,我會在公眾號中分享更多的技術文章。

相關文章