一、问题背景
后端框架:Spring Boot 2.x+ MyBatis-Plus 3.5.2
数据库:PostgreSQL,部分字段使用 jsonb 存储结构化数据
数据库表结构 :
CREATE TABLE “bpm_record_form_data”(
“id” serial4 NOT NULL,
“curr_time” timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
“record_id” int4,
“data” jsonb,
CONSTRAINT “bpm_record_form_data_pkey” PRIMARY KEY (“id”)
);
COMMENT ON COLUMN “bpm_record_form_data”.“id” IS ‘ID’;
COMMENT ON COLUMN “bpm_record_form_data”.“curr_time” IS ‘最后修改时间’;
COMMENT ON COLUMN “bpm_record_form_data”.“data” IS ‘表单数据’;
COMMENT ON COLUMN “bpm_record_form_data”.“record_id” IS ‘发起项目ID’;
COMMENT ON TABLE “bpm_record_form_data” IS ‘表单解析数据表’;
出现的错误:
17:30:56.402 [main] ERROR druid.sql.Statement - [statementLogError,148] - {conn-110005, pstmt-120001} execute error. INSERT INTO bpm_record_form_data ( record_id,
data ) VALUES ( ?,
? )
org.postgresql.util.PSQLException: ERROR: column “data” is of type jsonb but expression is of type character varying
建议:You will need to rewrite or cast the expression.
位置:72
二、错误原因分析
- 从 PostgreSQL 角度解释:
jsonb 是 PostgreSQL 的强类型字段,不能自动将字符串转换为 jsonb
MyBatis 默认会把 Java 对象当作字符串处理(varchar) - 从 JDBC/MyBatis 角度分析:
插入时执行的是 setString(…),会导致类型不匹配
正确的做法应该是 setObject(…, Types.OTHER),让 PostgreSQL 识别为 jsonb - 感兴趣可以断点调试下org.apache.ibatis.executor.resultset.DefaultResultSetHandler.applyAutomaticMappings
核心逻辑就是遍历未显式映射的列,尝试通过 TypeHandlerRegistry 查找合适的 TypeHandler,从而完成自动映射。
三、解决方案
- 使用自定义 TypeHandler
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONReader;
import com.alibaba.fastjson2.JSONWriter;
import com.alibaba.fastjson2.filter.Filter;
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.*;
@MappedTypes({Object.class})
@MappedJdbcTypes(JdbcType.OTHER)
public class JsonbTypeHandler<T> extends BaseTypeHandler<T> {
private static final Filter AUTO_TYPE_FILTER = JSONReader.autoTypeFilter("com.扫描包");
private Class<T> clazz;
public JsonbTypeHandler() {
super();
}
public JsonbTypeHandler(Class<T> clazz) {
super();
this.clazz = clazz;
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
ps.setObject(i, JSON.toJSONString(parameter, JSONWriter.Feature.WriteClassName), Types.OTHER);
}
@Override
public T getNullableResult(ResultSet rs, String columnName) throws SQLException {
String json = rs.getString(columnName);
if (json == null) return null;
return JSON.parseObject(json, clazz, AUTO_TYPE_FILTER);
}
@Override
public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String json = rs.getString(columnIndex);
if (json == null) return null;
return JSON.parseObject(json, clazz, AUTO_TYPE_FILTER);
}
@Override
public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String json = cs.getString(columnIndex);
if (json == null) return null;
return JSON.parseObject(json, clazz, AUTO_TYPE_FILTER);
}
}
这里是用的fastjson2
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.41</version>
</dependency>
- 实体类使用注解指定 TypeHandler
@Accessors(chain = true)
@Data
@NoArgsConstructor
@TableName("bpm_record_form_data")
public class BpmRecordFormData implements Serializable {
private static final long serialVersionUID = 1L;
/**
* ID
*/
@JsonFormat(shape = JsonFormat.Shape.STRING)
@TableId
private Long id;
/**
* 发起项目ID
*/
private Long recordId;
/**
* 最后修改时间
*/
@JsonSerialize(using = CustomLocalDateTimeSerializer.class)
@JsonDeserialize(using = CustomLocalDateTimeDeserializer.class)
private LocalDateTime currTime;
/**
* 表单数据
*/
@TableField(value = "data", typeHandler = JsonbTypeHandler.class)
private List<FormAnalysisModuleData> data;
}
3.MyBatis 配置注册 TypeHandler
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
/*
自动分页: PaginationInnerInterceptor
多租户: TenantLineInnerInterceptor
动态表名: DynamicTableNameInnerInterceptor
乐观锁: OptimisticLockerInnerInterceptor
sql 性能规范: IllegalSQLInnerInterceptor
防止全表更新与删除: BlockAttackInnerInterceptor
*/
// 新的分页插件,一缓和二缓遵循mybatis的规则,需要设置 MybatisConfiguration#useDeprecatedExecutor = false 避免缓存出现问题(该属性会在旧插件移除后一同移除)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 针对 update 和 delete 语句 作用: 阻止恶意的全表更新删除
//interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
// 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
//interceptor.addInnerInterceptor(new IllegalSQLInnerInterceptor());
return interceptor;
}
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> {
configuration.getTypeHandlerRegistry().register(List.class, JdbcType.OTHER, JsonbTypeHandler.class);
configuration.getTypeHandlerRegistry().register(Map.class, JdbcType.OTHER, JsonbTypeHandler.class);
configuration.getTypeHandlerRegistry().register(Object.class, JdbcType.OTHER, JsonbTypeHandler.class);
};
}
}
为什么必须注册 TypeHandler?
MyBatis-Plus 的 getById() 等通用方法,并不会读取你实体类字段上的 @TableField(typeHandler = …) 注解;
它依赖的是 MyBatis 全局的 TypeHandlerRegistry;
如果你没有注册 JsonbTypeHandler 到 Object.class 的处理器,它会使用默认的 StringTypeHandler 去尝试解析 PostgreSQL 的 jsonb 字段,结果就是返回原始 JSON 字符串甚至抛异常。