基于表达式引擎SPEL的数据权限控制

本文介绍抽奖系统将菜单权限改造为数据权限时使用的技术方案 - 基于表达式引擎SPEL的数据权限控制方案

什么是表达式引擎,为什么使用表达式引擎,表达式引擎选型调研分析

抽奖系统改造过程

  • 现状: 抽奖系统在改造之前,是基于菜单的权限控制,有菜单权限即可操作所有的抽奖活动,可能导致抽奖用户的信息泄露。

  • 需求: 在创建抽奖活动时,允许添加活动负责人,只有活动负责人、创建人、超管有权限查看、操作该抽奖活动。即:放开菜单权限,所有人都允许创建抽奖活动,但是只允许访问自己有权限的活动

  • 改造思路:

    • 通过分析现有接口,除了分页查询接口,其他的例如修改,查看详情,删除,兑奖等相关接口,都是传了抽奖活动drawId的,我们很容易就想到可以通过切面的方式来实现。那么剩下的一个问题是,如何从各类接口参数中识别出drawId字段,这里提供2种思路:通过注解标记,通过Spel表达式引擎
      • 注解标记: 在切面中获取到方法的args,遍历args,再遍历每个arg的field,判断field是否被特定的注解标记(eg:@DrawId),如果是的话则读取这个字段的值,这种方式对于简单的对象还比较好解析,但是对于嵌套比较深的对象,解析起来比较复杂。
      • Spel表达式引擎(参考Spring Cache注解的使用方式): 直接通过编写spel表达式来解析
  • 改造方案:

    1. 梳理出所有需要改造的接口,顺着接口往下理,在访问数据库之前,进行权限判断、拼接sql等操作
      • 这种方式对现有代码的改动很大,还有可能漏改,风险比较高
      • 需要的开发时间和测试时间也较长
    2. 基于切面+SPEL的改造方式
      • 几乎不改动现有代码,不影响之前的业务逻辑
      • 统一处理,节省开发和测试回归的时间
      • 数据解析很简单
      • 缺点: 在修改了方法参数的排序、字段名之后如果没有及时调整spel表达式可能导致数据解析错误、失败
    3. 基于切面+字段注解的改造方式
      • 也是一种比较好的方案,但是想尝试一下表达式引擎的用法
      • 对于嵌套比较深的对象,解析起来比较复杂
  • 着手开发:

    • 定义注解 ,用于标记需要拦截的API以及相应的spel表达式,@CheckDataRight
    java 复制代码
    /**
     * @author idle fish
     * @since 2023/12/9
     */
    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface CheckDataRight {
        /**
         * 读取drawId的spel表达式
         *
         * @return
         */
        String value();
    }
    • 数据权限拦截切面
java 复制代码
	/**
 * drawId数据权限拦截器
 *
 * @author idle fish
 * @since 2023/12/9
 */
@Slf4j
@Component
@Aspect
public class DataRightAop {
 
    /**
     * 如果访问的是页面,则返回这个没有权限的提示页面
     */
    public static final String NO_RIGHTS = "/idle/fish/prize/nodatarights.ms";
    @Resource
    private DrawPrizeService drawPrizeService;
 
    @Around("execution(* com.idle.fish.prize.web.controller.*.*(..)) && @annotation(checkDataRight)")
    public Object checkDataRight(ProceedingJoinPoint point, CheckDataRight checkDataRight) {
        // 初始化spel表达式解析器
        SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
        // 读取当前方法的参数列表
        Object[] args = point.getArgs();
        // 初始化上下文
        StandardEvaluationContext standardEvaluationContext = new StandardEvaluationContext(args);
        // 读取spel表达式,并解析args,读取drawId并转为Long类型
        Long drawId = spelExpressionParser.parseExpression(checkDataRight.value()).getValue(standardEvaluationContext, Long.class);
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
        // 读取当前登录的用户信息
        AdminContext adminContext = (AdminContext) request.getSession().getAttribute(AdminAuthInterceptor.SESSION_CONTEXT_NAME);
        // 校验权限
        Boolean hasRight = adminContext == null || (adminContext.isAdministrator() || drawPrizeService.hasRight(drawId, adminContext.getAdminId()));
        log.info("当前用户id:{}:name:{},超管:{},活动:{},权限:{}", adminContext == null ? "未登录" : adminContext.getAdminId(), adminContext == null ? "未登录" : adminContext.getName(), adminContext == null ? "未登录" : adminContext.isAdministrator(), drawId, hasRight);
        if (Boolean.TRUE.equals(hasRight)) {
            // 有权限,放行
            try {
                return point.proceed();
            } catch (Throwable e) {
                log.error("aop异常", e);
                return null;
            }
        } else {
            if (request.getRequestURI().endsWith(".ms")) {
                MethodSignature signature = (MethodSignature) point.getSignature();
                Method method = signature.getMethod();
                Class<?> returnType = method.getReturnType();
                if (Objects.equals(ModelAndView.class, returnType)) {
                    // 如果返回的是页面,则转发到没有权限的提示页面
                    try {
                        request.getRequestDispatcher(NO_RIGHTS).forward(request, response);
                        return null;
                    } catch (ServletException | IOException e) {
                        log.error("转发失败", e);
                        return null;
                    }
                } else {
                    // 如果返回的不是页面,则返回没权限的json数据
                    try (ServletOutputStream outputStream = response.getOutputStream()) {
                        outputStream.write(JSON.toJSONBytes(ResponseEntity.failed(null, "您没有当前活动的权限")));
                        response.setCharacterEncoding("utf-8");
                        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                        outputStream.flush();
                    } catch (IOException e) {
                        log.error("无权限响应写入失败", e);
                    }
                }
            } else {
                try {
                    request.getRequestDispatcher(NO_RIGHTS).forward(request, response);
                } catch (ServletException | IOException e) {
                    log.error("转发失败", e);
                }
            }
            return null;
        }
    }
}
  • 标记需要拦截的接口,编写Spel表达式

  • 拿一个接口举例子:

