若依自定义注解体系:@Log、@DataScope、@DataSource、@Excel 全览

本文基于若依3.9.2、SpringBoot3版本。

image

若依自定义注解体系:@Log/@DataScope/@DataSource/@Excel 全览

写若依的业务代码,离不开它自己的注解:Controller 方法上标 @Log 记操作日志,Service 方法上标 @DataScope 做数据权限过滤,实体字段上标 @Excel 控制导入导出。这些注解全部定义在 com.ruoyi.common.annotation 包下,共九个。

注解本身只是挂在类、方法、字段上的元数据,不含任何执行逻辑,真正干活的是与它配套的切面、拦截器或处理器——注解负责声明意图,配套实现负责兑现。

本文梳理这九个注解的定义与属性、元注解的选择,以及每个注解由谁消费、以什么方式配合。

九个注解一览

先给出全貌:

注解 标注位置 作用 配套实现
@Log 方法 记录操作日志 LogAspect 切面,异步写入 sys_oper_log
@DataScope 方法 数据权限过滤 DataScopeAspect 切面,拼接 SQL 条件
@DataSource 类、方法 切换主从数据源 DataSourceAspect 切面 + 动态数据源
@RateLimiter 方法 接口限流 RateLimiterAspect 切面 + Redis Lua 脚本
@RepeatSubmit 方法 防重复提交 RepeatSubmitInterceptor 拦截器 + Redis
@Anonymous 类、方法 标记免鉴权接口 启动时收集,注册进 Security 放行规则
@Excel 字段 单列导入导出标注 ExcelUtil 反射解析
@Excels 字段 @Excel 的容器注解 ExcelUtil 反射解析
@Sensitive 字段 JSON 输出脱敏 SensitiveJsonSerializer 序列化器

按消费方式可以分成四类:四个由 AOP 切面消费(@Log@DataScope@DataSource@RateLimiter),一个由 MVC 拦截器消费(@RepeatSubmit),一个在启动阶段收集(@Anonymous),三个由反射工具或序列化器消费(@Excel@Excels@Sensitive)。消费方式不同,注解能用在哪里、怎么被读取也随之不同。

元注解的选择

九个注解的元注解组合高度一致,以 @Log 的定义为例:

