生產環境「看不見」的調用鏈是排查疑難雜癥的最大阻力。本文提供兩套生產級切面實現:
配合統一日志格式,實現「有問題查日志、有日志定代碼、有代碼改邏輯」的閉環。
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);
}
});
}
}
<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 保證線程隔離計時。
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");
}
}
}
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;
}
}
<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"}
************************************【接口調用結束】*************************************
INFO 記錄關鍵節點,DEBUG 記錄完整參數/返回值,避免日志爆盤。@JsonSerialize(using = MaskingSerializer.class) 或切面中過濾。AsyncAppender 解耦業務線程與磁盤 IO。<parameters>true</parameters>(JDK 8+),或保留 LocalVariableTable(ASM 方案依賴此)。@Order 控制多切面執行順序,事務切面通常在日志切面外層。