SpringBoot+SpEL:打造优雅可维护的权限控制方案

📅 发布时间:2026/10/16 4:50:10
SpringBoot+SpEL:打造优雅可维护的权限控制方案
懂权限控制的人很多但能把权限控制写得优雅、维护成本低、还不让同事骂娘的真不多。最近我在一个项目里把老的一套权限判断全部重构了一遍核心方案就是标题里写的这套组合SpringBoot SpEL。用下来最大的感受是之前那些散落在业务代码里的 if/else 权限判断、各种“当前用户是不是管理员”的工具方法全部收敛成了一个个既清晰又好扩展的注解表达式。这篇文章就把我的完整落地思路、核心代码、还有踩过的坑一次性分享出来希望能给正在被权限逻辑折磨的朋友一些参考。1. 为什么我会选择用 SpEL 来重构权限控制先说说故事背景。我之前维护的一个电商项目模拟项目X权限逻辑开始很简单管理员、运营、普通用户几种角色。但随着业务复杂出现了非常多的细分场景比如“订单金额超过1万才能修改价格”、“只有负责该区域的运营才能审核优惠券”、“商品创建者在多少天内才能编辑”。这些规则如果用硬编码写在 Service 里就是一大把的 if 判断互相嵌套看得人头皮发麻。最痛苦的是每次权限规则调整都需要发版上线测试同学也得跟着回归一遍。后来我在一个技术分享里看到有人在方法上直接写表达式做权限控制一下打开了思路。SpELSpring Expression Language本身是 Spring 内置的表达式语言天生支持在运行时求值而且能很优雅地访问对象属性、调用方法、做逻辑运算。拿它来做权限规则本质上就是把“权限判断逻辑”从 Java 代码里抽离出来变成一段可以配置、可以动态求值的表达式。这就带来了两个直接好处权限规则和业务逻辑解耦Service 里不再出现权限判断代码只需要在方法上贴一个注解可读性提升一大截。规则可以配置化改动灵活表达式可以放在数据库或者配置中心权限调整的时候运维改个配置就能生效不需要重新发布应用。这套思路特别适合那种“角色多、规则杂、变化快”的中后台系统。当然如果你的项目只有两三个角色且一年都不动一次权限规则那我不建议上 SpEL直接写硬编码反而更简单直接。选型永远是看场景不是看技术炫不炫。2. SpEL 核心概念速览懂这些才能用得优雅既然要用 SpEL先把它的几个关键点搞明白不然后面会踩很多语法层面的坑。2.1 SpEL 表达式的基本构成一段 SpEL 表达式大体上由两部分组成表达式字符串和求值上下文。表达式字符串就是我们要定义的那条规则比如hasRole(ADMIN)上下文则相当于提供变量、函数、对象引用的环境让表达式里的标识符能找到对应的值。ExpressionParser parser new SpelExpressionParser(); Expression expression parser.parseExpression(hasRole(ADMIN) and user.status 1); StandardEvaluationContext context new StandardEvaluationContext(); context.setVariable(user, currentUser); Boolean result expression.getValue(context, Boolean.class);上面这段代码里user.status里访问的user是上下文里的一个变量hasRole则是上下文可以识别的一个方法。日常使用中我们一般把这两样东西都准备好表达式才好用。2.2 SpEL 的求值上下文比想象中灵活StandardEvaluationContext 是默认的求值上下文它允许我们往里面塞变量和函数。SpEL 的变量用#变量名访问比如#currentUser.age 18。方法调用则是通过 RootObject 的方法或者是注册自定义函数实现。在权限控制的场景里最常用的写法是通过setVariable放入当前用户对象、请求参数、甚至一些业务上下文数据。通过registerFunction注册静态工具方法例如#securityUtil.hasRole(ADMIN)。通过 RootObject 提供一个“隐藏对象”让表达式直接调用它的方法。理解了这两点其实已经掌握了 80% 的 SpEL 使用技巧。剩下的就是结合 Spring 的注解能力把“表达式”和“方法拦截”粘起来。3. 手把手实现自定义注解 AOP SpEL 的完整方案理论说再多不如直接上代码。我最终落地的方案包含四个文件一个权限注解、一个切面、一个 SpEL 解析工具还有一个跟 Spring 容器打交道的上下文准备工作。我会一步步展开讲并指出每一步为什么要这么做。3.1 权限注解定义我需要一个注解能贴在 Service 方法或者 Controller 方法上用来声明这段方法需要满足什么权限规则。这个注解至少需要两个属性一个是表达式本身比如hasRole(ADMIN)或者#order.amount 10000另一个是当表达式求值为 false 时给用户返回什么提示信息。Target({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface CheckPermission { // SpEL 表达式比如 hasRole(ADMIN) 或 #order.amount 10000 String value(); // 权限不足时的提示信息 String message() default 权限不足操作被拒绝; }我特别建议保留 message 属性。因为在真实项目中权限校验失败后产品可能会要求前端弹窗显示“你没有权限执行该操作”或者“只有负责该区域的运营才能审核”这些文案如果硬编码在切面里以后改文案也要动 Java 代码。放在注解里跟接口绑在一起直观又便于统一处理。3.2 切面拦截拿到方法参数既然要解析表达式就必须能访问到方法接的参数。AOP 切面天然支持在方法执行前拦截还能拿到Method对象和参数列表。这里我用的是自定义注解加Aspect切面拦截所有被CheckPermission标注的方法。这里面最核心的一个点如何把方法参数变成 SpEL 上下文里的变量。我的做法是将参数名绑定到参数值。比如方法签名为updateOrder(Long orderId, OrderDTO order)那么 SpEL 表达式里就可以直接写#order.status 1。Spring 的DefaultParameterNameDiscoverer可以帮我们拿到参数名但是要注意如果编译器没开-parameters参数参数名可能是arg0、arg1。为了稳妥我建议要么开启参数名保留要么直接用CheckPermission里再加一个属性显式指定参数别名。我在项目里是两者结合默认框架用参数名复杂场景用别名。Aspect Component public class PermissionAspect { private static final ExpressionParser PARSER new SpelExpressionParser(); private static final ParameterNameDiscoverer NAME_DISCOVERER new DefaultParameterNameDiscoverer(); Around(annotation(checkPermission)) public Object checkPermission(ProceedingJoinPoint joinPoint, CheckPermission checkPermission) throws Throwable { // 1. 解析 SpEL 表达式 Expression expression PARSER.parseExpression(checkPermission.value()); // 2. 构建求值上下文 Method method ((MethodSignature) joinPoint.getSignature()).getMethod(); Object[] args joinPoint.getArgs(); String[] paramNames NAME_DISCOVERER.getParameterNames(method); StandardEvaluationContext context new StandardEvaluationContext(); // 3. 把方法参数放入上下文 if (paramNames ! null) { for (int i 0; i paramNames.length; i) { context.setVariable(paramNames[i], args[i]); } } // 4. 权限判断 Boolean permitted expression.getValue(context, Boolean.class); if (Boolean.TRUE.equals(permitted)) { return joinPoint.proceed(); } throw new PermissionDeniedException(checkPermission.message()); } }上面这个版本是“可用”的最小实现但直接照搬到生产环境还有个明显的缺陷joinPoint.getSignature()拿到的方法在 JDK 动态代理场景下可能不是目标方法比如接口方法。更稳妥的做法是拿到method之后再通过AopUtils.getMostSpecificMethod(method, targetClass)转换成最终执行的方法。否则当类实现了接口时注解可能取不到。这一步是很多新手容易踩的坑我在后面会再强调。3.3 自定义 Root 对象让上下文自动注入用户信息权限控制里最高频的操作是“判断当前登录用户有没有某个角色”或者“当前用户是不是资源的所有者”。如果每次都在表达式里写#memorialUser.roles这种路径性访问稍微有点长而且用户信息怎么塞进上下文也成了一个统一性问题。我的方案是自定义一个 PermissionRoot把它作为 StandardEvaluationContext 的 RootObject。这样表达式里可以直接调用它的方法比如hasRole(ADMIN)、isOwner(#order.userId)。切面在构建上下文时自动把当前登录用户从安全上下文中取出来然后初始化这个 RootObject。业务代码里的权限注解就变得非常干净CheckPermission(hasRole(ADMIN) or hasRole(OPERATOR)) public void batchAdjustPrice() {} CheckPermission(isOwner(#order.userId)) public void editOrder(OrderDTO order) {}对应的 PermissionRoot 大致长这样public class PermissionRoot { private final CurrentUser currentUser; public PermissionRoot(CurrentUser currentUser) { this.currentUser currentUser; } public boolean hasRole(String role) { return currentUser.getRoles().contains(role); } public boolean isOwner(Long userId) { return currentUser.getUserId().equals(userId); } public CurrentUser getCurrentUser() { return currentUser; } }然后在切面构建上下文的时候把 RootObject 放进去StandardEvaluationContext context new StandardEvaluationContext(); PermissionRoot root new PermissionRoot(getCurrentUserFromSecurityContext()); context.setRootObject(root);这样表达式里的hasRole、isOwner就直接可以调用了。顺便说个细节SpEL 里的 RootObject 方法调用用的是“根对象属性/方法名”直接访问语法上不需要#。这样表达式看起来更像一句声明可读性也更好。3.4 注册自定义函数补充框架之外的工具能力有极少数场景我需要在表达式里用到一些字符串操作或者集合判断比如“用户权限标识列表里包含order:export”或者“请求 IP 在内网白名单内”。这些逻辑我既不想暴露内部实现又想在表达式里用得到。SpEL 提供 registerFunction 机制可以把自己的静态方法注册进去然后在表达式里调。context.registerFunction(containsAny, PermissionUtils.class.getMethod(containsAny, Collection.class, Collection.class));表达式可以这样写CheckPermission(containsAny(#currentUser.permissions, {order:create, order:update}))这里有个小技巧把静态方法注册进去后表达式调用的实际上是 Python 风格的命名更像名称的函数跟 Java 的方法名完全解耦。比如我的 Java 方法叫containsAny注册进去后名字还能保持但如果我想在表达式里叫hasAnyPermission只需要在 registerFunction 时指定别名。这样可以针对不同业务域定义更语义化的函数集合。3.5 完整版切面代码参考把上面的思路合并成生产级代码我是这样组织的Aspect Component public class PermissionAspect { private final ExpressionParser parser new SpelExpressionParser(); private final DefaultParameterNameDiscoverer nameDiscoverer new DefaultParameterNameDiscoverer(); Around(annotation(checkPermission)) public Object check(ProceedingJoinPoint joinPoint, CheckPermission checkPermission) throws Throwable { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); // 关键如果目标是接口实现需要拿到实际执行方法避免注解和参数名取不到 Method targetMethod AopUtils.getMostSpecificMethod(method, joinPoint.getTarget().getClass()); Object[] args joinPoint.getArgs(); String[] paramNames nameDiscoverer.getParameterNames(targetMethod); StandardEvaluationContext context new StandardEvaluationContext(); // 方法参数都是转换成变量塞进去 if (paramNames ! null) { for (int i 0; i paramNames.length; i) { context.setVariable(paramNames[i], args[i]); } } // 塞入当前用户 Root CurrentUser currentUser getCurrentUser(); context.setRootObject(new PermissionRoot(currentUser)); // 注册自定义函数 context.registerFunction(hasAnyPermission, PermissionUtils.class.getMethod(hasAnyPermission, Collection.class, Collection.class)); // 执行表达式判断权限 Expression expression parser.parseExpression(checkPermission.value()); Boolean permitted expression.getValue(context, Boolean.class); if (!Boolean.TRUE.equals(permitted)) { throw new PermissionDeniedException(checkPermission.message()); } return joinPoint.proceed(); } private CurrentUser getCurrentUser() { // 从自己的登录态上下文里取用户不直接用某个具体框架实现 return SecurityContextHolder.get(); } }注意我特意在代码注释里写了“不直接用某个具体框架实现”。真实项目中不同团队用的登录态方案各不相同有的存在 ThreadLocal 里有的存在 Redis 里有的用第三方的鉴权组件。你的核心切面不应该跟具体的方案强耦合最好是定义自己的接口然后把获取用户信息这块留作扩展点。这样以后换登录组件权限切面完全不用动。4. 复杂权限场景实战从方法级到数据级很多朋友看到这里会问上面这套注解能处理“方法能不能调用”这种粗粒度权限但“数据级权限”怎么办比如一个运营只能看到自己负责区域的订单。如果你把权限规则写在 SQL 层面那跟 SpEL 还有什么关系其实数据级权限也可以做得优雅。我总结下来SpEL 在权限控制上可以覆盖三个层次的场景下面逐个来说。4.1 方法级权限接口入口的规则校验这是最常见的比如接口只能被管理员和运营调用。传统方式是在方法开头写一堆判断现在只需要一行注解CheckPermission(hasRole(ADMIN) or hasRole(OPERATOR)) PostMapping(/batch-price) public Result batchPrice(RequestBody BatchPriceRequest request) { // 业务逻辑 }这里 Shiro 或者 Spring Security 也提供类似的注解能力但他们的表达式是框架绑定的。我们自己定义这套方案的优势在于表达式里可以直接引用方法的业务参数比如订单对象、金额、状态。这样就把“权限规则”和“业务字段”完美结合了。举几个例子// 只有订单初始状态才能取消 CheckPermission(hasRole(ADMIN) or #order.status 0) public void cancelOrder(OrderDTO order) {} // 如果订单金额超过1万必须 CEO 角色才能审批 CheckPermission(hasRole(CEO) or (#order.amount 10000)) public void approveHighAmountOrder(OrderDTO order) {} // 用户只能删除自己的评论 CheckPermission(isOwner(#comment.userId)) public void deleteComment(CommentDTO comment) {}这种方式把校验逻辑写得很直白每次看代码的时候方法的“业务要求”一眼就能看出来比翻一堆 if 强太多了。4.2 数据级权限通过表达式生成查询条件另一类数据权限是列表页的过滤。假设一个运营只能查看自己区域内的订单我们不可能在 Controller 上贴个注解就完事。此时我一般把 SpEL 用作“条件构造器”。具体做法是在查询参数里传入一个“数据范围表达式”然后在 Service 层将表达式解析成 SQL 片段或者 MyBatis 的查询条件。CheckPermission(hasRole(ADMIN) or #query.region currentUser.region) public PageResultOrder queryOrders(OrderQuery query) { // 查询逻辑 }你看这里其实还是在方法入口做了规则校验防止运营传入任意 region 参数去查别人区域的数据。但真正在数据库层面过滤还需要切面在表达式校验通过后往查询条件里注入被允许的region。这个逻辑我一般封装在切面的“后处理”环节里或者直接显式在 Service 代码最后调用一个DataScopeUtil.applyScope()来统一处理。不过坦白说如果项目的数据权限特别复杂如维度多、规则动态变化我更推荐引入成熟的数据权限框架或者自建一套规则引擎。SpEL 在方法级权限上确实优雅但到了动态 SQL 拼装层面单靠表达式会有点力不从心。这时候 SpEL 可以作为一个“规则解析器”存在但不一定要亲自做 SQL 拼接把它输出的条件结果交给专门的处理器就好。4.3 字段级权限同一个接口不同人返回不同字段有些时候同一个查询接口不同角色期望看到的字段不一样比如普通用户看订单不显示成本价运营能看到进价。如果为每个角色都写一遍查询逻辑那代码会爆炸。我在这类场景里用了一种“字段过滤表达式”方案。思路是这样的在返回的 DTO 里给敏感字段打上注解或者维护一个 MapString, 表达式 的配置中心配置内容类似于canSeeCostPrice: hasRole(ADMIN) or hasRole(OPERATOR)。然后在序列化阶段利用 Jackson 的扩展点动态决定是否输出某个字段。这样接口只有一个但是通过 SpEL 动态计算每个字段的可见性。这种方案虽然要写一些序列化相关的代码但代码结构很清晰跟业务逻辑完全解耦。每次产品提需求说“这个功能只有 VIP 能看到”我只需要在配置中心加一条表达式不需要碰 Java 代码。我做的时候是把这些表达式统一存在配置中心每次变更通过配置推送自动生效非常省心。4.4 配置化权限把表达式放进数据库或者配置中心前面提到的方案注解里的表达式是写死在 Java 代码里的。但如果你希望权限规则能动态调整就要做到“注解做硬编码 数据库做覆盖”的组合。我的做法是在注解里给一个规则 Key比如CheckPermission(ruleKey order.cancel)然后从数据库里读这个 Key 对应的表达式。如果数据库配置不存在则用注解里的默认表达式兜底。这样设计的好处是明显的紧急需要临时给某个用户放行时运维/产品可以直接在后台改规则不用动代码。规则变更可追溯数据库里可以记录修改人和时间。如果配置中心暂时不可用默认表达式还能保证系统按保守策略运行不至于完全失控。当然这里要额外考虑性能问题表达式本身解析是有开销的而且如果每次都重新 parseExpression在高并发下会有明显消耗。我的优化做法是使用一个 ConcurrentHashMap 做表达式缓存Key 是表达式字符串。因为表达式字符串本身是有限的缓存后可以极大降低重复解析的成本。private final ConcurrentHashMapString, Expression expressionCache new ConcurrentHashMap(); private Expression getExpression(String expressionString) { return expressionCache.computeIfAbsent(expressionString, parser::parseExpression); }在使用时从数据库拿到表达式字符串后先从缓存里取 Expression 对象再去求值。实测下来在高并发场景下单条表达式的解析开销几乎可以忽略不计了。5. 常见问题与排查技巧实录任何技术方案都不是完美的我重构这套权限体系的过程中也踩了不少坑在这里把最有价值的问题整理成速查表帮助你少走弯路。常见问题根因分析解决思路SpEL 表达式里访问不到方法参数编译器没有开启-parameters导致参数名是arg0开启参数名保留或在注解上增加参数名映射属性表达式里的数组/列表声明格式错误SpEL 里集合初始化用{}而不是[]例如{A, B}注意[]是索引访问方法在接口上定义时注解取不到JDK 动态代理时MethodSignature.getMethod()返回接口方法用AopUtils.getMostSpecificMethod获取实际执行方法切面里无法注入普通成员变量切面被 Spring 管理但基于new的方式初始化解析器没问题解析器可以 static final上下文对象每次方法调用新建表达式求值结果不是 Boolean表达式可能返回 null 或 Boolean 对象统一用Boolean.TRUE.equals(expression.getValue(context, Boolean.class))做判断用户对象在线程池里丢失登录态存在 ThreadLocal异步线程拿不到异步场景需要显式传递用户上下文或避免在异步方法上直接做权限校验表达式注入风险数据库里配置的表达式如果被恶意篡改可能调用危险方法对配置来源做鉴权或使用 SimpleEvaluationContext 限制功能5.1 优先级最高的问题参数名取不到这个坑我印象最深。为了提升性能当时把编译参数 -parameters 关掉了结果所有表达式里的#order全部失效。项目一启动所有带参数的权限校验全报错场面相当酸爽。排查时通过 debug 发现获取到的参数名数组里全是arg0这种占位符。解决方案有两个路要么在编译器设置里开启-parameters这样 Java 8 可以保留参数名Spring 就能直接拿到真实参数名要么像我上面说的在注解里加一个params属性显式把参数名映射定义出来。CheckPermission(value isOwner(#userId), params {userId}) public void deleteUser(Long userId) {}显式映射的好处是不依赖编译参数缺点是每个方法都要多写一个属性。我的建议是如果你在建新项目直接开启-parameters一劳永逸如果是老项目改造可以先加显式映射慢慢过渡。5.2 性能问题表达式解析开销与控制范围有人在接入这套方案后问我每个请求都刷新上下文设置的变量和函数会不会很慢其实这里要拆成两块看一个是表达式解析一个是上下文求值。表达式解析确实比较消耗 CPU所以必须做缓存。求值本身很快通常每次只要微秒级别因为表达式只是简单的属性访问和逻辑判断。另一个容易被忽视的问题是控制范围如果你的项目里每个 Service 方法都贴了权限注解那么几乎每个接口都会过一次切面这部分开销虽然不大但确实客观存在。所以我的设计原则是只在真正需要权限控制的“边界方法”上用注解不要在“查询列表内部子方法”上到处加。权限控制放在接口入口或核心业务入口既能保证覆盖又不会过度影响性能。5.3 安全边界防止 SpEL 注入SpEL 虽然强大但也容易被用来做坏事。如果你把表达式来源暴露给了不受信任的终端比如让用户自定义表达式那么对方可能通过表达式调用一些内部方法造成严重安全风险。不要以为只有关系型数据库才有注入表达式注入同样存在。在权限控制场景里表达式一般来自代码常量或数据库配置。如果来自代码常量那基本是安全的因为代码是我们自己人写的如果来自数据库那必须管理好谁有权限更改配置。另外SpEL 官方也提供了 SimpleEvaluationContext它可以限制表达式不能调用任意方法只能访问指定字段和调用安全的函数。等价的参考做法是对用户可控的表达式统一走受限上下文不给 RootObject 之外的高危方法开放。我在生产环境是这样做的数据库配置的表达式全部限制在 SimpleEvaluationContext 中求值并且只允许访问白名单里的方法和对象这能有效规避表达式注入的隐患。如果你的系统允许外部输入表达式这个安全点一定要把控死。5.4 权限校验失败后的统一异常处理切面里抛出 PermissionDeniedException 之后需要能在全局异常处理器里统一转成友好的响应。我在项目里给它定了错误码比如40301然后全局处理器把 message 带回给前端。这样前端的拦截器可以根据错误码做统一弹窗或者跳转。另外有一点要注意权限校验失败一定不能吞掉异常继续执行后续逻辑。我之前看过有人写if (!Boolean.TRUE.equals(permitted)) { return null; }这样写非常危险因为方法正常返回 null 可能导致 NPE或者业务数据没生效但前端拿到了成功响应。正确的姿势必须是抛异常把控制权交给全局异常处理保证业务不会“静默失败”。6. 实操中出现过的一些细节心得最后字字斟酌几个容易被忽略、但对体验影响很大的小细节。第一个是方法上有多个权限注解怎么办。我设计的注解是单注解里面用一个字符串表达式。有的朋友想让多个注解做“且”的关系其实没什么必要直接在表达式里用and、or组合就够了。嵌套多个注解反而会引入复杂的环绕逻辑后期排查困难。第二个是对 RootObject 的方法命名要足够语义化。比如hasRole、isOwner、hasAnyPermission就很直白。不要让表达式变成天书比如a() and b() or c()这种表达式未来一定会成为维护者的噩梦。给方法起名时多想想别人在阅读注解时能不能直接理解意思。第三个是关于日志。权限校验失败时切面里这个节点非常适合打印详细日志当前用户是谁、访问了哪个方法、被哪条规则拦住了、参数是什么。这些信息在排查线上“用户说我没权限”时特别有用。我一般用 warn 级别记录同时会记录完整的方法签名和表达式但不建议把敏感的业务参数打全防止日志泄露数据。第四个是关于测试。SpEL 表达式因为高度集中在注解上反而更容易做单元测试直接把表达式字符串喂给解析器手工构造各种状态的用户对象验证返回结果是否是预期布尔值。我在项目里给关键表达式建了独立的测试用例通过率非常高改动规则时敢放心上这就是可配置化带来的测试红利。说回最初的选择。用 SpringBoot SpEL 重构权限系统它解决的核心问题不是“能不能做权限判断”而是“权限判断能不能做得不再像补丁一样到处打”。经过这次重构我再回看那些写满 if/else 权限判断的旧代码真的有一种从凌乱工位搬到整洁办公桌的感觉。如果你当前的项目里权限规则多变、角色繁杂不妨试试这套方案。用一个小注解把规则声明出来让代码自己说话这种优雅感真的会让人上瘾。