大家好,关于从零开始构建MyBatis加密插件很多朋友都还不太明白,不过没关系,因为今天小编就来为大家分享关于的知识点,相信应该可以解决大家的一些困惑和问题,如果碰巧可以解决您的问题,还望关注下本站哦,希望对各位有所帮助!
一、需求背景
出于安全和合规原因,公司需要对数据库中以明文形式存储的一些字段进行加密,以防止未经授权的访问和泄露个人信息。
由于项目已经停止迭代,改造成本太高,所以我们选择了MyBatis插件来实现数据库加解密,保证向数据库写入数据时能够对指定字段进行加密,并且能够对指定字段进行加密。读取数据时解密。
二、思路解析
2.1 系统架构
定制Executor对SELECT/UPDATE/INSERT/DELETE等操作的明文字段进行加密,并将其设置为密文字段。
自定义插件ResultSetHandler负责解密查询结果,将SELECT等操作的密文字段解密并设置为明文字段。
2.2 系统流程
新增解密过程控制开关,控制写入时是否只写原始字段/双写/只写加密字段,以及读取时是读取原始字段还是加密字段。新增历史数据加密任务,批量加密历史数据并写入加密字段。出于安全考虑,过程中还会有一些验证/补偿任务,这里不再赘述。
三、方案制定
3.1 MyBatis插件简介
MyBatis 已保留org.apache.ibatis.plugin
.Interceptor接口,通过实现该接口,我们可以拦截MyBatis的执行过程。接口定义如下:
公共接口拦截器{
对象拦截(调用调用)抛出Throwable;
对象插件(对象目标);
voidsetProperties(Properties属性);
} 共有三种方法:
【拦截】:插件执行的具体流程。传入的Incation是MyBatis对代理方法的封装。 [plugin]:使用当前Interceptor创建代理。通常的实现是Plugin.wrap(target, this)。使用jdk在wrap方法中创建动态代理对象。 [setProperties]:参考下面的代码。您可以在MyBatis配置文件中配置插件时设置参数。在setProperties函数中调用Properties.getProperty('param1')方法获取配置的值。
在实现拦截函数拦截MyBatis的执行过程之前,我们需要使用@Intercepts注解来指定拦截方法。
@Intercepts({@Signature(typeExecutor.class,method'update',args{MappedStatement.class,Object.class}),
@Signature(typeExecutor.class,method'query',args{MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class}) }) 参考上面的代码,我们可以指定需要的类和方法被拦截。当然,我们不能拦截任何对象。 MyBatis软件可以拦截以下四个类。
ExecutorStatementHandlerParameterHandlerResultSetHandler 回到数据库加密的需求,我们需要从上面四个类中选择可以用来实现输入加密和输出参数解密的类。在介绍这四个类之前,需要对MyBatis的执行流程有一定的了解。
3.2 Spring-MyBatis执行流程
(1) Spring通过sqlSessionFactoryBean创建sqlSessionFactory。在使用sqlSessionFactoryBean时,我们通常会指定configLocation和mapperLocations来告诉sqlSessionFactoryBean从哪里读取配置文件以及从哪里读取mapper文件。
(2)获取配置文件和mapper文件的位置后,分别调用XmlConfigBuilder.parse()和XmlMapperBuilde
r.parse() 创建Configuration 和MappedStatement。顾名思义,Configuration类存储了MyBatis的所有配置,而MappedStatement类则存储了每条SQL语句的封装。 MappedStatement以map的形式存储在Configuration对象中,key是对应的方法。完整路径。
(3)Spring通过ClassPathMapperScanner扫描所有Mapper接口,并为其创建BeanDefinition对象。然而,由于它们本质上是未实现的接口,Spring会将其BeanDefinition的beanClass属性修改为MapperFactorybean。
(4)MapperFactoryBean也实现了FactoryBean接口。 Spring创建bean时,会调用FactoryBean.getObject()方法来获取bean。最后,它通过mapperProxyFactory的newInstance方法创建mapper接口的代理。创建代理的方法是JDK,最终生成的代理对象是MapperProxy。
(5)所有调用mapper的接口本质上都是调用MapperProxy.invoke方法,内部调用sqlSession的insert/update/delete等各种方法。
MapperMethod.java
publicObjectexecute(SqlSessionsqlSession,Object[]args) {
对象结果;
if(SqlCommandType.INSERTcommand.getType()) {
Objectparammethod.convertArgsToSqlCommandParam(args);
resultrowCountResult(sqlSession.insert(command.getName(),param));
}elseif(SqlCommandType.UPDATEcommand.getType()) {
Objectparammethod.convertArgsToSqlCommandParam(args);
resultrowCountResult(sqlSession.update(command.getName(),param));
}elseif(SqlCommandType.DELETEcommand.getType()) {
Objectparammethod.convertArgsToSqlCommandParam(args);
resultrowCountResult(sqlSession.delete(command.getName(),param));
}elseif(SqlCommandType.SELECTcommand.getType()) {
if(method.returnsVoid()method.hasResultHandler()) {
executeWithResultHandler(sqlSession,args);
结果为空;
}elseif(method.returnsMany()) {
resultexecuteForMany(sqlSession,args);
}elseif(method.returnsMap()) {
resultexecuteForMap(sqlSession,args);
}别的{
Objectparammethod.convertArgsToSqlCommandParam(args);
resultsqlSession.selectOne(command.getName(),param);
}
}elseif(SqlCommandType.FLUSHcommand.getType()) {
结果sqlSession.flushStatements();
}别的{
thrownewBindingException(':的未知执行方法'+command.getName());
}
if(resultnullmethod.getReturnType().isPrimitive()!method.returnsVoid()) {
thrownewBindingException('映射器方法''+command.getName()
+' 尝试从具有原始返回类型的方法返回null ('+method.getReturnType()+')。');
}
返回结果;
(6)SqlSession可以理解为会话。 SqlSession会从Configuration中获取对应的MappedStatement,交给Executor执行。
默认SqlSession.java
@覆盖
publicselectList(Stringstatement,Objectparameter,RowBoundsrowBounds) {
尝试{
//从配置对象中使用被调用方法的完整路径来获取对应的MappedStatement
MappedStatementmsconfiguration.getMappedStatement(语句);
returnexecutor.query(ms,wrapCollection(参数),rowBounds,Executor.NO_RESULT_HANDLER);
}catch(异常){
throwExceptionFactory.wrapException('查询数据库时出错。Cause: '+e,e);
}最后{
ErrorContext.instance().reset();
}
(7)Executor首先会创建一个StatementHandler,可以理解为执行一条语句。
(8) 然后Executor就会获取连接。具体获取连接的方法取决于Datasource的实现。您可以使用连接池等方法来获取连接。
(9)然后调用StatementHandler.prepare方法,对应JDBC执行过程中的Connection
.prepareStatement步骤。
(10)Executor然后调用StatementHandler的parameterize方法设置对应的参数
JDBC执行过程的StatementHandler.setXXX()设置参数,内部创建一个ParameterHandler方法。
简单执行器.java
@覆盖
公共查询(stmt,结果处理程序);
}最后{
关闭语句(stmt);
}
(11)然后ResultSetHandler处理返回结果,处理JDBC返回值,转换为Java对象。
3.3 MyBatis插件的创建时机
在Configuration类中,我们可以看到newExecutor、newStatementHandler、newParameterHandler、newResultSetHandler这四个方法。插件的代理类就是在这四个方法中创建的。我以StatementHandeler的创建为例:
配置.java
publicStatementHandlernewStatementHandler(Executorexecutor,MappedStatementmappedStatement,ObjectparameterObject,RowBoundsrowBounds,ResultHandlerresultHandler,BoundSqlboundSql) {
StatementHandlerstatementHandlernewRoutingStatementHandler(executor,mappedStatement,parameterObject,rowBounds,resultHandler,boundSql);
//使用责任链创建代理
statementsHandler(StatementHandler)interceptorChain.pluginAll(statementHandler);
返回语句处理程序;
}
拦截器链.java
publicObjectpluginAll(对象目标){
for(Interceptorinterceptor:interceptors) {
targetinterceptor.plugin(目标);
}
返回目标;
}interceptor.plugin对应的是我们自己实现的拦截器中的方法。通常的实现是Plugin.wrap(target, this);内部创建代理的方法是JDK。
3.4 MyBatis插件可拦截类选择
Mybatis本质上是对JDBC执行流程的封装。结合上图,我们简单总结一下Mybatis中这些代理类的功能。
[Executor] : 实际执行SQL语句的对象。当调用sqlSession方法时,实质上是调用了executor方法。它还负责获取连接并创建StatementHandler。 [StatementHandler] : 创建并保存ParameterHandler和ResultSetHandler对象,操作JDBC语句并执行数据库操作。 [ParameterHandler] : 处理输入参数并将Java方法上的参数设置到执行的语句中。 [ResultSetHandler] : 处理SQL语句的执行结果并将返回值转换为Java对象。对于输入参数的加密,我们需要在ParameterHandler调用prepareStatement.setXXX()方法设置参数之前,将参数值更改为加密后的参数。这样看来,拦截Executor/StatementHandler/ParameterHandler就可以完成了。
selectid'selectUserList'resultMap'BaseResultMap'parameterType'com.xxx.internet.demo.entity.UserInfo'
选择
从
`t_用户信息`
在哪里
iftest'电话!=null'
`电话`{电话}
如果
iftest'秘密!=null'
AND`秘密`{秘密}
如果
iftest'secretCiper!=null'
AND`secret_ciper`{secretCiper}
如果
iftest'名字'
AND`名称`{名称}
如果
在哪里
ORDERBY`更新时间`DESC
选择但实际上?由于我们不是对原来的字段进行加密,而是添加了一个新的加密字段,那么这会导致什么问题呢?请查看下面的mapper.xml文件中添加了加密字段的动态SQL:
可以看到这条语句带有动态标签,所以肯定不能直接交给JDBC来创建prepareStatement。首先需要将其解析为静态SQL,这一步是在Executor调用StatementHandler.parameterize()之前由MappedStatementHandler完成的。 getBoundSql(ObjectparameterObject)函数解析动态标签并生成静态SQL语句。我们可以暂时把这里的parameterObject看成是一个Map,键值为参数名和参数值。
那么我们来看看使用StatementHandler和ParameterHandler进行参数加密存在的问题。执行MappedStatementHandler.getBoundSql时,加密后的参数不会写入parameterObject中。判断标签时必须为No。最终生成的静态SQL不能包含加密字段。无论我们如何处理StatementHandler和ParameterHandler中的parameterObject,都无法实现输入参数的加密。
因此,对于输入参数的加密,我们只能选择拦截Executor的更新和查询方法。
那么返回值的解密呢?参照流程图,我们可以拦截ResultSetHandler和Executor。情况确实如此。在处理返回值方面,两者是等价的。 ResultSetHandler.handleResultSet()的返回值直接传递给Executor,然后Executor透明传递给SqlSession,所以可以选择其中之一。
四、方案实施
知道了需要拦截的对象后,就可以开始实现加解密插件了。首先定义方法维度的注释。
/**
• 使用注释来指示我们需要加密的字段/
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@遗传
@有据可查
公共@interface TEncrypt {
/*
• 加密时从srcKey 到destKey
• @返回
*/
String[]srcKey()default{};
/**
• 解密时从destKey 到srcKey
• @返回
*/
String[]destKey()默认{};
}将此注解放在需要加解密的DAO层方法上。
用户映射器.java
公共接口用户映射器{
@TEncrypt(srcKey{'secret'},destKey{'secretCiper'})
ListselectUserList(UserInfouserInfo);
}修改xxxMapper.xml文件
mappernamespace'com.xxx.internet.demo.mapper.UserMapper'
resultMapid'BaseResultMap'type'com.xxx.internet.demo.entity.UserInfo'
idcolumn'id'jdbcType'BIGINT'property'id'/
idcolumn'电话'jdbcType'VARCHAR'属性'电话'/
idcolumn'秘密'jdbcType'VARCHAR'属性'秘密'/
idcolumn'secret_ciper'jdbcType'VARCHAR'property'secretCiper'/
idcolumn'名称'jdbcType'VARCHAR'属性'名称'/
结果图
selectid'selectUserList'resultMap'BaseResultMap'parameterType'com.xxx.internet.demo.entity.UserInfo'
选择
从
`t_用户信息`
在哪里
iftest'电话!=null'
`电话`{电话}
如果
iftest'秘密!=null'
AND`秘密`{秘密}
如果
iftest'secretCiper!=null'
AND`secret_ciper`{secretCiper}
如果
iftest'名字'
AND`名称`{名称}
如果
在哪里
ORDERBY`update_time`DESCv
选择
映射器
进行以上修改后,我们就可以编写加密插件了。
@Intercepts({ @Signature(type=Executor.class, method='update', args={ MappedStatement.class, Object.class }),
@Signature(type=Executor.class, method='query', args={ MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class }) })
公共类ExecutorEncryptInterceptor 实现拦截器{
私有静态最终ObjectFactory DEFAULT_OBJECT_FACTORY=new DefaultObjectFactory();
私有静态最终ObjectWrapperFactory DEFAULT_OBJECT_WRAPPER_FACTORY=new DefaultObjectWrapperFactory();
私有静态最终ReflectorFactory REFLECTOR_FACTORY=new DefaultReflectorFactory()
; private static final ListCOLLECTION_NAME = Arrays.asList("list"); private static final String COUNT_SUFFIX = "_COUNT"; @Override public Object intercept(Invocation invocation) throws Throwable { // 获取拦截器拦截的设置参数对象DefaultParameterHandler final Object[] args = invocation.getArgs(); MappedStatement mappedStatement = (MappedStatement) args[0]; Object parameterObject = args[1]; // id字段对应执行的SQL的方法的全路径,包含类名和方法名 String id = mappedStatement.getId(); String className = id.substring(0, id.lastIndexOf(".")); String methodName = id.substring(id.lastIndexOf(".") + 1); // 分页插件会生成一个count语句,这个语句的参数也要做处理 if (methodName.endsWith(COUNT_SUFFIX)) { methodName = methodName.substring(0, methodName.lastIndexOf(COUNT_SUFFIX)); } // 动态加载类并获取类中的方法 final Method[] methods = Class.forName(className).getMethods(); // 遍历类的所有方法并找到此次调用的方法 for (Method method : methods) { if (method.getName().equalsIgnoreCase(methodName) && method.isAnnotationPresent(TEncrypt.class)) { // 获取方法上的注解以及注解对应的参数 TEncrypt paramAnnotation = method.getAnnotation(TEncrypt.class); // 支持加密的操作,这里只修改参数 if (parameterObject instanceof Map) { ListparamAnnotations = findParams(method); parameterMapHandler((Map) parameterObject, paramAnnotation, mappedStatement.getSqlCommandType(), paramAnnotations); } else { encryptParam(parameterObject, paramAnnotation, mappedStatement.getSqlCommandType()); } } } return invocation.proceed(); } }加密的主体流程如下: 判断本次调用的方法上是否注解了@TEncrypt。获取注解以及在注解上配置的参数。遍历parameterObject,找到需要加密的字段。调用加密方法,得到加密后的值。将加密后的字段和值写入parameterObject。难点主要在parameterObject的解析,到了Executor这一层,parameterObject已经不再是简单的Object[],而是由MapperMethod .convertArgsToSqlCommandParam(Object[] args)方法创建的一个对象,既然要对这个对象做处理,我们肯定得先知道它的创建过程。参考上图parameterObject的创建过程,加密插件对parameterObject的处理本质上是一个逆向的过程。如果是list,我们就遍历list里的每一个值,如果是map,我们就遍历map里的每一个值。 得到需要处理的Object后,再遍历Object里的每个属性,判断是否在@TEncrypt注解的srcKeys参数中,如果是,则加密再设置到Object中。 解密插件的逻辑和加密插件基本一致,这里不再赘述。五、问题挑战
5.1 分页插件自动生成count语句
业务代码里很多地方都用了 com.github.pagehelper 进行物理分页,参考下面的demo,在使用PageRowBounds时,pagehelper插件会帮我们获取符合条件的数据总数并设置到rowBounds对象的total属性中。 PageRowBoundsrowBoundsnewPageRowBounds(0,10); ListlistuserMapper.selectIf(1,rowBounds); longtotalrowBounds.getTotal();那么问题来了,表面上看,我们只执行了userMapper.selectIf(1, rowBounds)这一条语句,而pagehelper是通过改写SQL增加limit、offset实现的物理分页,在整个语句的执行过程中没有从数据库里把所有符合条件的数据读出来,那么pagehelper是怎么得到数据的总数的呢? 答案是pagehelper会再执行一条count语句。先不说额外一条执行count语句的原理,我们先看看加了一条count语句会导致什么问题。 参考之前的selectUserList接口,假设我们想选择secret为某个值的数据,那么经过加密插件的处理后最终执行的大致是这样一条语句 "select * from t_user_info where secret_ciper = ? order by update_time limit ?, ?"。 但由于pagehelper还会再执行一条语句,而由于该语句并没有 @TEncrypt 注解,所以是不会被加密插件拦截的,最终执行的count语句是类似这样的: "select count(*) from t_user_info where secret = ? order by update_time"。 可以明显的看到第一条语句是使用secret_ciper作为查询条件,而count语句是使用secret作为查询条件,会导致最终得到的数据总量和实际的数据总量不一致。 因此我们在加密插件的代码里对count语句做了特殊处理,由于pagehelper新增的count语句对应的mappedStatement的id固定以"_COUNT"结尾,而这个id就是对应的mapper里的方法的全路径,举例来说原始语句的id是"com.xxx.internet.demo.entity从零开始构建MyBatis加密插件和的问题分享结束啦,以上的文章解决了您的问题吗?欢迎您下次再来哦!
本文采摘于网络,不代表本站立场,转载联系作者并注明出处:https://www.iotsj.com//kuaixun/7984.html
用户评论
终于看到有人写了关于 MyBatis 加密插件的文章!我一直在寻找这样解决方法。
有5位网友表示赞同!
看标题感觉很有意思,学习一下自定义插件应该能提升开发效率。
有9位网友表示赞同!
从零开始实现?太棒了,我也想要了解如何进行二次开发。
有6位网友表示赞同!
MyBatis 加解密需求蛮重要的,这个插件肯定对很多项目很实用。
有20位网友表示赞同!
不知道这个插件支持哪些加密算法呢?
有12位网友表示赞同!
自定义插件确实可以提高代码可读性和复用性,有机会试试看!
有19位网友表示赞同!
希望作者能详细讲解下实现步骤,方便小白学习
有10位网友表示赞同!
能不能支持多种数据库系统?
有16位网友表示赞同!
如果能提供源码,那更棒了!
有7位网友表示赞同!
这个插件会不会增加sql执行的时间?
有19位网友表示赞同!
对于安全敏感的数据加密来说很重要啊!
有10位网友表示赞同!
感觉这样的文章对学习 MyBatis 自定义插件很实用。
有9位网友表示赞同!
我一直在想怎么把数据安全做更好一点, 这个插件很有用
有18位网友表示赞同!
这个插件能应用到哪些场景?
有17位网友表示赞同!
自定义插件是一个不错的技能 ,要学起来!
有12位网友表示赞同!
分享一下你使用的加密算法吗?
有8位网友表示赞同!
代码实现是不是比较复杂呢?
有20位网友表示赞同!
MyBatis 的功能果然很强大,自己还可以开发插件!
有12位网友表示赞同!
我也有类似的需求, 可以参考作者的代码改进我的项目。
有5位网友表示赞同!