Mybatis源碼詳解系列(四)–你不知道的Mybatis用法和細節

簡介

這是 Mybatis 系列部落格的第四篇,我本來打算詳細講解 mybatis 的配置、映射器、動態 sql 等,但Mybatis官方中文文檔對這部分內容的介紹已經足夠詳細了,有需要的可以直接參考。所以,我將擴展一些其他特性或使用細節,掌握它們可以更優雅、高效地使用 mybatis。

這裡補充一點,本文的所有測試例子都是基於本系列 Mybatis 第一篇文章的項目,其他相關部落格如下:

Mybatis源碼詳解系列(一)–持久層框架解決了什麼及如何使用Mybatis

Mybatis源碼詳解系列(二)–Mybatis如何載入配置及初始化

Mybatis源碼詳解系列(三)–從Mapper介面開始看Mybatis的執行邏輯

強大的結果處理器–ResultHandler

DO轉VO–常用方式

通常情況下,我們的持久層的對象不會(不應該)直接響應給調用者,需要轉換為 VO 對象再響應出去。基於本系列部落格的使用例子,這裡假設我需要在 web 層返回下面的 VO 對象,如下。在這個類中,除了員工表的欄位外,還包括了部門表的欄位。

public class EmployeeVO implements Converter<Employee, EmployeeVO>, Serializable {

    private static final long serialVersionUID = 1L;

    private String id;

    private String name;

    private String genderStr;

    private String no;

    private String password;

    private String phone;

    private String address;

    private Byte status;

    private String departmentId;
    
    private String departmentName;
    
    private String departmentNo;
    
        @Override
    public EmployeeVO convert(Employee value) {
        EmployeeVO employeeVO = new EmployeeVO();
        BeanUtils.copyProperties(employeeVO, value);
        employeeVO.setGenderStr(value.getGender()?"男":"女");
        Department department = value.getDepartment();
        if(department != null) {
            employeeVO.setDepartmentName(department.getName());
            employeeVO.setDepartmentNo(department.getNo());
        }
        return employeeVO;
    }
    
    // 省略其他方法
}

web 層的操作大致是這樣的,我先查詢出Employee的集合,然後再進行對象轉換。

    @RequestMapping("/getList")
	public ResponseData testResultHandler(@RequestBody EmployeeCondition con) {
        List<Employee> list = employeeService.list(con);
        return ResultDataUtil.getResultSucess(ConvertUtil.convertList(list, new EmployeeVO()));
    }

DO轉VO–ResultHandler方式

使用 Mybatis 的話,其實還有另外一種方案來處理 DO 轉 VO 的問題,就是採用結果處理器–ResultHandler,如下。

public interface ResultHandler<T> {
  void handleResult(ResultContext<? extends T> resultContext);
}

這是一個介面,實現類需要我們自己定義。作為測試例子,這裡我簡單定義了一個。

public class MyResultHandler<T, R> implements ResultHandler<T> {
    private List<R> list = new ArrayList<R>();
    private Converter<T, R> converter;
    
    public MyResultHandler(Converter<T, R> converter) {
        this.converter = converter;
    }
    
    @Override
    public void handleResult(ResultContext<? extends T> resultContext) {
        list.add(ConvertUtils.convertObject(resultContext.getResultObject(), converter));
    }
    
    public List<R> getList(){
        return list;
    }
}

使用ResultHandler時,Mapper 介面的方法定義需要調整,入參需傳入ResultHandler,且返回值必須為 void。至於 xml 對應的方法內容,還是和常用方式一樣,不需要更改。下面兩個方法共用一個 xml 的 select 節點不會出問題的,這一點不用擔心。

    // 常用的方式
	List<Employee> selectByCondition(@Param("con") EmployeeCondition con);	
	// ResultHandler的方式
	void selectByCondition(@Param("con") EmployeeCondition con, ResultHandler<Employee> resultHandler);

