我们知道MyBatis作为ORM框架,能够实现将我们的数据库中的数据类型转换成Java类型的对象,大家也知道数据库中的数据类型都是非常简单、不可再分的(第一范式),那么当我们的Java类中存在一个字段不是Java的基本类型,而是自定义的复杂类型怎么办呢?
通常我们编写Mapper XML文件,定义resultMap
将数据库表和Java类型相对应,以及使用关联查询的形式,可以解决类中包含其它类字段的情况。
如果Java类中存在的复杂类型字段,其类型并不存在于数据库表中呢?那就需要自定义TypeHandler
实现它们的转换了!
MyBatis-Flex是一个非常强大的MyBatis增强框架,它和MyBatis-Plus类似,都是基于MyBatis开发,使用它我们就可以免去编写大量Mapper XML和SQL语句的任务,解放双手。
今天我们以Spring Boot 3.1集成MyBatis-Flex为例,实现自定义一个类型处理器。
在学习之前,需要大家先了解一下MyBatis-Flex的基本使用 ,可以说是非常简单的:官方文档
1,前置知识
如果说我们直接上手学习TypeHandler
的使用,有些同学可能会觉得有点抽象,因此在这之前我们先来了解一些关于MyBatis的前置知识。如果说你对MyBatis的底层原理非常熟悉,那么可以跳过该部分。
(1) MyBatis的类型处理器 - TypeHandler
在我们使用MyBatis或者MyBatis-Flex的过程中,不难发现在查询一个表的记录的时候,MyBatis可以将查得的字段值填充到我们的Java类的属性上,从而转换成Java对象。
**那么MyBatis是如何把数据库类型例如datetime
自动转换成Java类型例如LocalDateTime
的呢?**事实上,MyBatis使用TypeHandler
接口实现Java类型和数据库类型之间的转换:
-
当MyBatis从数据库中检索数据时,它需要将这些数据转换为Java对象
-
当MyBatis将Java对象保存到数据库时,它需要将这些对象转换为数据库可以存储的数据类型
这就是TypeHandler
的主要作用:在Java类型和数据库类型之间进行转换。通过使用TypeHandler
,MyBatis可以处理各种不同的数据类型,确保数据在Java和数据库之间正确地流动。
在MyBatis中已经内置了非常多的TypeHandler
的实现类,用于实现数据库的字段类型和常用的Java类型之间互相转换:
还是以时间类型为例,我们来看一下内置的时间类型处理器源代码:
java
/*
* Copyright 2009-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.ibatis.type;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
/**
* @since 3.4.5
*
* @author Tomas Rohovsky
*/
public class LocalDateTimeTypeHandler extends BaseTypeHandler<LocalDateTime> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, LocalDateTime parameter, JdbcType jdbcType)
throws SQLException {
ps.setObject(i, parameter);
}
@Override
public LocalDateTime getNullableResult(ResultSet rs, String columnName) throws SQLException {
return rs.getObject(columnName, LocalDateTime.class);
}
@Override
public LocalDateTime getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return rs.getObject(columnIndex, LocalDateTime.class);
}
@Override
public LocalDateTime getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return cs.getObject(columnIndex, LocalDateTime.class);
}
}
可见上面主要实现了四个方法,不过这四个方法中后面三个都是一样的作用。
首先我们来看setNonNullParameter
方法,该方法用于转换并设定参数,其中的参数意义如下:
PreparedStatement ps
JDBC的Statement对象,用于参数化执行SQL语句int i
表示传入参数的下标LocalDateTime parameter
我们传入的参数值,具体类型取决于TypeHandler
的处理类型JdbcType jdbcType
对应的JDBC类型
当我们调用MyBatis进行insert
、delete
或者update
操作,传入参数类型(或者传入的对象中包含属性类型)为LocalDateTime
时,就会调用这个实现类中的setNonNullParameter
方法,实现将Java的LocalDateTime
类型转换成数据库类型datetime
,然后生成SQL语句并执行,总而言之,setNonNullParameter
方法实现了Java类型到其对应的数据库类型的转换,并将转换后的值内插到要执行的SQL语句中这个操作。
然后再来看getNullableResult
方法,该方法用于将查询得到的原始值转换成Java对象,以第二个为例,其中的参数意义如下:
ResultSet rs
查询得到的原始结果集,即查询得到的数据库结果,为数据库类型String columnName
该字段名称
当我们使用MyBatis进行select
查询操作时,如果查询的结果类型(或者是查询的结果中包含类型)为数据库的时间类型时,就会调用这个实现类中的getNullableResult
方法,实现将数据库的时间类型转换成Java的LocalDateTime
类型并返回,最后将返回值赋值到对应的Java类的字段上,可见getNullableResult
方法实现了数据库类型到Java类型的转换。
(2) PreparedStatement
对象
在上述TypeHandler
的setNonNullParameter
方法中有一个PreparedStatement
类型参数,这个参数是干什么的呢?
如果大家使用过JDBC进行开发,相信对该接口并不陌生。PreparedStatement
是JDBC中的一个接口,用于执行参数化的SQL语句,使用PreparedStatement
可以帮助防止SQL注入攻击,同时提高执行多次相同SQL语句的效率。
我们来看一个简单的JDBC PreparedStatement
示例:
java
// JDBC连接数据库
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "username", "password");
// 使用PreparedStatement构建SQL语句
PreparedStatement ps = conn.prepareStatement("insert into user_table (name, age) values (?, ?)");
可见上述使用占位符(问号?
)表示SQL语句中的参数,然后就可以使用setXXX
方法设置每个参数的值,例如setString
,setInt
,setDouble
等,例如:
java
ps.setString(1, "玩原神玩的");
ps.setInt(2, 18);
这样,我们设定了每个占位符的参数,实际得到的SQL语句就如下:
sql
insert into user_table (name, age) values ('玩原神玩的', 18);
相信到这里大家就明白了:MyBatis在执行SQL语句的时候,会自动地将我们的各个参数通过PreparedStatement
内插到SQL语句中,动态地生成SQL语句并执行。
所以为了正确地执行SQL语句,就会通过各种TypeHandler
中的setNonNullParameter
方法,先把Java类型转换成对应的数据库类型,然后借助PreparedStatement
内插到SQL语句中生成SQL语句。
(3) ResultSet
对象
在getNullableResult
方法中有一个ResultSet
类型参数,这个类型又是什么呢?
同样地,该类型也是JDBC中一个接口,如果你曾经使用JDBC操作数据库,你就知道该接口通常用于表示SQL查询返回的结果集。当执行一个SQL查询时,数据库会返回一个ResultSet
,这个对象可以被视为一个包含查询结果的数据表。
ResultSet
中包含了满足SQL查询条件的所有行,这些行中的数据可以通过一系列的getXXX
方法来访问,这些get
方法可以访问当前行中的不同列。
我们来看一个简单的例子:
java
// 连接数据库
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "username", "password");
// 执行查询
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("select id, name, age from mytable");
// 从结果集获取每个字段值
// ResultSet通过类似迭代器(游标)形式遍历每一条结果
while(rs.next()) {
// 通过列名检索
int id = rs.getInt("id");
String name = rs.getString("name");
int age = rs.getInt("age");
// 输出数据
System.out.println("ID: " + id);
System.out.println("Name: " + name);
System.out.println("Age: " + age);
}
可见很显然,ResultSet
接口获取到的数据类型基本上都是非常原始的Java数据类型,也因此在MyBatis中借助TypeHandler
的getNullableResult
方法,实现了先从ResultSet
取得原始类型,然后转换成对应的Java类型的操作。
2,案例背景
下面,我们来实现一个自定义的TypeHandler
,并在MyBatis-Flex中完成类型对应。
假设我们现在要设计一个遥感影像元数据查询系统 ,元数据中有一个字段spatialExtent
表示遥感影像涵盖区域的最小外包矩形,这个字段包含四个坐标,即矩形的左上、右上、右下和左下的经纬度坐标对。
我们可以在Java中设计出这么一个类Boundary
专门表示最小外包矩形,但是数据库中我们只能使用字符串记录坐标对了。所以我们需要设计一个自定义的TypeHandler
实现Java的Boundary
类和数据库中字符串形式的矩形的相互转换。
这里Java类图如下:
对应的数据库表格如下:
在数据库中,我们使用如下形式的字符串表示最小外包矩形:
css
[1,1] [2,2] [3,3] [4,4]
3,项目准备
部署并初始化你的MySQL数据库节点,并新建一个Spring Boot项目,我们要准备开始了!
(1) 初始化数据库
连接你的MySQL数据库并通过下列语句创建、切换至数据库:
sql
create database `type_handler_demo`;
use `type_handler_demo`;
然后执行下列语句初始化表格:
sql
drop table if exists `granule`;
create table `granule`
(
`id` int unsigned auto_increment primary key,
`name` varchar(64) not null,
`spatial_extent` varchar(128)
) engine = InnoDB
default charset = utf8mb4;
(2) 项目配置
创建Spring Boot项目,我这里Spring Boot版本是3.1.7
,Java版本是21
,加入下列依赖:
xml
<!-- Spring Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Flex -->
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot-starter</artifactId>
<version>1.7.7</version>
</dependency>
<!-- MyBatis-Flex注解生成器 -->
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-processor</artifactId>
<version>1.7.7</version>
<scope>provided</scope>
</dependency>
<!-- Hikari连接池 -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok注解 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring Boot测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
然后配置数据源:
yaml
# 数据源配置
spring:
datasource:
url: "jdbc:mysql://localhost:3306/type_handler_demo"
username: "root"
password: "wocaoop"
将上述地址、用户名和密码换成自己的。
(3) 实体类
首先是表示最小外包矩形的自定义类Boundary
:
java
package com.gitee.swsk33.typehandlerdemo.model;
import lombok.Data;
/**
* 边界对象类型
*/
@Data
public class Boundary {
/**
* 左上角经纬度
* 数组第一个元素为经度,第二个为纬度,后面几个一样
*/
private double[] leftTop;
/**
* 右上角经纬度
*/
private double[] rightTop;
/**
* 右下角经纬度
*/
private double[] rightBottom;
/**
* 左下角经纬度
*/
private double[] leftBottom;
/**
* 根据字符串创建Boundary对象
* 字符串格式形如:"[1,1] [2,2] [3,3] [4,4]",分别是左上、右上、右下和左下的坐标对
*
* @param boundaryString 边界字符串
* @return 边界对象
*/
public static Boundary createFromString(String boundaryString) {
Boundary result = new Boundary();
// 分割成坐标对
String[] coordinates = boundaryString.split(" ");
// 解析每个坐标对
for (int i = 0; i < 4; i++) {
// 获取一个坐标对
String eachCoordinate = coordinates[i];
// 去除括号和逗号
String[] eachCoordinateString = eachCoordinate.substring(1, eachCoordinate.length() - 1).split(",");
// 解析成数字
double[] coordinate = new double[2];
coordinate[0] = Double.parseDouble(eachCoordinateString[0]);
coordinate[1] = Double.parseDouble(eachCoordinateString[1]);
// 赋值到属性
switch (i) {
case 0:
result.setLeftTop(coordinate);
break;
case 1:
result.setRightTop(coordinate);
break;
case 2:
result.setRightBottom(coordinate);
break;
case 3:
result.setLeftBottom(coordinate);
break;
}
}
return result;
}
/**
* 对象转换成字符串
*
* @return 字符串格式形如:"[1,1] [2,2] [3,3] [4,4]",分别是左上、右上、右下和左下的坐标对
*/
@Override
public String toString() {
return String.format("[%f,%f] [%f,%f] [%f,%f] [%f,%f]", leftTop[0], leftTop[1], rightTop[0], rightTop[1], rightBottom[0], rightBottom[1], leftBottom[0], leftBottom[1]);
}
}
然后是遥感影像元数据Granule
类:
java
package com.gitee.swsk33.typehandlerdemo.dataobject;
import com.gitee.swsk33.typehandlerdemo.model.Boundary;
import com.gitee.swsk33.typehandlerdemo.typehandler.BoundaryTypeHandler;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import lombok.Data;
/**
* 遥感影像元数据对象
*/
@Data
@Table("granule")
public class Granule {
/**
* 主键ID
*/
@Id(keyType = KeyType.Auto)
private int id;
/**
* 遥感影像名称
*/
private String name;
/**
* 最小外包矩形边界(使用自定义类型及其类型处理器)
*/
private Boundary spatialExtent;
}
(4) 数据库访问层
在MyBatis-Flex中,我们只需创建DAO
接口并继承BaseMapper
即可:
java
package com.gitee.swsk33.typehandlerdemo.dao;
import com.gitee.swsk33.typehandlerdemo.dataobject.Granule;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface GranuleDAO extends BaseMapper<Granule> {
}
4,自定义TypeHandler
现在,我们就来自定义TypeHandler
实现我们自定义类型Boundary
和数据库中字符串类型的相互转换。
(1) 实现BaseTypeHandler
抽象类的方法
我们创建一个类BoundaryTypeHandler
并继承BaseTypeHandler
,实现其中的抽象方法,代码如下:
java
package com.gitee.swsk33.typehandlerdemo.typehandler;
import com.gitee.swsk33.typehandlerdemo.model.Boundary;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* 自定义Boundary字段类型处理器
* 使用@MappedTypes注解指定该类型处理器所处理的自定义对象(Boundary类型)
* 使用@MappedJdbcTypes注解将该类型处理器所处理的自定义对象(Boundary类型)和对应的数据库类型(varchar)对应起来
*/
@MappedTypes(Boundary.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class BoundaryTypeHandler extends BaseTypeHandler<Boundary> {
/**
* 自定义设定参数时的操作(自定义类型 -> 数据库基本类型)
* 即定义当我们执行insert、delete或者update操作,在Mapper方法传入Boundary类型作为参数时,要如何将其转换数据库表对应的字符串形式
*
* @param ps JDBC的Statement对象,用于参数化执行SQL语句
* @param i 表示Boundary类型参数的下标
* @param parameter 我们传入的具体的Boundary类型参数
* @param jdbcType JDBC类型
*/
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Boundary parameter, JdbcType jdbcType) throws SQLException {
// 将我们的Boundary类型参数转换成字符串使得可以存入或者更新至数据库
String convertedBoundary = parameter.toString();
// 设定至参数化Statement对象中填充对应参数
ps.setString(i, convertedBoundary);
}
/**
* 定义获取结果时的操作(数据库基本类型 -> 自定义类型)
* 即定义当我们查询到的对象中有Boundary类型字段时,如何将数据表中使用字符串类型表示的边界转换成我们的Boundary对象
* 该方法使用列名获取原始字段值
*
* @param rs 查询得到的全部列
* @param columnName 该列的列名
* @return 转换后的Boundary对象
*/
@Override
public Boundary getNullableResult(ResultSet rs, String columnName) throws SQLException {
// 获取原始结果(字符串形式)
String fieldValue = rs.getString(columnName);
// 转换成Boundary对象
return Boundary.createFromString(fieldValue);
}
/**
* 定义获取结果时的操作(重载)
* 该方法使用下标获取原始字段值
*/
@Override
public Boundary getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
// 获取原始结果(字符串形式)
String fieldValue = rs.getString(columnIndex);
// 转换成Boundary对象
return Boundary.createFromString(fieldValue);
}
/**
* 定义获取结果时的操作(重载)
* 该方法使用CallableStatement获取原始字段值
*/
@Override
public Boundary getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
// 获取原始结果(字符串形式)
String fieldValue = cs.getString(columnIndex);
// 转换成Boundary对象
return Boundary.createFromString(fieldValue);
}
}
相信其中的四个方法大家都知道是干啥的了,与此同时大家也可以看看上述是如何具体实现TypeHandler
中每个方法的。
上述代码中有以下要点:
- 继承的
BaseTypeHandler
中的泛型即为我们要处理的自定义Java类型,上述是Boundary
- 上述自定义的
TypeHandler
类使用了注解@MappedTypes
和@MappedJdbcTypes
,用于将我们自定义的类和数据库中类型对应起来 setNonNullParameter
方法中需要实现将自定义的Java类型转换为对应数据库类型(基本类型),并设定到PreparedStatement
对象中去这些操作getNullableResult
方法有三个重载,不过它们都是用于实现从数据库中取出原始类型,并转换成我们对应的Java类型的,只不过分别是通过字段名取出字段值、通过下标取出字段值以及通过CallableStatement
对象取出字段值
(2) 设定对应字段使用的TypeHandler
现在在Granule
的spatialExtent
字段上,通过MyBatis-Flex的@Column
注解指定我们自定义的类型处理器即可:
java
// 省略package和import...
@Data
@Table("granule")
public class Granule {
// 省略其它字段...
/**
* 最小外包矩形边界(使用自定义类型及其类型处理器)
*/
@Column(typeHandler = BoundaryTypeHandler.class)
private Boundary spatialExtent;
}
这样,当MyBatis-Flex处理该字段时,就会使用我们自定义的类型处理器。
5,测试一下
现在,在测试类中尝试一下插入数据和查询数据:
java
package com.gitee.swsk33.typehandlerdemo;
import com.gitee.swsk33.typehandlerdemo.dao.GranuleDAO;
import com.gitee.swsk33.typehandlerdemo.dataobject.Granule;
import com.gitee.swsk33.typehandlerdemo.model.Boundary;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Arrays;
import static com.gitee.swsk33.typehandlerdemo.dataobject.table.GranuleTableDef.GRANULE;
@SpringBootTest
class TypeHandlerDemoApplicationTests {
@Autowired
private GranuleDAO granuleDAO;
@Test
void contextLoads() {
// 遥感影像名称
String name = "LC81190392013200LGN01";
// 插入一个记录
Granule granule = new Granule();
granule.setName(name);
granule.setSpatialExtent(Boundary.createFromString("[1.1,1.2] [2.1,2.2] [3.1,3.2] [4.1,4.2]"));
granuleDAO.insert(granule);
// 查询尝试
QueryWrapper wrapper = new QueryWrapper();
wrapper.where(GRANULE.NAME.eq("LC81190392013200LGN01"));
Granule get = granuleDAO.selectOneByQuery(wrapper);
System.out.println("影像名称:" + get.getName());
System.out.println("影像最小外包矩形经纬度:");
Boundary bbox = get.getSpatialExtent();
System.out.println("左上:" + Arrays.toString(bbox.getLeftTop()));
System.out.println("右上:" + Arrays.toString(bbox.getRightTop()));
System.out.println("右下:" + Arrays.toString(bbox.getRightBottom()));
System.out.println("左下:" + Arrays.toString(bbox.getLeftBottom()));
}
}
可见成功地实现了我们自定义类型转换:
数据库中,也成功地将最小外包矩形保存成为了我们自定义的字符串:
6,总结
可见MyBatis的TypeHandler
接口是一个非常重要的概念,正是借助该接口,MyBatis才能够实现自动地把数据库中类型和Java类型对应起来。
我们也可以通过使用自定义的TypeHandler
实现自定义的类型定义操作,不过大家需要先理解该接口中每个方法及其参数的意义,以及它们做了什么操作,这样才能够更好地实现自定义类型处理器。
本文以MyBatis-Flex为例实现了自定义类型处理,在MyBatis-Plus或者MyBatis中,也是通过继承BaseTypeHandler
并实现其方法来完成自定义类型处理器的,只不过使用类型处理器的方式会有所不同。
通过自定义类型处理器,是否可以实现处理PostGIS中的Geometry类型呢?
本文参考文档:
本文实例代码仓库:传送门