java 复制代码
/**
 * 领奖,取消领奖
 *
 * @param claimBody
 * @return
 */
public ResponseEntity claim(@RequestBody ClaimBody claimBody) {...}
 
 
/**
 * @author idle fish
 * @since 2023/12/8
 */
@Getter
@Setter
public class ClaimBody implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * 活动id
     */
    private Long drawId;
    /**
     * 中奖人id
     */
    private Long personId;
    /**
     * 奖项id
     */
    private Long awardSettingId;
    /**
     * 领奖状态
     * 0=取消领奖,1=领奖
     */
    private Integer claimStatus;
}
  • 当前接口的drawId是在claim()方法的第一个参数ClaimBody中的drawId字段,那么对应的spel表达式为:#this[0].drawId

    • 表达式含义:结合切面的代码,我们传入的对象是args,是个数组,这个数组的第一个参数即为ClaimBody,读取ClaimBody.drawId即可
      • #this对应args
      • [0]对应args数组第一个值ClaimBody
      • .drawId对应ClaimBody.drawId
  • 验证:

扩展:SPEL表达式引擎使用案例

1. 创建对象,调用方法

java 复制代码
SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
Expression expression = spelExpressionParser.parseExpression("new String('good').toUpperCase()");
System.out.println("返回的是String:" + expression.getValue(String.class));
System.out.println("返回的是Object" + expression.getValue());

2. 读取传入的对象的属性进行计算

java 复制代码
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
private static class Item {
    private Integer price;
    private Integer count;
}

// 计算规则
String rule = "price+count";
Item item = new Item(10, 20);
StandardEvaluationContext standardEvaluationContext = new StandardEvaluationContext(item);
Object objVal = new SpelExpressionParser().parseExpression(rule).getValue(standardEvaluationContext);
Integer integerVal = new SpelExpressionParser().parseExpression(rule).getValue(standardEvaluationContext, Integer.class);
System.out.println(objVal);
System.out.println(integerVal);


// 也可以直接通过这种方式来设置rootObject
Object value = new SpelExpressionParser().parseExpression("price*count").getValue(item);
System.out.println(value);

3. null引用与下标越界

java 复制代码
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
private static class Item {
    private Integer price;
    private Integer count;
    private List<String> productNameList;
}
  
    
Item item = new Item();
SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
// 报错,因为productNameList是null
System.out.println(spelExpressionParser.parseExpression("productNameList.get(1)").getValue(item));


Item item = new Item();
// 如果list为null,不会自动初始化
item.setProductNameList(new ArrayList<>());
// 打开数组/集合自动增长
SpelExpressionParser spelExpressionParser = new SpelExpressionParser(new SpelParserConfiguration(true, true));
// 报错,因为productNameList是null
System.out.println(spelExpressionParser.parseExpression("productNameList.size()").getValue(item));
// 需要使用[n]这种方式访问,否则还是会报错
System.out.println(spelExpressionParser.parseExpression("productNameList[10]").getValue(item));
System.out.println(spelExpressionParser.parseExpression("productNameList.size()").getValue(item));

4. 读取属性、数组、集合、Map

java 复制代码
Item item = new Item();
item.setPrice(999);
item.setProductNameList(Lists.newArrayList("抽烟", "喝酒", "烫头"));

List<Item> items = new ArrayList<>();
items.add(item);

Map<String, Object> map = new HashMap<>();
map.put("username", "idle fish");

