在 Web 项目中,Controller 通常会面对很多重复问题:接口返回格式不统一、异常处理分散、登录校验到处写。
本篇文章结合一个图书管理系统 book-demo,总结 Spring MVC 中常见的统一功能和拦截器实践。
项目中主要涉及三类能力:
- 统一返回结果:
Result<T>、ResultCode - 统一响应包装:
ResponseBodyAdvice - 统一异常处理:
@ControllerAdvice+@ExceptionHandler - 登录拦截器:
HandlerInterceptor+WebMvcConfigurer
一、项目背景
book-demo 是一个简单的图书管理系统,包含登录、图书列表分页、添加图书、修改图书、删除图书等接口。
例如 :
java
@RestController
@RequestMapping("/book")
public class BookController {
@RequestMapping("getBookListByPage")
public Result<PageResponse<BookInfo>> getBookListByPage(PageRequest pageRequest) {
PageResponse<BookInfo> response = bookService.getListByPage(pageRequest);
return Result.success(response);
}
}
这些接口有一个共同特点:都需要返回给前端稳定的数据结构。
同时,除了登录接口和静态资源外,大部分接口都应该要求用户已经登录。
因此,项目中引入了统一返回、统一异常处理和登录拦截器。
二、什么是 Spring 统一功能
所谓"统一功能",本质上是把 Controller 中重复、横切的逻辑抽取出来,让业务接口只关注业务本身。
常见统一功能包括:
- 统一返回格式:所有接口都返回固定 JSON 结构
- 统一异常处理:异常不直接暴露给前端,而是转换成统一错误响应
- 统一登录校验:不在每个接口里手动判断用户是否登录
- 统一参数校验、日志、权限校验等
在 Spring MVC 中,这些能力通常通过以下机制实现:
ResponseBodyAdvice@ControllerAdvice@ExceptionHandlerHandlerInterceptorWebMvcConfigurer
三、统一返回结果 Result
项目中定义了统一响应对象 Result<T>:
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
private int code;
private String errMsg;
private T data;
public static Result unlogin() {
return new Result(ResultCode.UNLOGIN.getCode(), "用户未登录", null);
}
public static <T> Result<T> success(T data) {
return new Result(ResultCode.SUCCESS.getCode(), null, data);
}
public static <T> Result fail(String errMsg) {
return new Result(ResultCode.FAIL.getCode(), errMsg, null);
}
}
对应状态码枚举:
java
@Getter
@AllArgsConstructor
public enum ResultCode {
SUCCESS(200),
FAIL(-2),
UNLOGIN(-1);
private int code;
}
这样前端可以按照统一格式处理响应:
json
{
"code": 200,
"errMsg": null,
"data": {}
}
统一返回结构的好处是:
- 前端不用针对每个接口猜返回格式
- 成功、失败、未登录等状态表达清晰
- 后端接口风格统一,维护成本更低
四、使用 ResponseBodyAdvice 统一包装返回值
虽然可以在每个 Controller 中手动返回 Result.success(...),但更进一步,可以使用 ResponseBodyAdvice 对响应结果进行统一处理。
项目中的 ResponseAdvice:
java
@Slf4j
@RestControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Autowired
private ObjectMapper objectMapper;
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (body instanceof Result) {
return body;
}
if (body instanceof String) {
Result result = Result.success(body);
return objectMapper.writeValueAsString(result);
}
return Result.success(body);
}
}
它的执行逻辑是:
- Controller 方法返回结果
- Spring MVC 准备把返回值写入响应体
beforeBodyWrite被调用- 如果返回值已经是
Result,直接返回 - 如果不是
Result,自动包装成Result.success(body)
这里有一个特殊点:String 类型需要单独处理。
原因是 Spring MVC 对 String 返回值通常会使用 StringHttpMessageConverter,它期望最终返回的仍然是字符串。
如果直接返回 Result 对象,可能出现类型转换异常。因此项目中先把 Result 序列化成 JSON 字符串再返回。
五、统一异常处理
项目中使用 @ControllerAdvice 和 @ExceptionHandler 做全局异常处理:
java
@Slf4j
@ResponseBody
@ControllerAdvice
public class ErrorAdvice {
@ExceptionHandler
public Result exceptionHandler(Exception e) {
log.error("异常信息: {}", e);
return Result.fail("服务器异常");
}
@ExceptionHandler
public Result exceptionHandler(NoResourceFoundException e) {
log.error("异常信息: {}", e);
return Result.fail("资源不存在: " + e.getResourcePath());
}
}
它的作用是:当 Controller 或后续业务代码抛出异常时,不让异常堆栈直接返回给前端,而是转换成统一的失败响应。
例如服务端出现异常时,前端得到的是:
json
{
"code": -2,
"errMsg": "服务器异常",
"data": null
}
这样做有几个好处:
- 避免异常堆栈暴露给前端
- 接口失败格式统一
- 日志中保留异常详情,方便后端排查
- Controller 中不需要到处写重复的
try-catch
六、登录拦截器 LoginInterceptor
除了统一返回和异常处理,项目还使用拦截器完成登录校验。
核心代码如下:
java
@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
log.info("用户请求: {}", request.getRequestURI());
response.setContentType("application/json;charset=utf-8");
HttpSession session = request.getSession();
if (session == null ||
session.getAttribute(Constants.SESSION_USER_KEY) == null) {
log.warn("用户未登录: {}", request.getRequestURI());
Result result = Result.unlogin();
response.setStatus(401);
response.getOutputStream().write(objectMapper.writeValueAsBytes(result));
return false;
}
return true;
}
}
preHandle 是请求进入 Controller 之前执行的方法。
它的返回值很关键:
- 返回
true:请求继续执行,进入 Controller - 返回
false:请求被拦截,不再进入 Controller
在这个项目中,登录成功后会把用户信息放到 Session 中:
java
session.setAttribute(Constants.SESSION_USER_KEY, userInfo);
所以拦截器只需要判断 Session 中是否存在这个 key:
java
Constants.SESSION_USER_KEY
如果不存在,说明用户未登录,直接返回:
json
{
"code": -1,
"errMsg": "用户未登录",
"data": null
}
同时 HTTP 状态码设置为 401,表示未认证。
七、注册拦截器 WebConfig
拦截器定义之后,还需要注册到 Spring MVC 中。
项目中的配置类如下:
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
private List<String> excludePaths = List.of(
"/user/login",
"/**/*.js",
"/**/*.css",
"/**/*.jpg",
"/**/*.png",
"/**/*.html"
);
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(excludePaths);
}
}
这里有两个核心配置:
java
.addPathPatterns("/**")
表示拦截所有请求。
java
.excludePathPatterns(excludePaths)
表示放行指定路径。
项目中需要放行:
/user/login:否则用户还没登录就无法访问登录接口/**/*.html:登录页、图书页面等静态页面/**/*.js、/**/*.css、/**/*.png:前端静态资源
如果没有正确放行静态资源,页面可能会出现 HTML 能打开,但 CSS、JS、图片加载失败的问题。
八、整体请求执行流程
结合项目中的实现,一个普通图书接口请求大致流程如下:
text
浏览器发起请求
|
v
LoginInterceptor.preHandle
|
|-- 未登录:返回 401 + Result.unlogin(),请求结束
|
|-- 已登录:继续执行
v
Controller 处理业务
|
v
Service / Mapper 访问业务数据
|
v
Controller 返回结果
|
v
ResponseAdvice.beforeBodyWrite 统一包装响应
|
v
返回 JSON 给前端
如果执行过程中抛出异常,则会进入:
text
Controller / Service 抛出异常
|
v
ErrorAdvice 捕获异常
|
v
返回 Result.fail(...)
九、常见坑
1. 登录接口必须放行
如果 /user/login 没有配置到 excludePathPatterns 中,登录请求也会被拦截。
结果就是用户永远无法登录。
2. 静态资源也要放行
前端页面依赖 HTML、CSS、JS、图片等资源。
如果这些资源被登录拦截器拦截,页面样式和交互会异常。
3. String 返回值需要特殊处理
使用 ResponseBodyAdvice 统一包装返回值时,String 是一个特殊类型。
因为它默认使用字符串消息转换器,所以最好手动序列化成 JSON 字符串。
4. 前后端字段名要统一
项目中的统一返回字段是:
java
private int code;
private String errMsg;
private T data;
前端读取错误信息时也应该使用 errMsg。
如果前端读取的是 message,就会出现错误提示拿不到的问题。
十、总结
Spring MVC 中的统一功能和拦截器,本质上都是为了减少重复代码,让业务接口更加专注。
在 book-demo 项目中:
Result<T>统一了接口响应格式ResponseBodyAdvice实现了返回值自动包装@ControllerAdvice和@ExceptionHandler实现了全局异常处理HandlerInterceptor实现了登录校验WebMvcConfigurer负责注册拦截器并配置放行路径
这些能力组合起来后,Controller 就不需要关心大量通用逻辑,只需要处理具体业务。
这也是 Spring MVC 在实际项目中非常常见、非常实用的一套开发模式。