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);
}
測試以上程式碼,可以看到,只有部門被載入出來,而角色和菜單沒有,很好地實現了按需載入。
接著我們放開第 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 而言,它可以知道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" />
再次測試上面的例子。這時,嵌套對象都沒有被載入出來。
這裡再補充下,還有另一個配置項 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());
}
測試程式碼,可以看到分頁統計的總數和實際數量都會是一條,完全沒問題。
接下來我再 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 條???
這就是我提到的嵌套結果映射的一個大坑。
原因分析
難道是統計錯了?讓我們執行下控制台的 sql,記錄竟然也是 2 條,哪裡冒出來的???
其實,根本原因確實出在我們的使用方法上,collection 的嵌套結果映射就不應該被用在涉及到統計的場景。我們的 sql 查出來有兩條,仔細觀察就會發現,這兩條記錄的 id 是一模一樣的,我們再查詢出 1 個欄位:
看到這裡應該就明白了吧,統計出錯主要是聯表造成的。員工和角色是一對多的關係,當員工擁有多個角色時聯表查詢將出現比員工數量更多的記錄,而這些記錄,在 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-demo
本文為原創文章,轉載請附上原文出處鏈接://www.cnblogs.com/ZhangZiSheng001/p/12773971.html