最後回到我們的 web 層,至於 service 層的程式碼就忽略不看了。當調用 service 層時,我已經拿到了轉換好的 VO 對象,我不需要再做處理。

    @RequestMapping("/getList")
	public ResponseData testResultHandler(@RequestBody EmployeeCondition con) {
         MyResultHandler<Employee, EmployeeVO> resultHandler = new MyResultHandler<>(new EmployeeVO());
        employeeService.list(con, resultHandler);
        return ResultDataUtil.getResultSucess(resultHandler.getList());
    }

上一篇部落格在分析源碼過程中有提到過這個介面,當 Mapper 介面的方法入參包含ResultHandler且返回類型為 void,Mybatis 會對這種情況特殊處理:當遍歷結果集進行映射時,每映射完一個對象都會調用一次ResultHandler並將映射好的對象傳入,這時,我們可以隨意地對對象進行處理,包括我們常見的 DO 轉 VO,當然,它的功能並不局限於此

分頁不需要插件–RowBounds

本系列使用篇中提到使用 pagehelper 來支援分頁功能,本質上是使用了插件對 sql 植入分頁參數。其實,Mybatis 已經提供了RowBounds這類來支援分頁功能,這種方式不需要安裝插件,MybatisPlus 本質上就是使用了這種方式。

ResultHandler一樣,我們只需要改造下 Mapper 介面的方法,如下。

    List<Employee> selectByCondition(@Param("con") EmployeeCondition con, RowBounds rowBounds);

這裡我簡單編寫個測試類,直接使用RowBounds對象,實際上最好對RowBounds進行更多的包裝。

    /**
     * <p>測試RowBounds</p>
     */
    @Test
    public void testRowBounds() {
        EmployeeCondition con = new EmployeeCondition();
        // 設置條件
        con.setAddress("北京");

        // 執行,獲取員工對象
        RowBounds rowBounds = new RowBounds(1, 4);
        List<Employee> list = employeeRepository.list(con, rowBounds);

        // 列印
        list.forEach(System.out::println);
    }

測試以上程式碼,可看到打出的語句植入了分頁參數:

SELECT e.id, e.`name`, e.gender, e.no, e.password
	, e.phone, e.address, e.status, e.deleted, e.department_id
	, e.gmt_create, e.gmt_modified
FROM demo_employee e
WHERE 1 = 1
	AND e.address = ?
LIMIT ?, ?

相比使用插件,這種方式是否更加簡單呢?

延遲載入

回顧使用篇的內容

我們知道,在 resultMap 中使用嵌套 select 查詢,並且全局聲明使用懶載入,可以實現按需載入嵌套屬性。

    <settings>
        <setting name="lazyLoadingEnabled" value="true" />
    </settings>

還是回到使用篇中例子,mapper 的配置如下,員工對象關聯了部門(一對一)、角色(一對多)、菜單(一對多):

    <!-- 基礎映射表:嵌套 Select查詢映射 -->
    <resultMap id="BaseResultMap" type="Employee">
        <id column="id" property="id" javaType="string" jdbcType="VARCHAR" />
        <result column="department_id" property="departmentId" javaType="string" jdbcType="VARCHAR" />
        <result column="gmt_create" property="create" javaType="date" jdbcType="TIMESTAMP" />
        <result column="gmt_modified" property="modified" javaType="date" jdbcType="TIMESTAMP" />
        <association property="department" 
            column="department_id"
            select="cn.zzs.mybatis.mapper.DepartmentMapper.selectByPrimaryKey" />
        <collection property="roles" 
            column="id" 
            select="cn.zzs.mybatis.mapper.RoleMapper.selectByEmployeeId" />
        <collection property="menus" 
            column="id" 
            select="cn.zzs.mybatis.mapper.MenuMapper.selectByEmployeeId" />
    </resultMap>
    <!-- 根據id查詢-->
    <select id="selectByPrimaryKey" parameterType="java.lang.String" resultMap="BaseResultMap">
        select
        <include refid="Base_Column_List"></include>
        from
        demo_employee e
        where
        e.id = #{id,jdbcType=VARCHAR}
    </select>

