在曲折中前进
前情提要
在上一篇文章里,通过切面加反射,根据一棵模块-页面-字段
三层的权限树实现对返回字段权限的控制。实际上线运行一段时间之后还是发现了一些问题。
1.权限树膨胀
随着页面增加,每个页面都有大量字段,权限树会持续膨胀,而字段大多是相同的,只是分布在不同的模块和页面中,每次新建角色或者上线新模块时勾选权限的过程比较复杂,大多是重复劳动,而且也容易出现人为失误。
目前已知确实出现了权限的问题,因为线上权限管控的原因,开发人员没有权限配置线上权限,把相关数据交给产品去配置之后,开发和测试人员都没有能力去验证权限配置的正确性,线上的权限回归也是交给产品完成。这部分过分依赖产品的个人可靠度,导致线上出现了产品配置错误导致权限失效的问题。
2.时间复杂度过大
控制权限方法的时间复杂度过大,理论上遍历控制所有字段的权限时间复杂度应该是O(m*n)
,m是单个对象的字段数,n是返回的对象数量(此处不讨论对象内嵌套的情况)。而实际的时间复杂度因为每次都需要遍历一次权限树结构的原因,变成了O(m*n*i)
,i是权限树的叶子节点数,上一点也提到过,权限树在不断膨胀,也会影响性能。
由于代码实现不优雅导致的多余的性能损耗虽然不明显,但是作为一个哪怕只有一点技术追求的开发,这都是应该尽量避免的。
改进思路
1.产品逻辑优化
为了解决权限树膨胀的问题,通过与产品沟通想了个新的权限解决方案:将页面和字段的权限分离。约定同一个字段的权限只与人员身份相关,与具体的页面无关,这样就可以将页面权限和字段权限拆分成两颗权限树,页面部分由前端维护,后端只需要关注字段权限树即可。好处是取消了页面-字段叉乘的组合,权限树节点会大量减少,同时可以将不同类型的字段按照类型进行分类勾选,不管是后端判断权限还是前台配置权限都会方便不少。
不过也有缺点,就是前提要求过于严格,即权限和页面无关这个条件很难在项目里做到,在我们的项目里,因为字段等于指标,不同页面的相同指标大部分时间只是维度不同的关系,所以可以达到这个条件。
2.实现逻辑优化
为了优化每次权限控制的时间复杂度,首先是需要把每次遍历权限树的步骤取消掉,可以提前遍历生成一个需要过滤权限的字段key组成的set
。其次是考虑是否需要遍历所有的对象的每个字段,其实仔细想想是不需要的,只需要找到这个对象中需要受到权限控制的字段,然后就可以批量处理。对于多层对象嵌套的情况可能在逻辑理解上会稍微麻烦一点,但是其实代码上也只是在原先递归的基础上增加一点而已。这样就把时间复杂度里的m从对象的字段数变为了需要控制权限的字段数。最终的时间复杂度是O(m*n)
。
其实还有一个改动,是实现上不够优雅的地方。之前的写法针对的可能只是简单的List<Object>
,而实际的对象可能存在多层的嵌套,需要将这些嵌套逻辑统一一下,以及对入口方法的包装,简化api等,都期望在这个版本里能够完成。
代码实现
1 权限树处理:
public static Set<String> getNoPermissionKeySet(List<SourceTree> children) {
if (CollectionUtils.isEmpty(children)) {
return Sets.newHashSet();
}
LinkedList<SourceTree> sourceTrees = new LinkedList<>(children);
HashSet<String> noPermissionSet = Sets.newHashSet();
while (sourceTrees.size() != 0) {
SourceTree treeNode = sourceTrees.poll();
if (treeNode.getUnavailable()) {
noPermissionSet.add(treeNode.getKey());
}
if (CollectionUtils.isNotEmpty(treeNode.getChildren())) {
for (int i = 0; i < treeNode.getChildren().size(); i++) {
sourceTrees.addLast(treeNode.getChildren().get(i));
}
}
}
return noPermissionSet;
}
将原本返回boolean
值的方法替换成了返回一个无权限字段的set
。在判断某个字段是否是受权限控制时,不再需要每次都遍历权限树去找这个字段,只需要通过set.contains
方法来判断,执行的时间复杂度是O(1)
,这也得益于叶子节点的唯一性,以前的方案里,唯一性通过拼装模块-方法-字段
来保证,改造过后的结构可以直接认为唯一。
在这种方案下字段权限依旧是树结构的理由是需要进行分类勾选,分类才是减少前台操作量和失误可能性的最佳选择。通过对字段节点的分类,又一次减少了配置时的勾选操作量,维护了字段组之间的映射关系。只有简单易用才能降低人为操作失误的可能。
2 包装方法入口,递归逻辑优化
控制入口,如果是list
结构,会递归调用这个方法,如果是普通的对象会自动进入重载的方法,判断对象里字段的权限。
public static <T> void checkMetricAuthorityRefactor(List<T> objList, Set<String> noPermissionSet) {
if (CollectionUtils.isEmpty(objList)) {
return;
}
for (T obj : objList) {
checkMetricAuthorityRefactor(obj, noPermissionSet);
}
}
对于外部调用的方法,为了避免直接调用内部那个重载的方法,可以统一包装成List
。为什么不推荐调用里面的checkMetricAuthorityRefactor
方法。这样做的原因,一是为了统一入口,避免调用之前还要判断一次类型;二是考虑到内部的这个方法其实可以和外部的合并成一个方法。
具体的内部实现是通过反射获取字段信息,然后通过字段名称判断是否受到权限控制(这里封装一层的原因是会出现一些派生的字段,在基础字段名的前提下,含有一些前缀或后缀)。之后再通过反射,调用字段的get
方法赋值为null
。
方法里比较重要的是要对字段类型进行判断,检查是否是基础字段,如果是list
类型的字段,会返回到重载的方法里重新进行类型的判断。
这两个方法是可以合在一起的,写的时候为了梳理逻辑给拆开了,所以直观上看起来觉得单次执行在两个方法间跳来跳去,其实是内外层级的关系。
private static <T> void checkMetricAuthorityRefactor(T obj, Set<String> noPermisssionSet) {
if (obj == null) {
return;
}
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
if (BaseReflectionUtil.isPrimitiveType(field.getType())) {
String fieldName = field.getName();
if (checkNoPermissionMetric(fieldName, noPermisssionSet)) {
String setMethodName = "set" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
Class type = field.getType();
Method m = null;
try {
m = obj.getClass().getMethod(setMethodName, type);
m.invoke(obj, (Object) null);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
log.error("[checkFormMetricAuthorityRefactor] 指标值置空异常", e);
}
}
} else {
try {
Object innerObj = field.get(obj);
checkMetricAuthorityRefactor(Collections.singletonList(innerObj), noPermisssionSet);
} catch (IllegalAccessException e) {
log.error("[checkFormMetricAuthorityRefactor] 非基础类型指标值置空异常", e);
}
}
}
}
其实这个方法还有优化的地方,因为使用反射调用get
和set
方法查看和赋值看着就是很繁琐的操作,所以在知道字段名称的情况下,可以直接修改可见性,然后修改值。
private static <T> void checkMetricAuthorityRefactor(T obj, Set<String> noPermisssionSet) {
if (obj == null) {
return;
}
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
if (BaseReflectionUtil.isPrimitiveType(field.getType())) {
String fieldName = field.getName();
if (checkNoPermissionMetric(fieldName, noPermisssionSet)) {
try {
field.setAccessible(true);
field.set(obj, null);
} catch (IllegalAccessException e) {
log.error("[checkFormMetricAuthority] 指标值置空异常", e);
}
}
} else {
try {
field.setAccessible(true);
Object innerObj = field.get(obj);
checkMetricAuthorityRefactor(Collections.singletonList(innerObj), noPermisssionSet);
} catch (IllegalAccessException e) {
log.error("[checkFormMetricAuthorityRefactor] 非基础类型指标值置空异常", e);
}
}
}
}
这里出现了一个新的值得思考的问题,一旦使用setAccessible
对字段的可见性进行了修改,会带来什么样的负面后果?
此处场景下仅对返回的VO可见性进行了修改,个人感觉负面影响有限,不太需要担心。
3 TODO
由于返回结构中有可能存在多层嵌套,循环又是以单个对象为主体进行的,所以没有办法统一处理,只能按照对象逐层获取,导致最终目标,缩小m*n中的m这个想法没有实现。
总结
虽然这篇权限控制2.0的名字叫方案重构,其实在代码层面上的改动并不多,主要是产品逻辑的重构,后端的实现只是在原来的基础上搭车做了一些优化,毕竟线上用得好好的本来是没有机会做这种改动的。
这也是做产品的一个问题吧,有时候因为时间赶或者想法不周全写出来的不太优雅的代码没有机会改动,毕竟改动后功能需要测试回归,本来就紧张的排期再加上一些历史问题改动的回归就很捉襟见肘了。
后记
最终这个方案因为产品的原因还是搁浅了,没能上线。很难受,倒不是因为代码写了没上,而是我花了整整一天的时间去梳理之前一堆杂乱的权限。而且这种无效开发完全能够避免。
现在这段代码还处在呗注释掉的阶段,说不好哪天就会被当作无用代码删掉,在它完全消失之前,记录下来,也算是我那两天的劳动稍微有了点价值。