目录
前言
在 Spring Boot 项目中,Controller 接口通常需要记录请求参数、接口描述、响应结果和执行耗时。如果在每个 Controller 方法里手动写日志,不仅重复代码多,还容易出现日志格式不统一、敏感信息泄露和异常场景日志缺失等问题。
本文基于现有的 @ApiLog 注解与 ApilLogHandlerAdvice 切面实现,其余每个类只承担一种职责。
实现的效果:
- 业务方法抛出异常时,仍然记录接口结束日志和耗时。
- 监控上报或日志序列化失败时,不影响业务结果。
- 将请求日志、响应日志封装为独立 DTO。
- 将参数构建、响应处理、脱敏与日志输出分别封装。
- 控制集合、嵌套对象和字符串的日志大小。
- 对密码、Token、身份证号、银行卡号、手机号等常见敏感字段做字段级脱敏。
- 对
SseEmitter、CompletionStage等异步返回值作出提示,避免把“返回异步对象的耗时”误认为“完整业务耗时”。
适用说明: 以下示例面向 Java 8、Spring Boot / Spring MVC 与 Jackson。示例保留原项目的
@ApiLog包名,并将新类统一放在com.myproject.demo.apilog示例包下。原项目的MonitorLogService、IgnoreLog等自定义类型需要按真实包路径接入。若项目使用 JDK 17、Spring Boot 3,则 Servlet 包通常应由javax.servlet改为jakarta.servlet。
一、整体结构
建议将代码拆分为以下职责:
| 类 | 职责 |
|---|---|
ApiLog | 标记哪些方法需要记录 API 日志,并提供接口描述 |
ApiRequestLog | 请求日志数据模型,只负责承载请求日志字段 |
ApiResponseLog | 响应日志数据模型,只负责承载响应日志字段 |
ApiLogRequestBuilder | 从 AOP 参数、方法信息和 Servlet 请求中构建请求日志 |
ApiLogResponseBuilder | 构建成功或异常情况下的响应日志 |
ApiLogValueSanitizer | 过滤不适合记录的对象、字段脱敏、限制日志内容大小 |
ApiLogWriter | 将日志 DTO 转换为 JSON 并输出,负责处理日志写入失败 |
ApiLogMonitor | 定义监控上报接口,隔离具体监控实现 |
ApilLogHandlerAdvice | 仅负责 AOP 编排、计时和保证异常路径被记录 |
示例包结构如下:
com.myproject.demo
├── annotations
│ └── ApiLog.java
└── apilog
├── ApilLogHandlerAdvice.java
├── model
│ ├── ApiRequestLog.java
│ └── ApiResponseLog.java
└── service
├── ApiLogRequestBuilder.java
├── ApiLogResponseBuilder.java
├── ApiLogValueSanitizer.java
├── ApiLogWriter.java
└── ApiLogMonitor.java
这里的 service 只是为了示例清晰。也可以根据项目规范使用 support、processor 或 infrastructure 等包名。
二、定义 @ApiLog 注解
文件:ApiLog.java
package com.myproject.demo.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标记需要记录 API 请求、响应与耗时日志的方法。
*
* <p>该注解通常放在 Controller 的接口方法上。切面通过注解匹配目标方法,
* 并将注解中的 value 作为日志中的接口描述。</p>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ApiLog {
/**
* 接口的可读描述,例如“查询用车订单”。
* 未填写时,日志组件会使用 Java 方法名作为默认描述。
*/
String value() default "";
}
RUNTIME 是必要的,因为 AOP 在运行时需要识别注解。METHOD 限制该注解只能放在方法上,能避免误用在类或字段上。
三、定义请求日志 DTO
文件:ApiRequestLog.java
package com.myproject.demo.apilog.model;
import java.io.Serializable;
import java.util.Map;
/**
* 请求日志数据模型。
*
* <p>该类只负责承载数据,不负责获取请求、脱敏或输出日志,
* 以便日志字段可以被独立测试和扩展。</p>
*/
public class ApiRequestLog implements Serializable {
private static final long serialVersionUID = 1L;
/** 请求关联 ID,用于将同一次调用的请求日志和响应日志关联起来。 */
private String requestId;
/** 接口描述,优先使用 @ApiLog.value(),否则使用方法名。 */
private String apiName;
/** Java 方法定位信息,格式为“全限定类名#方法名”。 */
private String classMethod;
/** HTTP 请求方式,例如 GET、POST。 */
private String httpMethod;
/** 请求 URL;默认不拼接 query string,避免意外记录查询参数中的敏感数据。 */
private String requestUrl;
/** 直接连接到当前应用的客户端地址。若经过代理,该地址可能是代理地址。 */
private String remoteAddr;
/** 参数名与经过安全处理的参数摘要。 */
private Map<String, Object> parameters;
/** 请求开始时间的 Unix 毫秒时间戳。 */
private long timestamp;
public String getRequestId() { return requestId; }
public void setRequestId(String requestId) { this.requestId = requestId; }
public String getApiName() { return apiName; }
public void setApiName(String apiName) { this.apiName = apiName; }
public String getClassMethod() { return classMethod; }
public void setClassMethod(String classMethod) { this.classMethod = classMethod; }
public String getHttpMethod() { return httpMethod; }
public void setHttpMethod(String httpMethod) { this.httpMethod = httpMethod; }
public String getRequestUrl() { return requestUrl; }
public void setRequestUrl(String requestUrl) { this.requestUrl = requestUrl; }
public String getRemoteAddr() { return remoteAddr; }
public void setRemoteAddr(String remoteAddr) { this.remoteAddr = remoteAddr; }
public Map<String, Object> getParameters() { return parameters; }
public void setParameters(Map<String, Object> parameters) { this.parameters = parameters; }
public long getTimestamp() { return timestamp; }
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
}
这里没有把 HttpServletRequest 对象直接放进 DTO,而是只提取必要的元数据。请求对象内部包含大量容器状态、请求头和输入流,不应该被直接序列化进日志。
四、定义响应日志 DTO
文件:ApiResponseLog.java
package com.myproject.demo.apilog.model;
import java.io.Serializable;
/**
* 响应日志数据模型。
*
* <p>既记录正常返回,也记录业务方法抛出异常的情况。
* 为降低敏感信息泄露风险,默认只记录异常类型,不直接记录完整异常消息。</p>
*/
public class ApiResponseLog implements Serializable {
private static final long serialVersionUID = 1L;
/** 与 ApiRequestLog.requestId 保持一致。 */
private String requestId;
/** 可读的接口描述。 */
private String apiName;
/** Java 方法定位信息。 */
private String classMethod;
/** Java 方法执行耗时,单位毫秒。 */
private long durationMs;
/** 是否正常完成 Java 方法调用;不等价于 HTTP 状态码一定是 2xx。 */
private boolean methodSuccess;
/** 如果返回类型是 ResponseEntity,则记录其 HTTP 状态码;否则为空。 */
private Integer httpStatus;
/** 已经过滤、脱敏和大小限制的响应摘要。 */
private Object response;
/** 异常类型,例如 java.lang.IllegalStateException;正常时为空。 */
private String errorType;
/** 响应日志构建时间的 Unix 毫秒时间戳。 */
private long timestamp;
public String getRequestId() { return requestId; }
public void setRequestId(String requestId) { this.requestId = requestId; }
public String getApiName() { return apiName; }
public void setApiName(String apiName) { this.apiName = apiName; }
public String getClassMethod() { return classMethod; }
public void setClassMethod(String classMethod) { this.classMethod = classMethod; }
public long getDurationMs() { return durationMs; }
public void setDurationMs(long durationMs) { this.durationMs = durationMs; }
public boolean isMethodSuccess() { return methodSuccess; }
public void setMethodSuccess(boolean methodSuccess) { this.methodSuccess = methodSuccess; }
public Integer getHttpStatus() { return httpStatus; }
public void setHttpStatus(Integer httpStatus) { this.httpStatus = httpStatus; }
public Object getResponse() { return response; }
public void setResponse(Object response) { this.response = response; }
public String getErrorType() { return errorType; }
public void setErrorType(String errorType) { this.errorType = errorType; }
public long getTimestamp() { return timestamp; }
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
}
注意:methodSuccess 表示 Java 方法有没有正常返回。比如业务代码正常返回了一个 ResponseEntity,但状态码是 400,那么 methodSuccess 仍可能是 true。如果项目需要统一表示 HTTP 业务成功,应另行定义业务成功字段,不能混用这两个概念。
五、实现统一参数与响应安全处理器
文件:ApiLogValueSanitizer.java
该类集中处理三件事情:
- 忽略不适合进入日志的基础设施对象。
- 依据字段名脱敏,而不是仅靠字符串中的数字正则猜测敏感数据。
- 对 Map、集合、数组、嵌套 JSON 和字符串设置大小限制。
package com.myproject.demo.apilog.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Array;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* API 日志值处理器。
*
* <p>该类把允许记录的业务对象转换为受控的 JSON 数据结构,并执行敏感字段脱敏、深度限制和元素数量限制。</p>
*
*/
@Component
public class ApiLogValueSanitizer {
/** 用于将常见手机号中间四位替换为星号;不是通用身份证/银行卡脱敏规则。 */
private static final Pattern PHONE_PATTERN =
Pattern.compile("(?<!\\d)(1[3-9]\\d)\\d{4}(\\d{4})(?!\\d)");
/** 脱敏常见 Bearer 凭证,防止原始字符串响应直接返回 Token。 */
private static final Pattern BEARER_TOKEN_PATTERN =
Pattern.compile("(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]+");
/** 识别外观符合 JWT 三段式结构的字符串,避免完整 JWT 被写入日志。 */
private static final Pattern JWT_PATTERN =
Pattern.compile("\\beyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\b");
/** 敏感字段替换后的固定内容。 */
private static final String MASKED = "[已脱敏]";
/** 超过最大嵌套层数时使用的占位文本。 */
private static final String DEPTH_LIMITED = "[内容已省略:超过最大嵌套层级]";
/** 每个集合最多展示的元素数量。 */
private static final int MAX_COLLECTION_PREVIEW = 5;
/** Map 最多展示的键值对数量。 */
private static final int MAX_MAP_ENTRIES = 20;
/** JSON 对象最多输出的字段数量。 */
private static final int MAX_JSON_FIELDS = 30;
/** 最大递归深度,避免过深对象输出过多内容。 */
private static final int MAX_DEPTH = 5;
/** 单个字符串的最大字符数。 */
private static final int MAX_STRING_LENGTH = 512;
/** Jackson 的 JSON 转换器,使用 Spring Boot 自动配置的 ObjectMapper。 */
private final ObjectMapper objectMapper;
public ApiLogValueSanitizer(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 对外提供统一的日志值处理入口。
*
* @param value 原始参数或响应对象
* @param fieldName 当前字段名,用于判断字段是否敏感
* @return 可安全尝试输出到日志的数据结构或占位文本
*/
public Object sanitize(Object value, String fieldName) {
return sanitizeValue(value, fieldName, 0);
}
/**
* 判断字段名是否属于常见敏感信息。
*
* <p>采用忽略大小写、下划线和连字符的方式匹配。实际项目还应依据自身 DTO 字段
* 补充规则;不要把字段名规则当成防止泄露的唯一安全措施。</p>
*/
public boolean isSensitiveField(String fieldName) {
if (fieldName == null || fieldName.isEmpty()) {
return false;
}
String normalized = fieldName
.replace("_", "")
.replace("-", "")
.toLowerCase(Locale.ROOT);
String[] keywords = {
"password", "passwd", "pwd", "token", "authorization",
"accesskey", "secret", "privatekey", "idcard", "identitycard",
"mobile", "phone", "bankcard", "cardno", "creditcard",
"cookie", "credential"
};
for (String keyword : keywords) {
if (normalized.contains(keyword)) {
return true;
}
}
return false;
}
/**
* 递归处理单个值。
* 这里的递归是为了处理日志数据结构,不是判断 Serializable 的自定义递归逻辑。
*/
private Object sanitizeValue(Object value, String fieldName, int depth) {
// 字段名命中敏感规则时,直接隐藏值,不继续读取内容。
if (isSensitiveField(fieldName)) {
return MASKED;
}
if (value == null) {
return null;
}
if (depth >= MAX_DEPTH) {
return DEPTH_LIMITED;
}
// 忽略 Servlet、文件、流、上传对象等不适合完整记录的对象。
if (isInfrastructureObject(value)) {
return "[已忽略对象:" + value.getClass().getSimpleName() + "]";
}
// 认证上下文通常包含用户、权限或令牌相关内容,默认不输出完整对象。
if ("JwtUser".equals(value.getClass().getSimpleName())) {
return "[认证上下文对象已隐藏]";
}
// 保留原项目 IgnoreLog 标记语义,又不将该项目自定义接口绑定到此示例包。
// 正式项目可在这里改成 value instanceof IgnoreLog,并导入该接口的实际包名。
if (implementsInterfaceNamed(value.getClass(), "IgnoreLog")) {
return "[该对象标记为忽略日志]";
}
if (value instanceof String) {
return sanitizeText((String) value);
}
if (value instanceof Character || value instanceof Number
|| value instanceof Boolean || value instanceof Enum
|| value instanceof UUID) {
return value instanceof Enum ? ((Enum<?>) value).name() : value;
}
// 二进制内容绝不直接写入日志,只保留其长度。
if (value instanceof byte[]) {
return "[二进制内容已隐藏,长度=" + ((byte[]) value).length + "]";
}
// 集合先做摘要,不直接把整个集合交给 Jackson,避免常见的大列表日志膨胀。
if (value instanceof Collection) {
return sanitizeCollection((Collection<?>) value, depth);
}
if (value instanceof Map) {
return sanitizeMap((Map<?, ?>) value, depth);
}
if (value.getClass().isArray()) {
return sanitizeArray(value, depth);
}
try {
// 对普通 DTO 使用 Jackson 转成 JSON 树;不要求 DTO 实现 Serializable。
JsonNode jsonNode = objectMapper.valueToTree(value);
return sanitizeJsonNode(jsonNode, null, depth + 1);
} catch (RuntimeException ex) {
// 转换失败时只返回类型信息,不回退到打印原始对象。
return "[对象转换失败:" + value.getClass().getSimpleName() + "]";
}
}
/** 为集合生成类型、总数和有限数量的样本,避免完整输出大量元素。 */
private Map<String, Object> sanitizeCollection(Collection<?> collection, int depth) {
Map<String, Object> result = new LinkedHashMap<String, Object>();
result.put("type", collection.getClass().getSimpleName());
result.put("size", collection.size());
List<Object> preview = new ArrayList<Object>();
Iterator<?> iterator = collection.iterator();
int count = 0;
while (iterator.hasNext() && count < MAX_COLLECTION_PREVIEW) {
preview.add(sanitizeValue(iterator.next(), null, depth + 1));
count++;
}
result.put("preview", preview);
result.put("truncated", collection.size() > preview.size());
return result;
}
/** 只处理 Map 中有限数量的键值,且按键名处理敏感字段。 */
private Map<String, Object> sanitizeMap(Map<?, ?> source, int depth) {
Map<String, Object> result = new LinkedHashMap<String, Object>();
int count = 0;
for (Map.Entry<?, ?> entry : source.entrySet()) {
if (count >= MAX_MAP_ENTRIES) {
result.put("_truncated", true);
break;
}
String key = String.valueOf(entry.getKey());
result.put(key, sanitizeValue(entry.getValue(), key, depth + 1));
count++;
}
return result;
}
/** 只输出数组的有限数量样本。 */
private Map<String, Object> sanitizeArray(Object array, int depth) {
int length = Array.getLength(array);
Map<String, Object> result = new LinkedHashMap<String, Object>();
result.put("type", array.getClass().getComponentType().getSimpleName() + "[]");
result.put("length", length);
List<Object> preview = new ArrayList<Object>();
int limit = Math.min(length, MAX_COLLECTION_PREVIEW);
for (int i = 0; i < limit; i++) {
preview.add(sanitizeValue(Array.get(array, i), null, depth + 1));
}
result.put("preview", preview);
result.put("truncated", length > limit);
return result;
}
/** 对 Jackson JSON 树执行递归脱敏和大小限制。 */
private Object sanitizeJsonNode(JsonNode node, String fieldName, int depth) {
if (isSensitiveField(fieldName)) {
return MASKED;
}
if (node == null || node.isNull()) {
return null;
}
if (depth >= MAX_DEPTH) {
return DEPTH_LIMITED;
}
if (node.isTextual()) {
return sanitizeText(node.asText());
}
if (node.isBoolean()) {
return node.booleanValue();
}
if (node.isNumber()) {
return node.numberValue();
}
if (node.isArray()) {
List<Object> values = new ArrayList<Object>();
int limit = Math.min(node.size(), MAX_COLLECTION_PREVIEW);
for (int i = 0; i < limit; i++) {
values.add(sanitizeJsonNode(node.get(i), null, depth + 1));
}
if (node.size() > limit) {
Map<String, Object> summary = new LinkedHashMap<String, Object>();
summary.put("size", node.size());
summary.put("preview", values);
summary.put("truncated", true);
return summary;
}
return values;
}
if (node.isObject()) {
Map<String, Object> values = new LinkedHashMap<String, Object>();
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
int count = 0;
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
if (count >= MAX_JSON_FIELDS) {
values.put("_truncated", true);
break;
}
values.put(field.getKey(), sanitizeJsonNode(
field.getValue(), field.getKey(), depth + 1));
count++;
}
return values;
}
// 对二进制、POJO 等其他 JSON 节点类型不输出内部实现细节。
return node.asText();
}
/** 截断超长字符串,并遮盖常见手机号的中间四位。 */
private String sanitizeText(String text) {
if (text == null) {
return null;
}
// 先替换可能直接出现在 String 参数或响应中的 Bearer Token / JWT。
String masked = BEARER_TOKEN_PATTERN.matcher(text).replaceAll("Bearer [已脱敏]");
masked = JWT_PATTERN.matcher(masked).replaceAll("[JWT已脱敏]");
Matcher matcher = PHONE_PATTERN.matcher(masked);
masked = matcher.replaceAll("$1****$2");
if (masked.length() > MAX_STRING_LENGTH) {
return masked.substring(0, MAX_STRING_LENGTH) + "...[已截断]";
}
return masked;
}
/** 判断对象是否为不适合记录完整内容的文件、网络请求或流对象。 */
private boolean isInfrastructureObject(Object value) {
return value instanceof ServletRequest
|| value instanceof ServletResponse
|| value instanceof MultipartFile
|| value instanceof InputStream
|| value instanceof OutputStream
|| value instanceof Reader
|| value instanceof Writer
|| value instanceof File
|| value instanceof Path
|| value instanceof Resource
|| value instanceof Class;
}
/**
* 递归检查对象是否实现了指定简单名称的标记接口。
* 正式项目更推荐导入 IgnoreLog 后使用 instanceof 做编译期检查。
*/
private boolean implementsInterfaceNamed(Class<?> type, String interfaceSimpleName) {
if (type == null) {
return false;
}
for (Class<?> interfaceType : type.getInterfaces()) {
if (interfaceSimpleName.equals(interfaceType.getSimpleName())
|| implementsInterfaceNamed(interfaceType, interfaceSimpleName)) {
return true;
}
}
return implementsInterfaceNamed(type.getSuperclass(), interfaceSimpleName);
}
}
5.1 说明
上面代码流程为:
- 先识别明确不应记录的对象。
- 对集合、Map 和数组做有限摘要。
- 对普通对象使用 Jackson 转为 JSON 树。
- 对 JSON 树做字段脱敏和深度、字段数量限制。
- 转换失败时返回占位符,绝不回退到原始对象。
需要注意,ObjectMapper.valueToTree() 仍会先把普通 DTO 转换成树,再执行树上的深度限制。因此,如果某个 DTO 内嵌了极其庞大的集合,转换阶段仍可能消耗较多内存。对于特别大的对象,最佳做法是为日志创建专门的摘要 DTO,而不是把完整对象交给通用日志工具。
5.2 脱敏规则需要根据业务扩展
当前规则覆盖常见字段名,但不能自动识别所有企业内部的敏感字段。比如项目使用 certNo 表示证件号、acct 表示账号时,就需要补充规则。
另外,单独的手机号正则只是兜底,不等于身份证号、银行卡号、密钥和任意 Token 都能正确脱敏。对于密码、Token、授权头等字段,最可靠的处理是基于字段名或类型直接替换成固定占位符。
六、构建请求日志:ApiLogRequestBuilder
文件:ApiLogRequestBuilder.java
这个类负责从 ProceedingJoinPoint 中提取目标方法、注解、参数与当前 Servlet 请求信息。AOP 切面不需要再关心这些反射细节。
package com.myproject.demo.apilog.service;
import com.myproject.demo.annotations.ApiLog;
import com.myproject.demo.apilog.model.ApiRequestLog;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.aop.support.AopUtils;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/** 从 AOP 方法调用和当前 HTTP 请求中构建请求日志。 */
@Component
public class ApiLogRequestBuilder {
private final ApiLogValueSanitizer valueSanitizer;
public ApiLogRequestBuilder(ApiLogValueSanitizer valueSanitizer) {
this.valueSanitizer = valueSanitizer;
}
/**
* 构建请求日志。
*
* @param joinPoint 当前被 AOP 拦截的方法调用
* @return 请求日志 DTO
*/
public ApiRequestLog build(ProceedingJoinPoint joinPoint) {
ApiRequestLog log = new ApiRequestLog();
Method method = resolveTargetMethod(joinPoint);
ApiLog apiLog = findApiLog(joinPoint, method);
// 使用随机 ID,避免使用 AtomicLong 在多实例部署时发生 ID 冲突。
log.setRequestId(UUID.randomUUID().toString().replace("-", ""));
log.setTimestamp(System.currentTimeMillis());
log.setApiName(resolveApiName(apiLog, method));
log.setClassMethod(method.getDeclaringClass().getName() + "#" + method.getName());
log.setParameters(buildParameters(joinPoint));
// 非 HTTP 场景下允许构建普通方法日志,不因没有 Servlet 请求而失败。
ServletRequestAttributes attributes = getServletRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
log.setHttpMethod(request.getMethod());
// getRequestURL 不包含 query string,默认避免将查询参数中的密钥写入日志。
log.setRequestUrl(request.getRequestURL().toString());
log.setRemoteAddr(request.getRemoteAddr());
}
return log;
}
/** 优先获取目标类上的具体实现方法,兼容接口方法与代理方法的差异。 */
private Method resolveTargetMethod(ProceedingJoinPoint joinPoint) {
Method signatureMethod = ((MethodSignature) joinPoint.getSignature()).getMethod();
Object target = joinPoint.getTarget();
if (target == null) {
return signatureMethod;
}
return AopUtils.getMostSpecificMethod(signatureMethod, target.getClass());
}
/** 从目标方法或签名方法上读取 @ApiLog。 */
private ApiLog findApiLog(ProceedingJoinPoint joinPoint, Method targetMethod) {
ApiLog apiLog = AnnotatedElementUtils.findMergedAnnotation(targetMethod, ApiLog.class);
if (apiLog != null) {
return apiLog;
}
Method signatureMethod = ((MethodSignature) joinPoint.getSignature()).getMethod();
return AnnotatedElementUtils.findMergedAnnotation(signatureMethod, ApiLog.class);
}
/** 解析可读接口名称,未配置注解描述时使用方法名兜底。 */
private String resolveApiName(ApiLog apiLog, Method method) {
if (apiLog != null && StringUtils.hasText(apiLog.value())) {
return apiLog.value();
}
return method.getName();
}
/**
* 以参数名为键构建参数 Map。
* 如果编译产物没有保留参数名,使用 arg0、arg1 等可读性有限但稳定的占位名。
*/
private Map<String, Object> buildParameters(ProceedingJoinPoint joinPoint) {
Map<String, Object> parameters = new LinkedHashMap<String, Object>();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] names = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
for (int i = 0; i < args.length; i++) {
String name = names != null && names.length == args.length
&& StringUtils.hasText(names[i]) ? names[i] : "arg" + i;
// null 也明确记录,便于区分“没有参数”和“参数值为空”。
parameters.put(name, valueSanitizer.sanitize(args[i], name));
}
return parameters;
}
/** 安全获取当前 Servlet 请求属性;异步或非 Web 场景下可能为 null。 */
private ServletRequestAttributes getServletRequestAttributes() {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
return attributes instanceof ServletRequestAttributes
? (ServletRequestAttributes) attributes : null;
}
}
6.1 参数名说明
MethodSignature.getParameterNames() 可能因为编译配置而无法获得真实参数名。可以在 Maven 的编译配置中启用 -parameters,例如:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
如果项目通过其他方式管理编译参数,请合并配置,不要重复定义多个互相冲突的 maven-compiler-plugin 配置。
这里不再只过滤参数名恰好等于 token 或 authorization 的值。所有参数统一经过 ApiLogValueSanitizer,这样嵌套 DTO 中的 password、accessToken 等字段也可以按字段名处理。
七、构建响应日志:ApiLogResponseBuilder
文件:ApiLogResponseBuilder.java
package com.myproject.demo.apilog.service;
import com.myproject.demo.apilog.model.ApiRequestLog;
import com.myproject.demo.apilog.model.ApiResponseLog;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.concurrent.CompletionStage;
/** 构建成功或异常情况下的响应日志。 */
@Component
public class ApiLogResponseBuilder {
private static final String ASYNC_RESPONSE_NOTE =
"[异步响应对象已返回;当前耗时不代表异步任务或流式传输的完整耗时]";
private final ApiLogValueSanitizer valueSanitizer;
public ApiLogResponseBuilder(ApiLogValueSanitizer valueSanitizer) {
this.valueSanitizer = valueSanitizer;
}
/**
* 创建响应日志。
*
* @param requestLog 请求日志,用于复用关联 ID 和接口信息
* @param response 业务方法的返回对象,异常时可能为 null
* @param durationMs Java 方法执行耗时
* @param error 业务方法抛出的异常;正常返回时为 null
*/
public ApiResponseLog build(ApiRequestLog requestLog,
Object response,
long durationMs,
Throwable error) {
ApiResponseLog log = new ApiResponseLog();
log.setRequestId(requestLog.getRequestId());
log.setApiName(requestLog.getApiName());
log.setClassMethod(requestLog.getClassMethod());
log.setDurationMs(durationMs);
log.setTimestamp(System.currentTimeMillis());
log.setMethodSuccess(error == null);
if (error != null) {
// 默认只记录异常类型,避免异常消息包含 SQL 参数、Token 等敏感信息。
log.setErrorType(error.getClass().getName());
log.setResponse("[业务方法执行异常]");
return log;
}
Object body = response;
if (response instanceof ResponseEntity) {
ResponseEntity<?> responseEntity = (ResponseEntity<?>) response;
log.setHttpStatus(responseEntity.getStatusCodeValue());
body = responseEntity.getBody();
}
if (isAsyncResponse(body)) {
log.setResponse(ASYNC_RESPONSE_NOTE);
} else {
log.setResponse(valueSanitizer.sanitize(body, "response"));
}
return log;
}
/**
* 识别部分常见异步返回值。
* 这只能避免误解当前耗时,不能代替异步任务真正完成时的回调日志。
*/
private boolean isAsyncResponse(Object value) {
if (value == null) {
return false;
}
if (value instanceof SseEmitter || value instanceof CompletionStage) {
return true;
}
// 不直接依赖 Reactor 类型,避免没有引入 WebFlux/Reactor 的模块无法编译。
return implementsInterfaceNamed(value.getClass(), "Publisher");
}
/** 递归检查类型层级中的接口名,可识别 org.reactivestreams.Publisher。 */
private boolean implementsInterfaceNamed(Class<?> type, String interfaceSimpleName) {
if (type == null) {
return false;
}
for (Class<?> interfaceType : type.getInterfaces()) {
if (interfaceSimpleName.equals(interfaceType.getSimpleName())
|| implementsInterfaceNamed(interfaceType, interfaceSimpleName)) {
return true;
}
}
return implementsInterfaceNamed(type.getSuperclass(), interfaceSimpleName);
}
}
这里对 ResponseEntity 提取 HTTP 状态码与 body,而不是直接把整个 ResponseEntity 对象输出。对于普通对象,统一经过安全处理器。
7.1 异步方法的耗时边界
例如 Controller 返回 SseEmitter 或 Flux<String>,Java 方法可能只花几十毫秒就返回一个异步对象,而流内容可能持续传输几十秒。当前切面记录的是方法调用耗时,不是流式响应完整耗时。
如果业务需要统计真实流式耗时,应当在 SseEmitter 的 onCompletion、onTimeout、onError 回调,或者 Reactor 流的 doFinally 等位置另行记录完成时间。不同异步模型应采用各自合适的生命周期机制,不能只依赖同步 AOP 的 finally。
八、统一日志输出:ApiLogWriter
文件:ApiLogWriter.java
package com.myproject.demo.apilog.service;
import com.myproject.demo.apilog.model.ApiRequestLog;
import com.myproject.demo.apilog.model.ApiResponseLog;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/** 负责把日志 DTO 输出到应用日志,不处理 AOP、参数提取或脱敏规则。 */
@Component
@Slf4j
public class ApiLogWriter {
private final ObjectMapper objectMapper;
public ApiLogWriter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/** 输出请求日志。参数应已在 builder 阶段完成安全处理。 */
public void writeRequest(ApiRequestLog requestLog) {
try {
// 使用占位符传递 JSON 字符串,避免 JSON 内的大括号被误当成格式参数。
log.info("API_REQUEST {}", objectMapper.writeValueAsString(requestLog));
} catch (JsonProcessingException ex) {
// 兜底日志只包含少量元数据,不回退打印原始请求对象或原始参数。
log.warn("API_REQUEST 日志序列化失败,requestId={}, apiName={}",
requestLog.getRequestId(), requestLog.getApiName());
}
}
/** 输出响应日志。若日志序列化失败,不向业务调用方抛出异常。 */
public void writeResponse(ApiResponseLog responseLog) {
try {
log.info("API_RESPONSE {}", objectMapper.writeValueAsString(responseLog));
} catch (JsonProcessingException ex) {
log.warn("API_RESPONSE 日志序列化失败,requestId={}, apiName={}",
responseLog.getRequestId(), responseLog.getApiName());
}
}
}
ObjectMapper 通常由 Spring Boot 自动配置。如果项目没有自动配置 Jackson,需要自行提供 ObjectMapper Bean。
另外,不建议在序列化失败后使用 log.info("{}", 原始对象) 作为兜底,因为这相当于绕过了 JSON 脱敏流程。
九、抽象监控上报接口
在模块内部定义一个接口,再在业务工程中适配监控服务。
文件:ApiLogMonitor.java
package com.myproject.demo.apilog.service;
import javax.servlet.http.HttpServletRequest;
/**
* API 监控上报抽象。
* 日志切面只依赖这个接口,不需要了解监控数据写入数据库或其他系统的细节。
*/
public interface ApiLogMonitor {
/**
* 上报一次接口调用的耗时。
*
* @param request 当前 HTTP 请求;非 HTTP 场景下可能没有请求对象
* @param durationMs 方法执行耗时,单位毫秒
*/
void report(HttpServletRequest request, long durationMs);
}
在项目适配层中,调用监控服务 monitorLogService.monitorLog 把请求的日志落库或者其他操作:
import com.myproject.demo.apilog.service.ApiLogMonitor;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
// import 项目中 MonitorLogService 的真实包路径;
/**
* 适配器示例:把日志模块的统一监控接口转发给项目原有 MonitorLogService。
*
* <p>请在项目中导入 MonitorLogService 的真实包路径,并按实际方法签名调整。</p>
*/
@Component
public class ExistingMonitorLogAdapter implements ApiLogMonitor {
private final MonitorLogService monitorLogService;
public ExistingMonitorLogAdapter(MonitorLogService monitorLogService) {
this.monitorLogService = monitorLogService;
}
@Override
public void report(HttpServletRequest request, long durationMs) {
if (request == null) {
return;
}
// 调用监控服务 把请求的日志插入数据库或者其他操作,这里省略 monitorLog
monitorLogService.monitorLog(request, null, durationMs);
}
}
若暂时不需要数据库监控,可以先不注册 ApiLogMonitor 实现;下面的切面通过 ObjectProvider 获取可选 Bean,不会因为监控适配器缺失而无法启动。
十、 AOP 切面
文件:ApilLogHandlerAdvice.java
切面类现在只做协调工作:生成请求日志、开始计时、执行业务方法、记录成功或异常结束日志、尝试上报监控。
package com.myproject.demo.apilog;
import com.myproject.demo.apilog.model.ApiRequestLog;
import com.myproject.demo.apilog.model.ApiResponseLog;
import com.myproject.demo.apilog.service.ApiLogMonitor;
import com.myproject.demo.apilog.service.ApiLogRequestBuilder;
import com.myproject.demo.apilog.service.ApiLogResponseBuilder;
import com.myproject.demo.apilog.service.ApiLogWriter;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;
/**
* API 日志 AOP 切面。
*
* <p>只负责组织各个组件的调用,不在这里编写参数反射、脱敏、JSON 转换等细节。</p>
*/
@Aspect
@Component
@Slf4j
//设置本切面优先级为靠后的优先级
@Order(Ordered.LOWEST_PRECEDENCE - 5)
public class ApilLogHandlerAdvice {
private final ApiLogRequestBuilder requestBuilder;
private final ApiLogResponseBuilder responseBuilder;
private final ApiLogWriter logWriter;
private final ObjectProvider<ApiLogMonitor> monitorProvider;
public ApilLogHandlerAdvice(ApiLogRequestBuilder requestBuilder,
ApiLogResponseBuilder responseBuilder,
ApiLogWriter logWriter,
ObjectProvider<ApiLogMonitor> monitorProvider) {
this.requestBuilder = requestBuilder;
this.responseBuilder = responseBuilder;
this.logWriter = logWriter;
this.monitorProvider = monitorProvider;
}
/**
* 拦截标注 @ApiLog 的方法。
* 无论正常返回还是抛出异常,finally 都会尝试输出结束日志和上报耗时。
*/
@Around("@annotation(com.myproject.demo.annotations.ApiLog)")
public Object aroundApi(ProceedingJoinPoint joinPoint) throws Throwable {
ApiRequestLog requestLog = null;
// 日志初始化失败不应该阻止业务方法执行,因此将其与业务调用隔离。
try {
requestLog = requestBuilder.build(joinPoint);
logWriter.writeRequest(requestLog);
} catch (Exception logException) {
log.warn("API 请求日志初始化失败,继续执行业务方法", logException);
}
long startNanos = System.nanoTime();
Object result = null;
Throwable businessError = null;
try {
// 只有这一行执行真正的目标业务方法。
result = joinPoint.proceed();
return result;
} catch (Throwable ex) {
// 保存异常供 finally 构造失败日志,然后原样抛出,不能吞掉业务异常。
businessError = ex;
throw ex;
} finally {
long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
// 结束日志失败不能覆盖业务返回结果,也不能覆盖原始业务异常。
if (requestLog != null) {
try {
ApiResponseLog responseLog = responseBuilder.build(
requestLog, result, durationMs, businessError);
logWriter.writeResponse(responseLog);
} catch (Exception logException) {
log.warn("API 响应日志处理失败,requestId={}", requestLog.getRequestId(), logException);
}
}
// 监控组件是可选依赖;监控失败不能改变业务调用结果。
reportMonitor(durationMs);
}
}
/** 安全获取当前请求并调用可选监控组件。 */
private void reportMonitor(long durationMs) {
try {
ApiLogMonitor monitor = monitorProvider.getIfAvailable();
if (monitor == null) {
return;
}
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes instanceof ServletRequestAttributes
? ((ServletRequestAttributes) attributes).getRequest() : null;
monitor.report(request, durationMs);
} catch (Exception monitorException) {
// 监控数据库或外部系统出现故障,不应影响已经完成的业务处理。
log.warn("API 监控上报失败", monitorException);
}
}
}
10.1 为什么 joinPoint.proceed() 必须重新抛出异常?
切面不应该擅自改变业务方法原本的异常语义。这里捕获异常只是为了在 finally 中记录失败结果,随后通过 throw ex 将同一个异常继续交给 Spring MVC 的异常处理流程。
不要这样写:
catch (Exception ex) {
log.error("接口异常", ex);
return null; // 错误:吞掉异常并改变接口行为
}
这种写法可能让调用方拿到一个空响应,而统一异常处理器也无法按原计划处理异常。
10.2 为什么日志输出也要做异常保护?
日志属于辅助能力,不应轻易改变业务接口的返回结果。上面的代码把请求日志构建、响应日志构建和监控上报分别隔离,某一环节失败不会直接阻止 joinPoint.proceed(),也不会让监控异常覆盖业务异常。
需要说明的是,catch (Exception) 不是为了吞掉所有 JVM 级错误,例如 OutOfMemoryError。通常不应在通用日志切面中尝试捕获并恢复所有 Error。
十一、Controller 中如何使用
有了上述组件后,在 Controller 中相关接口上使用 @ApiLog("接口描述") 注解便可开启接口的入参、响应日志打印。
如果省略接口描述,直接使用@ApiLog会默认使用方法名作为接口描述。
注意:如果某个普通 Service 方法也标注了
@ApiLog,并且该方法调用经过 Spring AOP 代理,它也可能被拦截并打印日志。因此,这个注解和切点本身并没有限制只能记录 Controller 层方法。
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
/** 查询订单接口:请求参数与返回摘要由 @ApiLog 切面统一记录。 */
@ApiLog("查询用车订单")
@GetMapping("/{orderNo}")
public OrderVO getOrder(@PathVariable String orderNo) {
return orderService.getOrder(orderNo);
}
/** 创建订单接口:即使业务方法抛出异常,切面也会记录失败结束日志。 */
@ApiLog("创建用车订单")
@PostMapping
public OrderVO createOrder(@RequestBody CreateOrderRequest request) {
return orderService.createOrder(request);
}
}
请求日志和响应日志将使用相同的 requestId。日志平台可以通过 requestId 检索同一次调用的两条记录。
请求日志示例:
{
"requestId": "2a0d3f0abf3546b8a60f94164a791d5a",
"apiName": "创建用车订单",
"classMethod": "com.example.OrderController#createOrder",
"httpMethod": "POST",
"requestUrl": "http://localhost:8080/orders",
"remoteAddr": "127.0.0.1",
"parameters": {
"request": {
"mobile": "[已脱敏]",
"password": "[已脱敏]",
"orderType": "BUSINESS"
}
},
"timestamp": 1793100000000
}
响应日志示例:
{
"requestId": "2a0d3f0abf3546b8a60f94164a791d5a",
"apiName": "创建用车订单",
"classMethod": "com.example.OrderController#createOrder",
"durationMs": 36,
"methodSuccess": true,
"httpStatus": null,
"response": {
"orderNo": "ORD202610270001",
"status": "CREATED"
},
"errorType": null,
"timestamp": 1793100000036
}
以上内容仅为日志结构示例,具体字段以项目 DTO 和 Jackson 配置为准。
十二、需要特别注意的生产实践
12.1 不要把完整请求头、Cookie、Token 全部写入日志
即使排查问题需要身份信息,也应优先记录经过处理的用户标识或 TraceId,而不是完整 Authorization、Cookie 或 JWT。请求头中可能包含凭证,完整输出会形成额外的数据泄露面。
12.2 日志脱敏应采用失败关闭原则
脱敏或 JSON 转换失败时,只记录占位符和必要的对象类型信息。绝不能在失败后回退到打印原始对象。
12.3 日志长度控制不等于所有场景都不会消耗大量内存
示例代码对集合、Map、数组与 JSON 树增加了限制,但普通 DTO 仍可能先经 Jackson 转换成树,再应用节点限制。对于有大型嵌套对象、文件内容或大量明细的接口,建议给日志定义专用摘要 DTO,并优先记录数量、状态、关键业务 ID,而不是完整响应。
12.4 不要无条件记录每个接口的完整响应
登录、支付、财务、用车人信息、身份证件、发票、银行卡等接口,最好默认采用白名单方式决定记录哪些字段。通用日志组件应该优先记录有助于排查问题的必要信息,而不是复制一份完整的业务数据。
12.5 remoteAddr 不是永远等于真实客户端 IP
如果请求经过负载均衡器或反向代理,getRemoteAddr() 可能获得代理节点地址。如果需要获取真实客户端 IP,应在可信代理配置下处理转发头,例如 X-Forwarded-For;不要无条件相信外部客户端自行提交的转发头,否则可能被伪造。
12.6 @Order 的优先级需要与项目其他切面一起验证
@Order(Ordered.LOWEST_PRECEDENCE - 5) 表示该切面在已排序的 Spring AOP 通知中设置了一个靠后的优先级,但实际执行顺序依然取决于项目中事务、鉴权、审计等其他切面的配置。若日志必须覆盖某个切面的执行耗时,需要结合实际代理链做集成测试。
12.7 如果需要关联分布式链路,应优先考虑 TraceId / MDC
这里使用 UUID 作为请求和响应日志的关联 ID,解决单次调用的日志关联问题。如果项目已经接入 Micrometer Tracing、SkyWalking、Zipkin 或其他链路追踪系统,建议复用已有 TraceId,并通过 MDC 贯穿同一线程中的其他日志。异步切换线程时还应确认上下文传播方式。
十三、推荐的测试清单
测试时至少应覆盖以下场景:
| 场景 | 期望结果 |
|---|---|
| 普通 DTO 请求与响应 | 请求和响应都有日志,关联 ID 相同 |
参数包含 password、token、mobile | 对应字段被脱敏 |
| DTO 中嵌套 Map 或集合 | 敏感字段仍被脱敏,集合只输出有限样本 |
参数为 MultipartFile、Servlet 请求、输入流 | 不输出完整对象内容 |
| 业务方法抛出运行时异常 | 输出失败结束日志,并继续抛出原异常 |
| 请求日志构建失败 | 业务方法仍然执行 |
| 响应 JSON 转换失败 | 使用占位符或安全兜底,不输出原始对象 |
| 监控服务抛出异常 | 不改变业务接口的返回结果或原始异常 |
响应为 ResponseEntity | 记录状态码和经过处理的 body |
响应为 List / PageInfo | 只记录有限摘要,避免输出全部分页数据 |
响应为 SseEmitter / Flux | 明确标记当前耗时并非流式处理完整耗时 |
| 没有 HTTP 请求上下文 | 不因 RequestContextHolder 为空而报错 |
建议使用单元测试验证 ApiLogValueSanitizer,再使用 Spring Boot 集成测试验证 AOP 确实拦截目标方法。单测脱敏成功不代表 AOP 一定生效,二者应分别验证。
十四、总结
ApilLogHandlerAdvice负责 AOP 流程、计时与异常路径。ApiLogRequestBuilder负责构建请求日志。ApiLogResponseBuilder负责构建成功或异常的响应日志。ApiLogValueSanitizer负责敏感字段脱敏、对象过滤与日志大小控制。ApiLogWriter负责 JSON 序列化和安全输出。ApiRequestLog、ApiResponseLog只承载日志数据。ApiLogMonitor隔离项目现有的监控实现。
在生产环境中,API 日志应坚持三个原则:日志不能影响业务;脱敏失败不能泄露原始数据;日志内容只记录排查问题所必需的信息。 做到这些,统一日志切面才不仅是一个方便的工具,也是一项相对可靠的可观测性基础能力。
本文介绍基于Spring Boot的自定义注解与AOP切面实现API统一日志打印,涵盖请求/响应DTO封装、字段级敏感信息脱敏、异步耗时边界处理、日志大小控制及异常保护机制。强调生产实践中日志不干扰业务、脱敏失败降级、避免敏感头泄露等关键原则,并支持TraceId/MDC链路关联。

293

被折叠的 条评论
为什么被折叠?



