首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 綜合教學

Spring AOP 實戰:MVC 請求攔截器與 SDK 接口調用日志切面,參數/返回值/耗時/異常全維度可觀測

     編輯:Java 綜合教學

Spring AOP 實戰:MVC 請求攔截器與 SDK 接口調用日志切面,參數/返回值/耗時/異常全維度可觀測

生產環境「看不見」的調用鏈是排查疑難雜癥的最大阻力。本文提供兩套生產級切面實現:

  1. MVC 請求攔截器(HandlerInterceptor):記錄 HTTP 請求 URL、參數、處理耗時、視圖渲染、異常堆棧。
  2. SDK 接口調用切面(MethodInterceptor):記錄外部服務調用參數、返回值、耗時、異常,支持參數名解析(ASM 字節碼)。

配合統一日志格式,實現「有問題查日志、有日志定代碼、有代碼改邏輯」的閉環。

一、MVC 請求攔截器

核心代碼

public class MVCRequestInterceptor extends ApplicationLogging implements HandlerInterceptor {

    private static final ObjectMapper mapper = new ObjectMapper();
    private final NamedThreadLocal startTimeThreadLocal = new NamedThreadLocal<>("StopWatch-startTimed");

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        long start = System.currentTimeMillis();
        startTimeThreadLocal.set(start);

        info("##############################【MVC 請求開始】##############################");
        info("請求 URL: {}", request.getRequestURI());
        info("處理器: {}", handler);

        Map params = request.getParameterMap();
        if (!params.isEmpty()) {
            info("請求參數:");
            printMap(params, "參數");
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        long cost = System.currentTimeMillis() - startTimeThreadLocal.get();
        info("業務邏輯耗時: {} ms", cost);

        if (modelAndView != null) {
            info("視圖名: {}", modelAndView.getViewName());
            if (!modelAndView.getModel().isEmpty()) {
                info("視圖數據:");
                printMap(modelAndView.getModel(), "返回數據");
            }
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        long totalCost = System.currentTimeMillis() - startTimeThreadLocal.get();
        info("請求完成 URL: {}, 總耗時: {} ms", request.getRequestURI(), totalCost);

        if (ex != null) {
            error("請求異常: {}", ex.getMessage(), ex);
        }
        info("##############################【MVC 請求結束】##############################");
    }

    private void printMap(Map map, String prefix) {
        if (map == null) return;
        map.forEach((k, v) -> {
            if (k.contains("BindingResult")) return; // 過濾框架內部對象
            try {
                String json = mapper.writeValueAsString(v);
                info("{} \"{}\": {}", prefix, k, json);
            } catch (Exception e) {
                error("參數序列化失敗: {}", k, e);
            }
        });
    }
}

Spring MVC 配置

<mvc:interceptors>
    <mvc:interceptor>
        <mvc:mapping path="/**"/>
        <mvc:exclude-mapping path="/static/**"/>
        <mvc:exclude-mapping path="/favicon.ico"/>
        <bean class="com.xxx.eduyun.app.mvc.interceptor.MVCRequestInterceptor"/>
    </mvc:interceptor>
</mvc:interceptors>

關鍵點:exclude-mapping 過濾靜態資源,避免日志噪音;NamedThreadLocal 保證線程隔離計時。

二、SDK 接口調用切面

核心代碼


public class ExecutionApiLogMethodInterceptor implements MethodInterceptor {

    private static final Logger log = LogManager.getLogger(ExecutionApiLogMethodInterceptor.class);
    private static final ObjectMapper mapper = new ObjectMapper();

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        String targetName = invocation.getThis().getClass().getSimpleName();
        Method method = invocation.getMethod();
        String methodName = method.getName();

        log.info("************************************【接口調用開始】*************************************");
        log.info("執行方法: {}.{}", targetName, methodName);
        log.info("方法參數:");
        printArgs(invocation.getArguments(), method);

        long start = System.currentTimeMillis();
        try {
            Object result = invocation.proceed();
            long cost = System.currentTimeMillis() - start;

            log.info("方法耗時: {} ms", cost);
            log.info("返回結果: {}", result);
            log.info("返回數據詳情:");
            printResult(result);

            log.info("************************************【接口調用結束】*************************************");
            return result;
        } catch (Throwable t) {
            log.error("接口調用異常: {}.{}", targetName, methodName, t);
            log.info("************************************【接口異常結束】*************************************");
            throw t;
        }
    }

    private void printArgs(Object[] args, Method method) {
        String[] paramNames = ParameterNameUtils.getMethodParamNames(method); // ASM 解析
        if (args != null) {
            for (int i = 0; i < args.length; i++) {
                String name = (paramNames != null && i < paramNames.length) ? paramNames[i] : "arg" + i;
                if (args[i] != null) {
                    try {
                        log.info("參數 \"{}\": {}", name, mapper.writeValueAsString(args[i]));
                    } catch (Exception e) {
                        log.error("參數序列化失敗: {}", name, e);
                    }
                } else {
                    log.info("參數 \"{}\": NULL", name);
                }
            }
        }
    }

