Sa-Token SpEL 表达式注解鉴权实战:@SaCheckEL 插件的原理与完整用法
【免费下载链接】Sa-Token✨ 开源、免费、一站式 Java 权限认证框架,让鉴权变得简单、优雅!—— 登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、SSO 单点登录、OAuth2.0 统一认证、jwt 集成、API Key 秘钥授权、API 参数签名项目地址: https://gitcode.com/GitHub_Trending/sa/Sa-Token
本文讲解 Sa-Token 插件包sa-token-spring-el提供的@SaCheckEL注解鉴权能力:如何用 Spring SpEL 表达式在控制器方法上实现登录校验、权限校验、方法参数校验、Session 数据校验乃至多账号体系鉴权。文章以官方插件文档为主线,结合插件源码SaCheckELAspect、SaCheckELRootMap与 demo 工程、测试用例,讲清表达式的可用根对象、解析上下文构建方式和扩展点,读完即可在自己的 Spring Boot 项目中复制落地。
一、为什么要用 SpEL 表达式鉴权
Sa-Token 常规的注解鉴权(如登录、角色、权限检查)适合“固定规则”场景,而实际业务中经常出现组合条件:
- “已登录,或者查询的是公开数据(id > 10010)才放行”;
- “参数 name 长度必须大于 3,否则报自定义错误信息”;
- “Session 中记录的 name 必须是 zhangsan 才允许执行”。
这类“条件表达式式”的鉴权逻辑,用一行 SpEL(Spring Expression Language)表达式即可声明在方法上,而不必在每个方法里手写一堆if判断。@SaCheckEL注解正是为此设计的,它从 Sa-Token 1.40.0 版本开始提供(见 SaCheckEL.java 的@since 1.40.0注释)。
该注解定义只有一个属性String value(),即需要执行的 EL 表达式;可标注在方法和类上(类上标注等同于标注在此类的所有方法上):
/** * 注解鉴权:根据 EL 表达式执行鉴权 * * <p> 可标注在方法、类上(效果等同于标注在此类的所有方法上) * * @author click33 * @since 1.40.0 */ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.METHOD, ElementType.TYPE }) public @interface SaCheckEL { /** * 需要执行的 EL 表达式 */ String value() default ""; }二、引入插件 sa-token-spring-el
@SaCheckEL的底层依赖 Spring AOP 切面编程,因此不能只在 core 包中使用,需要单独引入插件包sa-token-spring-el。
Maven 方式
<!-- Sa-Token 注解鉴权使用 EL 表达式 --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-el</artifactId> <version>${sa.top.version}</version> </dependency>Gradle 方式
// Sa-Token 注解鉴权使用 EL 表达式 implementation 'cn.dev33:sa-token-spring-el:${sa.top.version}'从插件的 pom.xml 可以看到,它只依赖两个包:sa-token-core和spring-boot-starter-aop,这也解释了为什么它要求项目具备 Spring AOP 环境(Spring Boot 项目天然满足)。
插件本体非常轻量,只有三个核心源文件:
| 文件 | 职责 |
|---|---|
| SaCheckEL.java | 鉴权注解定义 |
| SaCheckELAspect.java | AOP 前置切面,负责表达式解析与执行 |
| SaCheckELRootMap.java | 表达式解析的根数据对象(根上下文 + NEED 断言函数) |
三、简单示例:完整控制器代码
以下是 demo 工程中的完整示例控制器 SaCheckELController.java,覆盖了登录校验、角色校验、权限校验、二级认证、参数校验、Session 取值校验等常见形态(其中test6、test7展示了自定义异常信息与“或”条件组合,是文档基础示例的重要补充):
@RestController @RequestMapping("/check-el/") public class SaCheckELController { // 登录校验 @SaCheckEL("stp.checkLogin()") @RequestMapping("test1") public SaResult test1() { return SaResult.ok(); } // 角色校验 @SaCheckEL("stp.checkRole('dev-admin')") @RequestMapping("test2") public SaResult test2() { return SaResult.ok(); } // 权限校验 @SaCheckEL("stp.checkPermission('user:edit')") @RequestMapping("test3") public SaResult test3() { return SaResult.ok(); } // 二级认证 @SaCheckEL("stp.checkSafe()") @RequestMapping("test4") public SaResult test4() { return SaResult.ok(); } // 参数长度校验 @SaCheckEL("NEED( #name.length() > 3 )") @RequestMapping("test5") public SaResult test5(@RequestParam(defaultValue = "") String name) { return SaResult.ok().set("name", name); } // 参数长度校验,并自定义异常描述信息 @SaCheckEL("NEED( #name !=null && #name.length() > 3, 'name长度不够' )") @RequestMapping("test6") public SaResult test6(String name) { return SaResult.ok().set("name", name); } // 已登录, 或者查询数据在公开范围内 @SaCheckEL("NEED( stp.isLogin() or (#id != null and #id > 10010) )") @RequestMapping("test7") public SaResult test7(long id) { return SaResult.ok().set("id", id); } // SaSession 里取值校验 @SaCheckEL("NEED( stp.getSession().get('name') == 'zhangsan' )") @RequestMapping("test8") public SaResult test8() { return SaResult.ok(); } }表达式中的几个关键语法:
#参数名:引用被切入方法的入参,例如#name.length() > 3;stp:全局默认账号体系的StpLogic对象,可直接调用checkLogin()、checkRole()、checkPermission()、checkSafe()、isLogin()、getSession()等标准校验 API;NEED( 条件 [, 错误信息] ):根上下文自带的断言函数,条件为false时抛出SaTokenException,第二个参数可自定义异常描述;- 表达式本身支持 SpEL 完整的布尔运算(
&&、||、and、or、!),因此可以自由组合“登录或公开数据”这类复合条件。
四、切面执行流程与可用根对象(源码解析)
@SaCheckEL的全部魔法集中在 SaCheckELAspect.java 这个前置通知中。其执行流程可以概括为四步:
1. 切入点与 @SaIgnore 短路
@Before("@within(cn.dev33.satoken.annotation.SaCheckEL) || @annotation(cn.dev33.satoken.annotation.SaCheckEL)") public void atBefore(JoinPoint joinPoint) { // 获取方法签名与参数列表 MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); Object[] args = joinPoint.getArgs(); // 如果标注了 @SaIgnore 注解,则跳过,代表不进行校验 if(SaAnnotationStrategy.instance.isAnnotationPresent.apply(method, SaIgnore.class)) { return; } // ... }切点表达式同时匹配“类上有注解”与“方法上有注解”两种情况;方法上如果标注了@SaIgnore,则直接跳过校验(见下文第五节)。
2. 构建根数据对象 SaCheckELRootMap
切面首先构造SaCheckELRootMap(一个继承自HashMap<String, Object>的根上下文),并注入以下内置根对象:
| KEY 常量 | 值 | 表达式中的用途 |
|---|---|---|
method | 被切入的Method | 访问方法元信息 |
args | 方法参数数组Object[] | 按索引访问入参 |
target | 被切入的目标对象 | 访问目标实例的成员 |
this | 注解所在类对象引用(与 target 一致) | 表达式中用this.xxx引用本类属性,语义更直观 |
stp | StpUtil.getStpLogic()全局默认账号体系 | 表达式中调用stp.checkLogin()等 |
joinPoint | 本次切入的JoinPoint | 供扩展时获取更多 AOP 信息 |
对应源码 SaCheckELRootMap.java 定义了KEY_METHOD、KEY_ARGS、KEY_TARGET、KEY_THIS、KEY_STP、KEY_JOIN_POINT六个常量及对应 getter,方便开发者在自定义扩展时按 key 取值。
3. 构建 SpEL 解析上下文
// 创建表达式解析上下文 MethodBasedEvaluationContext context = new MethodBasedEvaluationContext(rootMap, method, args, pnd); // 添加属性访问器,使之可以解析 Map 对象的属性作为根上下文 context.addPropertyAccessor(new MapAccessor()); // 设置 Bean 解析器,使之可以在表达式中引用 Spring 容器管理的所有 Bean 对象 context.setBeanResolver(new BeanFactoryResolver(beanFactory));这里有两个关键设计,直接决定了表达式的表达能力:
MethodBasedEvaluationContext+DefaultParameterNameDiscoverer:使#name、#id这类按参数名引用的语法生效;MapAccessor:让根对象SaCheckELRootMap(本质是 Map)里的 key 可以像属性一样被 SpEL 访问,这就是stp、this能直接出现在表达式中的原因;BeanFactoryResolver:使表达式支持@beanName语法直接引用 Spring 容器中的 Bean。这一点在测试用例 SaCheckELAspectDocTest.java 中有对应验证——测试中注册了名为testBean的单例 Bean,专门供表达式通过@beanName语法引用。
4. 先类后方法的校验顺序
// 先校验 Method 所属 Class 上的注解表达式 SaCheckEL ofClass = (SaCheckEL) SaAnnotationStrategy.instance.getAnnotation.apply(method.getDeclaringClass(), SaCheckEL.class); if (ofClass != null) { parser.parseExpression(ofClass.value()).getValue(context); } // 再校验 Method 上的注解表达式 SaCheckEL ofMethod = (SaCheckEL) SaAnnotationStrategy.instance.getAnnotation.apply(method, SaCheckEL.class); if (ofMethod != null) { parser.parseExpression(ofMethod.value()).getValue(context); }类级与方法级表达式都通过才放行,任一表达式执行结果为false(或内部校验方法抛出异常)即拒绝访问。解析器为标准SpelExpressionParser,表达式失败时会以NotLoginException、NotPermissionException或SaTokenException的形式抛出对应 Sa-Token 异常。
另外切面对可变长参数做了展开处理:extractArgs方法会在method.isVarArgs()为真时把最后一个参数数组拍平合并,保证#参数名引用在 varargs 方法上也能按语义工作。
五、忽略鉴权:配合 @SaIgnore
某些接口虽然标注了@SaCheckEL,但希望临时或永久放行时,叠加@SaIgnore注解即可,切面在入口处会先检测该注解并直接return:
// 忽略鉴权测试 @SaIgnore @SaCheckEL("stp.checkPermission( 'abc' )") @RequestMapping("test11") public SaResult test11() { return SaResult.ok(); }六、多账号体系鉴权:重写 SaCheckELRootMap 扩展函数
内置的stp根对象只能指向全局默认账号体系。如果项目中使用了多账号体系(例如同时存在StpUtil的普通账号和StpUserUtil的 user 账号),需要在配置类中重写SaAnnotationStrategy的checkELRootMapExtendFunction扩展函数,向根对象 Map 中追加新的根对象。
该扩展函数定义于 core 包的 SaCheckELRootMapExtendFunction.java,是一个Consumer<Map<String, Object>>型函数式接口;其在 SaAnnotationStrategy.java 中的默认实现是一个空操作 lambda,不重写时根对象保持内置集合不变。
demo 工程 SaTokenConfigure.java 中的真实写法如下:
@Configuration public class SaTokenConfigure { /** * 重写 Sa-Token 框架内部算法策略 */ @PostConstruct public void rewriteSaStrategy() { // 重写 SaCheckELRootMap 扩展函数,增加注解鉴权 EL 表达式可使用的根对象 SaAnnotationStrategy.instance.checkELRootMapExtendFunction = rootMap -> { System.out.println("--------- 执行 SaCheckELRootMap 增强,目前已包含的跟对象包括:" + rootMap.keySet()); // 新增 stpUser 根对象,使之可以在表达式中通过 stpUser.checkLogin() 方式进行多账号体系鉴权 rootMap.put("stpUser", StpUserUtil.getStpLogic()); }; } }配置生效后,控制器即可直接使用新根对象:
// 多账号体系鉴权测试 @SaCheckEL("stpUser.checkLogin()") @RequestMapping("test9") public SaResult test9() { return SaResult.ok(); }由于扩展函数收到的就是完整根 Map,也可以在其中读取rootMap.keySet()观察当前已注册的全部根对象,便于调试。
七、调用本类成员变量
表达式中的this根对象指向注解所在类的实例,因此可以把权限码等常量声明为成员变量,避免在多处硬编码:
// 本模块需要鉴权的权限码 public String permissionCode = "article:add"; // 调用本类的成员变量 @SaCheckEL("stp.checkPermission( this.permissionCode )") @RequestMapping("test10") public SaResult test10() { return SaResult.ok(); }切面通过rootMap.put(SaCheckELRootMap.KEY_THIS, joinPoint.getTarget())注入该引用(源码注释说明其与target指向同一对象,this只是为了语义化)。
八、测试用例对文档示例的逐条验证
插件包内置的文档级测试 SaCheckELAspectDocTest.java 用AspectJProxyFactory将切面织入测试服务,对本文列出的每个示例做了断言级验证,可以作为行为契约参考:
doc_stpCheckLogin:stp.checkLogin()未登录时抛NotLoginException,登录后放行;doc_stpCheckPermission:stp.checkPermission('user:edit')未登录抛NotLoginException,已登录但无权限抛NotPermissionException,注入权限后放行;doc_stpSessionValue:NEED( stp.getSession().get('name') == 'zhangsan' )在 Session 未放值时抛SaTokenException,set("name", "zhangsan")后放行;doc_paramReference:NEED( #name.length() > 3 )对"ab"抛异常、对"zhangsan"放行;doc_thisMemberField:this.permissionCode引用的成员变量正确参与权限校验;doc_customStpRoot:通过扩展函数注册自定义StpLogic("user")根对象后,stpUser.checkLogin()完成独立账号体系的登录校验。
九、书写表达式的代码提示(SpEL Assistant)
SpEL 表达式以字符串形式写在注解里,IDE 默认无法给出补全。官方文档推荐在 IntelliJ IDEA 中安装SpEL Assistant插件(由开发者@ly-chn开源),它为自定义注解中的 SpEL 表达式提供代码提示,可直接在 IDEA 插件商店中搜索 “SpEL Assistant” 安装。安装后书写@SaCheckEL表达式时即可获得方法、属性级别的自动补全,能显著降低写错方法名(如checkLogin写成checkLogn)这类拼写错误。
十、适用前提与注意事项
- 必须引入
sa-token-spring-el插件包,且项目需具备 Spring AOP 环境(Spring Boot Web 项目天然满足); - 表达式失败即抛异常:
NEED断言失败、stp.checkXxx校验失败都会抛出 Sa-Token 异常体系中的对应异常(如NotLoginException、NotPermissionException、SaTokenException),最终表现依赖项目的全局异常处理或 Sa-Token 的SaServletFilter.setError等异常出口; NEED的默认错误信息为“未通过 EL 表达式校验”(异常码SaErrorCode.CODE_UNDEFINED),生产环境建议尽量提供自定义错误信息,便于前端与用户定位问题;- 类级 + 方法级表达式会叠加校验,先执行类上表达式再执行方法上表达式,两者都通过才放行;
- 从源码结构看,表达式解析为每次请求实时解析(未做表达式缓存),对极端高并发场景如性能敏感,可结合自身的请求级缓存手段评估;一般情况下其开销可忽略。
参考文件
| 类型 | 路径 |
|---|---|
| 插件文档 | sa-token-doc-new/docs/plugin/spel-at.md |
| 注解定义 | sa-token-plugin/sa-token-spring-el/src/main/java/cn/dev33/satoken/annotation/SaCheckEL.java |
| AOP 切面 | sa-token-plugin/sa-token-spring-el/src/main/java/cn/dev33/satoken/aop/SaCheckELAspect.java |
| 根数据对象 | sa-token-plugin/sa-token-spring-el/src/main/java/cn/dev33/satoken/aop/SaCheckELRootMap.java |
| 扩展函数接口 | sa-token-core/src/main/java/cn/dev33/satoken/fun/strategy/SaCheckELRootMapExtendFunction.java |
| 文档示例测试 | sa-token-plugin/sa-token-spring-el/src/test/java/cn/dev33/satoken/aop/SaCheckELAspectDocTest.java |
| demo 控制器 | sa-token-demo/sa-token-demo-case/src/main/java/com/pj/cases/more/SaCheckELController.java |
| demo 配置类 | sa-token-demo/sa-token-demo-case/src/main/java/com/pj/satoken/SaTokenConfigure.java |
| 插件 pom | sa-token-plugin/sa-token-spring-el/pom.xml |
【免费下载链接】Sa-Token✨ 开源、免费、一站式 Java 权限认证框架,让鉴权变得简单、优雅!—— 登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、SSO 单点登录、OAuth2.0 统一认证、jwt 集成、API Key 秘钥授权、API 参数签名项目地址: https://gitcode.com/GitHub_Trending/sa/Sa-Token
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考