ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MyBatis自定义TypeHandler:搞定JSON字段与AES加密映射

MyBatis自定义TypeHandler:搞定JSON字段与AES加密映射 我接手过不少半路项目最头疼的就是数据库字段和Java类型对不上。数据库里明明存了个VARCHARJava代码里却想直接拿一个Map来接用户表里手机号是密文查询的时候却得先解密再比对枚举存进库里的到底是数字还是字符串每个项目规则还不一样——这些问题MyBatis和MyBatis-Plus默认的类型转换规则根本管不过来真正能兜底的方案就是自定义TypeHandler。这篇文章就围绕TypeHandler这条主线先讲清楚它到底在MyBatis执行链路里干了什么活再给出一套可以直接抄的JSON字段Handler和AES加密字段Handler实现最后把MyBatis和MyBatis-Plus两种场景下的注册、踩坑、排查路径全过一遍。不管你现在是刚接触框架定制还是已经在生产环境里被字段映射问题折腾过这里面的内容都能让你少走弯路。1. 什么时候你才真正需要自定义TypeHandler1.1 那些映射不上的字段类型先说个最直接的问题为什么不直接用TableField加上默认映射非要折腾Handler因为MyBatis自带的类型转换只覆盖了最常规的Java类型和JDBC类型对比如String对应VARCHAR、Integer对应INTEGER、LocalDateTime对应TIMESTAMP。一旦你的字段带上业务语义默认规则就失效了。我归纳了一下实际项目中触发自定义TypeHandler需求的基本是下面这几类场景数据库存的是JSON字符串Java实体里却声明成MapString, Object、ListLong或者某个自定义DTO对象。默认情况下MyBatis拿到字符串根本不知道往Map里塞直接抛TypeException。敏感字段需要加密存储数据库里是密文VARCHARJava对象里是明文String。你既不想在Service层每个方法里都手动加密解密又不想让加密逻辑污染业务代码。枚举类型。Java枚举在库里存的是code数字或value字符串但Java属性是枚举对象。MyBatis默认的EnumTypeHandler只处理枚举name一旦枚举值改过名字历史数据就全部错位。一些特殊JDBC类型比如PostgreSQL的jsonb、cidrMySQL的BIT存布尔数组这些用默认处理器也无法正确处理。自定义值对象比如金额类Money、身份证号类IdCard这类领域对象往往需要一套统一的序列化规则不适合在每个Mapper里重复写转换逻辑。这五类场景的共同点是什么**类型转换的规则不是框架能猜出来的而是业务方自己定义的。**框架猜不出来又不愿意把转换代码散落到业务层到处复制那就得让MyBatis在结果映射和参数赋值两个环节之间插入一段我们自己的转换逻辑——这段逻辑的载体就是TypeHandler。1.2 一个比喻快速理解Handler的定位很多人看官方文档会把TypeHandler理解成一个自定义转换器但这个说法太模糊。我更喜欢用物流分拣来打比方MyBatis执行一条SQL本质上就是Java对象 - SQL参数 - 数据库结果 - Java对象的双向运输过程。PreparedStatement是装货的卡车ResultSet是卸货的传送带。默认情况下MyBatis的运输队会根据货物标签Java类型 JdbcType自动选一辆默认车来搬运。但如果你的货是易碎品自定义类型或者标签贴错了类型不匹配默认车队就不知道怎么搬了。TypeHandler就是一组特制的打包箱。你在装货的时候告诉MyBatis这个Java对象进数据库之前请先套上我给的箱子转成特定JDBC类型或字符串格式。在卸货的时候也一样数据库给我这个值请先按我给的规则拆箱再塞进Java属性。理解了这层定位再去看官方文档里那四个方法就知道它们分别对应运输的哪个环节了。2. TypeHandler运行时到底做了什么四个方法的真相2.1 从PreparedStatement到ResultSet的双向转换TypeHandler这个接口的核心方法只有4个加上一个setParameter默认方法和两个泛型继承方法一共也就6个方法需要关注。但实际开发中你只需要实现BaseTypeHandlerT抽象类覆盖下面两个写入方法和两个读取方法方法名调用时机做什么setNonNullParameterSQL执行前给PreparedStatement绑定参数时把Java对象的属性转成JDBC能识别的类型调用ps.setXxxsetNonnullParameter串行版罕见场景可不重写与setNonNullParameter二选一一般用前者getNullableResult(ResultSet, String)查询结果按列名映射时从ResultSet按列名取值转成Java类型返回getNullableResult(ResultSet, int)查询结果按下标映射时同上的下标版本一般在resultMap列映射或游标查询时触发getNullableResult(CallableStatement, int)存储过程出参处理存储过程返回值场景setParameterParam注解等场景默认走setNonNullParameter很少直接重写2.2 为什么setNonNullParameter和getNullableResult要各写一遍我见过不少新手只实现setNonNullParameter然后发现查询结果还是解析不了——因为忘了读取方向。TypeHandler最大的特点就是它是双向的写入方向Java对象属性 -setNonNullParameter- PreparedStatement - 数据库读取方向数据库列值 -getNullableResult- Java对象属性这两条链路在MyBatis中分别由PreparedStatementHandler和ResultSetHandler去调用。如果你只处理了写入查询出来的字段依然会被默认规则处理该报错的还是报错该读错的还是读错。2.3 最小可用的代码骨架按照上面四个方法写一个最小骨架大概长这样public class MapTypeHandler extends BaseTypeHandlerMapString, Object { private static final ObjectMapper MAPPER new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, MapString, Object parameter, JdbcType jdbcType) throws SQLException { // 写入方向Java Map - JSON字符串 ps.setString(i, MAPPER.writeValueAsString(parameter)); } Override public MapString, Object getNullableResult(ResultSet rs, String columnName) throws SQLException { // 读取方向数据库字符串 - Java Map return parseToMap(rs.getString(columnName)); } Override public MapString, Object getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parseToMap(rs.getString(columnIndex)); } Override public MapString, Object getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parseToMap(cs.getString(columnIndex)); } private MapString, Object parseToMap(String json) { try { return MAPPER.readValue(json, new TypeReferenceMapString, Object() {}); } catch (Exception e) { throw new RuntimeException(JSON解析失败: json, e); } } }这段骨架别看简单其实已经覆盖了99%自定义Handler的模板结构。后面所有更复杂的Handler——加密的、枚举的、JSON数组的——都是在这个结构上填充自己的转换规则而已。3. 手写一个完整自定义TypeHandler以JSON字段为例3.1 先把需求和选型想清楚JSON字段是目前自定义Handler最常见的入口。比如商品表里有一列ext_info存的是活动标签、价格区间、推荐理由这类不定长扩展信息。你要是把这列在实体类里声明成String每次读取出来都还得手动JSON.parseObject声明成Map又没法直接用MyBatis默认映射。我的建议是业务频繁读写的JSON扩展字段用自定义Handler转成具体泛型类型只是偶尔读一下的可以在Mapper层用Result配合现有工具类转换。两个方案没有绝对优劣但前者代码更整洁后者少写Handler类。既然文章主题是Handler下面按前者的完整方案讲。序列化工具我用的Jackson也是Spring Boot默认的JSON库。这里有个小细节如果你的项目里已经配置了ObjectMapper的全局序列化规则比如日期格式、Null值处理、Long转StringHandler里的ObjectMapper最好和Spring容器里的是同一个避免两边序列化规则不一致带来诡异问题。推荐通过Spring注入而不是自己newComponent public class JacksonMapTypeHandler extends BaseTypeHandlerMapString, Object { private final ObjectMapper objectMapper; public JacksonMapTypeHandler(ObjectMapper objectMapper) { this.objectMapper objectMapper; } // 后续实现同上 }3.2 实现带泛型推断的JSON Handler如果是固定类型直接像2.3节那样写就行。但实际项目中JSON字段的类型往往不止Map一种可能是ListSku可能是某个ActivityConfig对象。这时候我们有两种选择选择一为每个类型都写一个Handler。代码重复但简单直接。 选择二写一个泛型Handler利用Jackson的JavaType做反序列化。我推荐选择二因为可维护性好很多。实现思路是通过构造函数接收目标类型然后在读取时用objectMapper.readValue(json, javaType)public class JacksonTypeHandlerT extends BaseTypeHandlerT { private final ObjectMapper objectMapper; private final JavaType javaType; public JacksonTypeHandler(ClassT type) { this(new ObjectMapper(), type); } public JacksonTypeHandler(ObjectMapper objectMapper, ClassT type) { this.objectMapper objectMapper; this.javaType objectMapper.getTypeFactory().constructType(type); } Override public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, objectMapper.writeValueAsString(parameter)); } Override public T getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } Override public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } Override public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private T parse(String json) { try { return objectMapper.readValue(json, javaType); } catch (IOException e) { throw new RuntimeException(JSON转换失败, e); } } }这里有个容易踩的坑泛型Handler在MyBatis-Plus中做注册时框架会通过反射推断JavaType。如果构造函数里只有ClassT一个参数MyBatis-Plus能识别如果用了多个参数或者没有默认构造部分版本可能注册失败。后面第4章会专门说这个问题。3.3 在单独使用MyBatis时如何注册如果你不是在Spring Boot环境而是纯MyBatis项目注册Handler有三种方式按优先级排列XML的resultMap里通过typeHandler属性指定全局配置文件mybatis-config.xml里的typeHandlers标签SQL语句里的#{param, typeHandlerxxx}XML场景最典型的是mapper文件里这样写resultMap idproductMap typecom.example.entity.Product id propertyid columnid/ result propertyextInfo columnext_info typeHandlercom.example.handler.JacksonTypeHandler/ /resultMap如果项目里所有MapString,Object属性的实体都要用同一个Handler处理更省事的方式是全局注册typeHandlers typeHandler handlercom.example.handler.JacksonTypeHandler javaTypejava.util.Map/ /typeHandlers这样只要Java属性类型是MapMyBatis就会自动找这个Handler来处理不用每个resultMap都写一遍。还有一种是SQL参数级指定适用于零散的单个字段insert idinsertProduct INSERT INTO product (id, ext_info) VALUES (#{id}, #{extInfo, typeHandlercom.example.handler.JacksonTypeHandler}) /insert三种方式的使用场景不同resultMap精确控制每个字段适合同一个字段在不同查询里有不同转换需求的场景全局注册省事适合全项目统一规则的场景SQL参数级指定最灵活但侵入性强写多了SQL很难维护。我的习惯是统一的项目规范用全局注册特殊情况用resultMap覆盖SQL参数级这种能不用就不用。3.4 在Spring Boot MyBatis-Plus中的注册方式MyBatis-Plus继承了MyBatis的TypeHandler体系所以前面讲的所有机制依然成立。但因为MyBatis-Plus自动化程度更高注册方式又多了一条路——注解。最常见的写法是直接在实体字段上加TableField注解指定typeHandlerTableName(product) public class Product { TableId(type IdType.AUTO) private Long id; TableField(typeHandler JacksonTypeHandler.class) private MapString, Object extInfo; }这里有个细节值得一提MyBatis-Plus官方文档里JSON字段的Handler还被要求开启TableName(autoResultMap true)。原因在于MP的BaseMapper内置方法如selectById、selectList生成的SQL映射默认不读取实体上的typeHandler注解。只有开启autoResultMap trueMP才会扫描注解并生成对应的resultMap。如果你不开启insert写参数时能正常用Handlerselect查出来extInfo却是null这个坑我见人踩过好多次。TableName(value product, autoResultMap true) public class Product { // ... }如果没有加autoResultMap true自定义Handler在自定义SQL里挺正常一旦用了MP内置CRUD方法查询结果就是null。排查链路后面还会讲这里先记住这个关键字。4. MyBatis-Plus场景下的注册细节TableField与自动填充的配合4.1 typeHandler属性与自动填充的坑MyBatis-Plus里很多项目会配一个元对象处理器MetaObjectHandler做插入/更新时自动填充创建时间、更新时间、操作人等字段。自动填充和TypeHandler配合的时候有一个非常隐蔽的坑自动填充发生在真正执行SQL之前是在实体对象层面操作字段值。如果你的自动填充字段本身也需要TypeHandler转换比如填一个JSON对象那填充器里set的值必须是你Handler能处理的Java类型。换句话说Handler作用于PreparedStatement参数赋值阶段而自动填充作用于更早的实体属性赋值阶段。两个机制是串行而非并行很多人在填充器里set了一个字符串以为Handler会再转一次JSON结果Handler拿到成功转成了JSON字符串的字符串再序列化一次就变成双引号嵌套的脏数据。解决办法也简单自动填充的时候直接set最终想要的那个Java类型比如对象或Map让Handler只负责序列化这一次不要重复转换。4.2 条件构造器中的类型转换陷阱再来说一个MP特有的场景QueryWrapper里用eq、in这些条件去查一个带有自定义TypeHandler的字段。MP的QueryWrapper在构建条件时是通过反射判断字段值类型然后交给MyBatis参数处理器去绑定参数。绝大多数情况下Wrapper里的值会作为普通参数传给PreparedStatement但它不会自动套用实体字段上的typeHandler注解。什么意思呢比如你加密存储了手机号实体上有字段级TypeHandler做加密你写queryWrapper.eq(phone, plainText)MP并不会先加密再比较最终SQL比较的是明文字符串和数据库里的密文查了个寂寞。遇到这种需求常规做法有两种在条件里手动调用加密方法queryWrapper.eq(phone, encryptUtil.encrypt(plainText))简单粗暴但依赖业务层记得调用。在Mapper自定义SQL使用#{phone, typeHandler...}来指定加密Handler。这样条件值也会走Handler转换SQL层更统一。我的建议是第二种因为第一种方案一旦有一个调用点漏了加密线上就会出现数据匹配不上的问题排查成本非常高。4.3 自定义TypeHandler时的自动注册次序Spring Boot环境下MyBatis-Plus会自动扫描配置的type-handlers-package下的Handler类并注册到MybatisConfiguration中。这里要注意一个次序问题Handler注册为Bean时如果你在Handler构造器里依赖了Spring的ObjectMapper通过构造注入那么Handler的注册时机必须在ObjectMapper Bean创建之后。Spring Boot自动配置会处理大部分情况但如果你自己写了一个Configuration类提前new了Handler可能会拿到一个未配置序列化规则的原生ObjectMapper。排查这类问题最直接的办法是在Handler注册的日志里打出ObjectMapper的类名和配置状态或者干脆把Handler也交给Spring管理标Component由Spring负责依赖装配再让MP注册这些Spring Bean。这样ObjectMapper初始化顺序由Spring容器保证问题基本不会再出现。5. 实战案例升级带AES加密的敏感字段Handler5.1 需求场景和设计思路JSON Handler只是热身真正体现TypeHandler价值的是敏感字段加密。我接手过一个跨境商城的项目用户的手机号、邮箱、身份证都必须加密落库但业务层希望CRUD时直接操作明文加密解密逻辑全部下沉到ORM层。这样Service代码不用关心加密细节也能保证所有访问敏感字段的路径都经过加密前提是字段只通过Mapper访问。设计思路是这样的数据库字段类型VARCHAR存AES加密后的Base64字符串Java实体属性类型String业务代码里是明文写入时Handler对明文做AES加密 - Base64编码 - ps.setString读取时rs.getString拿到密文 - Base64解码 - AES解密 - 返回明文密钥来源从配置中心读取Handler初始化时注入避免硬编码在代码里5.2 完整实现代码Component public class AesEncryptTypeHandler extends BaseTypeHandlerString { private final AesUtil aesUtil; public AesEncryptTypeHandler(AesUtil aesUtil) { this.aesUtil aesUtil; } Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { // 写入明文 - 密文 ps.setString(i, aesUtil.encrypt(parameter)); } Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { return decrypt(rs.getString(columnName)); } Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return decrypt(rs.getString(columnIndex)); } Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return decrypt(cs.getString(columnIndex)); } private String decrypt(String cipherText) { if (cipherText null || cipherText.isEmpty()) { return cipherText; } return aesUtil.decrypt(cipherText); } }注意几个实现细节空值判断放在解密方法里Base64解码空字符串会直接数组越界。读取方向上setNonNullParameter只处理非null但读取方向getNullableResult却可能拿到null所以在这里主动判断。密钥和向量不要写在Handler类里通过配置类注入方便后续轮换密钥。我在生产环境就吃过硬编码密钥的亏——换密钥要改代码重新发布而用配置中心的方案直接改配置再刷新就行。解密异常要区分业务异常和脏数据。如果数据库里混入了一条非加密的明文历史数据解密必然失败。我建议在decrypt方法里捕获异常后返回原文并打WARN日志而不是直接抛异常让查询崩溃。具体策略看团队要求但一定要有处理预案。5.3 加密字段在查询条件中的处理加了Handler的加密字段读取和写入都没有问题了但条件查询又是另一个故事——数据库里存的是密文你拿明文去WHERE phone ?肯定匹配不上。简单方案是把加密也应用到查询条件参数上。但这里有个更复杂的场景模糊查询。AES加密后的密文没有可搜索性你没法对密文做LIKE %keyword%。如果业务确实有模糊搜索敏感字段的需求常规做法有增加一个明文搜索辅助字段如sms_phone_plain专门用来做模糊查询加密字段只做精确匹配。这是目前业务系统用得最多的方案代价是冗余存储。用AES的确定性加密模式如AES-GCM-SIV保持同一明文加密结果一致可以支持精确查询但仍然不支持模糊查询。引入专门的可搜索加密方案复杂度较高一般项目没必要。我强烈建议第一套方案简单务实。模糊查询功能在合规和安全之间本来就是一道权衡题先用辅助字段支撑业务才是性价比最高的路线。6. 踩坑实录与完整排查链路6.1 最常见的四类TypeHandler问题前面每章都提了一些坑这里汇总一下我遇到过的最高频问题按出现频率排序问题现象根因解决方案插入/更新失效报No typehandler found for property xxx类型注册不完整全局没有扫描到Handler确认type-handlers-package配置或TableField(typeHandler...)显式指定查询返回nullinsert却是好的MP内置方法没有读取字段注解实体类TableName加autoResultMap true查询报错Cant set value或ClassCastExceptionHandler返回类型与实体属性类型不一致检查Handler泛型类型与实体字段申明是否一致报错JSON parse error: Illegal character数据库里有脏数据或历史明文数据清洗数据或在Handler里做兼容处理6.2 一条完整的排查链路从insert生效到select为null这里把6.1表格里第二个问题展开写一条真实的排查链路方便大家以后按图索骥。场景Spring Boot MyBatis-Plus项目给Product.extInfo字段配了JacksonTypeHandler。调用productMapper.insert(product)数据库里JSON正常写入调用productMapper.selectById(1L)返回的实体extInfo字段是null。排查步骤确认查询走的是MP内置方法还是自定义SQL。如果是内置方法先怀疑autoResultMap。我在代码里搜TableName果然只写了TableName(product)没有写autoResultMap true。加上之后重新启动问题消失。如果加了autoResultMap还是null接下来查Handler是否被Spring容器管理。在Handler构造函数里打个日志或断点看getNullableResult有没有被调用。没被调用说明映射根本没走到Handler。确认resultMap是否生成且正确引用handler。可以开启MyBatis SQL日志MP配置mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl看执行的SQL和返回映射是否符合预期。确认Handler泛型类型与字段类型一致。比如实体里extInfo是MapString, Object而Handler实现的是ListLong框架在映射时会因为类型不一致直接跳过或报错。这条链路走完大部分MP自定义Handler查询为null的问题都能定位到根因。6.3 规范建议Handler类命名、包结构与单元测试最后说点工程规范层面的经验。Handler类的命名我建议统一加TypeHandler或Handler后缀让人一眼能看出类型。比如JsonMapTypeHandler、AesEncryptTypeHandler不要起CustomJsonHandler这种模棱两可的名字。包结构上放在com.xxx.common.handler或者com.xxx.config.mybatis.handler下面集中管理。单元测试一定要写因为这个组件是基础设施出了问题影响面非常大。最少要覆盖写入SQL后PreparedStatement里被赋的值是否符合预期可以用Mockito或MyBatis的SqlSession做集成测试从ResultSet读取转换后类型是否正确空字符串、null、null值、非法JSON字符串四种边界情况我习惯把Handler的转换逻辑抽出一个纯静态方法或者独立的工具类这样测试不需要启动Spring容器直接对方法做断言又快又稳。这种设计也方便其他业务类直接复用同一个转换逻辑保持全项目加密/JSON规则一致。TypeHandler虽然只是MyBatis里一个小小的接口但真正用好了能让实体模型干净很多、业务层省掉大量重复代码。从我这些年接手的项目来看凡是后期在字段映射上频繁出问题的基本都是前期没有在Handler层统一设计把转换逻辑散落在Service和SQL片段里。花一下午把项目里JSON字段、加密字段、枚举字段的Handler一次性梳理清楚后续能省下好几天的排查时间这笔账怎么算都值。
返回列表