在关系型数据库的表设计中, 一般要遵循3个范式.第一范式就是:
单个字段不可分割
但是, 规则往往有人打破, 比如:
-
在一个字段里存个JSON -
在一个字段里存CSV -
在一个字段里存多个布尔值
今天, 分享下如何巧妙运用 Mybatis TypeHandler
, 优雅应对这个场景.
环境准备
数据表
建立这么一个表t
, 字段c
存着一个JSON 字符串.
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`config` text COLLATE utf8mb4_0900_bin NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin;
config 字段里的内容, 序列化自如下的类:
@Data
public class Config {
Boolean haveCat;
Boolean haveDog;
Boolean haveGirlFriend;
}
建立数据表映射类
如果不希望在业务层, 进行手动序列化和反序列化, 这里的类型不要用String, 而是写成强类型的Config.
@Data
public class T {
Integer id;
Config config;
//String config 不使用 String
}
接下来, 就到了TypeHandler出场时刻了.
TypeHandler 简介
众所周知, Java 里的类型是int, long, String
,等等等,而数据库里的类型则是JdbcType.NUMERIC, JdbcType.INTEGER
等等.
-
当从数据库里把数据查出来时候, MyBatis需要知道, 每一种数据库的类型应该如何转换为Java类型. -
当把Java对象写入数据库时候, MyBatis也需要知道, Java类型,如何变成 JdbcType
.
这里面的桥梁, 就是TypeHandler
.
在org.apache.ibatis.type
包下面, mybatis 默认提供里几十种 TypeHandler的实现.但如果不够用的话, 我们可以自由的来实现该接口, 添加新实现.
使用mybatis-spring
的话, 在配置里指定一下自定义TypeHandler的位置即可:
mybatis.type-handlers-package=sh.now.afk.sql.sqls.typehandler
自定义xml配置话,也有相应的配置区域. 也可以考虑是全局起作用, 还是局部起作用.
JSONTypeHandler
自己实现的 ConfigTypeHandler 如下
public class ConfigTypeHandler extends org.apache.ibatis.type.BaseTypeHandler<Config> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Config parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, JSON.toJSONString(parameter));
}
@Override
public Config getNullableResult(ResultSet rs, String columnName) throws SQLException {
return JSON.parseObject(rs.getString(columnName), getRawType());
}
}
这里偷懒用fastjson, 换任意JSON库均可.还有2个方法,已经省略.
如此这般, 在遇到 Config
这样不认识的类时候, mybatis就知道如何去处理, 在数据库类型和Java类型之间做映射了.
CSV Type Handler
CSV 也就逗号分割值,俗称 Comma Separate Values.
-
1,2,3 这样就是一个典型的csv -
1|2|3 这样属于自定义分隔符为(|)的广义CSV
我们也可以针对这种类型, 写一个typehandler, 比如这样.
public abstract class CSVTypeHandler<T extends List<E>, E> extends org.apache.ibatis.type.BaseTypeHandler<T> {
abstract protected E of(String s);
abstract protected T ofList(List<E> s);
@Override
public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, parameter.stream().map(x -> String.valueOf(x)).collect(Collectors.joining(",")));
}
@Override
public T getNullableResult(ResultSet rs, String columnName) throws SQLException {
return ofList(Arrays.stream(rs.getString(columnName).split(",")).map(this::of).toList());
}
}
这次这个是泛型的, 需要用时候, 继承该类, 并实现好两个工厂方法即可.
比如我们甚至可以在值里存成a=1,b=2,c=3
这种格式, 然后解析成List<Pair<String,Integer>
, 只需要这么写实现类:
public class KVListCSVHandler extends CSVTypeHandler<List<Pair<String,Integer>>, Pair<String,Integer>>{
@Override
protected Pair<String, Integer> of(String s) {
String[] arr = s.split("=");
return Pair.of(arr[0], Integer.valueOf(arr[1]));
}
@Override
protected List<Pair<String, Integer>> ofList(List<Pair<String, Integer>> s) {
return s;
}
}
前面的JSON类型, 也可以抽象出类似的抽象类, 就不再提了. 这个CSV的写法有问题, 不过因为我十分不推荐存CSV, 就不去改动他了, 仅作示意.
BIT TypeHandler
某些奇怪场景下, 会有往一个 COLUMN 里存入尽量多信息的要求. 比如一个int的32个bit, 要存入32个布尔值.
-
第0bit表示有没有男朋友 -
第1bit表示有没有女朋友 -
第2bit表示有没有猫 -
第3bit表示有没有狗 -
….
这种情况下, 也可以建立一个Config类,然后使用 TypeHandler. 不过相比之下, 最重要的部分,是如何记录和组织某一个bit究竟是什么意思, 已经避免重复使用同一个位置bit表示不同意思.
我也没想到什么好方法, 只能暂时用反射,考虑组织成一个java
+1个enum
, 比如这样:
@Data
public class Config {
Boolean haveCat;
Boolean haveDog;
Boolean haveGirlFriend;
Boolean live;
Boolean haveBoyFriend;
}
public enum ConfigEnum {
man(0, getField("haveBoyFriend")),
live(1, getField("live")),
haveGirlFriend(2, getField("haveGirlFriend")),
haveDog(3, getField("haveDog")),
haveCat(4, getField("haveCat")),
;
private final int bitPosition;
private final Field field;
ConfigEnum(int i, Field field) {
this.bitPosition = i;
this.field = field;
}
public static int agg(Config config) {
int value = 0L;
for (ConfigEnum configEnum : values()) {
Object fieldValue = ReflectionUtils.getField(configEnum.field, config);
if (fieldValue == null) continue;
int flag = (Boolean) fieldValue ? 1 : 0;
value |= flag << configEnum.bitPosition;
}
return value;
}
public static void write(Config target, int n) {
for (ConfigEnum configEnum : values()) {
int mask = 1 << configEnum.bitPosition;
int l = (n & mask) >>> configEnum.bitPosition;
ReflectionUtils.setField(configEnum.field, target, l == 1);
}
}
private static Field getField(String name) {
Field field = ReflectionUtils.findField(Config.class, name);
ReflectionUtils.makeAccessible(field);
return field;
}
}
这样在编写TypeHandler时候就比较简单了, 用Enum小工具即可.
public class IntegerToBitConfigTypeHander extends BaseTypeHandler<Config> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Config parameter, JdbcType jdbcType) throws SQLException {
ps.setInt(i, ConfigEnum.agg(parameter));
}
@Override
public Config getNullableResult(ResultSet rs, String columnName) throws SQLException {
int n = rs.getInt(columnName);
Config target = new Config();
ConfigEnum.write(target,n);
return target;
}
}
当然, 也可以用更多其他的类型或者Map之类东西来操作, 依赖反射是件不好的事.
后话
遵守数据库设计的范式是好习惯.
今天的例子全是坏例子, 是在表已经被设计烂了的情况下, 如何去补救, 如何把不应该暴露给业务层的JSON,CSV,BIT解析
给隐藏起来. 这些都是存储方式的具体细节, 不应该侵入业务. 当然, 写到数据库框架里到底对不对, 也是很有待商榷的事.
额外补充
前面JSON什么的, 其实还能忍.后面的, 就非常没必要了. 因为MySQL提供了一个原生的数据类型SET
, 他天生就是CSV+BIT化的, 最多占用8个字节,存放64个元素是否存在的信息.
比如按照下面语句建表
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`config` set('haveGirlFriend','haveBoyFriend','haveDog','haveCat') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin;
那这里的存储,每个串是否存在,就只占用了一个bit, 但是读写起来却更易懂.每个bit代表哪个值, 也是维护在表定义中, 而非暴露在Java里.自己搞CSV或者BIT,属实属于MySQL不熟,造方轮子了.
id | config |
---|---|
1 | haveDog,haveCat |
insert into t(`id`, `config`) VALUES(1, ('haveDog,haveCat'));
select * from t;
原文始发于微信公众号(K字的研究):如何用MyBatis TypeHandler应对表里的奇葩column
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/41692.html