You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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 通知统一处理

  1. 日志上下文 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();
    }
}
  1. 改用 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 14:43:31