測試程式碼中,我們注釋掉第1、3 和 4 點的程式碼,即只調用getDepartment()方法。

    /**
     * <p>測試懶載入觸發</p>
     */
    @Test
    public void testGetLazy() {
        // 設置輸出代理類到指定路徑
        // -Dcglib.debugLocation=D:/growUp/test
        String id = "cc6b08506cdb11ea802000fffc35d9fe";

        // 執行,獲取員工對象
        Employee employee = employeeRepository.get(id);
        
        // 1.列印員工
        // System.out.println(employee);
        // 2.列印部門
        System.out.println(employee.getDepartment());
        // 3.列印角色
        // employee.getRoles().forEach(System.out::println);
        // 4.列印菜單
        // employee.getMenus().forEach(System.out::println);

    }

測試以上程式碼,可以看到,只有部門被載入出來,而角色和菜單沒有,很好地實現了按需載入。

mybatis_extend_01

接著我們放開第 1 點,即增加列印員工,注意,使用例子中我並沒有重寫toString()方法,所以方法中也不會用到關聯對象。

    @Test
    public void testGetLazy() {
        // 設置輸出代理類到指定路徑
        // -Dcglib.debugLocation=D:/growUp/test
        String id = "cc6b08506cdb11ea802000fffc35d9fe";

        // 執行,獲取員工對象
        Employee employee = employeeRepository.get(id);
        System.out.println("================");
        
        // 1.列印員工
        System.out.println(employee);
        // 2.列印部門
        // System.out.println(employee.getDepartment());
        // 3.列印角色
        // employee.getRoles().forEach(System.out::println);
        // 4.列印菜單
        // employee.getMenus().forEach(System.out::println);

    }

測試以上程式碼,我們驚訝地發現,這時部門、角色和菜單都被列印出來了,說好的按需載入呢?

mybatis_extend_02

這就很奇怪了,我調用的方法並沒用到關聯對象啊,為什麼它們會被載入出來?

什麼時候觸發延遲載入

在上面的例子中,我們的按需載入失效了嗎?

其實並沒有,對於 Mybatis 而言,它可以知道getDepartment()這樣的方法會使用到關聯對象,但是toString()這樣的方法,它就沒辦法知道了。考慮我們會在重寫toString方法時使用到嵌套對象,所以,Mybatis 默認這個方法會觸發延遲載入。同樣道理,equals(),clone(),hashCode()等方法也是一樣的,項目中要重點關注equals()hashCode()

那麼,我們如何控制這種行為呢?Mybatis 提供了 lazyLoadTriggerMethods 配置項指定對象的哪些方法觸發延遲載入:

設置名 描述 有效值 默認值
lazyLoadTriggerMethods 指定哪些方法觸發載入該對象的所有延遲載入屬性。 用逗號分隔的方法列表。 equals,clone,hashCode,toString

我們將配置修改如下:

<setting name="lazyLoadingEnabled" value="true" />
<setting name="lazyLoadTriggerMethods" value="equals,clone,hashCode" />

再次測試上面的例子。這時,嵌套對象都沒有被載入出來。
mybatis_extend_03

這裡再補充下,還有另一個配置項 aggressiveLazyLoading 也會影響延遲載入的觸發,這個配置項在 3.4.1 之後我們保持默認就行,如果不是必須,強烈建議不要配置成 true。如果你將 aggressiveLazyLoading 配置為 true,即使你只是 getId() 也會將所有嵌套對象載入出來

設置名 描述 有效值 默認值
aggressiveLazyLoading 開啟時,幾乎任一方法的調用都會載入該對象的所有延遲載入屬性。
否則,每個延遲載入屬性會按需載入。
true | false false (在 3.4.1 及之前的版本中默認為 true)

作為延遲載入部分的總結,這裡對比下不同配置項組合的效果:

aggressiveLazyLoading lazyLoadTriggerMethods 效果
true / 員工類中任一方法、equals、clone、hashCode、toString被調用,會觸發延遲加
false equals,clone,hashCode,toString 員工類中關聯對象的getter方法、equals、clone、hashCode、toString被調用,會觸發延遲載入
false equals 員工類中關聯對象的getter方法、equals被調用,會觸發延遲載入