@Target({ ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
}

@Target 限定注解能标在哪里,@Retention(RetentionPolicy.RUNTIME) 保证注解信息保留到运行期、可以通过反射读取,@Documented 让注解出现在 javadoc 中。

RUNTIME 是整套体系能工作的前提:切面拦截到方法后要读出注解属性,序列化器要在字段上找注解,这些都要求注解在运行期可见。若依所有注解统一使用 RUNTIME,没有一个使用 CLASS 或 SOURCE。

@Target 的差异表达了每个注解的用途边界:

  • 只能标在方法上:@DataScope@RateLimiter@RepeatSubmit,它们都作用于某个接口方法这一粒度
  • 可标在方法和类上:@Anonymous@DataSource,标在类上时对整个类生效
  • 只能标在字段上:@Excel@Excels@Sensitive,它们描述的是单个数据列或单个输出值
  • @Log 声明了方法和参数两种位置,属于特例,后文单独说明

@Inherited 只有 @DataSource@RepeatSubmit 使用,两者效果不同。@Inherited 的实际能力是注解标在父类上时子类可以继承到,但它只对标注在类上的注解生效,对方法注解无效。@DataSource 同时允许类和方法两种位置,标在类上时子类能继承,这个配置有意义;@RepeatSubmit@Target 只有 METHOD,类上根本标不了,它的 @Inherited 没有任何作用,属于冗余配置。

方法上的四个切面注解

@Log:三个通知接力

@Log 有六个属性:

  • title:模块名称,如「用户管理」
  • businessType:操作类型,BusinessType 枚举,默认 OTHER,可选 INSERT、UPDATE、DELETE、EXPORT 等
  • operatorType:操作人类别,OperatorType 枚举,默认 MANAGE(后台用户),另有 MOBILE(手机端用户)
  • isSaveRequestData:是否保存请求参数,默认 true
  • isSaveResponseData:是否保存响应结果,默认 true
  • excludeParamNames:排除的请求参数数组,用于过滤密码等敏感字段

配套的 LogAspect 用三个通知接力完成一次日志记录。@Before 在方法执行前把时间戳放进 ThreadLocal;@AfterReturning 在方法正常返回后、@AfterThrowing 在方法抛出异常后分别触发,两者都调用同一个 handleLog 方法,从切点绑定的注解对象里读出 title、businessType 等属性填进 SysOperLog,收集请求参数和响应结果、算出执行耗时,最后交给 AsyncManager 异步写入数据库。

三个通知的切点表达式统一为 @annotation(controllerLog),这个表达式只匹配方法上的注解。

这里能解释 @Target 里的 PARAMETER:虽然注解声明了可以标在参数上,但 @annotation 表达式不匹配参数注解,标在参数上不会触发日志记录。系统里所有 @Log 都标在方法上,PARAMETER 只是定义时预留的扩展位,配套机制并没有支持它。

@DataScope:拼接 SQL 写入 params

@DataScope 有五个属性:

  • deptAliasuserAlias:部门表、用户表在 SQL 里的别名
  • deptFielduserField:部门、用户字段名,默认 dept_iduser_id
  • permission:权限字符,多个用逗号分隔,默认从权限上下文获取(由 @ss.hasPermi 校验时写入)

配套的 DataScopeAspect@Before 通知处理:取出当前登录用户的角色列表,按每个角色的数据权限范围(全部、自定义、本部门、部门及以下、仅本人)生成对应的 SQL 片段,拼接后放进方法第一个参数(要求是 BaseEntity 子类)的 params 集合中,Mapper 层的 XML 再把这段 SQL 追加到查询语句后面。

超级管理员直接跳过过滤;进入处理前还会先清空 params 里已有的 dataScope 值,防止调用方传入的内容被当作 SQL 拼接进去。

@DataSource:类上可继承,方法可覆盖

@DataSource 只有一个属性 value,类型是枚举 DataSourceType,只有主库 MASTER 和从库 SLAVE 两个值,默认 MASTER

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DataSource {
    public DataSourceType value() default DataSourceType.MASTER;
}

配套的 DataSourceAspect 切点写法与前面几个不同:

@Pointcut("@annotation(com.ruoyi.common.annotation.DataSource)"
        + "|| @within(com.ruoyi.common.annotation.DataSource)")
public void dsPointCut() {
}

@annotation 匹配方法上标注的情况,@within 匹配方法所在类上标注的情况,两种标法都能被切面拦截。拦截后先确定用哪个数据源:AnnotationUtils.findAnnotation 先找方法上的注解,找不到再找声明类上的注解,方法级配置覆盖类级配置。确定后把数据源类型写进 ThreadLocal,方法执行完在 finally 里清除,真正执行 SQL 时由动态数据源根据 ThreadLocal 里的值选择对应的数据源。这个注解用 @Around 通知,因为需要在方法执行前设置、执行后清理,一组前后动作放在同一个通知里最直接。

@Inherited 在这里发挥了作用:数据源类型标在 Service 父类上,子类自动继承同样的配置。

@RateLimiter:就绪但未启用

@RateLimiter 有四个属性:

  • key:限流 key 前缀,默认 rate_limit:
  • time:限流时间窗口,默认 60 秒
  • count:窗口内允许的最大次数,默认 100
  • limitType:限流类型,LimitType 枚举,DEFAULT 全局限流、IP 按请求者 IP 限流

配套的 RateLimiterAspect@Before 通知处理:把 key、IP(按配置)、类名、方法名拼成完整的 Redis key,执行一段 Lua 脚本对这个 key 计数,超过 count 就抛出 ServiceException。

脚本实现的算法是固定窗口计数器:先读当前计数值,已经超过 count 就直接返回、不再累加;否则自增一,并且只有计数值自增后等于 1(即窗口内第一次访问)时才给 key 设置 time 秒的过期时间。窗口从第一次访问开始计时,time 秒后计数连同 key 一起过期归零,窗口边界对齐首次访问时刻,既不对齐自然时间周期,也不是随请求滑动的滑动窗口。用 Lua 脚本是为了让读取计数、判断超限、自增、设置过期这几步在 Redis 端原子完成。

系统业务代码目前没有使用这个注解,限流机制处于就绪状态,需要时在方法上加注解即可。

拦截器消费的 @RepeatSubmit

@RepeatSubmit 有两个属性:interval 间隔时间,默认 5000 毫秒;message 提示消息,默认「不允许重复提交,请稍候再试」。

配套的 RepeatSubmitInterceptor 是一个抽象拦截器,实现了 Spring MVC 的 HandlerInterceptor,在 preHandle 里处理:

if (handler instanceof HandlerMethod) {
    HandlerMethod handlerMethod = (HandlerMethod) handler;
    Method method = handlerMethod.getMethod();
    RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
    if (annotation != null) {
        if (this.isRepeatSubmit(request, annotation)) {
            AjaxResult ajaxResult = AjaxResult.error(annotation.message());
            ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));
            return false;
        }
    }
    return true;
}

拦截器在 DispatcherServlet 解析出 HandlerMethod 之后执行,所以可以直接 method.getAnnotation(...) 读取注解,命中就调用抽象方法 isRepeatSubmit 判断是否重复,重复则把注解里的 message 写回响应并中断请求。具体判断规则由子类实现,默认实现 SameUrlDataInterceptor 基于 Redis 对比请求 URL、请求参数和时间间隔。这个注解当前同样没有业务代码使用,机制就绪。

这里与 @Anonymous 形成一组对照:同样是读方法上的注解,拦截器层能拿到 HandlerMethod,运行时读取一次即可;而 Security 过滤器层拿不到,只能启动时提前收集。

启动时收集的 @Anonymous

@Anonymous 是一个没有任何属性的标记注解,作用是标注哪些接口可以不登录访问:

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Anonymous {
}

它的消费发生在应用启动阶段:PermitAllUrlProperties 在 Bean 初始化完成后,通过 RequestMappingHandlerMapping 拿到全部接口映射,遍历找出标注了 @Anonymous 的方法及其所在类,把这些接口的 URL 收集到一个列表里,路径变量统一替换成 * 以便按模式匹配;Security 配置再把列表里的所有 URL 注册为 permitAll,请求到达时直接放行。

之所以必须在启动时收集,是因为 Spring Security 的过滤器执行在 DispatcherServlet 之前,那时还不知道请求会落到哪个 Controller 方法上,无法读取方法注解;把注解提前翻译成 URL 放行规则后,运行期的过滤器只需按 URL 匹配,不再碰注解。

字段上的 Excel、Excels 与 Sensitive

@Excel 与 @Excels

@Excel 只能标在字段上,是若依 Excel 导入导出的核心标注。它的属性有二十多个,按功能分组:

  • 基本信息类:name 列名、sort 排序、width 列宽、height 行高、cellType 单元格类型
  • 数据转换类:dateFormat 日期格式、dictType 字典类型、readConverterExp 值转换表达式(如 0=男,1=女,2=未知)、separator 分隔符、defaultValue 空值默认值
  • 显示控制类:prompt 提示信息、combo 下拉框内容、needMerge 是否合并单元格、isStatistics 是否追加统计行
  • 样式类:表头与单元格的背景色、字体颜色、对齐方式
  • 关联扩展类:targetAttr 指定关联对象中的属性名、handler 自定义数据处理器

注解内部还定义了两个枚举:

public enum Type {
    ALL(0), EXPORT(1), IMPORT(2);
}

public enum ColumnType {
    NUMERIC(0), STRING(1), IMAGE(2), TEXT(3);
}

Type 控制字段参与导出还是导入:ALL 两者都参与,EXPORT 仅导出,IMPORT 仅导入。ColumnType 控制导出单元格的数据类型,支持数值、字符串、图片、文本四种。配套的 ExcelUtil 在导入导出时反射读取实体字段上的 @Excel 标注,按注解属性完成列名生成、值转换、样式设置等全部动作,实体类只负责标注。

一个字段需要映射到多个 Excel 列时,用容器注解 @Excels 包装多个 @ExcelSysUserdept 字段是关联对象,导出时需要取它内部的两个属性:

@Excels({
    @Excel(name = "部门名称", targetAttr = "deptName", type = Type.EXPORT),
    @Excel(name = "部门负责人", targetAttr = "leader", type = Type.EXPORT)
})
private SysDept dept;

targetAttr 指定关联对象里的属性名,ExcelUtil 反射读取嵌套属性值填入对应列。这里没有使用 Java 8 的 @Repeatable 可重复注解机制,@Excels 是手动定义的容器注解,效果等价——同一个字段上不能直接写两个 @Excel,必须包一层。

@Sensitive:由 Jackson 序列化机制消费

@Sensitive 也标在字段上,作用是接口返回 JSON 时对值脱敏。它的消费方式与前面所有注解都不同,不经过切面和拦截器,而是通过 Jackson 序列化机制:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveJsonSerializer.class)
public @interface Sensitive {
    DesensitizedType desensitizedType();
}

唯一属性 desensitizedType 指定脱敏规则。注解上叠了两个 Jackson 注解:@JacksonAnnotationsInside@Sensitive 声明为组合注解,让叠加在它上面的 @JsonSerialize 能被 Jackson 识别并应用到目标字段;@JsonSerialize 指定该字段的序列化器为 SensitiveJsonSerializer。使用时只写一个 @Sensitive,Jackson 就会自动用它指定的序列化器处理该字段。

SensitiveJsonSerializer 继承 JsonSerializer<String> 并实现 ContextualSerializer 接口,脱敏发生在 JSON 序列化阶段。createContextual 在每个属性开始序列化前被调用,从这里读取字段上的 @Sensitive 注解、记住脱敏类型;真正写出值时,serialize 按脱敏类型对字符串做替换再写入。数据库里存的仍是完整值,只有返回给前端时被处理。管理员不脱敏:判断当前登录用户是否 admin,是则原样输出,取不到登录用户时默认脱敏。

脱敏规则由 DesensitizedType 枚举定义,每个枚举值持有一个 Function<String, String>:手机号保留前三后四(138****5678)、身份证保留前四和末四、邮箱只露首字符、密码全部星号等,共七种。

思考

注解与配套实现分离的结构:九个注解没有一个自带逻辑,全部把执行交给切面、拦截器、工具类或序列化器。这个结构的好处是职责干净——注解只描述要什么,配套实现决定怎么做,更换实现不需要动业务代码;代价是只看注解看不出效果,理解成本全部转移到机制侧,这也是若依这些注解必须对着配套实现一起学的原因。

消费时机决定读取方式:同样是读注解,不同机制在不同时机读取。AOP 切面和 MVC 拦截器在请求处理链中执行,此时已经解析出目标方法,运行时读取一次即可;Security 过滤器在 DispatcherServlet 之前执行,拿不到目标方法,只能启动时把注解翻译成 URL 规则。判断一个注解该怎么做实现,先看它要在哪个时机生效。

定义与实现不同步留下的痕迹@LogPARAMETER 声明、@RepeatSubmit 冗余的 @Inherited,都是注解定义先于配套机制定型留下的无效配置。使用这些注解时,以配套实现真正支持的位置和行为为准,不要按注解声明想当然。

posted @ 2026-09-22 22:39  咖啡八杯  阅读(6)  评论(0)    收藏  举报