前面,我们完成了spring boot与log4j2日志框架的整合,我们得到了漂亮的日志格式的输出。我们还不满足于此,很多时候为了排查问题,我们需要得到请求的入参和出参,通过AOP拦截机制我们可以实现请求与响应的统一日志输出,废话不多说,开干!
引入依赖
首先引入apo
依赖:
groovy
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-aop'
}
切面类
接下来我们定义切面类,在其中通过表达式形式来声明切入点,这里我们对controller包下的所有controller方法都进行切入拦截
:
java
package com.xiaojuan.boot.aop.log;
import ...
@Slf4j
@Aspect
@Component
public class WebLogAspect {
@Pointcut("execution(public * com.xiaojuan.boot.web.controller.*.*(..)))")
public void webLog() {}
@Before("webLog()")
public void doBefore(JoinPoint joinPoint) {
// 获取请求
HttpServletRequest request = WebRequestUtil.getRequest();
Signature signature = joinPoint.getSignature();
log.info("========== {}: {}", request.getMethod(), request.getRequestURL());
log.info("========== 所在类方法: {}.{}", signature.getDeclaringTypeName(), signature.getName());
}
@AfterReturning(returning = "resp", pointcut = "webLog()")
public void doAfterReturning(Object resp) throws JsonProcessingException {
}
}
注意,切面类它本身也是一个spring组件,需要加@Component
注解,同时要用@Aspect
修饰。
然后我们运行下web单元测试看下,控制台的输出:
说明我们的切面实现类凑效了,接下来就是完善输出了。
获取和输出请求参数
下面我们实现一个私有的方法来获取和输出请求参数等信息:
java
private void logParams(HttpServletRequest request) {
Map<String, String[]> parameterMap = request.getParameterMap();
int size = parameterMap.size();
if (size == 0) return;
StringBuilder paramBuilder = new StringBuilder();
Set<String> paramNames = parameterMap.keySet();
int index = 0;
for (String paramName : paramNames) {
paramBuilder.append(paramName).append("=").append(StringUtils.join(parameterMap.get(paramName)));
if (index < size - 1) {
paramBuilder.append(", ");
}
index++;
}
log.info("========== params: {}", paramBuilder);
}
这里的StringUtils
类我们需要引入org.apache.commons:commons-lang3
依赖。
注意,这里我们是以表单形式提交的数据,可以直接用类似request.getParamter
的方法来获取。如果是post的json体内容,则我们采用另外的形式,这里的思路是,我们获取方法签名中增加了@RequestBody
注解的参数,并找到对应的实参,然后对其序列化输出,实现如下:
java
@SneakyThrows
private void logRequestBody(JoinPoint joinPoint) {
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
Parameter[] parameters = method.getParameters();
for (int i = 0; i < parameters.length; i++) {
Parameter parameter = parameters[i];
if (parameter.getAnnotation(RequestBody.class) != null) {
Object body = joinPoint.getArgs()[i];
log.info("========== body: {}", objectMapper.writeValueAsString(body));
break;
}
}
}
实现最后,看下两个切面拦截方法的实现:
java
@Before("webLog()")
public void doBefore(JoinPoint joinPoint) {
// 获取请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
Signature signature = joinPoint.getSignature();
log.info("====================================================================================================");
log.info("========== {}: {}", request.getMethod(), request.getRequestURL());
log.info("========== 所在类方法: {}.{}", signature.getDeclaringTypeName(), signature.getName());
logParams(request);
logRequestBody(joinPoint);
}
@AfterReturning(returning = "resp", pointcut = "webLog()")
public void doAfterReturning(Object resp) throws JsonProcessingException {
log.info("========== 响应结果: {}", objectMapper.writeValueAsString(resp));
log.info("====================================================================================================");
}
这里我们输出了一些分割线,让我们的日志看起来更漂亮。
输出效果
最后,我们来运行web单元测试,看下控制台输出的效果:
修复问题
这里我们输出的是响应的body内容,我们应该输出完整的格式;如果发生异常,最终我们应该响应全局异常处理后的内容,为此我们这里的doAfterReturning(resp)
方法就显得无能为力了。比如,这里判断没有管理员角色操作权限的错误响应就没有输出:
不用担心,还记得我们之前在讲全局响应输出时用的RestBodyAdvice
类吗,不管是正常响应还是抛异常的错误输出,最终都会经过我们的beforeBodyWrite
方法,为此,我们把doAfterReturning(resp)
方法中的代码移植过去,并把这个方法删掉。看下我们调整后的RestBodyAdvice
类:
java
package com.xiaojuan.boot.common.web.support;
import ...
@Slf4j
@RestControllerAdvice
public class RestBodyAdvice implements ResponseBodyAdvice<Object> {
@Resource
private ObjectMapper objectMapper;
@Override
public boolean supports...
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
Object resp = null;
if (body instanceof String) {
// 字符串需要手动序列化为json
response.getHeaders().set("Content-Type", MediaType.APPLICATION_JSON_UTF8_VALUE);
resp = Response.ok(body);
logResponse(resp);
return objectMapper.writeValueAsString(resp);
}
// 如果是Response类型说明被包装为全局错误响应
resp = body instanceof Response ? body : Response.ok(body);
logResponse(resp);
return resp;
}
private void logResponse(Object resp) throws JsonProcessingException {
log.info("========== 响应结果: {}", objectMapper.writeValueAsString(resp));
log.info("====================================================================================================");
}
}
这样我们再来执行下前面的web单元测试,看到的输出:
响应日志慢慢变成我们想要的样子了,我们可以再优化下,把响应是null
的字段不参与json序列化:这里有一种快捷的做法:
java
package com.xiaojuan.boot.common;
import ...
@JsonInclude(JsonInclude.Include.NON_NULL)
...
public class Response<T> {
...
}
再来看效果,确实达到了预期:
但是,这是全局影响的,就是说我们给前端响应的通用结构的字段如果有null
值,也不会输出了,这可能并不是前端mm所希望的;我们这里只希望把影响控制在日志输出这个范围内,为此我们取消这种做法,而把解决方案改为如下实现:
java
private void logResponse(Object resp) throws JsonProcessingException {
log.info("========== 响应结果: {}", objectMapper.writeValueAsString(ObjectMapUtil.bean2Map(resp, true)));
...
}
这里我们写了一个工具类实现bean(对象)到map
结构的转换,并提供是否排除null
值的字段的复制,用到了spring框架提供的BeanMap
类:
java
package com.xiaojuan.boot.util;
import ...
public class ObjectMapUtil {
public static <T> Map<String, Object> bean2Map(T bean, boolean excludeNull) {
Map<String, Object> map = null;
if (bean != null) {
map = new HashMap<>();
BeanMap beanMap = BeanMap.create(bean);
for (Object key : beanMap.keySet()) {
Object value = beanMap.get(key);
if (value == null && excludeNull) continue;
map.put(String.valueOf(key), value);
}
}
return map;
}
}
再来执行下web单元测试,输出没有问题!
待完善:日志脱敏
最后的问题只剩下一个------日志脱敏,这个问题在生产环境特别要重视,不能泄露用户的个人信息!同样我们可以利用AOP
的拦截机制来实现,就留给小伙伴们当作作业了,大家学习扩展来实现下吧!