SpringBoot记录用户操作日志

在很多系统平台中台,特别是一些敏感的后台系统,需要对用户的操作日志进行全链路记录,所以需要后台拦截所有Http调用日志。

由于大多开发框架都整合了Swagger,所以本次操作以Swagger为基准进行扩展。

一、RequestHandlerMappingInfoCache

首先,我们需要将SpringMVC中的所有请求信息进行缓存,方便后续通过请求URL获取一些日志记录需要的信息,如:方法、方法描述等。

java 复制代码
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;

    @Override
    public void setEnvironment(Environment environment) {
        this.environment = environment;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    @Override
    public 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());
            // 获取所有url
            Set<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

java 复制代码
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

添加拦截器,拦截请求并记录相关信息。

java 复制代码
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;
    }

    @Override
    public 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;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        HTTP_CALL_LOG_INFO_THREAD_LOCAL.remove();
    }
}

HttpCallLogInfo

java 复制代码
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 进行扩展。

java 复制代码
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);
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return byteArrayInputStream.available() == 0;
            }

            @Override
            public boolean isReady() {
                return true;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
            }

            @Override
            public 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调用日志存储。

java 复制代码
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()
    );

    @Override
    public 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);
    }

    @Override
    public 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容器中。

java 复制代码
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 {

    @Autowired
    private HttpCallLogInterceptor httpCallLogInterceptor;

    @Autowired
    private HttpCallLogProperties httpCallLogProperties;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        List<String> pathPatterns = httpCallLogProperties.getPathPatterns();
        if (CollectionUtils.isEmpty(pathPatterns)) {
            pathPatterns = Collections.singletonList("/**"); // 拦截所有请求
        }
        registry.addInterceptor(httpCallLogInterceptor)
                .addPathPatterns(pathPatterns)
                .excludePathPatterns(httpCallLogProperties.getExcludePathPatterns());
    }

    @Bean
    public HttpCallLogAutoStorage httpCallLogAutoStorage() {
        return new HttpCallLogAutoStorage();
    }

    /**
     * Http调用日志监听器
     *
     * @return
     */
    @Bean
    public 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 {

        @Bean
        public RequestHandlerMappingInfoCache requestHandlerMappingInfoCache() {
            return new RequestHandlerMappingInfoCache();
        }

        /**
         * Http调用日志拦截器
         */
        @Bean
        public HttpCallLogInterceptor httpCallLogInterceptor(RequestHandlerMappingInfoCache requestHandlerMappingInfoCache,
                                                             HttpCallLogProperties httpCallLogProperties) {
            return new HttpCallLogInterceptor(requestHandlerMappingInfoCache, httpCallLogProperties);
        }

		/**
		 * 过滤器
		 */
        @Bean
        public 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

java 复制代码
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;
}
相关推荐
纪元A梦1 小时前
华为OD机试真题——荒岛求生(2025A卷:200分)Java/python/JavaScript/C/C++/GO最佳实现
java·c语言·javascript·c++·python·华为od·go
苹果酱05671 小时前
iview 表单验证问题 Select 已经选择 还是弹验证提示
java·vue.js·spring boot·mysql·课程设计
电商数据girl3 小时前
【Python爬虫电商数据采集+数据分析】采集电商平台数据信息,并做可视化演示
java·开发语言·数据库·爬虫·python·数据分析
夏季疯3 小时前
学习笔记:黑马程序员JavaWeb开发教程(2025.3.30)
java·笔记·学习
源码云商3 小时前
基于 SpringBoot + Vue 的校园管理系统设计与实现
vue.js·spring boot·后端
LUCIAZZZ3 小时前
简单介绍分布式定时任务XXL-JOB
java·spring boot·分布式·spring·操作系统·定时任务
bing_1583 小时前
Spring MVC Controller 方法的返回类型有哪些?
java·spring·mvc
奔驰的小野码4 小时前
SpringAI实现AI应用-内置顾问
java·人工智能·后端·spring