Spring AOP自定义日志切面出现NullPointerException问题求助
Spring AOP 自定义日志切面 NPE 及重试问题解决方案
针对你遇到的 HttpServletRequest.getRequestURL() 因 scheme 为 null 触发空指针、重试导致日志字段填充异常的问题,结合 K8s 微服务环境特点,给出以下具体解决方案:
一、解决 scheme 为 null 导致的 NPE
问题根源
K8s 负载均衡/Ingress 转发请求时,后端服务收到的请求可能丢失真实的 scheme(http/https),且默认 getRequestURL() 依赖 scheme 非空,直接调用会触发 NPE。此外,部分场景下请求头未传递 X-Forwarded-Proto(LB 转发的真实协议),也会导致 scheme 为空。
解决方案:手动安全构建请求 URL
替换直接调用 getRequestURL() 的逻辑,优先从转发头获取真实协议,为空时兜底默认值:
import org.apache.commons.lang3.StringUtils; import javax.servlet.http.HttpServletRequest; import java.util.Optional; private String getSafeRequestUrl(HttpServletRequest request) { if (request == null) { return ""; } // 1. 从LB转发头获取真实协议(K8s Ingress通常会传递X-Forwarded-Proto) String scheme = request.getHeader("X-Forwarded-Proto"); // 2. 转发头为空时,用request自带的scheme,仍为空则默认http scheme = StringUtils.isBlank(scheme) ? Optional.ofNullable(request.getScheme()).orElse("http") : scheme; String serverName = request.getServerName(); int serverPort = request.getServerPort(); String requestUri = request.getRequestURI(); String queryString = request.getQueryString(); StringBuilder urlBuilder = new StringBuilder(); urlBuilder.append(scheme).append("://").append(serverName); // 隐藏默认端口(http 80、https 443) if (!(("http".equals(scheme) && serverPort == 80) || ("https".equals(scheme) && serverPort == 443))) { urlBuilder.append(":").append(serverPort); } urlBuilder.append(requestUri); if (StringUtils.isNotBlank(queryString)) { urlBuilder.append("?").append(queryString); } return urlBuilder.toString(); }
K8s 环境配套配置
确保 Ingress 传递转发头,以 Nginx Ingress 为例,添加注解:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: nginx.ingress.kubernetes.io/use-forwarded-headers: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true" # 若启用HTTPS
同时在 Spring Boot 配置中开启转发头处理:
server: forward-headers-strategy: framework
二、解决重试导致日志字段填充为 null 的问题
问题根源
异常重试时,@Before 通知会重复执行,但之前的日志上下文未被清理,导致字段复用或为空,且 @Before + @AfterReturning/@AfterThrowing 的组合无法统一控制重试流程。
解决方案:用 ThreadLocal 隔离上下文 + Around 通知统一处理
- 日志上下文 Holder:用 ThreadLocal 隔离每个请求(包括重试)的日志对象,避免脏数据:
public class SysLogContextHolder { private static final ThreadLocal<SysLog> LOG_THREAD_LOCAL = new ThreadLocal<>(); public static void setSysLog(SysLog sysLog) { LOG_THREAD_LOCAL.set(sysLog); } public static SysLog getSysLog() { return LOG_THREAD_LOCAL.get(); } public static void clear() { LOG_THREAD_LOCAL.remove(); } }
- 改用 Around 通知:统一控制请求前初始化、执行、异常、最终保存的全流程,确保重试时每次都生成新的日志对象:
import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; @Aspect @Component public class SysLogAspect { @Autowired private HttpServletRequest request; @Around("@annotation(toLogger)") public Object handleSysLog(ProceedingJoinPoint point, ToLogger toLogger) throws Throwable { // 每次执行前强制清理上下文,避免重试时的脏数据 SysLogContextHolder.clear(); SysLog sysLog = new SysLog(); try { // 初始化日志基础字段 sysLog.setRequestUrl(getSafeRequestUrl(request)); sysLog.setOperateType(toLogger.operateType()); sysLog.setRequestMethod(request.getMethod()); // 填充其他字段(如请求参数、操作人等) sysLog.setRequestParams(getRequestParams(point)); SysLogContextHolder.setSysLog(sysLog); // 执行目标Controller方法 Object result = point.proceed(); // 执行成功后填充结果 sysLog.setStatus("SUCCESS"); sysLog.setResponseData(JSON.toJSONString(result)); return result; } catch (Exception e) { // 异常时填充错误信息 SysLog currentLog = SysLogContextHolder.getSysLog(); if (currentLog != null) { currentLog.setStatus("FAIL"); currentLog.setErrorMsg(e.getMessage()); } throw e; // 不中断原有异常流程 } finally { // 异步保存日志,避免阻塞请求 saveSysLogAsync(SysLogContextHolder.getSysLog()); // 最终清理上下文 SysLogContextHolder.clear(); } } @Async // 需提前配置线程池 private void saveSysLogAsync(SysLog sysLog) { if (sysLog != null) { // logService.save(sysLog); } } // 辅助方法:获取请求参数 private String getRequestParams(ProceedingJoinPoint point) { Object[] args = point.getArgs(); // 过滤掉HttpServletRequest、HttpServletResponse等参数后序列化 // ... 实现逻辑 return JSON.toJSONString(filterArgs(args)); } }
三、额外注意事项
- 避免在 Service 层标注
@ToLogger:Service 层事务传播问题是因为 AOP 切面顺序导致,若必须在 Service 层记录,需调整切面优先级(@Order)让日志切面在事务切面之后执行,但推荐保持在 Controller 层记录入口日志。 - 异步保存日志:务必用异步线程池保存,避免日志操作阻塞业务请求。
内容的提问来源于stack exchange,提问作者ogbozoyan
相关产品推荐
相关产品推荐

