作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
我们会分为上、下两篇分别介绍DAO及Controller层如何处理枚举。重点不是枚举本身,而是希望帮大家开阔眼界,实际开发手动转换枚举也未尝不可。
不了解枚举的同学请先去阅读小册中与枚举相关的其他章节。另外,本文会用到反射及注解相关知识,不熟悉的同学请戳:
反射
注解
这一篇先介绍DAO中枚举相关的处理。
强调一下,这里我直接使用原生MyBatis,而不是通用Mapper或MyBatis-Plus,意在说明MyBatis本身有注意到枚举转换的问题并预留了接口。
环境准备
SQL(注意,这里rest_day是故意使用varchar的,后面会解释)
CREATE TABLE `t_user` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',`name` varchar(50) DEFAULT '' COMMENT '姓名',`age` tinyint(3) unsigned DEFAULT NULL COMMENT '年龄',`rest_day` varchar(20) DEFAULT '' COMMENT '休息日',`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',`deleted` tinyint(1) unsigned DEFAULT '0' COMMENT '是否删除',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
pom.xml
<dependencies><!--SpringBoot Web,下篇会用到--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--MyBatis依赖--><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.1.3</version></dependency><!--MySQL驱动--><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><scope>runtime</scope></dependency><!--Lombok--><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><!--测试--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope><exclusions><exclusion><groupId>org.junit.vintage</groupId><artifactId>junit-vintage-engine</artifactId></exclusion></exclusions></dependency>
</dependencies>
application.yml
server:port: 8080spring:datasource:url: jdbc:mysql:///test?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowMultiQueries=trueusername: rootpassword: rootdriver-class-name: com.mysql.jdbc.Drivermybatis:mapper-locations: classpath:mapper/**/*.xmlconfiguration:map-underscore-to-camel-case: onlogging:level:com.example.dao: debug
启动类
/*** @author mx*/
@MapperScan("com.example.dao")
@SpringBootApplication
public class MybatisEnumDemoApplication {public static void main(String[] args) {SpringApplication.run(MybatisEnumDemoApplication.class, args);}}
DO
/*** @author mx* @date 2023-11-25 09:56*/
@Data
public class UserDO {/*** 主键id*/private Long id;/*** 姓名*/private String name;/*** 年龄*/private Integer age;/*** 休息日,实际数据库字段是tinyint或varchar*/private WeekDayEnum restDay;/*** 创建时间*/private Date createTime;/*** 修改时间*/private Date updateTime;/*** 是否删除*/private Boolean deleted;
}
WeekDayEnum
/*** @author mx*/
@Getter
public enum WeekDayEnum {MONDAY(1,"星期一"),TUESDAY(2,"星期二"),WEDNESDAY(3,"星期三"),THURSDAY(4,"星期四"),FRIDAY(5,"星期五"),SATURDAY(6,"星期六"),SUNDAY(7,"星期日");WeekDayEnum(Integer code, String desc) {this.code = code;this.desc = desc;}private final Integer code;private final String desc;
}
UserMapper.java
/*** @author mx*/
public interface UserMapper {/*** 插入用户** @param userDO*/void insertUser(UserDO userDO);/*** 根据id查询* @param id* @return*/UserDO selectUserById(@Param("id") Long id);
}
UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.dao.UserMapper"><resultMap id="BaseResultMap" type="com.example.entity.UserDO"><!--WARNING - @mbg.generated--><id column="id" jdbcType="BIGINT" property="id"/><result column="name" jdbcType="VARCHAR" property="name"/><result column="age" jdbcType="TINYINT" property="age"/><result column="rest_day" jdbcType="VARCHAR" property="restDay"/><result column="create_time" jdbcType="TIMESTAMP" property="createTime"/><result column="update_time" jdbcType="TIMESTAMP" property="updateTime"/><result column="deleted" jdbcType="BIT" property="deleted"/></resultMap><insert id="insertUser">INSERT INTO t_user (`name`, age, rest_day)VALUES(#{name}, #{age}, #{restDay})</insert><!-- VALUES(#{name, jdbcType=VARCHAR}, #{age, jdbcType=INTEGER}, #{restDay, jdbcType=VARCHAR})--><select id="selectUserById" resultType="com.example.entity.UserDO">SELECT * FROM t_user WHERE id=#{id}</select>
</mapper>
@SpringBootTest
class MybatisEnumTest {@Autowiredprivate UserMapper userMapper;@Testpublic void testInsert() {UserDO userDO = new UserDO();userDO.setName("MyBatis枚举测试");userDO.setAge(18);userDO.setRestDay(WeekDayEnum.FRIDAY);userMapper.insertUser(userDO);}@Testpublic void testSelect() {UserDO userDO = userMapper.selectUserById(1L);System.out.println(userDO);}}
插入测试:
查询测试:
至此,我们完成了最简单的环境搭建。
但你们应该会发现一个神奇的现象:
- 存入时:private WeekDayEnum restDay(内存) --> MyBatis --> "FRIDAY"(数据库)
- 查询时:private WeekDayEnum restDay(内存) <-- MyBatis <-- "FRIDAY"(数据库)
在Java和数据库之间,MyBatis承担了中间人的角色,存入时会自动将枚举对象转为字符串,而取出时又把字符串转为枚举对象。
怎么做到的呢?
枚举的存入
我们发现,MyBatis默认对枚举的处理是将枚举的名称插入数据库,而枚举的名称其实就是Enum.name,定义在父类Enum中:
那么MyBatis是在哪里调用WeekDayEnum的name()方法进行转换的呢?
注意截图中这个类的名字:EnumTypeHandler。
MyBatis提供了两个枚举转换器,EnumTypeHandler是其中之一,另一个是EnumOrdinalTypeHandler:
通过源码很容易看出两者的区别
- EnumTypeHandler:取枚举的name作为值插入数据库
- EnumOrdinalTypeHandler:取枚举的ordinal作为值插入数据库
name和ordinal被定义在Enum抽象类中,而所有枚举类实际上都会继承Enum,所以每一个枚举对象都有name和ordinal。
MyBatis默认的枚举转换器是EnumTypeHandler,所以开头的SQL我故意把rest_day设置为varchar类型,刚好接收被EnumTypeHandler转换后的枚举字符串。
如果我们改为tinyint,就会报错:
DROP TABLE IF EXISTS t_user;
CREATE TABLE `t_user` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',`name` varchar(50) DEFAULT '' COMMENT '姓名',`age` tinyint(3) unsigned DEFAULT NULL COMMENT '年龄',`rest_day` tinyint(1) DEFAULT 1 COMMENT '休息日',`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',`deleted` tinyint(1) unsigned DEFAULT '0' COMMENT '是否删除',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
重新执行测试案例:
如果数据库rest_day使用的是tinyint类型,需要将MyBatis的默认枚举转换器切换为EnumOrdinalTypeHandler:
mybatis:mapper-locations: classpath:mapper/**/*.xmlconfiguration:map-underscore-to-camel-case: on# 显式声明Mybatis枚举转换器,默认是EnumTypeHandlerdefault-enum-type-handler: org.apache.ibatis.type.EnumOrdinalTypeHandler
给EnumOrdinalTypeHandler打上断点,再次测试插入:
注意,数据库存的是ordinal,而不是code,Enum的ordinal从0开始,所以4代表FRIDAY
查询:
为什么会打印"FRIDAY"呢?
此时UserDO中的restDay确实是WeekDayEnum对象,打印一个对象通常会调用它的toString(),而Enum父类重写了toString(),实际返回Enum.name,所以打印一个枚举对象最终输出的是Enum.name。
小结:
- MyBatis默认存入时会使用EnumTypeHandler枚举类型进行转换,调用Enum.name()存入枚举名称
- 如果希望存入ordinal,可以切换默认枚举转换器为EnumOrdinalTypeHandler
枚举的取出
接着,我们来观察一下数据库中rest_day字段的"FRIDAY"和4被查出来以后如何变成枚举对象FRIDAY的。
同样的,肯定还是EnumTypeHandler和EnumOrdinalTypeHandler帮我们转换的。由于刚才数据库的rest_day已经被我们改为tinyint,所以我们通过EnumOrdinalTypeHandler观察取出时的操作:
好了好了,我知道了,你别说了。我就想知道enums哪来的?
往上看:
在项目启动时MyBatis会初始化EnumTypeHandler,调用构造器时会对private final E[] enums属性赋值。然而神奇的是,type此时enumConstants为null,但从最终结果来看enumConstants是有值的,所以getEnumConstants()内部必然发生了什么。
跟踪进去会发现:
我们之前在设计山寨枚举及反编译枚举时介绍过values()方法和VALUES数组了:
OK,至此EnumOrdinalTypeHandler介绍完了。我们顺便看看EnumTypeHandler:
哦?底层调用了父类Enum的valueOf()方法,根据枚举名称获取枚举实例:
又会跳到Class类的方法中,而且这个方法上面见过了:
T[] universe就是T[] values,而且准备了一个名为m的Map,把枚举的名称作为key,枚举对象本身作为value,把枚举存了起来(你看,又是实用小算法)。
最终Enum.valueOf()其实就是传入枚举名称,然后从Map中得到对应的枚举实例:
所以数据库的"FRIDAY"会被转为FRIDAY对象。
对MyBatis默认提供的枚举转换器的介绍就到这里了。
但不论EnumTypeHandler还是EnumOrdinalTypeHandler,其实都不好用。实际开发中,我们往往使用的不是ordinal或name,而是自己定义的枚举字段,比如code、desc。
默认的两个枚举转换器,一个针对name,另一个针对ordinal,这两个字段属于抽象父类Enum,会在初始化时赋值,而子类特有的code和desc却没用到。
简单版枚举转换器
核心思想是,照着EnumOrdinalTypeHandler抄,搞一个山寨的,然后让MyBatis用我们的Handler转换。
自定义枚举转换器分3步:
- 编写枚举转换类,实现MyBatis提供的TypeHandler接口
- 指定type-handlers-package,告诉MyBatis在哪里可以找到我们自定义的转换器
- 在转换器上用@MappedTypes({WeekDayEnum.class})指定用来处理哪个枚举
TypeHandler是个接口,啥都没有,白手起家太难了:
MyBatis另外提供了BaseTypeHandler让我们继承,EnumOrdinalTypeHandler也是这么干的:
要想自定义枚举转换器,最快的办法是“抄袭”EnumOrdinalTypeHandler:
再把里面的内容全部拷过来:
为了方便确认最终起作用的是我们自定义的MyEnumTypeHandler,稍作修改:
如果最终插入的值会在原来的基础上加100,就说明走了我们自定义的转换器。
然后加上@MappedTypes({WeekDayEnum.class})注解指定处理WeekDayEnum:
最后告诉MyBatis我们自定义的转换器包路径:
mybatis:mapper-locations: classpath:mapper/**/*.xmlconfiguration:map-underscore-to-camel-case: on# 显式声明Mybatis默认枚举转换器(默认EnumTypeHandler)default-enum-type-handler: org.apache.ibatis.type.EnumOrdinalTypeHandler# 指定自定义的枚举转换器路径type-handlers-package: com.example.handlers
传入的是4,经过我们的Handler后实际插入104,说明自定义转换器成功了!但别高兴得太早,上面仅仅是拷贝EnumOrdinalTypeHandler,最终插入的还是ordinal,并不是自定义的枚举字段code。
什么是JdbcType
在正式改代码之前,我们先来解决一个疑惑:JdbcType是什么?
不论是EnumTypeHandler还是EnumOrdinalTypeHandler,setNonNullParameter()的参数列表都有JdbcType:
而且EnumTypeHandler的setNonNullParameter()内部还对JdbcTye做了判断。
所以,什么是JdbcType呢?
MyBatis在org.apache.ibatis.type包下定义了一个JdbcType枚举,用来定义数据库的字段类型,与JdbcType对应的还有JavaType。
如果你跟着上面的代码做了实验,会发现不论怎么修改UserDO,type始终是null:
JdbcType是在SQL中规定的,而不是UserDO中。
其实,只要大家仔细回想,就会发现以前在写SQL语句时好像会指定JdbcType:
<insert id="insertUser">INSERT INTO t_user (`name`, age, rest_day)VALUES(#{name, jdbcType=VARCHAR}, #{age, jdbcType=INTEGER}, #{restDay, jdbcType=INTEGER})
</insert>
如果不指定,MyBatis会自己判断。
现在我把restDay设为jdbcType=INTEGER,再次启动程序就会发现:
注意,这里的4可不是FRIDAY,而是INTEGER:
意思是把Object类型转为INTEGER插入。特别注意,由于上面ps.setObject()方法中传入的是parameter.name(),也就是Enum.name(),所以实际传入的String类型的"FRIDAY"。
当然,此时会报错(实际参数是String,你偏要转为Integer存入):
Cause: java.sql.SQLException: Cannot convert class java.lang.String to SQL type requested due to java.lang.NumberFormatException - For input string: "FRIDAY"
在追踪源码的过程中,有一点很不解:枚举类型最终被归为DECIMAL_UNSIGNED...
但本文不是研究MyBatis源码的,就此打住。总之,必须在MyEnumTypeHandler中对WeekDayEnum进行转换。
注解+反射实现枚举自动类型转换
由于这是demo,且平常我都不写JdbcType,所以我们不考虑type!=null的情况:
/*** 标记需要转换的枚举字段** @author mx*/
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EnumValue {
}
@Getter
public enum WeekDayEnum {MONDAY(1,"星期一"),TUESDAY(2,"星期二"),WEDNESDAY(3,"星期三"),THURSDAY(4,"星期四"),FRIDAY(5,"星期五"),SATURDAY(6,"星期六"),SUNDAY(7,"星期日");WeekDayEnum(Integer code, String desc) {this.code = code;this.desc = desc;}// 标记最终把code作为枚举的值插入数据库@EnumValueprivate final Integer code;private final String desc;
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) throws SQLException {if (jdbcType == null) {// 获取WeekDayEnum的所有字段并循环,找到带有@EnumValue注解的字段Field[] declaredFields = type.getDeclaredFields();for (Field declaredField : declaredFields) {// 是否有@EnumValue注解EnumValue enumValue = declaredField.getAnnotation(EnumValue.class);if (enumValue != null) {Object fieldValue = null;try {// 反射获取标记了@EnumValue注解的字段的valuedeclaredField.setAccessible(true);fieldValue = declaredField.get(parameter);} catch (IllegalAccessException e) {e.printStackTrace();}// 设置值ps.setObject(i, fieldValue);return;}}} else {// 不考虑jdbcType!=null的情况ps.setObject(i, parameter.name(), jdbcType.TYPE_CODE);}
}
大家自己测试,一般来说是没问题的:
==> Preparing: INSERT INTO t_user (`name`, age, rest_day) VALUES(?, ?, ?)
==> Parameters: MyBatis枚举测试(String), 18(Integer), 5(Integer)
<== Updates: 1
注意,存入的是WeekDayEnum.FRIDAY,而数据库显示5,说明这次不是ordinal,而是code。
当然,你也可以把数据库rest_day字段改回VARCHAR,然后把@EnumValue注解加在private String desc上。
还是不要高兴得太早,我们来测一下查询:
是的,MyEnumTypeHandler在取出时会把5当做ordinal解析,所以最终得到的是SATURDAY。
为什么呢?因为当初照抄EnumOrdinalTypeHandler,我们只改了存入的逻辑。
取出的逻辑就不详细说了,大家复制过去看看即可:
@MappedTypes({WeekDayEnum.class})
public class MyEnumTypeHandler<E extends Enum<E>> extends BaseTypeHandler<E> {private final Class<E> type;private final E[] enums;public MyEnumTypeHandler(Class<E> type) {if (type == null) {throw new IllegalArgumentException("Type argument cannot be null");}this.type = type;this.enums = type.getEnumConstants();if (this.enums == null) {throw new IllegalArgumentException(type.getSimpleName() + " does not represent an enum type.");}}@Overridepublic void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) throws SQLException {if (jdbcType == null) {// 获取WeekDayEnum的所有字段并循环,找到带有@EnumValue注解的字段Field[] declaredFields = type.getDeclaredFields();for (Field declaredField : declaredFields) {// 是否有@EnumValue注解EnumValue enumValue = declaredField.getAnnotation(EnumValue.class);if (enumValue != null) {Object fieldValue = null;try {// 反射获取标记了@EnumValue注解的字段的valuedeclaredField.setAccessible(true);fieldValue = declaredField.get(parameter);} catch (IllegalAccessException e) {e.printStackTrace();}// 设置值ps.setObject(i, fieldValue);return;}}} else {// 不考虑jdbcType!=null的情况ps.setObject(i, parameter.name(), jdbcType.TYPE_CODE);}}@Overridepublic E getNullableResult(ResultSet rs, String columnName) throws SQLException {// 定义一个变量,接收从数据库查出的rest_dayObject valueFromDB = null;// 确定当初存入时指定了哪个字段Field enumValueField = null;Field[] declaredFields = type.getDeclaredFields();for (Field field : declaredFields) {// 是否有@EnumValue注解EnumValue enumValue = field.getAnnotation(EnumValue.class);if (enumValue != null) {// 找到带有@EnumValue的字段enumValueField = field;// 数据库返回了ResultSet,也即是查询结果集,我们可以从中获取restDay的值valueFromDB = rs.getObject(columnName, enumValueField.getType());break;}}if (enumValueField == null) {// 如果没有标注@EnumValue,还是按默认的解析返回return getResultByOrdinal(rs, columnName);}// 遍历WeekDayEnum的所有实例,反射获取每个实例中标注了@EnumValue的字段值并比较enumValueField.setAccessible(true);for (E weekday : enums) {Object value = null;try {value = enumValueField.get(weekday);if (valueFromDB.equals(value)) {// 值相等,返回对于的枚举对象return weekday;}} catch (IllegalAccessException e) {e.printStackTrace();}}return null;}private E getResultByOrdinal(ResultSet rs, String columnName) throws SQLException {int ordinal = rs.getInt(columnName);if (ordinal == 0 && rs.wasNull()) {return null;}return toOrdinalEnum(ordinal);}private E toOrdinalEnum(int ordinal) {try {return enums[ordinal];} catch (Exception ex) {throw new IllegalArgumentException("Cannot convert " + ordinal + " to " + type.getSimpleName() + " by ordinal value.", ex);}}@Overridepublic E getNullableResult(ResultSet rs, int columnIndex) {return null;}@Overridepublic E getNullableResult(CallableStatement cs, int columnIndex) {return null;}}
MyBatis-Plus对枚举的处理
有时候就是这么巧,万万没想到MyBatis-Plus的处理方式和我们惊人地相似。
自3.1.0
开始,如果你无需使用原生枚举,可配置默认枚举来省略扫描通用枚举配置
- 升级说明:
3.1.0
以下版本改变了原生默认行为,升级时请将默认枚举设置为EnumOrdinalTypeHandler
- 影响用户:
实体中使用原生枚举 - 其他说明:
配置枚举包扫描的时候能提前注册使用注解枚举的缓存
声明通用枚举属性
方式一: 使用 @EnumValue 注解枚举属性
public enum GradeEnum {PRIMARY(1, "小学"), SECONDORY(2, "中学"), HIGH(3, "高中");GradeEnum(int code, String descp) {this.code = code;this.descp = descp;}@EnumValue//标记数据库存的值是codeprivate final int code;//。。。
}
方式二: 枚举属性,实现 IEnum 接口如下:
public enum AgeEnum implements IEnum<Integer> {ONE(1, "一岁"),TWO(2, "二岁"),THREE(3, "三岁");private int value;private String desc;@Overridepublic Integer getValue() {return this.value;}
}
实体属性使用枚举类型
public class User {/*** 名字* 数据库字段: name varchar(20)*/private String name;/*** 年龄,IEnum接口的枚举处理* 数据库字段:age INT(3)*/private AgeEnum age;/*** 年级,原生枚举(带{@link com.baomidou.mybatisplus.annotation.EnumValue}):* 数据库字段:grade INT(2)*/private GradeEnum grade;
}
配置扫描通用枚举
mybatis-plus:# 支持统配符 * 或者 ; 分割typeEnumsPackage: com.baomidou.springboot.entity.enums....
没想到,我的构思被MyBatis-Plus抄袭了。
一些说明
其实数据库有一种枚举字段类型,大家可以了解下,本文并没有介绍,个人不建议使用。
另外,MyEnumTypeHandler还有很多不足,最大的不足就是仍然不够通用。
假设现在系统新增InvitationStatusEnum:
DROP TABLE IF EXISTS t_user;
CREATE TABLE `t_user` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',`name` varchar(50) DEFAULT '' COMMENT '姓名',`age` tinyint(3) unsigned DEFAULT NULL COMMENT '年龄',`rest_day` tinyint(1) DEFAULT 1 COMMENT '休息日',`invitation_status` varchar(50) DEFAULT '' COMMENT '面试状态',`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',`deleted` tinyint(1) unsigned DEFAULT '0' COMMENT '是否删除',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
@Getter
public enum InvitationStatusEnum {WAIT_FOR_DEAL("wait_for_deal", "等待处理"),SUITABLE("suitable", "合适"),;@EnumValueprivate final String value;private final String desc;InvitationStatusEnum(String value, String desc) {this.value = value;this.desc = desc;}
}
你会发现又不行了。除非在@MappedTypes的属性中另外指定InvitationStatusEnum.class:
也就是说,作为通用组件的MyEnumTypeHandler还是无法避免被反复修改,不如MyBatis-Plus来得优雅。有兴趣的同学可以自行研究(意义不大)。
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