- 有些BO模型放在bo包下用来装一些SQL语句查询返回,用于在Service层中进行处理业务逻辑,不会出现在Controller接口层的包装中。
- 在持久层的基础组件中,我司业务种用的是Mysql5.7版本。
- 在前后端开发使用的是分离技术,即前端使用流行的VUE+Element-UI ,后端提供json数据格式的http接口。
在贫血模型的大部分情况下,各层的各种O基本都是只有属性,也可以叫字段 的get,set;而且很多时候,各层的O直接的属性和字段差异都不大,所以为了解决各种O之间的数据传递问题,我发现core项目代码中大部分是一半是用hutool提供的BeanUtil中的copyProperties方法,另一大半的是用的是bop脚手架封装的BeanUtils的convert或者copyProperties;至于 common-lang3提供的或者Spring提供的 BeanUtils几乎没有!
设计思路
在数据权限模块下,我们坚持的原则是数据库驱动原理去做的,也就是数据库有的字段,我们才能做处理。我们基本只关心分页,列表,详情,修改这4种接口,(分页,列表,详情)对应select语句,(修改)对应update语句。用图来描述如下一次HTTP请求中的流程:
如上图,对于【行权限】的处理原理,就是将配置的条件,转化成SQL语句种where/join后面的条件,拦截SQL语句并改写,从而达到行权限过滤数据行的目的!在针对【列权限】的处理,最开始的想法是将值进行修改,以达到传递权限标识的目的,后来在讨论过程中一致认为改值的风险太大,不管是针对前端还是后端风险都大,所以后来决定给前端的json结构中原字段同级返回多一个后缀字段传递标识。由于原值也一并返回了,所以【列权限】的update语句就可以暂时不用处理。
在一次HTTP请求中,在java应用里面,从接收参数直到给前端返回json,所有的代码都是在一个线程下执行的。初步设想就是用Http拦截器在执行SQL之前,根据用户ID,从redis中拿到用户的【数据权限配置】放到ThreadLocal里,再使用Mybatis的拦截器从ThreadLocal里读取到配置进行修改SQL的处理,下面的图给大家详细介绍下,数据权限的整体逻辑和方案:
在上图中,围绕redis的操作逻辑比较简单,日常工作用过redis基本可以理解。难点在于围绕【Mybatis框架及其拦截器】这个地方去做处理,最开始领导建议用anltr4去解析SQL,并改写SQL。后来发现在我们项目依赖的mybatis-plus里,依赖了jsqlparser,所以后面重点是去研究jsqlparser解析并修改SQL的功能,以达到处理【行权限】的目的。(PS:其实仔细一想,我们这个是Saas系统,而且用的是数据库行隔离方案,也就是绝大部分业务表都有tenent_id这个字段,Mybatis-Plus已经提供这个租户隔离的功能了,而且免费,学习研究一下相关源码,实现行权限也不算是太难。)
最开始在考虑【列权限】实现方案时,初步设想是要在处理update语句的时候进行修改SQL语句,(举个栗子,比如name不可编辑,那update table set name = ‘abc’,update_time = ‘xxx’ where id = 123这句语句中将set 里面 的 name 字段去掉;)也是要在【Mybatis框架及其拦截器】这个地方去做处理,不过后来由于采用的列权限方案是把原值也返回给前端,这样这里就不用去处理update语句了。
这里简单聊下Mybatis,它是一个轻量级的ORM框架;在ORM框架问世之前,大家基本是基于jdbc去操作数据库的,手动的将DTO里的字段作为参数set到PreparedStatement里,在返回的ResultSet里迭代,一个个get字段再set到VO模型里。有了ORM后就省去很多这种无脑操作,专注于业务逻辑了。详细的Mybatis层次结构,网上一搜一大把,这里不贴图了。在我们这里,只需要关心拦截器怎么用,所以下面简单通过一个图了解下Mybatis拦截器的相关知识:
Mybatis支持我们去拦截Executor,StatementHandler,ParameterHandler,ResultSetHandler这4个地方的方法,研究发现sql语句是包装在BoundSql这个对象中,所以针对select语句,处理【行权限】,增加where/join条件,统一在StatementHandler的prepare方法中拦截SQL并进行修改。至于【列权限】,上文提到过,我们不需要修改update语句了,所以只需要专心处理怎样把select语句中涉及的字段,通过和ThreadLocal中传递过来的字段(表名和列名以及是否隐藏和是否可编辑)做比较,然后在json序列化的时候进行处理。为了传递列权限标识到json那层,我们决定将字段进行包装一层返回,如下以常见的String类型字段举例:
@Getter
@JsonSerialize(using = StringPrivilegeSerializer.class)
public class StringPrivilege implements java.io.Serializable {
public static final StringPrivilege EMPTY = new StringPrivilege();
String value;
String privilege;
public StringPrivilege(String value) {
this.value = value;
}
public void setStringPrivilege(String value, String privilege) {
this.value = value;
this.privilege = privilege;
}
//忽略其他的 equals hashCode toString 方法,那3个方法基本上重写时候只用value属性去计算即可
}
JSON序列化器StringPrivilegeSerializer的代码如下
@Component
public class StringPrivilegeSerializer extends JsonSerializer<StringPrivilege> {
@Override
public void serialize(StringPrivilege obj, JsonGenerator gen, SerializerProvider serializers) throws IOException {
//每个包装类引入单例 EMPTY对象
if(StringPrivilege.EMPTY.equals(obj)){
gen.writeNull();
}else {
gen.writeString(obj.getValue());
}
if (obj.getPrivilege() != null) {
gen.writeStringField(gen.getOutputContext().getCurrentName() + "__privilege", obj.getPrivilege());
}
}
}
上面提到的,如何通过 从 select的列 到json多返回一个同级字段,StringPrivilege 包装类起到了传递权限标识作用,但是往包装类里的privilege设置值的关键逻辑就在Mybatis提供的TypeHandler 里,代码如下:
@Component
@Slf4j
public class StringPrivilegeTypeHandler extends BaseTypeHandler<StringPrivilege> {
//...忽略其他的方法
@Override
public StringPrivilege getNullableResult(ResultSet rs, String columnName) throws SQLException {
List<List<String>> fieldList = ThreadLocalContent.getAllTableColumnName();
Set<ColumnRight> columnRights = ThreadLocalContent.getColumnRights();
// 拿到ThreadLocal里的列权限配置,和 columnName 进行过滤匹配,算出是 隐藏 --- 还是 只读 -r-
String right = calRight(columnName,fieldList,columnRights);
String bd = rs.getString(columnName);
return new StringPrivilege(bd, right);
}
}
上述只是用了常见的String字段举例,真实代码里需要把 Long , Integer , BigDecimal 也包装一遍,至于Double等其他数据类型,尽量转成使用前面的4种,否则不支持数据权限控制。
然后在PO/VO/BO中可以这么用
@Data
public class DemoPO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "主键")
@TableId("id")
private Long id;
@ApiModelProperty(value = "昵称")
@TableField("nick_name")
private StringPrivilege nickName;
}
为了解决PO,VO,BO,DTO之间的转换问题,我们又提供了一个增强的属性拷贝工具
@Slf4j
public class EnhancedBeanUtil {
public static void copyProperties(Object source, Object target){
//其他代码
if (value instanceof String && targetPd.getPropertyType() == StringPrivilege.class) {
StringPrivilege sp = new StringPrivilege();
sp.setValue((String) value);
Method writeMethod = targetPd.getWriteMethod();
if (!writeMethod.isAccessible()) {
writeMethod.setAccessible(true);
}
writeMethod.invoke(target, sp);
}
//其他代码
//如果原值是null,上面不会进入任何一个 ,但如果目标是包装类,则设置为EMPTY
if (value == null && targetPd.getPropertyType() == StringPrivilege.class){
value = StringPrivilege.EMPTY;
}
//为了防止null拷贝到包装类型上时候,从target直接get字段再getValue 产生NPE,在每个包装类引入单例 EMPTY对象
}
}
经过上文的全部分析过程,我们针对数据权限的【行】和【列】都有了统一的处理方案了!
未做事项
对于String,Long , Integer , BigDecimal 的包装类,或许可以抽取出公共接口层,封装一下,进行代码优化。