// 通过方括号[n]读取数组,集合
// 通过.读取属性,属性可以直接写属性名,不需要写getter方法
System.out.println(new SpelExpressionParser().parseExpression("[0].price").getValue(items, Integer.class));
// 通过方括号[key]读取map
System.out.println(new SpelExpressionParser().parseExpression("[username]").getValue(map, String.class));

5. 构造集合、Map、数组

java 复制代码
// 构造集合
System.out.println(new SpelExpressionParser().parseExpression("{1,2,3}").getValue(List.class));
// 构造集合
System.out.println(new SpelExpressionParser().parseExpression("{1,2,3,3}").getValue(Set.class));
// 构造map
System.out.println(new SpelExpressionParser().parseExpression("{'username':'idle fish','age':188}").getValue(Map.class));
// 构造数组
int[] value = (int[]) new SpelExpressionParser().parseExpression("new int[]{1,2,3}").getValue();
System.out.println(value);

6. 运算

java 复制代码
Item item = new Item();
item.setPrice(10);
item.setCount(3);
System.out.println(new SpelExpressionParser().parseExpression("price + count").getValue(item, Integer.class));
System.out.println(new SpelExpressionParser().parseExpression("price - count").getValue(item, Integer.class));
System.out.println(new SpelExpressionParser().parseExpression("price * count").getValue(item, Integer.class));
System.out.println(new SpelExpressionParser().parseExpression("price / count").getValue(item, Integer.class));
System.out.println(new SpelExpressionParser().parseExpression("price % count").getValue(item, Integer.class));
System.out.println(new SpelExpressionParser().parseExpression("price == count").getValue(item, Boolean.class));
System.out.println(new SpelExpressionParser().parseExpression("price > count").getValue(item, Boolean.class));
System.out.println(new SpelExpressionParser().parseExpression("price >= count").getValue(item, Boolean.class));
System.out.println(new SpelExpressionParser().parseExpression("(price*count) >= 50").getValue(item, Boolean.class));
// 逻辑运算 and or not(!)
System.out.println(new SpelExpressionParser().parseExpression("price >= 15 and count>=3").getValue(item));
System.out.println(new SpelExpressionParser().parseExpression("price >= 15 or count>=3").getValue(item));
// 三元运算
System.out.println(new SpelExpressionParser().parseExpression("price=(price>=10?15:price)").getValue(item));

7. 赋值

java 复制代码
Item rootObject = new Item();
new SpelExpressionParser().parseExpression("price").setValue(rootObject, "99999999");
System.out.println(rootObject);
// 也可以通过get来赋值
Object value = new SpelExpressionParser().parseExpression("count=555").getValue(rootObject);
// 555
System.out.println(value);
System.out.println(rootObject);

8. 变量

java 复制代码
Item item = new Item();
// 设置rootObject
StandardEvaluationContext standardEvaluationContext = new StandardEvaluationContext(item);
// 增加变量
standardEvaluationContext.setVariables(new JSONObject().fluentPut("username", "idle fish").fluentPut("age", 29));
SpelExpressionParser spelExpressionParser = new SpelExpressionParser(new SpelParserConfiguration(true, true));
spelExpressionParser.parseExpression("productNameList").setValue(standardEvaluationContext, new ArrayList<>());
System.out.println(item);
// 取值与赋值
spelExpressionParser.parseExpression("productNameList[0]=#username").getValue(standardEvaluationContext);
System.out.println(item);
// 赋值
spelExpressionParser.parseExpression("price=#age").getValue(standardEvaluationContext);
System.out.println(item);

9. #this与#root

java 复制代码
// create an array of integers
List<Integer> primes = Lists.newArrayList(2, 3, 5, 7, 11, 13, 17);
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("primes", primes);
List<Integer> primesGreaterThanTen = (List<Integer>) parser.parseExpression(
        //.?[] 用来从集合中选择符合特定条件的元素。在这种情况下,它会遍历 #primes 集合中的每一个元素
        "#primes.?[#this>10]").getValue(context);
System.out.println(primesGreaterThanTen);

10. 方法注册与引用

public void registerFunction(String name, Method m)

java 复制代码
// 原始数据
String hello = "hello world";
// 标准评估上下文,并将rootObject传入
StandardEvaluationContext standardEvaluationContext = new StandardEvaluationContext(hello);
// 注册方法
standardEvaluationContext.registerFunction("revert", StringUtils.class.getDeclaredMethod("reverse", String.class));
// 调用注册的方法,并通过this将context中的rootObject传进去
System.out.println(new SpelExpressionParser().parseExpression("#revert(#this)").getValue(standardEvaluationContext));

11. bean引用

  • StandardEvaluationContext中设置BeanResolver
  • Expression中通过@beanName获取bean
