Mybatis Plus 空字符串插入问题引起的思考Mybatis Plus 空字符串插入问题引起的思考 在这篇文章中,
Mybatis Plus 空字符串插入问题引起的思考
在这篇文章中,结合笔者在工作中使用到 Mybatis Plus 的过程中遇到的空字符串丢失的问题,谈一谈对 Mybatis Plus 的一些看法。由于项目历史问题,使用到的 Mybatis Plus 的版本如下:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.0-beta</version>
</dependency>
一、问题背景
我们存在如下数据库表:
create table ad_person_send_rule
(
id bigint unsigned auto_increment
primary key,
status tinyint(3) default 1 not null comment '状态',
operator_user varchar(128) charset utf8 default '' not null comment '更新人',
account_type varchar(10) charset utf8 default '' not null comment '账户类型',
account_kinds varchar(10) charset utf8 default '' not null comment '账户性质',
include_account_ids text charset utf8 null comment '允许的账户id',
exclude_account_ids text charset utf8 null comment '排除账户id',
create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
对于 text 类型的 exclude_account_ids
字段,是不能设置默认值的,这是由于 MySQL 5.7 的严格模式导致的,什么都不写的话只能为空。对于业务来说,exclude_account_ids
空字符串是有意义的,所以在某些情况下,我们需要直接插入空字符串。
二、Mybatis Plus 执行插入过程丢失空字符串
构建对应的数据库实体类:
public class AdPersonSendRule implements Serializable {
@TableField(exist = false)
private static final long serialVersionUID = -3646240230891328158L;
@TableId(type = IdType.AUTO)
private Long id;
private Integer status;
private String operatorUser;
private String accountType;
private String accountKinds;
private String includeAccountIds;
private String excludeAccountIds;
private Date createTime;
private Date updateTime;
}
执行插入操作:
public void createPersonAuditRuleTest() {
PersonAuditRuleRequest personAuditRuleRequest = new PersonAuditRuleRequest();
personAuditRuleRequest.setIncludeAccountIDs("129,240,9123,100");
// 设置为空字符串
personAuditRuleRequest.setExcludeAccountIDs("");
personAuditRuleRequest.setAccountKinds(new HashSet<>());
personAuditRuleRequest.setAccountTypes(new HashSet<>());
Result robotAuditRule = personAuditController.createPersonAuditRule(personAuditRuleRequest);
log.info("插入结果结果为:{}", JSONUtil.toJsonStr(robotAuditRule));
}
其中我们的 excludeAccountIds 字段的值为空字符串,数据库的插入结果为:
若把 excludeAccountIds 字段上标注 @TableField
注解后,数据库中插入结果为:
可见,没有带 @TableField
注解时,插入空字符串对应的值为 null,而带了 @TableField
注解时,插入空字符串对应的值为空字符串,这才是我想要的结果,但是,为什么会造成这样的情况呢,我们要到看 Mybatis Plus 底层实际插入的 SQL 是什么,接下来我们从源码的角度出发去分析。
三、源码剖析 Mybatis Plus 中 SQL 解析与执行过程
我们应该如何去排查这个问题呢?众所周知,Mybatis Plus 框架其实是对 Mybatis 框架的进一步封装,提供一些更加便捷的方式,所以我们可以从 Mybatis 框架的执行流程去排查分析流程。
在 Mybatis 中,一条 SQL 的执行的流程如下图所示:
3.1 SQL 执行过程分析
所以,我们可以在 PrepareStatementHandler 声明语句处理器中打下断点,去排查原因。对于我们的 insert 方法来说,在 PrepareStatementHandler 中实际上调用的是 update 方法(其实 insert、update、delete 的 SQL 语句的执行都会调用该类的 update 方法)
@Override
public int update(Statement statement) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
int rows = ps.getUpdateCount();
Object parameterObject = boundSql.getParameterObject();
KeyGenerator keyGenerator = mappedStatement.getKeyGenerator();
keyGenerator.processAfter(executor, mappedStatement, ps, parameterObject);
return rows;
}
对于没有带 @TableField
情况,我们可以查看 boundSql
这个成员变量:
- 可见,插入的 SQL 语句为
INSERT INTO ad_person_send_rule(status,operator_user,include_account_ids) VALUES ( ?,?,? )
- 明显不存在我们想要的
exclude_account_ids
字段;
对于带 @TableField
情况,我们可以继续查看 boundSql
这个成员变量:
- 可见,插入的 SQL 语句为
INSERT INTO ad_person_send_rule (status,operator_user,include_account_ids,exclude_account_ids )VALUES ( ?,?,?,? )
- 明显不存在我们想要的
exclude_account_ids
字段,对应的参数为空字符串。
3.2 SQL 解析过程分析
为什么只因为一个注解,Mybatis Plus 解析后生成 SQL 语句会不同呢?这就要从 Mybatis Plus 的解析 SQL 语句开始找答案了。
在 Mybatis Plus 中,SQL 的解析其实跟 Mybatis 大同小异,入口基本都是 XmlMapperBuilder 类。对于注解类型的注入,是在 MybatisMapperAnnotationBuilder 的 parse 方法进行解析的。但是对于 BaseMapper 接口里面的方法,Mybatis Plus 会优先进行解析:
@Override
public void parse() {
String resource = type.toString();
if (!configuration.isResourceLoaded(resource)) {
loadXmlResource();
configuration.addLoadedResource(resource);
assistant.setCurrentNamespace(type.getName());
parseCache();
parseCacheRef();
Method[] methods = type.getMethods();
//d注入 CURD 动态 SQL (应该在注解之前注入)
if (BaseMapper.class.isAssignableFrom(type)) {
GlobalConfigUtils.getSqlInjector(configuration).inspectInject(assistant, type);
}
for (Method method : methods) {
try {
// issue #237
if (!method.isBridge()) {
parseStatement(method);
}
} catch (IncompleteElementException e) {
configuration.addIncompleteMethod(new MethodResolver(this, method));
}
}
}
parsePendingMethods();
}
核心解析方法就是 AbstractSqlInjector 的 inspectInject 方法:
@Override
public void inspectInject(MapperBuilderAssistant builderAssistant, Class<?> mapperClass) {
String className = mapperClass.toString();
Set<String> mapperRegistryCache = GlobalConfigUtils.getMapperRegistryCache(builderAssistant.getConfiguration());
if (!mapperRegistryCache.contains(className)) {
List<AbstractMethod> methodList = this.getMethodList();
if (CollectionUtils.isEmpty(methodList)) {
throw new MybatisPlusException("No effective injection method was found.");
}
// 循环注入BaseMapper方法
methodList.forEach(m -> m.inject(builderAssistant, mapperClass));
mapperRegistryCache.add(className);
}
}
public void inject(MapperBuilderAssistant builderAssistant, Class<?> mapperClass) {
configuration = builderAssistant.getConfiguration();
this.builderAssistant = builderAssistant;
languageDriver = configuration.getDefaultScriptingLanguageInstance();
Class<?> modelClass = extractModelClass(mapperClass);
if (null != modelClass) {
// 注入自定义方法
TableInfo tableInfo = TableInfoHelper.initTableInfo(builderAssistant, modelClass);
injectMappedStatement(mapperClass, modelClass, tableInfo);
}
}
- 对于 insert 方法,AbstractMethod 的实现类是 Insert,所以调用
injectMappedStatement
方法时,实际上是调用 Insert 类的injectMappedStatement
方法;
对于 insert 方法的解析:
public class Insert extends AbstractMethod {
@Override
public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
//...
StringBuilder fieldBuilder = new StringBuilder();
StringBuilder placeholderBuilder = new StringBuilder();
SqlMethod sqlMethod = SqlMethod.INSERT_ONE;
fieldBuilder.append("\n<trim prefix="(" suffix=")" suffixOverrides=",">");
placeholderBuilder.append("\n<trim prefix="(" suffix=")" suffixOverrides=",">");
// 是否 IF 标签判断
List<TableFieldInfo> fieldList = tableInfo.getFieldList();
for (TableFieldInfo fieldInfo : fieldList) {
if (FieldFill.INSERT == fieldInfo.getFieldFill()
|| FieldFill.INSERT_UPDATE == fieldInfo.getFieldFill()) {
fieldBuilder.append(fieldInfo.getColumn()).append(",");
placeholderBuilder.append("#{").append(fieldInfo.getEl()).append("},");
} else {
fieldBuilder.append(convertIfTagIgnored(fieldInfo, false));
fieldBuilder.append(fieldInfo.getColumn()).append(",");
fieldBuilder.append(convertIfTagIgnored(fieldInfo, true));
placeholderBuilder.append(convertIfTagIgnored(fieldInfo, false));
placeholderBuilder.append("#{").append(fieldInfo.getEl()).append("},");
placeholderBuilder.append(convertIfTagIgnored(fieldInfo, true));
}
}
fieldBuilder.append("\n</trim>");
placeholderBuilder.append("\n</trim>");
String sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), fieldBuilder.toString(), placeholderBuilder.toString());
//构造sql语句
SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
return this.addInsertMappedStatement(mapperClass, modelClass, sqlMethod.getMethod(), sqlSource, keyGenerator, keyProperty, keyColumn);
}
}
- 最终构造 SQL 语句其实是 createSqlSource 方法,但是现在已经可以看出 SQL 是如何构造出来的了。
我们来看看没有 TableField 情况,构造出实际的 SQL 如下图:
<script>INSERT INTO ad_person_send_rule
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="status!=null ">status,</if>
<if test="operatorUser!=null and operatorUser!=''">operator_user,</if>
<if test="accountType!=null and accountType!=''">account_type,</if>
<if test="accountKinds!=null and accountKinds!=''">account_kinds,</if>
<if test="includeAccountIds!=null and includeAccountIds!=''">include_account_ids,</if>
<if test="excludeAccountIds!=null and excludeAccountIds!=''">exclude_account_ids,</if>
<if test="createTime!=null ">create_time,</if><if test="updateTime!=null ">update_time,</if>
</trim> VALUES
<trim prefix="(" suffix=")" suffixOverrides=","><if test="status!=null ">#{status},</if>
<if test="operatorUser!=null and operatorUser!=''">#{operatorUser},</if>
<if test="accountType!=null and accountType!=''">#{accountType},</if>
<if test="accountKinds!=null and accountKinds!=''">#{accountKinds},</if>
<if test="includeAccountIds!=null and includeAccountIds!=''">#{includeAccountIds},</if>
<if test="excludeAccountIds!=null and excludeAccountIds!=''">#{excludeAccountIds},</if>
<if test="createTime!=null ">#{createTime},</if><if test="updateTime!=null ">#{updateTime},</if>
</trim>
</script>
- 可以看出在 excludeAccountIds 字段中,它的判断条件只是
excludeAccountIds!=null and excludeAccountIds!=''
,所以对于空字符串的情况,它是可以不插入的。
对于带了 TableField 注解的情况,构造出实际的 SQL 如下图:
<script>INSERT INTO ad_person_send_rule
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="status!=null ">status,</if>
<if test="operatorUser!=null and operatorUser!=''">operator_user,</if>
<if test="accountType!=null and accountType!=''">account_type,</if>
<if test="accountKinds!=null and accountKinds!=''">account_kinds,</if>
<if test="includeAccountIds!=null and includeAccountIds!=''">include_account_ids,</if>
<if test="excludeAccountIds!=null">exclude_account_ids,</if>
<if test="createTime!=null ">create_time,</if>
<if test="updateTime!=null ">update_time,</if>
</trim> VALUES
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="status!=null ">#{status},</if>
<if test="operatorUser!=null and operatorUser!=''">#{operatorUser},</if>
<if test="accountType!=null and accountType!=''">#{accountType},</if>
<if test="accountKinds!=null and accountKinds!=''">#{accountKinds},</if>
<if test="includeAccountIds!=null and includeAccountIds!=''">#{includeAccountIds},</if>
<if test="excludeAccountIds!=null">#{excludeAccountIds},</if><if test="createTime!=null ">#{createTime},</if>
<if test="updateTime!=null ">#{updateTime},</if>
</trim>
</script>
- 可以看出在 excludeAccountIds 字段中,它的判断条件只是
excludeAccountIds!=null
,所以对于空字符串的情况,它是可以插入的。
所以我们就可以得出,其实是 test 中判断条件的不同,导致生成 SQL 的不同,而 test 中的判断条件,取决于 TableFieldInfo 类中的 FieldStrategy 字段:
public enum FieldStrategy {
IGNORED(0, "忽略判断"),
NOT_NULL(1, "非 NULL 判断"),
NOT_EMPTY(2, "非空判断");
}
没有 TableField 注解情况,它的类型是 NOT_EMPTY:
我们看看没有有 TableField参数 的 TableFieldInfo 的构造函数:
public TableFieldInfo(boolean underCamel, GlobalConfig.DbConfig dbConfig, TableInfo tableInfo, Field field) {
if (dbConfig.isColumnUnderline()) {
/* 开启字段下划线申明 */
this.setColumn(dbConfig, StringUtils.camelToUnderline(field.getName()));
/* 未开启下划线转驼峰模式 AS 转换 */
if (!underCamel) {
this.related = true;
}
} else {
this.setColumn(dbConfig, field.getName());
}
this.property = field.getName();
this.el = field.getName();
//全局配置,默认是NOT_EMPTY
this.fieldStrategy = dbConfig.getFieldStrategy();
this.propertyType = field.getType();
this.setCondition(dbConfig);
tableInfo.setLogicDelete(this.initLogicDelete(dbConfig, field));
}
- 默认使用 dbConfig 的全局配置,默认是 NOT_EMPTY;
有 TableField 注解情况,它的类型是 NOT_NULL:
我们看看有 TableField 参数 的 TableFieldInfo 的构造函数:
//这是有
public TableFieldInfo(boolean underCamel, GlobalConfig.DbConfig dbConfig, TableInfo tableInfo,
String column, String el, Field field, TableField tableField) {
//...
// 默认使用TableField中的NOT_NULL
if (dbConfig.getFieldStrategy() != tableField.strategy()) {
this.fieldStrategy = tableField.strategy();
} else {
this.fieldStrategy = dbConfig.getFieldStrategy();
}
//...
}
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
public @interface TableField {
String value() default "";
String el() default "";
boolean exist() default true;
String condition() default "%s=#{%s}";
String update() default "";
FieldStrategy strategy() default FieldStrategy.NOT_NULL;
FieldFill fill() default FieldFill.DEFAULT;
}
- 默认使用 @TableField 的默认配置,默认是 NOT_EMPTY;
四、关于 Mybatis Plus 的看法
在 Mybatis Plus 框架中,就一个简单的 insert 方法,因为 TableField 中 FieldStrategy 默认配置的问题,导致 insert 有不同的 SQL 实现方式。况且 TableField 注解中的属性,我们不会经常去使用,最常用到的只是 value 字段,做一个 column 名称的映射罢了。在我经常使用到的 MybatisX 生成插件中是不会生成 TableField 字段的,默认使用 DO 类的类名去做数据库字段的映射。
我们回到刚开始问题,如果我不配置 TableField 字段,那么对于空字符串来说,是不是就插入不到表中了呢?这是不是不太合理呢?毕竟在这里,空字符串是符合业务逻辑的。虽然可以通过配置 TableField 注解来避免这种情况,又或者是升级 Mybatis Plus 版本去解决这个问题,但这个排查问题过程着实让人烦恼,更别说没有接触过 Mybatis 源码的同学了。
在某种层面中,Mybatis Plus 可以提高我们开发的效率,提供了很多便捷的数据库操作的方法。但是我始终认为:「SQL 的执行是关乎数据的生成与变更的,这么重要的语句应该暴露在我们开发者的眼中,而不是让框架的底层把这些语句给屏蔽掉,所以我更建议使用配置 XML 文件的方式去编写 SQL,这样 SQL 清晰且可控。」
转载自:https://juejin.cn/post/7420827144110997544