有的延遲?有的不延遲

如果我希望部分關聯對象不用延遲載入,部分關聯對象又需要,例如,查詢員工對象時,部門跟著查出來,而角色等到需要用的時候再載入。針對這種情況,可以在映射關係中使用 fetchType來覆蓋延遲載入的開關狀態:

		<association property="department" 
            column="department_id"
            fetchType="eager" 
            select="cn.zzs.mybatis.mapper.DepartmentMapper.selectByPrimaryKey" />
        <collection property="roles" 
            column="id" 
            select="cn.zzs.mybatis.mapper.RoleMapper.selectByEmployeeId" />
        <collection property="menus" 
            column="id" 
            select="cn.zzs.mybatis.mapper.MenuMapper.selectByEmployeeId" />

嵌套結果映射的一個大坑

在使用篇里我說過這麼一句話:嵌套結果里如果是collection的話,分頁總數會存在問題,所以,嵌套結果映射的方式最好僅針對 association 使用

當時我沒有解釋具體原因,這裡我補充下吧。

錯誤的總數

還是回到使用篇的例子,mapper 的 resultMap 是這樣配置的:

    <!-- 基礎映射表:嵌套結果映射-->
    <resultMap id="BaseResultMap2" type="Employee" autoMapping="true">
        <id column="id" property="id" javaType="string" jdbcType="VARCHAR" />
        <result column="department_id" property="departmentId" javaType="string" jdbcType="VARCHAR" />
        <result column="gmt_create" property="create" javaType="date" jdbcType="TIMESTAMP" />
        <result column="gmt_modified" property="modified" javaType="date" jdbcType="TIMESTAMP" />
        <association property="department" 
            columnPrefix="d_"
            resultMap="cn.zzs.mybatis.mapper.DepartmentMapper.BaseResultMap" />
    </resultMap>

編寫測試方法如下。這裡會採用分頁插件 pagehelper 來統計查詢總數,及進行分頁。如果使用RowBounds,也不影響測試結果。注意,資料庫中的「zzs001」只有一條記錄,所查詢到的總數和映射對象都會是一條

    @Test
    public void testlistPage() {
        EmployeeCondition con = new EmployeeCondition();
        // 設置條件
        con.setName("zzs001");
        con.setJoinDepartment(true);
        // con.setJoinRole(true);// 這個注釋待會放開
        
        // 設置分頁資訊
        PageHelper.startPage(0, 3);

        // 執行查詢
        List<Employee> list = employeeRepository.list2(con);
        // 遍歷結果
        list.forEach(System.out::println);

        // 封裝分頁模型
        PageInfo<Employee> pageInfo = new PageInfo<>(list);

        // 取分頁模型的數據
        System.out.println(Long.valueOf(pageInfo.getTotal()).intValue() == list.size());
    }

測試程式碼,可以看到分頁統計的總數和實際數量都會是一條,完全沒問題。

mybatis_extend_04

接下來我再 resultMap 中增加一個 collection 類型的嵌套對象。

    <!-- 基礎映射表:嵌套結果映射-->
    <resultMap id="BaseResultMap2" type="Employee" autoMapping="true">
        <id column="id" property="id" javaType="string" jdbcType="VARCHAR" />
        <result column="department_id" property="departmentId" javaType="string" jdbcType="VARCHAR" />
        <result column="gmt_create" property="create" javaType="date" jdbcType="TIMESTAMP" />
        <result column="gmt_modified" property="modified" javaType="date" jdbcType="TIMESTAMP" />
        <association property="department" 
            columnPrefix="d_"
            resultMap="cn.zzs.mybatis.mapper.DepartmentMapper.BaseResultMap" />
        <collection property="roles" 
            columnPrefix="r_" 
            resultMap="cn.zzs.mybatis.mapper.RoleMapper.BaseResultMap" />
    </resultMap>

