22. MVC中的错误处理与日志:让系统在出问题时依然优雅
说实话,错误处理和日志记录,在很多MVC项目里都是最后才被想起的环节。我见过太多项目,功能跑得飞起,但一上线就抓瞎——因为根本不知道哪里出了错。今天我们就来聊聊,怎么在MVC里把这件事做得漂亮。
为什么错误处理在MVC里这么重要?
MVC把应用分成了三层:Model、View、Controller。每一层都可能出问题。Model层可能数据库连不上,View层可能模板渲染报错,Controller层可能参数校验失败。如果没有统一的处理机制,这些错误就会像野狗一样到处乱窜——要么直接白屏,要么弹出看不懂的堆栈信息。
我个人习惯,在项目一开始就搭好错误处理的架子。别等到上线前再补,那时候代码已经耦合得乱七八糟了。
全局错误处理机制:一个入口管所有
在MVC框架里,通常都有一个全局异常处理器。比如在Spring MVC里是@ControllerAdvice,在ASP.NET Core里是ExceptionFilter。说白了,就是给所有Controller加一个统一的“兜底”逻辑。
我曾经在一个老项目里看到,每个Controller都自己写try-catch,有的返回JSON,有的返回错误页面,有的直接抛出500。维护起来简直是一场噩梦。后来我重构时,把所有错误处理集中到一个地方,代码量直接砍掉三分之一。
一个典型的全局错误处理流程
- 请求进入Controller
- Controller调用Service/Model
- 如果抛出异常,被全局异常处理器捕获
- 处理器判断异常类型,决定返回什么
- 同时记录日志
- 返回友好的错误信息给客户端
你想想看,如果没有这个全局机制,每个方法都要写一遍try-catch,是不是很烦?
异常捕获的最佳实践:别什么都抓
很多新手喜欢写catch(Exception ex),然后什么都不做。这是最糟糕的做法。异常捕获要讲究“粒度”。
- 业务异常: 比如用户余额不足、订单已取消。这类异常应该自定义,比如
BusinessException,然后在全局处理器里返回特定的错误码和消息。 - 系统异常: 比如数据库连接超时、文件读写失败。这类异常应该记录详细日志,并返回500错误,但不要暴露内部细节。
- 意料之外的异常: 比如空指针、数组越界。这类异常说明代码有bug,必须记录完整堆栈,并触发告警。
日志记录:别只记“出错了”
日志是调试的命根子。但很多项目的日志就像天书——只有“Error occurred”几个字,连哪个请求、哪个用户、哪个参数都不知道。
我建议每条日志至少包含以下信息:
| 字段 | 说明 | 示例 |
|---|---|---|
| 时间戳 | 精确到毫秒 | 2025-03-21 14:23:45.123 |
| 日志级别 | DEBUG/INFO/WARN/ERROR | ERROR |
| 请求ID | 用于关联同一个请求的所有日志 | req-abc123 |
| 用户ID | 如果已登录 | user-456 |
| 请求路径 | URL | /api/order/create |
| 异常类型 | 全类名 | com.example.BusinessException |
| 消息 | 人类可读的描述 | 订单金额不能为负数 |
| 堆栈 | 仅ERROR级别 | at com.example... (line 42) |
嗯,这里要注意:堆栈信息只在ERROR级别记录,INFO和WARN级别不要打堆栈,否则日志文件会爆炸。
代码示例:一个简单的全局错误处理
下面是一个伪代码示例,展示如何在MVC中实现全局错误处理和日志记录。我用的是类似Spring Boot的风格,但思想是通用的。
// 全局异常处理器
@ControllerAdvice
public class GlobalExceptionHandler {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
// 处理业务异常
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result handleBusinessException(BusinessException ex, HttpServletRequest request) {
// 记录WARN级别日志,不记录堆栈
logger.warn("业务异常 | 请求: {} | 用户: {} | 消息: {}",
request.getRequestURI(),
request.getAttribute("userId"),
ex.getMessage());
return Result.error(ex.getCode(), ex.getMessage());
}
// 处理系统异常
@ExceptionHandler(SystemException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result handleSystemException(SystemException ex, HttpServletRequest request) {
// 记录ERROR级别日志,记录堆栈
logger.error("系统异常 | 请求: {} | 用户: {}",
request.getRequestURI(),
request.getAttribute("userId"),
ex);
return Result.error(500, "系统繁忙,请稍后重试");
}
// 处理意料之外的异常(兜底)
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result handleException(Exception ex, HttpServletRequest request) {
logger.error("未预期异常 | 请求: {} | 用户: {}",
request.getRequestURI(),
request.getAttribute("userId"),
ex);
return Result.error(500, "服务器内部错误");
}
}
日志记录的最佳实践:结构化与异步
我个人强烈推荐使用结构化日志。什么意思呢?就是日志不是一段字符串,而是一组键值对。比如用JSON格式记录,这样后续可以方便地接入日志分析系统(比如ELK)。
举个例子,不要这样写:
logger.error("用户" + userId + "下单失败,原因是" + message);
而要这样写:
logger.error("下单失败",
"userId", userId,
"orderId", orderId,
"reason", message);
这样在日志系统里,你可以直接搜索userId=123,而不是去字符串里模糊匹配。
另外,日志记录最好是异步的。因为写日志本身也是I/O操作,如果在请求处理线程里同步写日志,会影响响应速度。很多日志框架(如Logback、Log4j2)都支持异步Appender,配置一下就好。
避坑指南:我曾经踩过的三个坑
- 坑一: 在catch块里又抛出异常,但没有处理。结果全局处理器捕获了两次,日志重复。我的建议是:要么在catch里处理完,要么直接抛出给全局处理器,不要混着来。
- 坑二: 日志里打印了敏感信息,比如密码、身份证号。有一次我在日志里看到了用户的明文密码,吓得我赶紧改了代码。记住:日志里永远不要记录敏感数据。
- 坑三: 全局处理器没有处理404错误。很多框架默认404不会走异常处理器,需要单独配置。否则用户访问一个不存在的页面,可能看到的是框架默认的丑陋404页面。
知识体系图:错误处理与日志的核心逻辑
下面这张图展示了MVC中错误处理和日志的整体流程。你可以看到,请求从Controller进来,经过Service,如果出错,会被全局异常处理器拦截,然后根据异常类型走不同的分支,同时日志系统会记录相关信息。
总结一下
错误处理和日志记录,说白了就是给系统装上一个“黑匣子”和“安全气囊”。黑匣子记录事故原因,安全气囊保护用户不受伤害。我在项目中一直坚持三个原则:
- 所有异常必须被捕获,但捕获后要分类处理
- 日志必须结构化,方便后续分析
- 用户看到的信息和开发者看到的信息必须隔离
做到这三点,你的MVC应用在出问题时,就不会手忙脚乱了。