Java案例:如何优雅地自定义拦截逻辑?——从入门到实战
目录导读
问答提示:本文每节末尾均设有问答环节,帮你快速检验学习成果。
为什么需要自定义拦截逻辑?
在Java企业级开发中,我们经常遇到这样的场景:
- 某些API接口需要用户登录后才能访问
- 某些操作需要特定角色(如管理员、普通用户)才能执行
- 需要对请求进行日志记录、参数校验或限流
如果把这些逻辑硬编码到每个业务方法中,代码会变得臃肿且难以维护。自定义拦截逻辑正是为了解决这些问题——它将横切关注点(如权限、日志)与核心业务解耦,实现“低侵入式”的复用。
常见拦截场景对比
| 场景 | 传统方式 | 拦截方式 |
|---|---|---|
| 身份验证 | 每个Controller方法写获取session代码 | 编写一个拦截器,自动检查token |
| 日志记录 | 手动log.info() | AOP切面自动打印接口耗时 |
| 参数校验 | 每个方法里if...else | 注解+拦截器统一校验 |
核心价值
- 复用性:同一个拦截逻辑可应用于多个接口
- 可维护性:修改拦截逻辑只需改动一处
- 可扩展性:新增拦截逻辑只需添加新拦截器
问答1:拦截器和过滤器(Filter)有何区别?
答:Filter是Servlet规范的一部分,能拦截所有请求(包括静态资源);拦截器(Interceptor)是Spring框架特性,仅拦截Controller层,且能获取Spring容器中的Bean,通常推荐在Spring项目中使用拦截器。
Java拦截器核心原理
1 三种主流实现方式
| 方式 | 技术栈 | 适用场景 | 控制粒度 |
|---|---|---|---|
| Servlet Filter | Java EE | 字符编码、请求日志 | 全局粗粒度 |
| Spring Interceptor | Spring MVC | 权限控制、请求预处理 | 路径级别 |
| AOP (AspectJ) | Spring/AspectJ | 方法级别拦截 | 最细粒度(可到参数) |
2 AOP的底层秘密
Spring AOP基于动态代理实现:
- JDK动态代理:目标类实现了接口(默认方式)
- CGLIB代理:目标类未实现接口
AOP的核心概念:
切面(Aspect) = 对象(AspectJ类) + 通知(Advice)
通知类型:
- @Before:方法执行前
- @After:方法执行后(含异常)
- @Around:包裹方法执行(最灵活)
3 执行顺序
当一个请求进入时,经过的拦截层顺序:
Filter1 → Filter2 → Interceptor.preHandler → AOP环绕通知 → Controller
← 视图渲染 ← 后处理 ← AOP后通知
问答2:多个拦截器如何排序?
答:Filter通过web.xml的配置顺序;Interceptor通过addInterceptor顺序;AOP通过@Order注解或实现Ordered接口,建议按“认证→授权→日志”顺序排列。
实战案例:基于Spring AOP的登录拦截器
1 场景需求
所有/api/private/*接口必须先登录,未登录返回401状态码。
2 代码实现
步骤1:自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
boolean needToken() default true;
}
步骤2:编写AOP切面
@Aspect
@Component
@Order(1)
public class LoginAspect {
@Autowired
private TokenService tokenService;
@Around("@annotation(loginRequired)") // 生效范围
public Object checkLogin(ProceedingJoinPoint pjp, LoginRequired loginRequired) throws Throwable {
// 1. 从请求头获取token
HttpServletRequest request = getHttpServletRequest();
String token = request.getHeader("Authorization");
if (loginRequired.needToken() && !tokenService.isValid(token)) {
throw new UnauthorizedException("登录已过期,请重新登录");
}
// 2. 放行请求
return pjp.proceed();
}
private HttpServletRequest getHttpServletRequest() {
RequestAttributes ra = RequestContextHolder.getRequestAttributes();
return ((ServletRequestAttributes) ra).getRequest();
}
}
步骤3:在Controller上使用
@RestController
@RequestMapping("/api/private")
public class UserController {
@LoginRequired
@GetMapping("/profile")
public Result getProfile() {
// 业务逻辑 - 无需关注token校验
return Result.success(userService.getCurrentUser());
}
}
3 扩展:拦截多个方法
如果所有接口都需要拦截,可直接指定全包:
@Around("execution(* com.example.controller..*.*(..))")
public Object globalCheck(ProceedingJoinPoint pjp) throws Throwable {
// ...
}
问答3:AOP切面中如何获取请求体(Request Body)?
答:通过pjp.getArgs()获取方法参数数组,然后从参数中提取@RequestBody注解对应的参数对象,但需注意,流只能读取一次——可在切面中配合ContentCachingRequestWrapper使用。
案例进阶:注解驱动+参数解析的权限拦截
1 需求分析
管理员接口(标记@AdminOnly)需要检测当前用户角色是否为ROLE_ADMIN,并自动将用户信息注入到方法参数中。
2 组合方案:拦截器 + 参数解析器
// 步骤1:权限注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminOnly {
String[] allowedRoles() default {"ADMIN"};
}
// 步骤2:自定义参数注解
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
}
拦截器核心代码:
@Component
public class PermissionInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) return true;
HandlerMethod method = (HandlerMethod) handler;
AdminOnly anno = method.getMethodAnnotation(AdminOnly.class);
if (anno == null) return true;
// 从已解析的token中获取角色
String role = tokenService.getCurrentUserRole(request);
for (String allowed : anno.allowedRoles()) {
if (role.equalsIgnoreCase(allowed)) {
request.setAttribute("currentUser", tokenService.getCurrentUser(request));
return true;
}
}
throw new ForbiddenException("无权限访问");
}
}
参数解析器:
@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(CurrentUser.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container,
NativeWebRequest webRequest, WebDataBinderFactory factory) throws Exception {
ServletRequestAttributes ra = (ServletRequestAttributes) webRequest.getNativeRequest();
return ra.getRequest().getAttribute("currentUser");
}
}
控制器使用:
@AdminOnly
@GetMapping("/admin/users")
public Result listUsers(@CurrentUser User admin) {
// admin对象已被自动注入,且保证是管理员角色
return Result.success(userService.listAll());
}
3 为什么推荐这种方式?
- 注解驱动声明式拦截,代码更简洁
- 参数解析器避免每次手动从Request中getUser
- 职责单一:拦截器只做权限校验,解析器只做参数注入
问答4:拦截器与参数解析器的执行顺序是怎样的?
答:先执行preHandle拦截器,然后执行参数解析器(在进入Controller方法前解析参数),因此参数解析器可以读取拦截器放到Request中的属性。
性能优化:防止重复拦截与缓存策略
1 常见陷阱
- 重复执行拦截:多个AOP切面同时作用在同一个方法上
- 重复解析Token:每个拦截器都解析一次JWT Token
- 数据库查询:权限校验时每次都查询数据库
2 优化方案
引入缓存
@Aspect
@Component
public class CachedPermissionAspect {
private static final Cache<String, Boolean> PERMISSION_CACHE = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.maximumSize(1000)
.build();
@Around("@annotation(adminOnly)")
public Object checkWithCache(ProceedingJoinPoint pjp, AdminOnly adminOnly) throws Throwable {
String token = extractToken();
Boolean result = PERMISSION_CACHE.get(token, k -> doCheck(token, adminOnly));
if (!result) throw new ForbiddenException();
return pjp.proceed();
}
}
合并拦截器
将多个小切面合并为一个切面,减少方法调用栈深度:
@Around("loginRequired() || adminOnlyAnno() || loggingRequired()")
public Object combinedAspect(ProceedingJoinPoint pjp) throws Throwable {
// 按优先级依次校验
checkLogin();
checkAdmin();
startLogTimer();
try {
return pjp.proceed();
} finally {
stopLogPrint();
}
}
Filter提前处理
对于全局性操作(如Token解析),放在Filter中执行一次,后续拦截器直接读取Filter放入Request的属性:
@Component
public class TokenResolveFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) {
String token = request.getHeader("Authorization");
UserInfo userInfo = tokenService.resolve(token);
request.setAttribute("userInfo", userInfo); // 供拦截器复用
chain.doFilter(request, response);
}
}
3 性能对比
| 方案 | Token解析次数 | 数据库查询 | 执行耗时 |
|---|---|---|---|
| 无优化 | 3次/请求 | 3次 | 15ms |
| 放入Filter | 1次/请求 | 1次 | 5ms |
| 加缓存 | 1次/请求 | 0次(缓存命中) | 2ms |
问答5:如何在拦截器中优雅地处理异常?
答:不要在拦截器中直接返回页面或响应,而是抛出自定义异常,统一使用@ControllerAdvice的@ExceptionHandler处理,保持异常处理逻辑集中。
常见问题答疑(Q&A)
Q6.1:自定义拦截逻辑应该放在Filter、Interceptor还是AOP?
答:遵循“广度优先,深度渐进”原则:
- 全局粗粒度(编码、CORS):Filter
- 路径级别(登录校验、多语言):Interceptor
- 方法级别(权限、缓存、事务):AOP
Q6.2:为什么我的拦截器没有生效?
常见原因排查:
- 拦截器是否已注入Spring容器(
@Component) - AOP是否启用了
@EnableAspectJAutoProxy(Spring Boot默认开启) - 调用方法时是否通过代理对象(内部方法调用不会触发AOP)
- 是否在Controller方法上正确声明了注解
Q6.3:如何拦截静态资源?
Filter可以拦截所有路径,但Interceptor默认不拦截静态资源,可在WebMvcConfigurer.addInterceptors中通过excludePathPatterns排除误拦截的静态资源:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns("/api/public/**", "/css/**", "/js/**");
}
Q6.4:AOP能拦截私有方法吗?
AOP基于代理,只能拦截被public或protected修饰的方法(如果是CGLIB代理可拦截protected)。私有方法无法被代理——因为代理类无法继承私有方法。
Q6.5:如何测试自定义拦截逻辑?
推荐使用Spring Boot的@WebMvcTest:
@WebMvcTest(UserController.class)
public class LoginAspectTest {
@Autowired
private MockMvc mockMvc;
@Test
void testWithoutToken_shouldReturn401() throws Exception {
mockMvc.perform(get("/api/private/profile"))
.andExpect(status().isUnauthorized());
}
}
自定义拦截逻辑是Java框架设计中最具实用价值的模式之一,从简单的登录校验到复杂的权限矩阵,从性能监控到业务降级,拦截器为我们提供了一种非侵入式的扩展能力。
在实际开发中,请遵循以下原则:
- 单一职责:一个拦截器只做一件事
- 合理排序:认证拦截器应在最外层
- 性能优先:避免在拦截器中执行IO操作(如数据库查询)
- 异常统一:拦截器内部只抛异常,不处理异常