    private void printResult(Object result) {
        if (result != null) {
            try {
                log.info("返回數據: {}", mapper.writeValueAsString(result));
            } catch (Exception e) {
                log.error("返回值序列化失敗", e);
            }
        } else {
            log.info("返回數據: NULL");
        }
    }
}

參數名解析工具類(基於 Spring ASM)


public abstract class ParameterNameUtils {
    public static String[] getMethodParamNames(Method method) throws Exception {
        return getMethodParamNames(method.getDeclaringClass(), method);
    }

    private static String[] getMethodParamNames(Class clazz, Method method) throws Exception {
        String[] paramNames = new String[method.getParameterCount()];
        String classFile = clazz.getSimpleName() + ".class";
        try (InputStream is = clazz.getResourceAsStream(classFile)) {
            ClassReader cr = new ClassReader(is);
            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
            cr.accept(new ClassVisitor(Opcodes.ASM4, cw) {
                @Override
                public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
                    Type[] args = Type.getArgumentTypes(desc);
                    if (!name.equals(method.getName()) || !sameType(args, method.getParameterTypes())) {
                        return super.visitMethod(access, name, desc, signature, exceptions);
                    }
                    return new MethodVisitor(Opcodes.ASM4) {
                        @Override
                        public void visitLocalVariable(String name, String desc, String signature, Label start, Label end, int index) {
                            int i = Modifier.isStatic(method.getModifiers()) ? index : index - 1;
                            if (i >= 0 && i < paramNames.length) paramNames[i] = name;
                        }
                    };
                }
            }, 0);
        }
        return paramNames;
    }

    private static boolean sameType(Type[] types, Class[] clazzes) {
        if (types.length != clazzes.length) return false;
        for (int i = 0; i < types.length; i++) {
            if (!Type.getType(clazzes[i]).equals(types[i])) return false;
        }
        return true;
    }
}

AOP 配置(精準切點表達式)


<bean id="externalApiMethodInterceptor" class="com.xxx.flipclass.sdk.framework.aop.ExecutionApiLogMethodInterceptor" />

<aop:config proxy-target-class="true">
    <aop:pointcut id="externalApiMethodPointcut" expression="
        !execution(* com.xxx.flipclass.sdk.client.interfaces..*.loginInfoService.*(..)) and
        (execution(* com.xxx.*.sdk.client.interfaces..*.*Client*.*(..)) or
         execution(* com.xxx.*.sdk.client.interfaces..*.*Service*.*(..)))
    "/>
    <aop:advisor advice-ref="externalApiMethodInterceptor" pointcut-ref="externalApiMethodPointcut" />
</aop:config>

表達式解讀:攔截 com.xxx.*.sdk.client.interfaces 包下所有 *Client* 和 *Service* 類的所有方法,但排除登錄相關服務(避免敏感信息日志)。

三、日志輸出示例

##############################【MVC 請求開始】##############################
請求 URL: /api/user/123
處理器: HandlerMethod: public String UserController.detail(Long,Model)
請求參數:
參數 "id": "123"
業務邏輯耗時: 45 ms
視圖名: user/detail
視圖數據:
返回數據 "user": {"id":123,"name":"张三","email":"zhangsan@example.com"}
##############################【MVC 請求結束】##############################

************************************【接口調用開始】*************************************
執行方法: UserClient.getUserById
方法參數:
參數 "id": 123
方法耗時: 120 ms
返回結果: UserDTO(id=123, name=张三)
返回數據詳情:
返回數據: {"id":123,"name":"张三","email":"zhangsan@example.com"}
************************************【接口調用結束】*************************************

四、最佳實踐與避坑

  1. 日志級別分層:生產環境 INFO 記錄關鍵節點,DEBUG 記錄完整參數/返回值,避免日志爆盤。
  2. 敏感字段脫敏:密碼、Token、身份證號等在序列化前用 @JsonSerialize(using = MaskingSerializer.class) 或切面中過濾。
  3. 大對象截斷:返回值超過 5KB 截斷記錄,防止單條日志過大導致傳輸/存儲/查詢性能下降。
  4. 異步日志:Log4j2/Logback AsyncAppender 解耦業務線程與磁盤 IO。
  5. 參數名編譯保留:Maven 編譯插件添加 <parameters>true</parameters>(JDK 8+),或保留 LocalVariableTable(ASM 方案依賴此)。
  6. 切面順序:@Order 控制多切面執行順序,事務切面通常在日志切面外層。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved