1. 为什么需要记录接口请求日志
在前后端分离的现代Web应用中,接口日志记录是每个Java开发者必须掌握的基础技能。想象这样一个场景:凌晨3点,生产环境突然出现异常请求,而你手头只有模糊的用户反馈"页面显示不正常"。此时如果系统没有完善的请求日志,排查问题就如同大海捞针。
我经历过多次这样的深夜救火,深刻体会到良好的日志记录系统能节省80%以上的问题定位时间。传统的Controller层手动打印日志方式存在几个致命缺陷:
- 代码侵入性强:每个接口方法都需要重复编写相似的日志代码
- 维护成本高:当需要修改日志格式时,需要逐个修改所有接口
- 关键信息缺失:开发人员容易遗漏重要参数的记录
而通过自定义注解+AOP的方案,我们可以实现:
- 零侵入的日志记录
- 统一的日志格式和输出位置
- 完整的请求/响应/异常信息捕获
- 灵活的日志级别控制
2. 核心组件设计与原理解析
2.1 自定义注解设计
首先我们定义日志注解@ApiLog,这是整个系统的配置入口:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiLog {
/**
* 业务模块名称
*/
String module() default "";
/**
* 是否记录请求参数
*/
boolean recordRequest() default true;
/**
* 是否记录返回值
*/
boolean recordResponse() default true;
/**
* 日志级别
*/
LogLevel level() default LogLevel.INFO;
enum LogLevel {
DEBUG, INFO, WARN, ERROR
}
}
设计要点解析:
@Target(ElementType.METHOD):限定注解只能用于方法上module字段:支持按业务模块分类日志- 灵活的开关控制:可单独控制请求/响应日志的记录
- 日志级别:适应不同环境的需求(开发环境可能需要DEBUG级别)
2.2 AOP切面实现
基于Spring AOP的切面类核心结构:
java复制@Aspect
@Component
@Slf4j
public class ApiLogAspect {
@Around("@annotation(apiLog)")
public Object around(ProceedingJoinPoint joinPoint, ApiLog apiLog) throws Throwable {
// 前置处理:记录请求日志
logRequest(joinPoint, apiLog);
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
// 后置处理:记录响应日志
logResponse(joinPoint, apiLog, result, startTime);
return result;
} catch (Exception e) {
// 异常处理:记录异常日志
logException(joinPoint, apiLog, e, startTime);
throw e;
}
}
// 其他方法实现...
}
关键设计决策:
- 使用
@Around通知:可以完整控制方法的执行过程 - 采用try-catch结构:确保异常也能被正确记录
- 记录执行时间:通过
startTime计算接口耗时
3. 完整实现与细节处理
3.1 请求日志记录实现
java复制private void logRequest(ProceedingJoinPoint joinPoint, ApiLog apiLog) {
if (!apiLog.recordRequest()) return;
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
StringBuilder requestLog = new StringBuilder();
requestLog.append("\n==================== Request Start ====================\n");
requestLog.append("Module : ").append(apiLog.module()).append("\n");
requestLog.append("URL : ").append(request.getRequestURL()).append("\n");
requestLog.append("HTTP Method : ").append(request.getMethod()).append("\n");
requestLog.append("Class Method : ")
.append(joinPoint.getSignature().getDeclaringTypeName())
.append(".")
.append(joinPoint.getSignature().getName())
.append("\n");
requestLog.append("IP : ").append(request.getRemoteAddr()).append("\n");
if (LogLevel.DEBUG.equals(apiLog.level())) {
requestLog.append("Request Args: ").append(Arrays.toString(joinPoint.getArgs())).append("\n");
requestLog.append("Headers : ").append(getHeaders(request)).append("\n");
}
requestLog.append("==================== Request End ====================");
log(apiLog.level(), requestLog.toString());
}
注意事项:
- 通过
RequestContextHolder获取请求上下文 - 敏感参数处理:生产环境应过滤密码等敏感信息
- DEBUG级别才记录完整参数,避免日志量过大
3.2 响应日志优化处理
响应日志需要特别处理大对象和循环引用问题:
java复制private void logResponse(ProceedingJoinPoint joinPoint, ApiLog apiLog,
Object result, long startTime) {
if (!apiLog.recordResponse()) return;
long endTime = System.currentTimeMillis();
StringBuilder responseLog = new StringBuilder();
responseLog.append("\n==================== Response Start ====================\n");
responseLog.append("Module : ").append(apiLog.module()).append("\n");
responseLog.append("Time Cost : ").append(endTime - startTime).append("ms\n");
if (result != null) {
try {
ObjectMapper mapper = new ObjectMapper();
String resultJson = mapper.writeValueAsString(result);
responseLog.append("Response : ").append(abbreviate(resultJson, 1000)).append("\n");
} catch (JsonProcessingException e) {
responseLog.append("Response : [JSON PARSE ERROR]").append("\n");
}
} else {
responseLog.append("Response : null\n");
}
responseLog.append("==================== Response End ====================");
log(apiLog.level(), responseLog.toString());
}
private String abbreviate(String str, int maxLength) {
if (str == null || str.length() <= maxLength) {
return str;
}
return str.substring(0, maxLength) + "...[TRUNCATED]";
}
优化点:
- 使用Jackson序列化响应对象
- 对长文本进行截断处理
- 添加方法执行耗时统计
3.3 异常日志增强
异常日志需要包含堆栈信息:
java复制private void logException(ProceedingJoinPoint joinPoint, ApiLog apiLog,
Exception e, long startTime) {
long endTime = System.currentTimeMillis();
StringBuilder errorLog = new StringBuilder();
errorLog.append("\n==================== Exception Start ====================\n");
errorLog.append("Module : ").append(apiLog.module()).append("\n");
errorLog.append("Time Cost : ").append(endTime - startTime).append("ms\n");
errorLog.append("Exception : ").append(e.getClass().getName()).append("\n");
errorLog.append("Message : ").append(e.getMessage()).append("\n");
if (LogLevel.DEBUG.equals(apiLog.level())) {
errorLog.append("Stack Trace : \n").append(getStackTrace(e)).append("\n");
}
errorLog.append("==================== Exception End ====================");
log(LogLevel.ERROR, errorLog.toString());
}
private String getStackTrace(Exception e) {
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
return sw.toString();
}
异常处理要点:
- 始终以ERROR级别记录异常
- DEBUG模式下才输出完整堆栈
- 包含异常类型和原始消息
4. 高级功能与生产实践
4.1 日志脱敏处理
生产环境中必须对敏感信息进行脱敏:
java复制private String maskSensitiveInfo(String original) {
// 身份证号脱敏
original = original.replaceAll("(\\d{4})\\d{10}(\\w{4})", "$1****$2");
// 手机号脱敏
original = original.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
// 银行卡号脱敏
original = original.replaceAll("(\\d{4})\\d{10}(\\d{4})", "$1****$2");
return original;
}
重要提示:脱敏规则需要根据实际业务需求调整,建议通过配置中心动态管理
4.2 异步日志记录
高并发场景下,同步写日志可能影响性能,可采用异步方案:
java复制@Async("logExecutor")
public void asyncLog(LogLevel level, String content) {
log(level, content);
}
@Bean("logExecutor")
public Executor logExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("log-exec-");
executor.initialize();
return executor;
}
性能优化建议:
- 使用有界队列防止内存溢出
- 合理设置线程池参数
- 添加拒绝策略处理队列满的情况
4.3 日志链路追踪
在微服务架构中,需要添加TraceID实现请求追踪:
java复制private String generateTraceId() {
return UUID.randomUUID().toString().replace("-", "");
}
private void addTraceId(HttpServletRequest request) {
String traceId = request.getHeader("X-Trace-ID");
if (StringUtils.isEmpty(traceId)) {
traceId = generateTraceId();
}
MDC.put("traceId", traceId);
}
然后在logback.xml中配置:
xml复制<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n</pattern>
5. 实际应用中的经验总结
经过多个项目的实践验证,这套日志方案需要注意以下几点:
-
性能影响评估:在压测环境中验证日志系统对接口性能的影响,确保增加的耗时在可接受范围内(通常应<5%)
-
日志分级策略:
- 开发环境:DEBUG级别,记录完整参数
- 测试环境:INFO级别,记录关键信息
- 生产环境:WARN级别+ERROR级别,只记录异常和重要操作
-
日志清理机制:
bash复制# 示例:保留最近7天的日志 find /var/log/app -name "*.log" -mtime +7 -exec rm {} \; -
常见问题排查:
- 切面不生效:检查是否开启了
@EnableAspectJAutoProxy - 获取不到Request:确保在过滤器链中设置了
RequestContextHolder - 循环引用问题:在Jackson中配置
@JsonIgnore解决
- 切面不生效:检查是否开启了
-
扩展思考:
- 结合ELK实现日志集中管理和分析
- 对接监控系统实现异常报警
- 基于日志数据生成接口调用统计报表
这套方案在我负责的多个电商和金融项目中得到了成功应用,平均减少30%以上的问题排查时间。特别是在分布式系统中,配合TraceID可以快速定位跨服务调用的问题。