放開測試程式碼中的注釋,測試如下。映射對象一條,沒錯,但是查詢總數,竟然是 2 條???

mybatis_extend_05

這就是我提到的嵌套結果映射的一個大坑。

原因分析

難道是統計錯了?讓我們執行下控制台的 sql,記錄竟然也是 2 條,哪裡冒出來的???

mybatis_extend_06

其實,根本原因確實出在我們的使用方法上,collection 的嵌套結果映射就不應該被用在涉及到統計的場景。我們的 sql 查出來有兩條,仔細觀察就會發現,這兩條記錄的 id 是一模一樣的,我們再查詢出 1 個欄位:

mybatis_extend_07

看到這裡應該就明白了吧,統計出錯主要是聯表造成的。員工和角色是一對多的關係,當員工擁有多個角色時聯表查詢將出現比員工數量更多的記錄,而這些記錄,在 Mybatis 映射對象時會將其合併起來。

這就造成了所謂的錯誤總數問題。所以,collection 的嵌套結果映射並不適合統計場景。

自動映射

開啟自動映射

mybatis 的結果自動映射默認是開啟的,可以在使用 setting 配置項進行修改,它有三種自動映射等級:

  • NONE – 禁用自動映射。僅對手動映射的屬性進行映射。
  • PARTIAL – 對除在內部定義了嵌套結果映射(也就是連接的屬性)以外的屬性進行映射。默認配置。
  • FULL – 自動映射所有屬性。

默認使用 PARTIAL,另外, 無論設置的自動映射等級是哪種,你都可以通過在映射文件中設置 resultMap 的 autoMapping 屬性來為指定的結果映射設置啟用/禁用自動映射。

    <resultMap id="BaseResultMap2" type="Employee" autoMapping="true">
        <id column="id" property="id" javaType="string" jdbcType="VARCHAR" />
    </resultMap>

自動映射駝峰命名的屬性

當自動映射查詢結果時,MyBatis 會獲取結果中返回的列名並在 Java 類中查找相同名字的屬性(忽略大小寫)。如果列名和實體中的屬性名對不上,則需要顯式地配置。在使用例子中,我們使用resultMap來映射表和對象,如下:

    <!-- 基礎映射表 -->
    <resultMap id="BaseResultMap" type="cn.zzs.mybatis.entity.Employee">
        <id column="id" property="id" javaType="string" jdbcType="VARCHAR" />
        <result column="department_id" property="departmentId" javaType="string" jdbcType="VARCHAR" />
        <result column="gmt_create" property="create" javaType="date" jdbcType="TIMESTAMP" />
        <result column="gmt_modified" property="modified" javaType="date" jdbcType="TIMESTAMP" />
    </resultMap>
    <!-- 基礎欄位 -->
    <sql id="Base_Column_List">
        e.id,
        e.`name`,
        e.gender,
        e.no,
        e.password,
        e.phone,
        e.address,
        e.status,
        e.deleted,
        e.department_id,
        e.gmt_create,
        e.gmt_modified
    </sql>
    <!-- 根據id查詢 -->
    <select id="selectByPrimaryKey" parameterType="java.lang.String" resultMap="BaseResultMap">
        select
        <include refid="Base_Column_List" />
        from
        demo_employee e
        where
        e.id = #{id}
    </select>

除了表列名和實體的屬性名一致的情況,其他的欄位都需要我們手動配置映射,這樣做比較麻煩。但是,大部分情況下,我們都會遵循駝峰命名的規則來定義實體的屬性名,是否可以直接通過這種規則來自動映射呢?

mybatis 提供了mapUnderscoreToCamelCase配置項來處理這種情況。

    <settings>
        <setting name="mapUnderscoreToCamelCase" value="true" />
    </settings>

參考資料

Mybatis官方中文文檔

相關源碼請移步:mybatis-demo

本文為原創文章,轉載請附上原文出處鏈接://www.cnblogs.com/ZhangZiSheng001/p/12773971.html

Tags: