模块深度解析 · 框架 Web 层(多数据源 / AOP / 限流 / 异步 / 异常 / 过滤器)
📍 核心源码在
ruoyi-framework/src/main/java/com/ruoyi/framework/下的aspectj/、datasource/、interceptor/、manager/、config/、web/exception/,以及ruoyi-common的filter/、xss/。
1. 模块概述
如果说 02 章 讲了”你是谁、能做什么”,那么本章讲的是横切关注点(Cross-cutting Concerns)——那些与具体业务无关、但每个请求都要经历的能力:
| 关注点 | 注解/机制 | 实现 |
|---|---|---|
| 多数据源动态切换 | @DataSource |
DataSourceAspect + DynamicDataSource |
| 操作日志(业务审计) | @Log |
LogAspect |
| 接口限流 | @RateLimiter |
RateLimiterAspect + Redis Lua |
| 防重复提交 | @RepeatSubmit |
SameUrlDataInterceptor 拦截器 |
| 统一异常处理 | —— | GlobalExceptionHandler |
| 异步日志/任务 | AsyncManager |
ScheduledExecutorService |
| XSS / 防盗链 / 可重复读 Body | —— | XssFilter / RefererFilter / RepeatableFilter |
这些能力大多通过 AOP(面向切面)+ 注解 的方式挂接到业务方法上,实现”声明式、无侵入”。
graph TB
subgraph 过滤器链 Servlet Filter
F1[RepeatableFilter
JSON体可重复读]
F2[XssFilter
XSS过滤]
F3[RefererFilter
防盗链]
end
subgraph 拦截器 Spring MVC Interceptor
I1[RepeatSubmitInterceptor
防重复提交]
end
subgraph AOP 切面
A1[DataSourceAspect 多数据源]
A2[LogAspect 操作日志]
A3[RateLimiterAspect 限流]
A4[DataScopeAspect 数据权限]
end
subgraph 异步
M1[AsyncManager 线程池]
end
2. 命名体系与易混淆函数对比
2.1 命名规律拆解
| 前缀 | 类 | 操作 | 含义 |
|---|---|---|---|
DataSourceAspect. |
ds |
PointCut |
声明 @DataSource 切点 |
DataSourceAspect. |
around |
getDataSource |
解析方法/类上的注解 |
DynamicDataSource |
context |
set/get/clear |
ThreadLocal 中读写当前数据源 |
LogAspect. |
do |
Before/AfterReturning/AfterThrowing |
标注切面通知 |
LogAspect. |
v |
handleLog |
组装 SysOperLog 并保存 |
RateLimiterAspect. |
v |
getCombineKey |
拼限流 Redis key |
命名规律:AOP 切面统一为 XxxAspect,通知方法用 doBefore / doAfterReturning / doAfterThrowing / around,切点用 xxxPointCut。
2.2 易混淆函数对比:DataSourceAspect.getDataSource(方法级)vs @within(类级)
| 对比维度 | Annotation on method | Annotation on class |
|---|---|---|
| 注解位置 | 方法上 @DataSource(value=SLAVE) |
类上 @DataSource |
| 优先级 | @annotation 匹配 |
@within 匹配 |
| 解析顺序 | 先方法后类 | 类 |
| 一句话区分 | 方法注解优先,类注解兜底 | 用 findAnnotation 先在方法找,没有再找类 |
3. API Signatures
graph LR
REQ[请求] --> DS["DataSourceAspect
@Around"]
DS --> SSL["DataSourceSwitch"]
REQ --> LG["LogAspect
@Before/@AfterReturning"]
LG --> AM["AsyncManager -> 异步线程池"]
LG --> OL[("sys_oper_log")]
REQ --> RL["RateLimiterAspect
@Before"]
RL --> LUA["Redis Lua 脚本"]
REQ --> RPT["RepeatSubmitInterceptor"]
RPT --> REDIS[("Redis")]
1 |
|
1 | public class DynamicDataSourceContextHolder |
1 | public class DynamicDataSource extends AbstractRoutingDataSource |
1 |
|
1 |
|
1 | public class AsyncManager |
4. 数据结构深度解析
DynamicDataSource(动态数据源)
4.a 存在的理由
业务系统常需要”读写分离”或”多库”。Spring 的
AbstractRoutingDataSource允许在运行时通过determineCurrentLookupKey()返回的 key 选择实际数据源。若依在此之上加了一个 ThreadLocal 切换器,让同一线程内的一组 mapper 调用保持同一数据源,方法结束自动切回默认源。
4.b 结构定义
1 | public class DynamicDataSource extends AbstractRoutingDataSource |
4.c 字段三层分析表
| 字段 | 设计动机 | 反事实(去掉) | 替代方案 |
|---|---|---|---|
defaultTargetDataSource |
默认主数据源 | 无兜底源,未标注时无法连接 | 无 |
targetDataSources |
按 key 存放多个数据源 | 无法多库 | 无 |
| ThreadLocal key | 记录当前线程选哪个源 | 无法知道用哪个源;线程复用会串源 | 但必须 finally 清理 |
4.d 生命周期
1 | stateDiagram-v2 |
SysOperLog(操作日志实体)
字段:operId operModul businessType requestMethod operName deptName operUrl operIp operParam jsonResult status errorMsg operTime costTime operLocation。用于 LogAspect 组装后异步入库 sys_oper_log。
5. 函数逐行精讲
5.a + 5.b:DataSourceAspect.around(多数据源切换核心)
函数:
around
- 调用时机:任何标记
@DataSource的方法执行- 目的:环绕控制数据源切换与清理
1 |
|
5.a + 5.b:LogAspect.handleLog(操作日志组装)
函数:
handleLog
- 调用时机:
@Log方法的成功/异常通知回调- 目的:收集请求信息 → 组
SysOperLog→ 异步入库
1 | protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) |
5.a + 5.b:RateLimiterAspect.doBefore(限流)
函数:
doBefore
- 调用时机:
@RateLimiter方法执行前- 目的:用 Redis Lua 原子计数实现限流
1 |
|
6. 关键算法剖析
6.1 多数据源路由(AbstractRoutingDataSource)
DynamicDataSource继承AbstractRoutingDataSource。- 每次
DataSource.getConnection()→ Spring 调用determineCurrentLookupKey()→ 返回 ThreadLocal 里的源名。 DataSourceAspect在@DataSource方法前后用set/clear切换。
sequenceDiagram
participant C as 业务方法 @DataSource
participant A as DataSourceAspect
participant TL as ThreadLocal
participant D as DynamicDataSource
C->>A: around 开始
A->>TL: set(SLAVE)
C->>D: getConnection()
D->>TL: get() → SLAVE
D-->>C: 从库连接
C->>A: around 结束 finally
A->>TL: clear()
6.2 限流 Lua 脚本(原子 INCR + EXPIRE)
经典计数器限流。通过 Lua 保证”判断+自增+设过期”的原子性,避免并发下超发。Redis script 在 RedisConfig 中定义,加了 Local.incr + expire 逻辑。
6.3 防重复提交(SameUrlDataInterceptor)
- 用
url + 用户token作 Redis key,值是{url: {repeatParams, repeatTime}}。 - 若
interval(默认 5 秒) 内再次请求且参数完全相同,判定重复提交。 - 依赖
RepeatableFilter让 JSON body 可重复读取(body 只读一次的问题)。
7. 设计决策分析
7.1 为什么用 AOP 而不是在每个方法手写?
@Log、@DataScope、@RateLimiter 都是声明式横切。好处:
- 业务方法保持纯净,只关心业务参数。
- 统一口径:日志格式、限流策略、权限SQL拼接都在一个切面,改一处全局生效。
- 可通过注解属性灵活开关(
@Log(isSaveRequestData=false))。
代价:需要理解 AOP 代理(Spring 默认对非 final 方法用 CGLIB/JDK 代理),且切面引入的性能开销必须控制(日志极快,但要注意不抛异常)。
7.2 为什么日志/异步任务要走线程池而非同步?
登录日志、操作日志入库都是”可后置”的副作用。用 AsyncManager(ScheduledExecutorService,延迟 10ms)异步执行:
- 避免阻塞用户主请求(写库慢时不影响响应时间)。
AsyncFactory用SpringUtils.getBean(...)从容器取 Service,实现”延迟获取”。
代价:异步一到 JVM 重启可能丢日志(业务可接受),且无法访问请求作用域(所以 AsyncFactory 在创建 Task 时就把 ip/UA 等捕获成 final 变量)。
7.3 为什么全局异常用 @RestControllerAdvice + 异常类型映射?
GlobalExceptionHandler 集中捕获并映射成统一的 AjaxResult,前端只需解析 {code, msg, data}。优点:异常消息不下渗到协议细节;按异常类型细分处理(AccessDenied/ServiceException/参数不匹配/演示模式等)。
8. 学习检查点
📝 本章小结
- 多数据源 =
@DataSource注解 +DataSourceAspect(切源/清理)+DynamicDataSource(AbstractRoutingDataSource 路由)+ ThreadLocal。 - 操作日志 =
@Log注解 +LogAspect,组装SysOperLog后用AsyncManager异步入库。 - 接口限流 =
@RateLimiter+RateLimiterAspect+ Redis Lua;防重复提交 =@RepeatSubmit+SameUrlDataInterceptor。 - 横切逻辑统一进 AOP/拦截器/过滤器,业务方法保持纯净。
- 异步任务统一走
AsyncManager线程池,且任务内提前捕获请求上下文。
🤔 思考题
DataSourceAspect为什么必须在finally中clearDataSourceType()?参考答案
DynamicDataSourceContextHolder用ThreadLocal记录当前源(DynamicDataSourceContextHolder.java:19),而 Tomcat/ScheduledExecutor 会复用线程。若不清理,下一个复用该线程的请求会读到上一个请求残留的源名,导致误用错误的数据库。finally 保证任何路径(含异常)都清理。AsyncFactory.recordLogininfor为何在run()外就提前捕获 request、ip 等值,而不是在 Task 里调ServletUtils.getRequest()?参考答案
异步线程没有请求作用域(
RequestContextHolder只在请求线程有效)。所以在创建TimerTask时(同步阶段),就先把userAgent、ip捕获为final局部变量(AsyncFactory.java:40-41),再传给异步任务使用,否则异步执行时拿不到请求数据。RepeatableFilter(JSON body 可重复读)与SameUrlDataInterceptor(防重复提交)如何配合?参考答案
Servlet 的
InputStream只能读一次,日志切面和防重复提交都要读 body。RepeatableFilter把 JSON 请求包装成RepeatedlyRequestWrapper,构造时缓存 body(RepeatedlyRequestWrapper.java)。SameUrlDataInterceptor判断request instanceof RepeatedlyRequestWrapper后调用HttpHelper.getBodyString取参数做比对(SameUrlDataInterceptor.java:44-48),从而能比较 JSON body 是否重复。