likes
comments
collection
share

MyBatis-Flex自定义类型处理器TypeHandler

作者站长头像
站长
· 阅读数 2

我们知道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或者timestamp)自动转换成Java类型(例如LocalDateTime或者Date)的呢?

事实上,MyBatis使用TypeHandler接口实现Java类型和数据库类型之间的互相转换:

  • 当MyBatis从数据库中检索数据时,它需要将这些数据转换为Java对象
  • 当MyBatis将Java对象保存到数据库时,它需要将这些对象转换为数据库可以存储的数据类型

这就是TypeHandler的主要作用:在Java类型和数据库类型之间进行转换。通过使用TypeHandler,MyBatis可以处理各种不同的数据类型,确保数据在Java和数据库之间正确地流动。

在MyBatis中已经内置了非常多的TypeHandler的实现类,用于实现数据库的字段类型和常用的Java类型之间互相转换:

MyBatis-Flex自定义类型处理器TypeHandler

还是以时间类型为例,我们来看一下内置的时间类型处理器源代码:

/*
 *    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进行insertdelete或者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对象

在上述TypeHandlersetNonNullParameter方法中有一个PreparedStatement类型参数,这个参数是干什么的呢?

如果大家使用过JDBC进行开发,相信对该接口并不陌生。PreparedStatement是JDBC中的一个接口,用于执行参数化的SQL语句,使用PreparedStatement可以帮助防止SQL注入攻击,同时提高执行多次相同SQL语句的效率。

我们来看一个简单的JDBC PreparedStatement示例:

// 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方法设置每个参数的值,例如setStringsetIntsetDouble等,例如:

ps.setString(1, "玩原神玩的");
ps.setInt(2, 18);

这样,我们设定了每个占位符的参数,实际得到的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方法可以访问当前行中的不同列。

我们来看一个简单的例子:

// 连接数据库
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中借助TypeHandlergetNullableResult方法,实现了先从ResultSet取得原始类型,然后转换成对应的Java类型的操作。

2,案例背景

下面,我们来实现一个自定义的TypeHandler,并在MyBatis-Flex中完成类型对应。

假设我们现在要设计一个遥感影像元数据查询系统,元数据中有一个字段spatialExtent表示遥感影像涵盖区域的最小外包矩形,这个字段包含四个坐标,即矩形的左上、右上、右下和左下的经纬度坐标对。

MyBatis-Flex自定义类型处理器TypeHandler

我们可以在Java中设计出这么一个类Boundary专门表示最小外包矩形,但是数据库中我们只能使用字符串记录坐标对了。所以我们需要设计一个自定义的TypeHandler实现Java的Boundary类和数据库中字符串形式的矩形的相互转换。

这里Java类图如下:

MyBatis-Flex自定义类型处理器TypeHandler

对应的数据库表格如下:

MyBatis-Flex自定义类型处理器TypeHandler

在数据库中,我们使用如下形式的字符串表示最小外包矩形:

[1,1] [2,2] [3,3] [4,4]

3,项目准备

部署并初始化你的MySQL数据库节点,并新建一个Spring Boot项目,我们要准备开始了!

(1) 初始化数据库

连接你的MySQL数据库并通过下列语句创建、切换至数据库:

create database `type_handler_demo`;
use `type_handler_demo`;

然后执行下列语句初始化表格:

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,加入下列依赖:

<!-- 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>

然后配置数据源:

# 数据源配置
spring:
  datasource:
    url: "jdbc:mysql://localhost:3306/type_handler_demo"
    username: "root"
    password: "wocaoop"

将上述地址、用户名和密码换成自己的。

(3) 实体类

首先是表示最小外包矩形的自定义类Boundary

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类:

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即可:

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,实现其中的抽象方法,代码如下:

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

现在在GranulespatialExtent字段上,通过MyBatis-Flex的@Column注解指定我们自定义的类型处理器即可:

// 省略package和import...

@Data
@Table("granule")
public class Granule {

	// 省略其它字段...

	/**
	 * 最小外包矩形边界(使用自定义类型及其类型处理器)
	 */
	@Column(typeHandler = BoundaryTypeHandler.class)
	private Boundary spatialExtent;

}

这样,当MyBatis-Flex处理该字段时,就会使用我们自定义的类型处理器。

5,测试一下

现在,在测试类中尝试一下插入数据和查询数据:

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()));
	}

}

可见成功地实现了我们自定义类型转换:

MyBatis-Flex自定义类型处理器TypeHandler

数据库中,也成功地将最小外包矩形保存成为了我们自定义的字符串:

MyBatis-Flex自定义类型处理器TypeHandler

6,总结

可见MyBatis的TypeHandler接口是一个非常重要的概念,正是借助该接口,MyBatis才能够实现自动地把数据库中类型和Java类型对应起来。

我们也可以通过使用自定义的TypeHandler实现自定义的类型定义操作,不过大家需要先理解该接口中每个方法及其参数的意义,以及它们做了什么操作,这样才能够更好地实现自定义类型处理器。

本文以MyBatis-Flex为例实现了自定义类型处理,在MyBatis-Plus或者MyBatis中,也是通过继承BaseTypeHandler并实现其方法来完成自定义类型处理器的,只不过使用类型处理器的方式会有所不同。

通过自定义类型处理器,是否可以实现处理PostGIS中的Geometry类型呢?

本文参考文档:

本文实例代码仓库:传送门