SpringBoot记录用户操作日志
- 一、RequestHandlerMappingInfoCache
- RequestHandlerMappingInfo
- 二、拦截器Interceptor
- HttpCallLogInfo
- 三、HttpServletRequestWrapper
- 四、存储调用日志
- 五、Config
- HttpCallLogProperties
在很多系统平台中台,特别是一些敏感的后台系统,需要对用户的操作日志进行全链路记录,所以需要后台拦截所有Http调用日志。
由于大多开发框架都整合了Swagger,所以本次操作以Swagger为基准进行扩展。
一、RequestHandlerMappingInfoCache
首先,我们需要将SpringMVC中的所有请求信息进行缓存,方便后续通过请求URL获取一些日志记录需要的信息,如:方法、方法描述等。
import io.swagger.v3.oas.annotations.Operation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import org.springframework.util.CollectionUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.pattern.PathPattern;import java.lang.annotation.Annotation;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;@Slf4j
public class RequestHandlerMappingInfoCache implements CommandLineRunner, EnvironmentAware, ApplicationContextAware {private static final Map<String, RequestHandlerMappingInfo> REQUEST_HANDLER_MAPPING_INFO_MAP = new ConcurrentHashMap<>(128);private ApplicationContext applicationContext;private Environment environment;@Overridepublic void setEnvironment(Environment environment) {this.environment = environment;}@Overridepublic void setApplicationContext(ApplicationContext applicationContext) {this.applicationContext = applicationContext;}@Overridepublic void run(String... args) throws Exception {log.info("requestHandlerMappingInfoCache>>>init start");RequestMappingHandlerMapping requestMappingHandlerMapping = applicationContext.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping.class);Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingHandlerMapping.getHandlerMethods();String systemName = environment.getProperty("http.call-log.systemName", environment.getProperty("spring.application.name"));handlerMethods.forEach((requestMappingInfo, handlerMethod) -> {// 类名String className = handlerMethod.getMethod().getDeclaringClass().getName();// 方法名String methodName = handlerMethod.getMethod().getName();// 方法描述String requestMappingDesc = findRequestMappingDesc(handlerMethod.getMethod().getDeclaredAnnotations());// 获取所有urlSet<String> urls = getRequestMappingInfoAllUrl(requestMappingInfo);if (CollectionUtils.isEmpty(urls)) {return;}for (String url : urls) {RequestHandlerMappingInfo info = new RequestHandlerMappingInfo();info.setSystemName(systemName);info.setRequestUrl(url);info.setClassName(className);info.setMethodName(methodName);if (StringUtils.isBlank(requestMappingDesc)) {info.setRequestMappingDesc(url);} else {info.setRequestMappingDesc(requestMappingDesc);}REQUEST_HANDLER_MAPPING_INFO_MAP.put(url, info);}});log.info("requestHandlerMappingInfoCache>>>init stop, RequestHandlerMappingInfoMap size={}", REQUEST_HANDLER_MAPPING_INFO_MAP.size());}/*** 获取所有url* @param requestMappingInfo* @return*/private Set<String> getRequestMappingInfoAllUrl(RequestMappingInfo requestMappingInfo) {if (requestMappingInfo.getPatternsCondition() != null && !CollectionUtils.isEmpty(requestMappingInfo.getPatternsCondition().getPatterns())) {return requestMappingInfo.getPatternsCondition().getPatterns();}if (requestMappingInfo.getPathPatternsCondition() != null && !CollectionUtils.isEmpty(requestMappingInfo.getPathPatternsCondition().getPatternValues())) {return requestMappingInfo.getPathPatternsCondition().getPatternValues();}return null;}public RequestHandlerMappingInfo getRequestHandlerMappingInfo(String url) {return REQUEST_HANDLER_MAPPING_INFO_MAP.get(url);}/*** 查找接口描述信息** @param annotations* @return*/private String findRequestMappingDesc(Annotation[] annotations) {if (annotations == null) {return null;}for (Annotation annotation : annotations) {if (annotation instanceof Operation) {Operation operation = (Operation) annotation;if (StringUtils.isBlank(operation.summary())) {return operation.description();}return operation.summary();}}return null;}
}
RequestHandlerMappingInfo
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;import java.io.Serializable;@Data
@AllArgsConstructor
@NoArgsConstructor
public class RequestHandlerMappingInfo implements Serializable {private static final long serialVersionUID = 6719771449788171956L;/*** 系统名称*/private String systemName;/*** 请求url*/private String requestUrl;/*** 描述*/private String requestMappingDesc;/*** 类名*/private String className;/*** 方法名*/private String methodName;
}
二、拦截器Interceptor
添加拦截器,拦截请求并记录相关信息。
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.ServiceLoader;@Slf4j
public class HttpCallLogInterceptor implements HandlerInterceptor {public static final ThreadLocal<HttpCallLogInfo> HTTP_CALL_LOG_INFO_THREAD_LOCAL = new ThreadLocal<>();public static final String URL_PARAMS_KEY = "urlParams";public static final String BODY_PARAMS_KEY = "bodyParams";private final RequestHandlerMappingInfoCache requestHandlerMappingInfoCache;private final HttpCallLogProperties httpCallLogProperties;public HttpCallLogInterceptor(RequestHandlerMappingInfoCache requestHandlerMappingInfoCache, HttpCallLogProperties httpCallLogProperties) {this.requestHandlerMappingInfoCache = requestHandlerMappingInfoCache;this.httpCallLogProperties = httpCallLogProperties;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {try {String requestURI = getUrl(request);RequestHandlerMappingInfo requestHandlerMappingInfo = requestHandlerMappingInfoCache.getRequestHandlerMappingInfo(requestURI);if (httpCallLogProperties.isEnable() && requestHandlerMappingInfo != null) {HttpCallLogInfo httpCallLogInfo = new HttpCallLogInfo();httpCallLogInfo.setSystemName(requestHandlerMappingInfo.getSystemName());httpCallLogInfo.setRequestUrl(requestHandlerMappingInfo.getRequestUrl());httpCallLogInfo.setRequestMappingDesc(requestHandlerMappingInfo.getRequestMappingDesc());httpCallLogInfo.setTraceId("TraceId");httpCallLogInfo.setRequestStartTime(new Date());httpCallLogInfo.setRequestParams(this.getAllRequestParams(request));httpCallLogInfo.setOperateUser("操作用户");HTTP_CALL_LOG_INFO_THREAD_LOCAL.set(httpCallLogInfo);}} catch (Exception e) {log.error("httpCallLogInterceptor>>>exception", e);}return true;}/*** 获取url,只带一个 / 前缀* @param request* @return*/private String getUrl(HttpServletRequest request) {String url = request.getRequestURI();String prefix = "//";while (StringUtils.isNotBlank(url) && url.startsWith(prefix)) {url = url.substring(1);}return url;}/*** 获取所有请求参数* @param request* @return*/private String getAllRequestParams(HttpServletRequest request) {// 获取url所有参数Map<String, Object> urlParams = getUrlParams(request);// 获取body参数String bodyParams = getBodyParams(request);if (CollectionUtils.isEmpty(urlParams) && StringUtils.isBlank(bodyParams)) {return null;}if (StringUtils.isBlank(bodyParams)) {return JSON.toJSONString(urlParams);}if (CollectionUtils.isEmpty(urlParams)) {return bodyParams;}Map<String, Object> paramMap = new HashMap<>(2);paramMap.put(URL_PARAMS_KEY, urlParams);paramMap.put(BODY_PARAMS_KEY, bodyParams);return JSON.toJSONString(paramMap);}private Map<String, Object> getUrlParams(HttpServletRequest request) {Map<String, Object> params = new HashMap<>();Enumeration<String> parameterNames = request.getParameterNames();while (parameterNames.hasMoreElements()) {String name = parameterNames.nextElement();params.put(name, request.getParameter(name));}return params;}private String getBodyParams(HttpServletRequest request) {if (request instanceof BodyReaderHttpServletRequestWrapper) {BodyReaderHttpServletRequestWrapper requestWrapper = (BodyReaderHttpServletRequestWrapper) request;return requestWrapper.getBody();}return null;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {HTTP_CALL_LOG_INFO_THREAD_LOCAL.remove();}
}
HttpCallLogInfo
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;import java.io.Serializable;
import java.util.Date;@Data
@AllArgsConstructor
@NoArgsConstructor
public class HttpCallLogInfo implements Serializable {private static final long serialVersionUID = -737060657123005568L;/*** 系统名称*/private String systemName;/*** 描述*/private String requestMappingDesc;/*** 请求url*/private String requestUrl;private String traceId;/*** 请求时间*/private Date requestStartTime;/*** 请求参数JSON*/private String requestParams;/*** 响应结果*/private String responseResult;/*** 执行时间(毫秒)*/private Long executeTime;/*** 操作用户*/private String operateUser;
}
三、HttpServletRequestWrapper
由于 HttpServletRequest 中的 inputStream 读取一次就会失效,所以需要进行包装,利用 HttpServletRequestWrapper 进行扩展。
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;@Getter
@Slf4j
public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {private final String body;public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) throws IOException {super(request);body = getBodyString(request);}@Overridepublic BufferedReader getReader() throws IOException {return new BufferedReader(new InputStreamReader(getInputStream()));}@Overridepublic ServletInputStream getInputStream() throws IOException {final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));return new ServletInputStream() {@Overridepublic boolean isFinished() {return byteArrayInputStream.available() == 0;}@Overridepublic boolean isReady() {return true;}@Overridepublic void setReadListener(ReadListener readListener) {}@Overridepublic int read() throws IOException {return byteArrayInputStream.read();}};}/*** 获取请求Body*/private String getBodyString(ServletRequest request) {StringBuilder sb = new StringBuilder();try (InputStream inputStream = request.getInputStream();BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {String line;while ((line = reader.readLine()) != null) {sb.append(line);}} catch (Exception e) {log.error("bodyReaderHttpServletRequestWrapper>>>exception", e);}return sb.toString();}
}
四、存储调用日志
利用 ResponseBodyAdvice 进行响应结果出来,并进行Http调用日志存储。
import com.alibaba.fastjson.JSON;
import com.google.common.collect.Queues;
import io.netty.util.concurrent.DefaultThreadFactory;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;@ControllerAdvice(basePackages = "com.lzq.http")
@Slf4j
public class HttpCallLogAutoStorage implements ResponseBodyAdvice<Object> {private static final int CPUS = Runtime.getRuntime().availableProcessors() <= 0 ? 64 : Runtime.getRuntime().availableProcessors();private final ExecutorService executorService = new ThreadPoolExecutor(CPUS * 2, CPUS * 2 + 1, 2, TimeUnit.MINUTES,Queues.newLinkedBlockingDeque(),new DefaultThreadFactory("HTTP-CALL-LOG-AUTO-STORAGE"),new ThreadPoolExecutor.CallerRunsPolicy());@Overridepublic boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> converterType) {if (Objects.isNull(methodParameter) || Objects.isNull(methodParameter.getMethod())) {return false;}Class<?> returnType = methodParameter.getMethod().getReturnType();// 只拦截返回类型为 Result 的方法return Result.class.equals(returnType);}@Overridepublic Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {try {HttpCallLogInfo httpCallLogInfo = HttpCallLogInterceptor.HTTP_CALL_LOG_INFO_THREAD_LOCAL.get();if (httpCallLogInfo != null) {executorService.execute(() -> {log.info("httpCallLogAutoStorage>>>publish event");if (StringUtils.isBlank(httpCallLogInfo.getResponseResult())) {httpCallLogInfo.setResponseResult(JSON.toJSONString(body));}httpCallLogInfo.setExecuteTime(System.currentTimeMillis() - httpCallLogInfo.getRequestStartTime().getTime());// TODO 存储});}} catch (Exception e) {log.error("httpCallLogAutoStorage>>>exception", e);}return body;}
}
五、Config
将上述对象注入到Spring IoC容器中。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.CollectionUtils;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.ServiceLoader;@AutoConfiguration
@ConditionalOnProperty(prefix = "http.call-log", value = "enable", havingValue = "true")
@EnableConfigurationProperties(value = HttpCallLogProperties.class)
public class HttpCallLogConfiguration implements WebMvcConfigurer {@Autowiredprivate HttpCallLogInterceptor httpCallLogInterceptor;@Autowiredprivate HttpCallLogProperties httpCallLogProperties;@Overridepublic void addInterceptors(InterceptorRegistry registry) {List<String> pathPatterns = httpCallLogProperties.getPathPatterns();if (CollectionUtils.isEmpty(pathPatterns)) {pathPatterns = Collections.singletonList("/**"); // 拦截所有请求}registry.addInterceptor(httpCallLogInterceptor).addPathPatterns(pathPatterns).excludePathPatterns(httpCallLogProperties.getExcludePathPatterns());}@Beanpublic HttpCallLogAutoStorage httpCallLogAutoStorage() {return new HttpCallLogAutoStorage();}/*** Http调用日志监听器** @return*/@Beanpublic HttpCallLogInfoListener httpCallLogInfoListener(List<IHttpCallLogStorageService> httpCallLogStorageServices,HttpCallLogProperties httpCallLogProperties) {return new HttpCallLogInfoListener(httpCallLogStorageServices, httpCallLogProperties);}@Configuration@ConditionalOnProperty(prefix = "http.call-log", value = "enable", havingValue = "true")@EnableConfigurationProperties(value = HttpCallLogProperties.class)public static class HttpCallLogConfig {@Beanpublic RequestHandlerMappingInfoCache requestHandlerMappingInfoCache() {return new RequestHandlerMappingInfoCache();}/*** Http调用日志拦截器*/@Beanpublic HttpCallLogInterceptor httpCallLogInterceptor(RequestHandlerMappingInfoCache requestHandlerMappingInfoCache,HttpCallLogProperties httpCallLogProperties) {return new HttpCallLogInterceptor(requestHandlerMappingInfoCache, httpCallLogProperties);}/*** 过滤器*/@Beanpublic FilterRegistrationBean httpCallLogAccessFilter() {return new FilterRegistrationBean((req, res, chain) -> {HttpServletRequest request = (HttpServletRequest) req;HttpServletResponse response = (HttpServletResponse) res;if ("POST".equalsIgnoreCase(request.getMethod())) {chain.doFilter(new BodyReaderHttpServletRequestWrapper(request), response);return;}chain.doFilter(req, res);});}}
}
HttpCallLogProperties
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;import java.util.Collections;
import java.util.List;@Data
@ConfigurationProperties(prefix = "http.call-log")
public class HttpCallLogProperties {private boolean enable;private String systemName;/*** 需要拦截的url*/private List<String> pathPatterns;/*** 排除的url*/private List<String> excludePathPatterns = Collections.EMPTY_LIST;
}