java 复制代码
StandardEvaluationContext standardEvaluationContext = new StandardEvaluationContext();
// bean解析
standardEvaluationContext.setBeanResolver(new BeanResolver() {
    @Override
    public Object resolve(EvaluationContext context, String beanName) throws AccessException {
        // 模拟bean解析
        return new String("bean@" + beanName);
    }
});
SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
// 通过@拿到bean
Object value = spelExpressionParser.parseExpression("@userService").getValue(standardEvaluationContext);
System.out.println(value);

12. 避免空指针

  • 在访问属性之前,通过?判定,如果为空则直接返回null
java 复制代码
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
private static class Item {
    private Integer price;
    private Integer count;
    private List<String> productNameList;
    private Item item;
}
 
    
Item item = new Item();
// 当前item的item字段为null,如果直接调用.priceInt会报错,但是在访问属性之前标记?,那么为空的话程序直接返回null,不会再往下执行
System.out.println(new SpelExpressionParser().parseExpression("item?.price").getValue(item));

13. 集合与map操作

java 复制代码
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
private static class Item {
    private Integer price;
    private Integer count;
    private List<String> productNameList;
    private Map<String, Integer> studentScoreMap;
}
    
    
Item item = new Item();
// 这种方式会将整个花括号中的内容当成一个字符串,最终list中只有一个字符串
new SpelExpressionParser().parseExpression("productNameList").setValue(item, "{'抽烟','喝酒','烫头','',null}");
// 先通过表达式将字符串转为list,再setValue
new SpelExpressionParser().parseExpression("productNameList").setValue(item, new SpelExpressionParser().parseExpression("{'抽烟','喝酒','烫头','',null}").getValue(List.class));
// 将字符串转为map,并设值
new SpelExpressionParser().parseExpression("studentScoreMap").setValue(item, new SpelExpressionParser().parseExpression("{'Tim':59,'李四维':69,'青青':99,'juicy':89}").getValue(Map.class));
// 新建计算上下文,并设置rootObject
StandardEvaluationContext standardEvaluationContext = new StandardEvaluationContext(item);
// 注册方法
standardEvaluationContext.registerFunction("StringUtils_isNotBlank", StringUtils.class.getDeclaredMethod("isNotBlank", CharSequence.class));
// 通过.?[表达式]过滤集合,并产生一个新的集合
// 通过#methodName调用上面注册的方法
// 通过#this,引用list当前循环的元素
List filteredList = new SpelExpressionParser().parseExpression("productNameList.?[#StringUtils_isNotBlank(#this)]").getValue(standardEvaluationContext, List.class);
System.out.println(item.getProductNameList());
System.out.println(filteredList);

// 通过value过滤数据
Map value = new SpelExpressionParser().parseExpression("studentScoreMap.?[value<60]").getValue(standardEvaluationContext, Map.class);
// 通过key即可引用当前键值对的key
Map value2 = new SpelExpressionParser().parseExpression("studentScoreMap.?[key=='juicy']").getValue(standardEvaluationContext, Map.class);
// .^[表达式] 获取匹配的第一个元素
Map first = new SpelExpressionParser().parseExpression("studentScoreMap.^[value>60]").getValue(standardEvaluationContext, Map.class);
// .$[表达式] 获取匹配的最后一个元素
Map last = new SpelExpressionParser().parseExpression("studentScoreMap.$[value>60]").getValue(standardEvaluationContext, Map.class);
// .![表达式] 根据表达式生成一个新的集合
Set nameSet = new SpelExpressionParser().parseExpression("studentScoreMap.![key]").getValue(standardEvaluationContext, Set.class);
System.out.println(item.getStudentScoreMap());
System.out.println(value);
System.out.println(value2);
System.out.println(first);
System.out.println(last);
System.out.println(nameSet);

英文教程:docs.spring.io/spring-fram... 中文教程:itmyhome.com/spring/expr...

相关推荐
秋野酱5 小时前
如何在 Spring Boot 中实现自定义属性
java·数据库·spring boot
安的列斯凯奇6 小时前
SpringBoot篇 单元测试 理论篇
spring boot·后端·单元测试
架构文摘JGWZ6 小时前
FastJson很快,有什么用?
后端·学习
BinaryBardC6 小时前
Swift语言的网络编程
开发语言·后端·golang
邓熙榆6 小时前
Haskell语言的正则表达式
开发语言·后端·golang
多则惑少则明8 小时前
SSM开发(一)JAVA,javaEE,spring,springmvc,springboot,SSM,SSH等几个概念区别
spring boot·spring·ssh
Swift社区8 小时前
【分布式日志篇】从工具选型到实战部署:全面解析日志采集与管理路径
人工智能·spring boot·分布式
专职9 小时前
spring boot中实现手动分页
java·spring boot·后端
Ciderw9 小时前
Go中的三种锁
开发语言·c++·后端·golang·互斥锁·
m0_7482463510 小时前
SpringBoot返回文件让前端下载的几种方式
前端·spring boot·后端